diff --git a/Cargo.lock b/Cargo.lock index 0d45bc8044..d2f4dadad0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,17 +1178,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1211,7 +1211,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "egui", "serde", @@ -1221,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-keymap", "craft-quit", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-winprint-gdi", "log", @@ -1248,7 +1248,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "dispatch2", "egui", @@ -1259,7 +1259,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1280,7 +1280,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index c9db94b27b..edd9e682b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,18 +13,18 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # One menu definition for the native macOS menu bar and the in-window bar elsewhere. -craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } -craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false } +craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Whether a path stored in a document may be opened (GoToR link targets). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.17.0" } pdfcraft-filters = { path = "crates/filters", version = "0.17.0" } diff --git a/ROADMAP.md b/ROADMAP.md index 74d9241e1c..6e7140b909 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -221,6 +221,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. +- **2026-11-01 (M5/M14, customizable hotbar, freehand highlight, and moveable navigation rail):** Added Freehand-Highlight (`/Subtype /Ink`, `/IT /FreehandHighlight`, Multiply blending, 100 pt max line width) inside the standard Highlight flyout and headless `comment_add`; added a customizable floating or side-docked hotbar with a full-side chrome strip mode, settings gear icon anchored at the bar end, and 24–64 px sizing; and added Left/Right docking for the navigation rail (`rail_side`). Focused unit and integration tests pass across `annot`, `automation`, and `ui-egui`. Estimates unchanged. - 2026-11-01: PR #2155 further review preserves inherited Form text state and soft-mask chronology during Arrange, composes crop-group moves in the proper scope, and uses affine/press-relative image resize plus a shared rotation pivot. No-op script buttons preserve hidden source text; paste coordinates, visible selection ownership, and Snapshot/Export permissions have dedicated regressions. Candidate only; estimates unchanged. - 2026-11-01: PR #2155 latest follow-up handles explicit image-paste events without release-time guesses, refreshes the font-size field when a selection becomes mixed, and specifies mixed-script normalization with exact Undo restoration. Candidate only; estimates unchanged. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index abc746c0dd..909250ef26 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -392,9 +392,13 @@ struct AppearanceDrawing { } impl AppearanceDrawing { + /// Initialize an appearance drawing buffer and configure its graphics state for opacity and blend mode. fn new(context: &AppearanceContext<'_>) -> Self { let mut drawing = Self::default(); - if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") { + if let Some(gs) = ext_gstate( + context.opacity, + context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")), + ) { drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); drawing.content.push_str("/GS0 gs\n"); } diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index a6db5b940b..1904745e95 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -33,6 +33,16 @@ pub(crate) fn grow(r: [f64; 4], by: f64) -> [f64; 4] { [r[0] - by, r[1] - by, r[2] + by, r[3] + by] } +/// Tight bounding box of ink strokes, including Bezier control points for strokes with three or +/// more points (matching [`appearance::smooth_segments`]). +pub(crate) fn ink_bounds(strokes: &[Vec<[f64; 2]>]) -> Option<[f64; 4]> { + let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { + let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); + appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) + }); + bounds(strokes.iter().flatten().copied().chain(controls)) +} + pub(crate) fn ending_draws(ending: LineEnding) -> bool { ending != LineEnding::None } @@ -87,17 +97,11 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) } - Shape::Ink { strokes } | Shape::Signature { strokes } => { + Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => { if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) { return Err(bad("drawing (no points)")); } - // Strokes of three or more points are drawn as curves, which stay within their - // points and control points. - let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { - let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); - appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) - }); - grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0) + grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0) } Shape::Polygon { vertices, cloud } => { let b = bounds(vertices.iter().copied()) @@ -164,6 +168,7 @@ fn subject(shape: &Shape) -> &'static str { Shape::Oval { .. } => "Oval", Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow", Shape::Line { .. } => "Line", + Shape::FreehandHighlight { .. } => "Freehand Highlight", Shape::Ink { .. } => "Pencil", Shape::TextBox { .. } => "Text Box", Shape::Typewriter { .. } => "Typewriter", @@ -292,10 +297,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } } } - Shape::Ink { strokes } | Shape::Signature { strokes } => { + Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => { d.set(b"C".to_vec(), rgb(style.color)); let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect(); d.set(b"InkList".to_vec(), Object::Array(list)); + if matches!(&new.shape, Shape::FreehandHighlight { .. }) { + d.set(b"IT".to_vec(), Object::name("FreehandHighlight")); + } border(&mut d); } Shape::Polygon { vertices, cloud } => { diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 2e3ae0b7d0..a615b8e013 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -1,5 +1,5 @@ use super::create::{ - add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb, + add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; use super::info::{line_endings_of, text_value}; @@ -529,7 +529,33 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } fn border_width_of(d: &Dict) -> f64 { - d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0) + appearance::border_width(d) +} + +/// Recompute `/Rect` for an `Ink` annotation when its stroke width changes from `old_w` to `w`. +/// Preserves any baseline margin beyond `old_w / 2.0` (at least `1.0` pt) without accumulating +/// padding across repeated width increases and decreases. +fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> { + let list = d.get(b"InkList")?.as_array()?; + let mut strokes: Vec> = Vec::with_capacity(list.len()); + for s in list { + let pts: Vec = s.as_array()?.iter().map(Object::as_f64).collect::>()?; + strokes.push(pts.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()); + } + let inner = ink_bounds(&strokes)?; + let vals: Vec = d.get(b"Rect").and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + let Some(old_rect) = four(&vals).map(normalize) else { + return Some(grow(inner, w / 2.0 + 1.0)); + }; + let old_half = old_w / 2.0; + let new_half = w / 2.0; + let base = [ + (inner[0] - old_rect[0] - old_half).max(1.0), + (inner[1] - old_rect[1] - old_half).max(1.0), + (old_rect[2] - inner[2] - old_half).max(1.0), + (old_rect[3] - inner[3] - old_half).max(1.0), + ]; + Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half]) } /// Change a comment's colour, opacity, line width and/or line endings, and redraw it. @@ -576,10 +602,17 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha } } if let Some(w) = width { + let old_w = border_width_of(d); + let w = w.max(0.0); let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default(); - bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); + bs.set(b"W".to_vec(), Object::Real(w)); d.set(b"BS".to_vec(), Object::Dict(bs)); d.remove(b"Border"); + if d.name(b"Subtype") == Some(b"Ink") + && let Some(rect) = ink_rect_for_width(d, old_w, w) + { + d.set(b"Rect".to_vec(), num_array(&rect)); + } } if let Some(ends) = endings { if ends.len() == 1 { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a3dc14e262..cf800fa98a 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2194,3 +2194,59 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); assert!(!long.contains(&"w".repeat(257))); } + +#[test] +fn freehand_highlight_is_compatible_ink_with_marker_blending() { + let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] }; + let mut annotation = new(0, shape.clone()); + annotation.style = Style::default_for(&shape); + let mut doc = fixture(); + let index = add_annotation(&mut doc, &annotation, &meta("marker")).unwrap(); + let doc = reopen(&doc); + let ink = &list(&doc, 0)[index]; + + assert_eq!(ink.name(b"Subtype"), Some(&b"Ink"[..])); + assert_eq!(ink.name(b"IT"), Some(&b"FreehandHighlight"[..])); + assert_eq!(ink.get(b"CA").and_then(Object::as_f64), Some(0.35)); + let ap = ink.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let resources = doc.get(ap).as_dict().unwrap().get(b"Resources").cloned().unwrap(); + let blend = resources.as_dict().unwrap().get(b"ExtGState").unwrap().as_dict().unwrap().get(b"GS0").unwrap().as_dict().unwrap(); + assert_eq!(blend.name(b"BM"), Some(&b"Multiply"[..])); + + let mut doc = doc; + let rect_of = |doc: &Document, idx: usize| -> Vec { + list(doc, 0)[idx].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect() + }; + let rect_before = rect_of(&doc, index); + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider")).unwrap(); + let rect_after = rect_of(&doc, index); + assert!((rect_after[0] - (rect_before[0] - 10.0)).abs() < 1e-4); + assert!((rect_after[2] - (rect_before[2] + 10.0)).abs() < 1e-4); + + // Repeated increase/decrease cycles must keep bounds stable and never accumulate padding. + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(14.0), endings: None }, &meta("narrower")).unwrap(); + let rect_restored = rect_of(&doc, index); + for (a, b) in rect_restored.iter().zip(rect_before.iter()) { + assert!((a - b).abs() < 1e-4, "expected {rect_before:?}, got {rect_restored:?}"); + } + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider-again")).unwrap(); + let rect_second_wide = rect_of(&doc, index); + for (a, b) in rect_second_wide.iter().zip(rect_after.iter()) { + assert!((a - b).abs() < 1e-4, "expected {rect_after:?}, got {rect_second_wide:?}"); + } + + // Imported ink whose width is specified only by `/Border [0 0 20]` must use 20 pt as its + // previous width (shrinking by 5 pt on a decrease to 10 pt rather than expanding as if 1 pt). + let (_, r) = lookup::annot_ref(&mut doc, 0, index).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"BS"); + d.set(b"Border".to_vec(), Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(20.0)])); + d.set(b"Rect".to_vec(), create::num_array(&[5.0, 85.0, 135.0, 115.0])); + }) + .unwrap(); + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(10.0), endings: None }, &meta("border-shrink")).unwrap(); + let rect_from_border = rect_of(&doc, index); + for (a, b) in rect_from_border.iter().zip([10.0, 90.0, 130.0, 110.0]) { + assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}"); + } +} diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index bd3409b5ae..522995b9f9 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -355,6 +355,10 @@ pub enum Shape { start: LineEnding, end: LineEnding, }, + /// Translucent freehand highlighting, stored as ink with a distinct intent. + FreehandHighlight { + strokes: Vec>, + }, /// Freehand strokes (Draw tool). Ink { strokes: Vec>, @@ -474,6 +478,7 @@ pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { } impl Shape { + /// Return the PDF `/Subtype` name for this annotation shape. pub fn subtype(&self) -> &'static str { match self { Shape::Note { .. } => "Text", @@ -481,7 +486,7 @@ impl Shape { Shape::Rectangle { .. } => "Square", Shape::Oval { .. } => "Circle", Shape::Line { .. } => "Line", - Shape::Ink { .. } | Shape::Signature { .. } => "Ink", + Shape::Ink { .. } | Shape::FreehandHighlight { .. } | Shape::Signature { .. } => "Ink", Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText", Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp", Shape::Redact { .. } => "Redact", @@ -610,6 +615,7 @@ impl Style { } Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), + Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None }, Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index f87fd0b58e..5dbd5b8542 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -183,6 +183,7 @@ impl Automation { "page": c.page + 1, "index": c.index + 1, "type": c.subtype, + "intent": c.intent, "author": c.author, "contents": c.contents, "modified": c.modified, @@ -328,7 +329,7 @@ impl Automation { let [start, end] = ending_pair(kind, endings.as_deref())?; Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end } } - "ink" => { + "ink" | "freehand_highlight" => { let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into()); let strokes = a.get("strokes").ok_or_else(|| ToolError::InvalidArgs("ink needs `strokes`".into()))?; let strokes = strokes @@ -346,7 +347,7 @@ impl Automation { .collect::>>() }) .collect::>>()?; - Shape::Ink { strokes } + if kind == "freehand_highlight" { Shape::FreehandHighlight { strokes } } else { Shape::Ink { strokes } } } "polygon" | "cloud" | "polyline" => { let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points")); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f23cb6a7ec..7cafcb4a37 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -754,7 +754,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, - "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] }, + "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "freehand_highlight", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] }, "path": { "type": "string", "description": "attachment: the file to attach." }, "points": { "type": "array", "items": point(), "minItems": 2 }, "knee": point(), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 98a1887730..0b0a7d598e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2149,6 +2149,27 @@ fn comments_through_tools() { save_and_reopen_comments(&dir, &mut a, doc); } +#[test] +fn freehand_highlight_through_tools() { + let dir = workdir("freehand-highlight"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "freehand_highlight", "width": 100, + "strokes": [[[20, 100], [120, 100]]] }), + ); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1); + assert_eq!(list["comments"][0]["type"], "Ink"); + assert_eq!(list["comments"][0]["intent"], "FreehandHighlight"); + assert_eq!(list["comments"][0]["color"], "#FFF000"); + let undone = ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(undone["undone"], "Add freehand highlight"); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0); +} + fn add_comment_examples(a: &mut Automation, doc: u64) -> String { // "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page. let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 9d47fd9bd3..ec7078590f 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -337,6 +337,17 @@ impl Document { pdfcraft_filters::decode_ccitt_within(&data, &p, rows, work).map_err(filter) } + /// The `/JBIG2Globals` stream a JBIG2 image `s` names by reference in its last filter's + /// `/DecodeParms`: `None` for a stream whose last filter isn't `JBIG2Decode`, or one without + /// globals. Redaction finds with it the other images that share a redacted image's globals. + pub fn jbig2_globals_ref(&self, s: &Stream) -> Option { + let chain = s.readable_filters().ok()?; + if !matches!(chain.last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { + return None; + } + self.last_filter_parms(s).get(b"JBIG2Globals").and_then(Object::as_ref) + } + /// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`) /// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is /// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6bfe9fa826..0647726452 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -163,6 +163,8 @@ const HELP: Option<&str> = Some("Help"); /// Every command, in menu order. pub const COMMANDS: &[CommandSpec] = &[ + c("view.select_tool", "Select", None, None, Document, "mouse-pointer-2"), + c("view.hand_tool", "Hand", None, None, Document, "hand"), c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"), c("file.open_recent", "Open Recent", FILE, None, Nothing, "clock"), // Shown at the foot of File ▸ Open Recent and on Home, not as its own File menu item. @@ -259,6 +261,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.underline", "Underline text", None, None, Annotate, "underline"), c("comment.strikeout", "Strikethrough text", None, None, Annotate, "strikethrough"), c("comment.squiggly", "Squiggly underline text", None, None, Annotate, "spline"), + c("comment.freehand_highlight", "Freehand-Highlight", None, None, Annotate, "highlighter"), c("comment.ink", "Draw freehand", None, None, Annotate, "pencil"), c("comment.line", "Draw a line", None, None, Annotate, "minus"), c("comment.arrow", "Draw an arrow", None, None, Annotate, "move-right"), diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs index eee799ed2f..c8262a7241 100644 --- a/crates/engine/src/comments.rs +++ b/crates/engine/src/comments.rs @@ -82,6 +82,7 @@ pub fn comment_type_name(subtype: &str) -> &str { /// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { match a.intent.as_deref() { + Some("FreehandHighlight") if a.subtype == "Ink" => "Freehand Highlight", Some("FreeTextCallout") => "Callout", Some("PolygonCloud") => "Cloud", Some("FreeTextTypeWriter") => "Typewriter", diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index afc6056e85..5343804ef3 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -327,6 +327,7 @@ fn add_annotation(s: &Shape) -> &'static str { Shape::Oval { .. } => "Add oval", Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", Shape::Line { .. } => "Add line", + Shape::FreehandHighlight { .. } => "Add freehand highlight", Shape::Ink { .. } => "Add drawing", Shape::TextBox { .. } => "Add text box", Shape::Typewriter { .. } => "Add text", diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 8d1515d8a4..fe21d13b32 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -17,8 +17,9 @@ pdfcraft-forms = { workspace = true } pdfcraft-edit = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-xfa = { workspace = true } +# CCITT Group 4 and JBIG2 encoding for cleared one-bit images. +pdfcraft-filters = { workspace = true } thiserror = { workspace = true } [dev-dependencies] -pdfcraft-filters = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/redact/README.md b/crates/redact/README.md index dddf17bf24..db9f46b8c9 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -21,9 +21,13 @@ clear_marks(&mut doc, None)?; // remove marks without app (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its `/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy - is Flate, and refers to no globals: when no other image uses them, they leave the file with the - original on the full rewrite; when one does, they stay as they are, and may hold the shapes of - marks under the area (not where they were). Codecs redaction doesn't decode (DCT, JPX) are + is Flate, or for a one-bit image of one component (a scan as a rule) CCITT Group 4, or JBIG2 + when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to + no globals. Globals hold the shapes of every image that shares them (a scan's pages, often), + those of marks under the area included, so every other image that uses the globals of a removed + or replaced image is decoded and stored again in place without them, as a copy is (Flate, + Group 4 or JBIG2, whichever is smallest; `Report::images_restored`), and they leave the file on the full rewrite; one that can't be + decoded for certain refuses the redaction (`RedactError::SharedJbig2Globals`). Codecs redaction doesn't decode (DCT, JPX) are removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's @@ -112,6 +116,5 @@ Patterns are heuristic: dates, protocol identifiers and longer number runs are r `CodeSet::from_id("foia")?.overlay(&["(b)(6)"])` gives the overlay text for the picked codes. Custom code sets aren't supported yet. -Not yet: pattern locales, struct-tree/alt-text cleanup, metadata and hidden-information -sanitising (M8.4), re-encoding of DCT images (they are removed instead), and storing a cleared black-and-white -image as CCITT Group 4 or JBIG2 again (it is Flate, larger; the optimizer makes it smaller). +Not yet: pattern locales, struct-tree/alt-text cleanup, re-encoding of DCT images (they are removed +instead). diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 52377078be..67234497b8 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,13 +1,14 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel //! whose footprint intersects a region is set to zero (no ink for image masks), and the result is -//! written as a new Flate image; the original object is untouched (other pages may use it). +//! written as a new image, Flate, or for a one-bit image of one component Group 4, or JBIG2 when it +//! was JBIG2, whichever is smallest; the original object is untouched (other pages may use it). //! CCITT fax and JBIG2 images decode through `Document::decoded_image_within`, but for Group 3 on //! bytes, which PdfCraft's renderer may draw as other rows, and a JBIG2 page of other rows than the //! image's. Images whose codec PdfCraft can't decode (DCT, JPX), and those, return `None`, and the //! caller removes the whole image instead; that is fail-closed. use pdfcraft_content::{Matrix, overlaps}; -use pdfcraft_cos::{Document, ImageBudget, Object, Stream}; +use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; /// Most pixels an image may have to be cleared in place (larger ones are removed). const MAX_PIXELS: u64 = 64 << 20; @@ -171,5 +172,74 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] dict.remove(b"Length"); dict.remove(b"DecodeParms"); dict.remove(b"Alternates"); - Some(Some(Stream::flate(dict, &data))) + Some(Some(smallest(dict, &data, w, h, ncomp == 1 && bpc == 1, is_jbig2))) +} + +/// The cleared samples `data` of a `w` × `h` image as a stream with `dict`: Flate, or, for one bit +/// of one component (`bilevel`, a scan as a rule, which Flate stores several times larger), CCITT +/// Group 4, or JBIG2 when the image was JBIG2 (`jbig2`; JBIG2 is PDF 1.4, which such a file already +/// is), whichever is smallest as stored, dictionary included (a tiny image can lose by Group 4's +/// parameters). Both decode to `data`, cleared before it is coded, so nothing of the original's +/// code is carried over; Group 4 or JBIG2 that can't code the rows (too wide) is left out. +fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream { + let flate = Stream::flate(dict.clone(), data); + let Some((columns, rows)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()).filter(|_| bilevel) else { return flate }; + let g4 = pdfcraft_filters::encode_g4(data, columns, rows).ok().map(|coded| { + let mut dict = dict.clone(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(columns))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(rows))); + dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(dict, coded) + }); + let jbig = jbig2.then(|| pdfcraft_filters::encode_jbig2(data, columns, rows).ok()).flatten().map(|coded| { + let mut dict = dict.clone(); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + Stream::from_raw(dict, coded) + }); + [g4, jbig].into_iter().flatten().fold(flate, |best, s| if stored_len(&s) < stored_len(&best) { s } else { best }) +} + +/// The bytes stream `s` takes in a file: its data and its dictionary as written. +fn stored_len(s: &Stream) -> usize { + let mut dict = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut dict); + dict.len().saturating_add(s.raw.len()) +} + +/// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn, +/// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest +/// (`smallest`), its other entries kept. `None` when it can't +/// be decoded for certain (too large, data that doesn't decode, a JBIG2 page of another size than +/// the image, or not a one-bit image of one component). Redaction does this to every image that +/// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn +/// under the area too. +pub(crate) fn without_globals(doc: &Document, s: &Stream) -> Option { + let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); + if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { + return None; + } + let (w, h) = (w as usize, h as usize); + let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))); + // JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7). + if !mask && (components(doc, s)?, s.dict.int(b"BitsPerComponent")?) != (1, 1) { + return None; + } + let bytes = w.div_ceil(8).checked_mul(h)?; + // As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one + // of the raster and a byte a row. + let allowance = bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?; + let mut data = doc.decoded_image_within(s, allowance).ok()?; + if data.len() < bytes { + return None; + } + data.truncate(bytes); + let mut dict = s.dict.clone(); + dict.remove(b"Length"); + // The globals are named in the parameters; the copy is coded alone, as `clear`'s are. + dict.remove(b"DecodeParms"); + dict.remove(b"Alternates"); + Some(smallest(dict, &data, w, h, true, true)) } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 9ecca0a57d..611745a42f 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -74,6 +74,12 @@ pub enum RedactError { /// take what no mark covers with it, unreported. #[error("the images under these marks are more than one redaction can decode, so nothing was changed; redact fewer pages at a time")] ImagesPastAllowance, + /// A JBIG2 image shares its `/JBIG2Globals` with a redacted one and can't be stored again + /// without them, so the shapes they hold, those of marks under the area included, would stay. + #[error( + "{0} JBIG2 image(s) share their symbol data with a redacted image and can't be decoded to be stored without it, so that data, which can show what is under the marks, would stay in the file; nothing was redacted" + )] + SharedJbig2Globals(usize), #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -136,6 +142,9 @@ pub struct Report { pub glyphs: usize, pub images_removed: usize, pub images_cleared: usize, + /// JBIG2 images, anywhere in the document, stored again (as a cleared copy is) without the + /// `/JBIG2Globals` they shared with a redacted image, so those globals leave the file. + pub images_restored: usize, pub paths_removed: usize, pub paths_clipped: usize, pub forms_rewritten: usize, @@ -523,6 +532,7 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co // A removed image or form must not stay reachable through another resource dictionary (an // inherited or shared /Resources): a full save keeps whatever is still referenced. interp::unlink_doomed(doc, &doomed_xobjects, &retired_properties)?; + report.images_restored = restore_shared_jbig2(doc, &doomed_xobjects)?; // 5. Verify: nothing readable may remain under a region. let mut residue = 0; @@ -549,6 +559,69 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co Ok(report) } +/// Most XObjects followed down from the removed and replaced ones to find their JBIG2 images. +const MAX_JBIG2_WALK: usize = 100_000; + +/// The `/JBIG2Globals` of a redacted JBIG2 image hold the symbol dictionary of every image that +/// shares them (`jbig2enc -s` and most scanners give one to all the pages of a scan): the bitmaps +/// of marks drawn only under the area too. The image itself is replaced or removed, but the globals +/// would stay for as long as another image uses them. So every image that uses the globals of a +/// removed or replaced image (`doomed`), or of one drawn in a removed or replaced form, is decoded +/// and stored again in place without them, as a cleared copy is, and a full save then leaves them +/// out. An image that can't be stored again refuses the redaction (nothing changes, as `apply` +/// works on a copy), but for a removed or replaced one, which is either unlinked or still drawn +/// whole on another page. Returns how many images that weren't removed or replaced were stored again. +fn restore_shared_jbig2(doc: &mut Document, doomed: &[ObjRef]) -> Result { + let mut globals = HashSet::new(); + let mut seen = HashSet::new(); + let mut stack: Vec = doomed.to_vec(); + while let Some(r) = stack.pop() { + if !seen.insert(r) || seen.len() > MAX_JBIG2_WALK { + continue; + } + let object = doc.get(r); + let Object::Stream(s) = &*object else { continue }; + if let Some(g) = doc.jbig2_globals_ref(s) { + globals.insert(g); + } + // A form's images, and its forms' (an image's /SMask is a stream of its own, found below). + let resources = s.dict.get(b"Resources").map(|r| doc.resolve(r)); + let xobjects = resources.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x)); + if let Some(x) = xobjects.as_deref().and_then(Object::as_dict) { + stack.extend(x.iter().filter_map(|(_, v)| v.as_ref())); + } + if let Some(m) = s.dict.get(b"SMask").and_then(Object::as_ref) { + stack.push(m); + } + } + if globals.is_empty() { + return Ok(0); + } + let sharing: Vec = doc + .scan_objects() + .filter(|(_, o)| matches!(&**o, Object::Stream(s) if doc.jbig2_globals_ref(s).is_some_and(|g| globals.contains(&g)))) + .map(|(r, _)| r) + .collect(); + let doomed: HashSet = doomed.iter().copied().collect(); + let (mut restored, mut stuck) = (0, 0); + for r in sharing { + let object = doc.get(r); + let Object::Stream(s) = &*object else { continue }; + match image::without_globals(doc, s) { + Some(new) => { + doc.set(r, Object::Stream(new)); + restored += usize::from(!doomed.contains(&r)); + } + None if doomed.contains(&r) => {} + None => stuck += 1, + } + } + if stuck > 0 { + return Err(RedactError::SharedJbig2Globals(stuck)); + } + Ok(restored) +} + /// What applying the marks collects across pages. struct Applying { report: Report, diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index e5ff4621f7..6e9a0b2810 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -520,13 +520,19 @@ fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document { ]) } -/// The samples of the image a page's (possibly inherited) resources name `name`. +/// The samples of the image a page's (possibly inherited) resources name `name`, decoded through +/// its image codec too (`Document::decoded_image`). fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> { + named_stream(doc, page, name).and_then(|s| doc.decoded_image(&s).ok()) +} + +/// The image a page's (possibly inherited) resources name `name`. +fn named_stream(doc: &Document, page: usize, name: &[u8]) -> Option { let p = pdfcraft_model::pages(doc).swap_remove(page); let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?; let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?; match &*doc.get(xo.get(name)?.as_ref()?) { - Object::Stream(s) => s.decoded().ok(), + Object::Stream(s) => Some(s.clone()), _ => None, } } @@ -737,6 +743,60 @@ fn a_busy_fax_image_behind_another_filter_is_cleared() { assert_eq!((report.images_cleared, report.images_removed), (1, 0)); } +/// After a save no stream holds the pixels under a mark, read as it would be shown, JBIG2 included, +/// on a scan coded with symbols and on one coded as a generic region. `COVERED` and `KEPT` are pixels +/// found nowhere else in the image, one lot under the mark and one beside it, so the search is seen +/// to find what stays. The one letter over and over, lower down, is what makes symbols worth coding. +/// The picture saved is the one with every pixel under the mark cleared and no other changed, so a +/// search that one cleared byte would blind isn't all that holds this. +#[test] +fn no_stream_holds_the_pixels_under_a_mark_on_a_jbig2_scan() { + const COVERED: [u8; 8] = [0x0F, 0x3C, 0x5A, 0xC3, 0x96, 0x69, 0xA5, 0x1E]; + const KEPT: [u8; 8] = [0x87, 0x2D, 0x4B, 0xE1, 0x78, 0xD2, 0xB4, 0x1F]; + const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"]; + let (w, h) = (512usize, 64usize); + let stride = w / 8; + let mut bits = vec![0xFFu8; stride * h]; + bits[stride * 2..stride * 2 + 8].copy_from_slice(&COVERED); + bits[stride * 2 + 16..stride * 2 + 24].copy_from_slice(&KEPT); + for top in (16..h - 8).step_by(10) { + for x in (4..w - 8).step_by(8) { + for (dy, row) in LETTER.iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let holds = |doc: &Document, pixels: &[u8]| { + doc.scan_objects().any(|(_, object)| match &*object { + Object::Stream(s) => doc.decoded_image(s).is_ok_and(|d| d.windows(pixels.len()).any(|w| w == pixels)), + _ => false, + }) + }; + // Columns 0 to 99 of rows 0 to 7. + let mut cleared = bits.clone(); + for row in cleared.chunks_mut(stride).take(8) { + row[..12].fill(0); + row[12] &= 0x0F; + } + let symbols = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap(); + assert!(symbols.len() < generic.len(), "symbol coding has to be what this tests"); + for coded in [symbols, generic] { + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode"); + // Half a point a pixel, its top row at y 132. + let mut doc = one_page(b"q 256 0 0 32 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, coded)]); + assert!(holds(&doc, &COVERED) && holds(&doc, &KEPT), "the pixels are there to be found"); + mark(&mut doc, 0, &[[0.25, 128.25, 49.75, 132.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!holds(&saved, &COVERED), "the covered pixels stayed in the file"); + assert!(holds(&saved, &KEPT), "the pixels beside them stay"); + assert!(holds(&saved, &cleared), "every pixel under the mark is cleared, and no other changed"); + } +} + /// `/JBIG2Globals` data a decoder skips: one extension segment (ISO/IEC 14492 7.4.14) of a type it /// needn't read, holding `secret`; its 11-byte header gives the length of the data after it. fn jbig2_globals(secret: &[u8]) -> Vec { @@ -896,7 +956,8 @@ fn a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole() { /// A scan stored as JBIG2 symbols, with globals, keeps every pixel outside the area once it is /// redacted and saved, and the pixels under it are cleared; neither its JBIG2 code nor its globals, -/// which no other image uses, stay in the file. +/// which no other image uses, stay in the file. The cleared copy is coded afresh as JBIG2, smaller +/// than Flate, and refers to no globals. #[test] fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() { const LETTERS: [[&str; 7]; 3] = [ @@ -917,6 +978,7 @@ fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() { } } let coded = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + let original = coded.clone(); assert!(coded.len() < pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap().len(), "symbol coded"); let dict = format!( "/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 8 0 R >>" @@ -940,13 +1002,309 @@ fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() { let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(cleared)); assert_no_secret(&doc, b"LEAK_GLOBALS"); + let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap(); + assert_eq!(copy.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + assert!(!copy.dict.contains(b"DecodeParms"), "the copy refers to no globals"); + let flate = pdfcraft_filters::encode_flate(&named_image(&saved, 0, b"PCRedacted1").unwrap()); + assert!(copy.raw.len() < flate.len(), "JBIG2 {} bytes, Flate {}", copy.raw.len(), flate.len()); for (r, object) in saved.scan_objects() { if let Object::Stream(s) = &*object { - assert_ne!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..]), "the JBIG2 image stays as {r:?}"); + assert_ne!(&s.raw[..], &original[..], "the original JBIG2 code stays as {r:?}"); } } } +/// `w` × `h` rows, `w` a multiple of 8: lines of word-sized dark blocks 14 rows tall, their edges +/// ragged by a sample from row to row, as a scan's are; Group 4 codes such rows from the row above. +fn ragged_scan(w: usize, h: usize) -> Vec { + let stride = w / 8; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let mut bits = vec![0xFFu8; stride * h]; + for top in (3..h - 14).step_by(20) { + let mut x = 4; + while x + 70 < w { + let len = 20 + (next() % 40) as usize; + for y in top..top + 14 { + let (left, right) = (x + (next() % 2) as usize, x + len - (next() % 2) as usize); + for xx in left..right { + bits[y * stride + xx / 8] &= !(0x80 >> (xx % 8)); + } + } + x += len + 6 + (next() % 6) as usize; + } + } + bits +} + +/// A cleared one-bit image of one component, a scan as a rule, is stored as CCITT Group 4 when +/// that is smaller than Flate, whatever it was stored as (Flate here), and never as JBIG2 unless it +/// was JBIG2; an eight-bit image stays Flate. The copy decodes to the cleared samples. +#[test] +fn a_cleared_black_and_white_scan_is_stored_as_group_4() { + let (w, h) = (800usize, 200usize); + let stride = w / 8; + let bits = ragged_scan(w, h); + let g4 = pdfcraft_filters::encode_g4(&bits, w as u32, h as u32).unwrap(); + assert!(g4.len() < pdfcraft_filters::encode_flate(&bits).len(), "Group 4 is smaller than Flate here"); + let grey: Vec = (0..w * h).map(|i| (i % 251) as u8).collect(); + for (dict, data, filter) in [ + ( + format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode"), + pdfcraft_filters::encode_flate(&bits), + &b"CCITTFaxDecode"[..], + ), + ( + format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"), + pdfcraft_filters::encode_flate(&grey), + &b"FlateDecode"[..], + ), + ] { + let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + // The left quarter of the top half. + mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{dict}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap(); + assert_eq!(copy.dict.name(b"Filter"), Some(filter), "{dict}"); + // Covered: rows 0 to 99 (y 150 down to 125, a quarter point a row), samples 0 to 199. + let mut expected = if filter == b"FlateDecode" { grey.clone() } else { bits.clone() }; + for y in 0..100 { + for x in 0..200 { + if filter == b"FlateDecode" { + expected[y * w + x] = 0; + } else { + expected[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap(), expected, "{dict}"); + } +} + +/// A cleared one-bit image is stored as the smallest stream, its dictionary counted: a tiny one, +/// whose Group 4 code is shorter than Flate's but whose `/DecodeParms` aren't, stays Flate. Rows +/// wider than Group 4 codes (`MAX_G4_COLUMNS`) stay Flate too, and still clear. +#[test] +fn a_cleared_image_group_4_wouldnt_shrink_stays_flate() { + let wide = pdfcraft_filters::MAX_G4_COLUMNS as usize + 1; + for (w, h) in [(3usize, 2usize), (wide, 2)] { + let stride = w.div_ceil(8); + assert_eq!(pdfcraft_filters::encode_g4(&vec![0xFF; stride * h], w as u32, h as u32).is_ok(), w != wide, "{w} × {h}"); + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode"); + let mut doc = one_page( + b"q 200 0 0 20 0 100 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![stream(&dict, pdfcraft_filters::encode_flate(&vec![0xFF; stride * h]))], + ); + // The top row's left half. + mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{w} × {h}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "{w} × {h}"); + let samples = named_image(&saved, 0, b"PCRedacted1").unwrap(); + let sample = |x: usize| samples.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + assert!(!sample(0) && sample(w - 1), "{w} × {h}: the top row's first sample is cleared and its last kept"); + assert!(samples.get(stride..).is_some_and(|row| row.iter().all(|b| *b == 0xFF)), "{w} × {h}: the bottom row is kept"); + } +} + +/// Only one bit of one component goes to Group 4 or JBIG2. An 8-bit grey image here starts as white +/// as a bilevel scan and goes on as noise Flate can't shrink, so Group 4, which would read only the +/// first rows' worth of bytes, would look far smaller while losing the picture. +#[test] +fn a_cleared_image_of_more_than_one_bit_stays_flate() { + let (w, h) = (256usize, 256usize); + let mut data = vec![0xFF; w * h]; + let mut state = 0x1234_5678u32; + for b in data.iter_mut().skip(w * h / 8) { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + *b = state.to_le_bytes()[0]; + } + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"); + let mut doc = one_page(b"q 200 0 0 20 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, pdfcraft_filters::encode_flate(&data))]); + mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap().get(h * w / 2..), data.get(h * w / 2..), "the rows below the mark are kept"); +} + +/// A copy stored as Group 4 or JBIG2 is read like any image: a second redaction, across a save, +/// clears it again rather than removing it whole, keeps what the first cleared, and stores it +/// smaller than Flate again. +#[test] +fn a_cleared_copy_stored_as_group_4_or_jbig2_can_be_redacted_again() { + let (w, h) = (800usize, 200usize); + let stride = w / 8; + let bits = ragged_scan(w, h); + let image = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray"); + for (filter, data, wanted) in [ + ("/FlateDecode", pdfcraft_filters::encode_flate(&bits), &b"CCITTFaxDecode"[..]), + ("/JBIG2Decode", pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(), &b"JBIG2Decode"[..]), + ] { + let dict = format!("{image} /Filter {filter}"); + let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{filter}"); + let mut saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(wanted), "{filter}"); + mark(&mut saved, 0, &[[150.0, 100.0, 200.0, 125.0]], ""); + let report = apply(&mut saved, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{filter}: cleared again, not removed"); + let again = Document::open(Arc::new(write_full(&saved, &SaveOptions::default()).unwrap())).unwrap(); + // Rows 0 to 99 of the left quarter, then 100 to 199 of the right. + let mut expected = bits.clone(); + for (rows, columns) in [(0..100, 0..200), (100..200, 600..800)] { + for y in rows { + for x in columns.clone() { + expected[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let names: Vec> = (1..4).map(|n| format!("PCRedacted{n}").into_bytes()).collect(); + let stored = names.iter().find_map(|n| named_image(&again, 0, n)); + assert_eq!(stored, Some(expected), "{filter}"); + } +} + +/// The segments of a JBIG2 stream `encode_jbig2` wrote, each with its header: a header is the +/// segment's number (4 bytes), type, flags whose top three bits count the segments it refers to, +/// those (a byte each), its page (a byte) and its data's length (4 bytes; ISO/IEC 14492 7.2). +fn jbig2_segments(mut coded: &[u8]) -> Vec> { + let mut segments = Vec::new(); + while !coded.is_empty() { + let refers = usize::from(coded[5] >> 5); + let header = 4 + 1 + 1 + refers + 1 + 4; + let length = u32::from_be_bytes(coded[header - 4..header].try_into().unwrap()) as usize; + segments.push(coded[..header + length].to_vec()); + coded = &coded[header + length..]; + } + segments +} + +/// Two pages of a scan share one `/JBIG2Globals` stream holding the symbol dictionary, as +/// `jbig2enc -s` and most scanners write them, and the first has a mark found nowhere else (a +/// signature, say) under the area. The dictionary holds that mark's bitmap, so once the first page +/// is redacted the second page's image is stored again without the globals, and neither the +/// dictionary nor any stream naming globals stays in the saved file; both pages keep every pixel +/// outside the area. +#[test] +fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { + const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"]; + const SIGNATURE: [&str; 7] = ["#..#.##", ".##.#..", "#.#..##", "..##.#.", "##..#.#", ".#.##..", "#..#..#"]; + let (w, h) = (400usize, 40usize); + let stride = w.div_ceil(8); + let draw = |bits: &mut [u8], shape: &[&str], top: usize, left: usize| { + for (dy, row) in shape.iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (left + dx) / 8] &= !(0x80 >> ((left + dx) % 8)); + } + } + }; + // The letter over and over lower down, and the signature once, top left, under the mark. + let mut first = vec![0xFFu8; stride * h]; + for top in [14usize, 24, 32] { + for x in (3..w - 6).step_by(7) { + draw(&mut first, &LETTER, top, x); + } + } + draw(&mut first, &SIGNATURE, 2, 10); + // The second page is a scan's ragged text, which Group 4 or JBIG2 stores smaller than Flate. + let second = ragged_scan(w, h); + // The first page's symbol dictionary (its second segment) goes to the globals, on no page; the + // second page's segments are numbered past it. + let mut segments = jbig2_segments(&pdfcraft_filters::encode_jbig2(&first, w as u32, h as u32).unwrap()); + assert_eq!(segments[1][4], 0, "the second segment is the symbol dictionary"); + let mut dictionary = segments.remove(1); + dictionary[6] = 0; + let page_one: Vec = segments.concat(); + let page_two: Vec = jbig2_segments(&pdfcraft_filters::encode_jbig2_generic(&second, w as u32, h as u32).unwrap()) + .into_iter() + .enumerate() + .flat_map(|(k, mut s)| { + s[..4].copy_from_slice(&(100 + k as u32).to_be_bytes()); + s + }) + .collect(); + let image = format!( + "/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 9 0 R >>" + ); + // Half a point a pixel, the image's top row at y 120. + let page = |content: u32, im: u32| { + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents {content} 0 R /Resources << /XObject << /Im {im} 0 R >> >> >>") + .into_bytes() + }; + let build = |second_image: &str| { + pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + page(5, 7), + page(6, 8), + stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()), + stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()), + stream(&image, page_one.clone()), + stream(second_image, page_two.clone()), + stream("", dictionary.clone()), + ]) + }; + let mut doc = build(&image); + // A page's picture as it is drawn, JBIG2 read with its globals. + let picture = |doc: &Document, page: usize, name: &[u8]| { + let p = pdfcraft_model::pages(doc).swap_remove(page); + let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?; + let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?; + match &*doc.get(xo.get(name)?.as_ref()?) { + Object::Stream(s) => doc.decoded_image(s).ok(), + _ => None, + } + }; + assert_eq!(picture(&doc, 0, b"Im"), Some(first.clone()), "the first page reads its symbols from the globals"); + assert_eq!(picture(&doc, 1, b"Im"), Some(second.clone())); + // Columns 0 to 99 of rows 0 to 12. + mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed, report.images_restored), (1, 0, 1)); + let mut cleared = first.clone(); + for y in 0..=12 { + for x in 0..=99 { + cleared[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + let saved = reopen(&doc); + assert_eq!(picture(&saved, 0, b"PCRedacted1"), Some(cleared)); + assert_eq!(picture(&saved, 1, b"Im"), Some(second.clone()), "the second page keeps its picture"); + // Stored again as a scan is: Group 4 or JBIG2 when smaller than Flate. + let restored = named_stream(&saved, 1, b"Im").unwrap(); + let flate = pdfcraft_filters::encode_flate(&second); + assert_ne!(restored.dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert!(restored.raw.len() < flate.len(), "{:?} {} bytes, Flate {}", restored.dict.name(b"Filter"), restored.raw.len(), flate.len()); + // The dictionary's data (after its 11-byte header) codes the signature's bitmap. + let coded = &dictionary[11..]; + for (r, object) in saved.scan_objects() { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&object, &mut bytes); + assert!(!bytes.windows(b"JBIG2Globals".len()).any(|w| w == b"JBIG2Globals"), "{r:?} still names globals"); + let data = match &*object { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }; + assert!(!bytes.windows(coded.len()).chain(data.windows(coded.len())).any(|w| w == coded), "the symbol dictionary stays as {r:?}"); + } + // The second page's image can't be read for certain (its JBIG2 page is shorter than the + // image), so it can't be stored without the globals: the redaction is refused. + let mut doc = build(&image.replace(&format!("/Height {h}"), &format!("/Height {}", h + 1))); + mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. @@ -2588,7 +2946,8 @@ fn assert_no_secret(doc: &Document, secret: &[u8]) { pdfcraft_cos::serialize(&object, &mut bytes); assert!(!bytes.windows(secret.len()).any(|w| w == secret), "secret remains in object {r:?}"); if let Object::Stream(s) = &*object { - let data = s.decoded().unwrap(); + // Image codecs, a cleared scan coded as Group 4 or JBIG2, decode through the document. + let data = s.decoded().or_else(|_| saved.decoded_image(s)).unwrap(); assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}"); } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 4de70f3d9e..43d0066e63 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -88,6 +88,7 @@ impl PdfCraftApp { space_hand: None, full_screen_tool: None, comment_prefs: Default::default(), + hotbar: Default::default(), theme: ThemeKind::Light, // A fresh install follows the desktop's appearance (#1248); saved settings override. theme_preference: ThemePreference::System, diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 26e20f8df5..e4078efd13 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -16,6 +16,18 @@ impl PdfCraftApp { pub fn set_option(&mut self, key: &str, value: &str) -> Result<(), String> { let view = self.active.and_then(|i| self.views.get_mut(i)); match (key, view) { + ("hotbar-size", _) => { + let size: f32 = value.parse().map_err(|_| "hotbar-size must be 24–64 pixels")?; + if !size.is_finite() || !(24.0..=64.0).contains(&size) { + return Err("hotbar-size must be 24–64 pixels".into()); + } + self.hotbar.button_size = size; + } + ("hotbar-full-side", _) => { + self.hotbar.full_side = value.parse().map_err(|_| "hotbar-full-side must be true or false")?; + } + ("hotbar-tools", _) => self.hotbar.set_tools(value)?, + ("hotbar-side", _) => self.hotbar.place(value)?, ("language", _) => { let language = i18n::normalize_pref(value).ok_or_else(|| { let codes: Vec<&str> = std::iter::once(i18n::AUTO).chain(i18n::Lang::all().map(i18n::Lang::code)).collect(); @@ -217,6 +229,13 @@ impl PdfCraftApp { _ => return Err("tools-side must be left or right".into()), } } + ("rail-side", _) => { + self.side_panels.rail_side = match value { + "left" => side_panels::ToolsSide::Left, + "right" => side_panels::ToolsSide::Right, + _ => return Err("rail-side must be left or right".into()), + } + } ("organize", Some(v)) => { if value == "off" { v.organize = false; diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index c2a7de31dc..7bd8943774 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -146,6 +146,7 @@ impl PdfCraftApp { // 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); v["show_page_edge_shadows"] = self.show_page_edge_shadows.into(); + v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default(); v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["organize_zoom"] = serde_json::json!(self.organize_zoom); @@ -157,6 +158,10 @@ impl PdfCraftApp { /// Restore state written by `persist`. Unknown or malformed data is ignored. pub fn restore(&mut self, json: &str) { let Ok(v) = serde_json::from_str::(json) else { return }; + if let Ok(mut bar) = serde_json::from_value::(v["hotbar"].clone()) { + bar.normalize(); + self.hotbar = bar; + } let panels = side_panels::restore(&v); self.side_panels = panels.panels; if let Some(open) = panels.tools_open { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7a0976644b..a220644421 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4056,6 +4056,7 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, // the quick tools come back with the window. if app.mode != crate::Mode::Read && !app.full_screen { quick_bar(app, avail, ui); + crate::hotbar::show(app, avail, ui.ctx()); } if let Some((source, file, destination)) = remote_open { app.open_remote_link(source, file, destination); diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 2472366672..4ad2bf05db 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -279,7 +279,15 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te /// The floating quick-action bar at the left edge of the document area. pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let pos = area.left_top() + vec2(14.0, 14.0); + let mut offset = vec2(0.0, 0.0); + if app.hotbar.full_side { + match app.hotbar.dock.as_deref() { + Some("left") => offset.x += app.hotbar.button_size + 14.0, + Some("top") => offset.y += app.hotbar.button_size + 14.0, + _ => {} + } + } + let pos = area.left_top() + vec2(14.0, 14.0) + offset; egui::Area::new(egui::Id::unique("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.card) @@ -302,6 +310,12 @@ pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let current = app.comment_prefs.group_tool[g]; let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g); let resp = icons::button(ui, current.icon(), 32.0, active, tl!(current.label())); + if comments::GROUPS[g].len() == 1 { + if resp.clicked() { + app.execute(current.command()); + } + continue; + } // A small corner triangle marks the flyout. let r = resp.rect; let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index aa6233c133..59525aae7b 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1,6 +1,6 @@ //! Window chrome: tab strip (with the integrated macOS title bar), mode bar, right rail. -use craft_ui::panes::{Edge, Rail, RailItem, RailRequest}; +use craft_ui::panes::{Rail, RailItem, RailRequest}; use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; use crate::canvas::{DocView, Fit, PageLayout}; @@ -572,13 +572,18 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let view = &mut app.views[index]; let mut page_display = None; let rail = Rail::new(egui::Id::unique("rail")) - .edge(Edge::End) + .edge(app.side_panels.rail_edge()) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider))) .show(ui, &items, app.right.as_ref(), |ui| { // Page navigation cluster at the bottom, a panel of its own so that in a short // window the buttons above scroll instead of running into it (#3282). ui.with_layout(Layout::bottom_up(Align::Center), |ui| { ui.spacing_mut().item_spacing.y = 2.0; + let right = app.side_panels.rail_on_right(); + let dock_tip = if right { tl!("Dock navigation rail on Left") } else { tl!("Dock navigation rail on Right") }; + if icons::button(ui, if right { "panel-left" } else { "panel-right" }, 32.0, false, dock_tip).clicked() { + app.side_panels.rail_side = if right { crate::side_panels::ToolsSide::Left } else { crate::side_panels::ToolsSide::Right }; + } let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { view.zoom_step(false); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 08d2040e23..f072b7febe 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -259,6 +259,8 @@ impl PdfCraftApp { self.notify(error); } } + "view.select_tool" => self.quick_tool = crate::QuickTool::Select, + "view.hand_tool" => self.quick_tool = crate::QuickTool::Hand, "view.side_panel" => self.toggle_side_panel(), "view.tools_pane" => self.left_open = !self.left_open, "view.focus_page_input" => { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c3c730ed68..44e5481a11 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -39,6 +39,11 @@ //! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference` //! reports the choice and `ui.state.theme` reports the resolved light/dark colours. //! `comment-tool-pinned=true|false` remembers the annotation quick bar's pin across restarts; +//! `hotbar-tools` accepts comma-separated command IDs (empty clears the bar), and +//! `hotbar-side=float|left|right|top|bottom` floats or docks the customizable hotbar; +//! `hotbar-full-side=true|false` extends a docked bar along the entire side. +//! `hotbar-size=24..64` controls its button size in logical pixels; `ui.state.hotbar` +//! reports its tools, relative position, orientation and strip preference. These options persist across restarts. //! `ui.state.comment_tool_pinned` reports the same preference. //! - `ui.open {path}`: open a file. A file whose tab is already open focuses it //! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 64b10b69fb..b803f2738c 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -133,9 +133,11 @@ impl Host for crate::PdfCraftApp { "home": self.active.is_none(), "mode": format!("{:?}", self.mode), "default_mode": self.default_mode, + "hotbar": self.hotbar, "comment_tool_pinned": self.comment_prefs.pinned, "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, + "rail_side": if self.side_panels.rail_on_right() { "right" } else { "left" }, "right_panel": self.right.map(|r| format!("{r:?}")), "dialog": self.dialog.map(|d| format!("{d:?}")), "palette_open": self.palette_open, diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 4702e63f9e..35ce510ae7 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -240,7 +240,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 }; if let Some(w) = e.width.as_mut() { ui.label(tl!("Thickness")); - ui.add(egui::Slider::new(w, min_width..=12.0).step_by(0.5).suffix(" pt")); + ui.add(egui::Slider::new(w, min_width..=crate::comments::MAX_LINE_WIDTH).step_by(0.5).suffix(" pt")); ui.end_row(); } if let Some(ends) = e.endings.as_mut() { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index d4520a15b4..b810e2abe5 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -52,8 +52,11 @@ fn select_after_edit( // A stroke drawn with the pen stays unselected, so the selection box and // author popup don't sit over the next stroke (#429). Edit::AddAnnotation(a) - if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. }) - && quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) => + if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. } | pdfcraft_engine::Shape::FreehandHighlight { .. }) + && matches!( + quick_tool, + crate::QuickTool::Comment(crate::comments::CommentTool::Ink | crate::comments::CommentTool::FreehandHighlight) + ) => { view.comments.selected = None; } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs new file mode 100644 index 0000000000..f6a0c14c7f --- /dev/null +++ b/crates/ui-egui/src/hotbar.rs @@ -0,0 +1,372 @@ +//! A movable, persistent set of shortcuts to the shared command registry. +use egui::{Rect, Sense, vec2}; +use pdfcraft_engine::commands::{COMMANDS, command}; +use serde::{Deserialize, Serialize}; + +use crate::{PdfCraftApp, icons, theme::Tokens}; + +/// Persistent state and UI state for the customizable document hotbar. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub(crate) struct Hotbar { + pub tools: Vec, + /// Position as a fraction of the available travel, so resizing keeps the bar visible. + pub position: [f32; 2], + pub vertical: bool, + pub button_size: f32, + pub dock: Option, + pub full_side: bool, + #[serde(skip)] + search: String, + #[serde(skip)] + picker_open: bool, +} + +impl Default for Hotbar { + /// Create a default empty horizontal floating hotbar. + fn default() -> Self { + Self { + tools: Vec::new(), + position: [0.5, 0.0], + vertical: false, + button_size: 32.0, + dock: None, + full_side: false, + search: String::new(), + picker_open: false, + } + } +} + +impl Hotbar { + /// Clamp button size and normalized position coordinates, drop unknown docked sides, + /// and deduplicate/filter tool command identifiers against the command registry. + pub fn normalize(&mut self) { + self.button_size = if self.button_size.is_finite() { self.button_size.clamp(24.0, 64.0) } else { 32.0 }; + if self.dock.as_deref().is_some_and(|side| !matches!(side, "left" | "right" | "top" | "bottom")) { + self.dock = None; + } + let mut seen = std::collections::HashSet::new(); + self.tools.retain(|id| command(id).is_some() && seen.insert(id.clone())); + for coordinate in &mut self.position { + *coordinate = if coordinate.is_finite() { coordinate.clamp(0.0, 1.0) } else { 0.5 }; + } + } + + /// Parse and validate a comma-separated list of command identifiers for the hotbar. + pub fn set_tools(&mut self, value: &str) -> Result<(), String> { + let tools: Vec = value.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect(); + if let Some(id) = tools.iter().find(|id| command(id).is_none()) { + return Err(format!("Unknown hotbar command: {id}")); + } + self.tools = tools; + self.normalize(); + Ok(()) + } + + /// Dock the hotbar to `"left"`, `"right"`, `"top"`, or `"bottom"`, or undock with `"float"`. + pub fn place(&mut self, side: &str) -> Result<(), String> { + if side == "float" { + self.dock = None; + return Ok(()); + } + let (position, vertical) = match side { + "left" => ([0.0, 0.5], true), + "right" => ([1.0, 0.5], true), + "top" => ([0.5, 0.0], false), + "bottom" => ([0.5, 1.0], false), + _ => return Err("hotbar-side must be float, left, right, top or bottom".into()), + }; + self.dock = Some(side.to_owned()); + self.position = position; + self.vertical = vertical; + Ok(()) + } +} + +/// Render the hotbar settings gear button and open the tool picker when clicked. +fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) { + let settings = ui + .push_id("hotbar-settings", |ui| { + icons::button(ui, "settings", cell, false, tl!("Hotbar settings")).on_hover_text(tl!("Hotbar settings / add tools")) + }) + .inner; + if settings.clicked() { + *picker_open = true; + } +} + +/// Whether `spec` matches the hotbar picker search query across the translated label, canonical +/// English label, or command identifier (case-insensitively). +fn matches_search(spec: &pdfcraft_engine::commands::CommandSpec, query: &str) -> bool { + let search = query.to_lowercase(); + tl!(spec.label).to_lowercase().contains(&search) || spec.label.to_lowercase().contains(&search) || spec.id.to_lowercase().contains(&search) +} + +/// Render the configured tool shortcut buttons and their removal context menus. +fn tool_buttons(ui: &mut egui::Ui, app: &PdfCraftApp, tools: &[String], cell: f32, run: &mut Option<&'static str>, remove: &mut Option) { + for (index, id) in tools.iter().enumerate() { + let Some(spec) = command(id) else { continue }; + let active = crate::comments::CommentTool::from_command(id).is_some_and(|tool| app.quick_tool == crate::QuickTool::Comment(tool)); + let button = ui.add_enabled_ui(app.command_enabled(spec), |ui| icons::button(ui, spec.icon, cell, active, tl!(spec.label))).inner; + if button.clicked() { + *run = Some(spec.id); + } + button.context_menu(|ui| { + if ui.button(tl!("Remove from hotbar")).clicked() { + *remove = Some(index); + ui.close(); + } + }); + } +} + +/// Draw the floating or side-docked hotbar and its tool picker window inside `area`. +pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) { + let mut bar = std::mem::take(&mut app.hotbar); + let t = Tokens::get(ctx); + let id = egui::Id::unique("custom-hotbar"); + let full_side = bar.full_side && bar.dock.is_some(); + let bounds = if full_side { area } else { area.shrink(10.0) }; + if let Some(side) = bar.dock.clone() { + let _ = bar.place(&side); + } + let cell = bar.button_size; + let mut size = ctx.data_mut(|data| data.get_temp::(id)).unwrap_or(vec2(90.0, 44.0)); + if full_side { + size = if bar.vertical { vec2(cell + 12.0, bounds.height()) } else { vec2(bounds.width(), cell + 12.0) }; + } + let travel = (bounds.size() - size).max(vec2(0.0, 0.0)); + let pos = bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1]); + let mut run = None; + let mut picker_open = bar.picker_open; + let shown = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).constrain_to(bounds).show(ctx, |ui| { + let frame = if full_side { + egui::Frame::NONE.fill(t.chrome).corner_radius(egui::CornerRadius::ZERO).inner_margin(6) + } else { + egui::Frame::new().fill(t.card).stroke(egui::Stroke::new(1.0, t.border)).corner_radius(10).inner_margin(6) + }; + let frame_resp = frame.show(ui, |ui| { + if full_side { + ui.set_min_size((size - vec2(12.0, 12.0)).max(vec2(0.0, 0.0))); + } + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + if bar.vertical { + ui.set_width(cell); + } else { + ui.set_height(cell); + } + let mut remove = None; + if full_side { + if bar.vertical { + ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { + settings_button(ui, cell, &mut picker_open); + ui.add_space(4.0); + let remaining = ui.available_height().max(cell); + egui::ScrollArea::vertical().max_width(cell).max_height(remaining).show(ui, |ui| { + ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + }); + } else { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + settings_button(ui, cell, &mut picker_open); + ui.add_space(4.0); + let remaining = ui.available_width().max(cell); + egui::ScrollArea::horizontal().max_height(cell).max_width(remaining).show(ui, |ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + }); + } + } else { + let layout = + if bar.vertical { egui::Layout::top_down(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }; + ui.with_layout(layout, |ui| { + let grip_size = if bar.vertical { vec2(cell, 18.0) } else { vec2(18.0, cell) }; + let (grip_rect, grip) = ui.allocate_exact_size(grip_size, Sense::drag()); + for x in [-3.0, 3.0] { + for y in [-6.0, 0.0, 6.0] { + let offset = if bar.vertical { vec2(y, x) } else { vec2(x, y) }; + ui.painter().circle_filled(grip_rect.center() + offset, 1.5, t.text_faint); + } + } + let drag_label = tl!("Drag hotbar"); + let grip = grip.on_hover_text(drag_label).on_hover_cursor(egui::CursorIcon::Grab); + grip.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, drag_label)); + if grip.dragged() && bar.dock.is_none() { + let delta = grip.drag_delta(); + bar.position[0] = (bar.position[0] + delta.x / travel.x.max(1.0)).clamp(0.0, 1.0); + bar.position[1] = (bar.position[1] + delta.y / travel.y.max(1.0)).clamp(0.0, 1.0); + } + let scroll_max = if bar.vertical { (bounds.height() - cell - 50.0).max(cell) } else { (bounds.width() - cell - 50.0).max(cell) }; + let scroll = if bar.vertical { + egui::ScrollArea::vertical().max_width(cell).max_height(scroll_max) + } else { + egui::ScrollArea::horizontal().max_height(cell).max_width(scroll_max) + }; + scroll.auto_shrink([true, true]).show(ui, |ui| { + ui.with_layout(layout, |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + settings_button(ui, cell, &mut picker_open); + }); + } + if let Some(index) = remove { + bar.tools.remove(index); + } + }); + if full_side { + let r = frame_resp.response.rect; + let divider = egui::Stroke::new(1.0, t.divider); + match bar.dock.as_deref() { + Some("left") => { + ui.painter().vline(r.right() - 0.5, r.top()..=r.bottom(), divider); + } + Some("right") => { + ui.painter().vline(r.left() + 0.5, r.top()..=r.bottom(), divider); + } + Some("top") => { + ui.painter().hline(r.left()..=r.right(), r.bottom() - 0.5, divider); + } + Some("bottom") => { + ui.painter().hline(r.left()..=r.right(), r.top() + 0.5, divider); + } + _ => {} + } + } + }); + let picker_id = egui::Id::unique("hotbar-picker"); + // A remembered collapsed state can survive disabling the collapse affordance. + let mut expanded = egui::collapsing_header::CollapsingState::load_with_default_open(ctx, picker_id.with("collapsing"), true); + expanded.set_open(true); + expanded.store(ctx); + let screen = ctx.content_rect().shrink(12.0); + let picker_x = if pos.x + cell + 380.0 < screen.right() { pos.x + cell + 20.0 } else { pos.x - 360.0 }; + let picker_pos = egui::pos2( + picker_x.clamp(screen.left(), (screen.right() - 350.0).max(screen.left())), + pos.y.clamp(screen.top(), (screen.bottom() - 640.0).max(screen.top())), + ); + egui::Window::new(tl!("Hotbar tools")) + .id(picker_id) + .order(egui::Order::Foreground) + .open(&mut picker_open) + .collapsible(false) + .resizable(false) + .fixed_pos(picker_pos) + .max_height((screen.height() - 24.0).max(180.0)) + .vscroll(true) + .show(ctx, |ui| { + ui.set_min_width(260.0); + ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).text(tl!("Toolbar size")).suffix(" px")); + ui.add_enabled(bar.dock.is_none(), crate::widgets::checkbox(&mut bar.vertical, tl!("Vertical layout"))); + if ui.selectable_label(bar.dock.is_none(), tl!("Float (drag anywhere)")).clicked() { + let _ = bar.place("float"); + } + ui.label(tl!("Stick to a side:")); + ui.horizontal(|ui| { + for (label, side) in [(tl!("Left"), "left"), (tl!("Right"), "right"), (tl!("Top"), "top"), (tl!("Bottom"), "bottom")] { + if ui.selectable_label(bar.dock.as_deref() == Some(side), label).clicked() { + let _ = bar.place(side); + } + } + }); + let strip_hint = tl!("Extend the docked toolbar along the entire side. Choose a side above to enable."); + ui.add_enabled(bar.dock.is_some(), crate::widgets::checkbox(&mut bar.full_side, tl!("Full-side strip"))) + .on_hover_text(strip_hint) + .on_disabled_hover_text(strip_hint); + if ui.button(tl!("Close menu")).clicked() { + ui.close(); + } + ui.separator(); + ui.label(tl!("Add tools to hotbar")); + ui.add(egui::TextEdit::singleline(&mut bar.search).hint_text(tl!("Search all tools…"))); + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for spec in COMMANDS { + if !matches_search(spec, &bar.search) { + continue; + } + let mut selected = bar.tools.iter().any(|id| id == spec.id); + if ui.add(crate::widgets::checkbox(&mut selected, tl!(spec.label))).changed() { + if selected { + bar.tools.push(spec.id.to_owned()); + } else { + bar.tools.retain(|id| id != spec.id); + } + } + } + }); + ui.separator(); + }); + ctx.data_mut(|data| data.insert_temp(id, shown.response.rect.size())); + bar.picker_open = picker_open; + app.hotbar = bar; + if let Some(id) = run { + app.execute(id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hotbar_validation_and_placement() { + let mut bar = Hotbar::default(); + assert!(bar.tools.is_empty()); + bar.set_tools("comment.ink,comment.freehand_highlight,comment.ink").unwrap(); + assert_eq!(bar.tools.len(), 2); + assert!(bar.set_tools("does.not.exist").is_err()); + assert_eq!(bar.tools.len(), 2); + bar.place("right").unwrap(); + assert!(bar.vertical); + assert_eq!(bar.position, [1.0, 0.5]); + bar.place("bottom").unwrap(); + assert!(!bar.vertical); + assert_eq!(bar.position, [0.5, 1.0]); + assert_eq!(bar.dock.as_deref(), Some("bottom")); + bar.place("float").unwrap(); + assert!(bar.dock.is_none()); + } + + #[test] + fn hotbar_preferences_round_trip() { + let mut app = PdfCraftApp::new(); + app.set_option("hotbar-tools", "comment.freehand_highlight,comment.ink").unwrap(); + app.set_option("hotbar-side", "left").unwrap(); + app.set_option("hotbar-size", "48").unwrap(); + app.set_option("hotbar-full-side", "true").unwrap(); + assert!(app.set_option("hotbar-size", "NaN").is_err()); + let mut restored = PdfCraftApp::new(); + restored.restore(&app.persist()); + assert_eq!(restored.hotbar.tools, app.hotbar.tools); + assert_eq!(restored.hotbar.position, [0.0, 0.5]); + assert!(restored.hotbar.vertical); + assert_eq!(restored.hotbar.button_size, 48.0); + assert!(restored.hotbar.full_side); + assert_eq!(restored.hotbar.dock.as_deref(), Some("left")); + } + + #[test] + fn hotbar_restore_filters_unknown_tools_and_offscreen_positions() { + let mut app = PdfCraftApp::new(); + app.restore(r#"{"hotbar":{"tools":["invalid","comment.ink","comment.ink"],"position":[-10,999]}}"#); + assert_eq!(app.hotbar.tools, ["comment.ink"]); + assert_eq!(app.hotbar.position, [0.0, 1.0]); + } + + #[test] + fn hotbar_search_matches_translated_and_canonical_labels() { + let ink = command("comment.ink").unwrap(); + crate::i18n::set_current(crate::i18n::lang_from_tag("fr").unwrap()); + assert_eq!(tl!(ink.label), "Dessiner à main levée"); + assert!(matches_search(ink, "dessiner"), "translated French label must match"); + assert!(matches_search(ink, "freehand"), "canonical English label must still match"); + assert!(matches_search(ink, "comment.ink"), "command ID must still match"); + assert!(!matches_search(ink, "introuvable")); + crate::i18n::set_current(crate::i18n::Lang::EN); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ab5d1334e3..fe0138f27f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -56,6 +56,7 @@ mod dialogs; pub use edit_text_ui::{CropMode, DragTarget, ImageDrag, ImageHandle, ImageSelection}; mod editing; mod home; +mod hotbar; mod panels; pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; @@ -294,6 +295,7 @@ pub struct PdfCraftApp { full_screen_tool: Option, /// Comment author, per-tool colours and widths, pin. pub comment_prefs: comments::CommentPrefs, + pub(crate) hotbar: hotbar::Hotbar, /// Resolved colours, including the current OS theme when following the system. pub theme: ThemeKind, pub theme_preference: ThemePreference, diff --git a/crates/ui-egui/src/panels/side_panels.rs b/crates/ui-egui/src/panels/side_panels.rs index 71c3baa648..447aeac9fb 100644 --- a/crates/ui-egui/src/panels/side_panels.rs +++ b/crates/ui-egui/src/panels/side_panels.rs @@ -65,13 +65,14 @@ pub struct SidePanels { /// The one setting that places the Tools pane; [`SidePanels::tools_edge`] is its only reader /// in the layout. pub tools_side: ToolsSide, + pub rail_side: ToolsSide, pub tools_width: f32, pub side_width: f32, } impl Default for SidePanels { fn default() -> Self { - Self { tools_side: ToolsSide::Left, tools_width: TOOLS_WIDTH, side_width: SIDE_WIDTH } + Self { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, tools_width: TOOLS_WIDTH, side_width: SIDE_WIDTH } } } @@ -103,6 +104,19 @@ impl SidePanels { } } + /// Whether the navigation rail is docked on the right side of the window. + pub fn rail_on_right(&self) -> bool { + self.rail_side == ToolsSide::Right + } + + /// Return the layout [`Edge`] (`Start` or `End`) where the navigation rail and side panel dock. + pub fn rail_edge(&self) -> Edge { + match self.rail_side { + ToolsSide::Left => Edge::Start, + ToolsSide::Right => Edge::End, + } + } + pub fn width(&self, pane: Pane) -> f32 { match pane { Pane::Tools => self.tools_width, @@ -123,6 +137,7 @@ impl SidePanels { json!({ "version": VERSION, "tools_side": self.tools_side, + "rail_side": self.rail_side, "tools_open": tools_open, "tools_width": self.tools_width, "side_width": self.side_width, @@ -143,6 +158,9 @@ pub fn restore(saved: &Value) -> Restored { if let Some(side) = value.get("tools_side").and_then(|v| serde_json::from_value::(v.clone()).ok()) { panels.tools_side = side; } + if let Some(side) = value.get("rail_side").and_then(|v| serde_json::from_value::(v.clone()).ok()) { + panels.rail_side = side; + } if let Some(width) = value.get("tools_width").and_then(Value::as_f64).and_then(|w| clamp_width(Pane::Tools, w)) { panels.tools_width = width; } @@ -346,10 +364,11 @@ impl PaneContent for Content<'_> { } } +/// Return the docked [`PaneState`] (edge and width) for `pane`. fn state_of(panels: &SidePanels, pane: Pane) -> PaneState { let edge = match pane { Pane::Tools => panels.tools_edge(), - Pane::Side => Edge::End, + Pane::Side => panels.rail_edge(), }; PaneState::docked(edge, panels.width(pane)) } @@ -434,7 +453,7 @@ mod tests { #[test] fn docked_default_keeps_widths() { let restored = restore(&docked(300.0, 400.0)); - assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: 300.0, side_width: 400.0 }); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, tools_width: 300.0, side_width: 400.0 }); assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); } @@ -448,6 +467,7 @@ mod tests { ); let restored = restore(&old); assert_eq!(restored.panels.tools_side, ToolsSide::Right); + assert_eq!(restored.panels.rail_side, ToolsSide::Right); assert_eq!(restored.panels.tools_width, 290.0); // The Inspector's own split fixes Tools' width, not its. assert_eq!(restored.panels.side_width, SIDE_WIDTH); @@ -461,7 +481,10 @@ mod tests { json!([]), ); let restored = restore(&old); - assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: TOOLS_WIDTH, side_width: 360.0 }); + assert_eq!( + restored.panels, + SidePanels { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, tools_width: TOOLS_WIDTH, side_width: 360.0 } + ); assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); } @@ -470,6 +493,7 @@ mod tests { let old = workspace(split(json!({"FixedSecond": 350.0}), tabs(&["canvas"]), tabs(&["inspector", "tools"])), json!([]), json!([])); let restored = restore(&old); assert_eq!(restored.panels.tools_side, ToolsSide::Left); + assert_eq!(restored.panels.rail_side, ToolsSide::Right); assert_eq!((restored.panels.tools_width, restored.panels.side_width), (TOOLS_WIDTH, SIDE_WIDTH)); assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); } @@ -485,7 +509,7 @@ mod tests { ]), ); let restored = restore(&old); - assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 }); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, rail_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 }); assert_eq!((restored.tools_open, restored.side_closed), (Some(false), true)); } @@ -504,7 +528,10 @@ mod tests { } // Non-finite and out-of-range widths fall back or clamp; an unknown side keeps Left. let restored = restore(&json!({"side_panels": {"version": 1, "tools_side": "up", "tools_width": 9000.0, "side_width": "x"}})); - assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: 520.0, side_width: SIDE_WIDTH }); + assert_eq!( + restored.panels, + SidePanels { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, tools_width: 520.0, side_width: SIDE_WIDTH } + ); // Deep trees are cut off rather than walked. let mut deep = tabs(&["tools"]); for _ in 0..200 { @@ -515,8 +542,19 @@ mod tests { #[test] fn saved_value_round_trips() { - let panels = SidePanels { tools_side: ToolsSide::Right, tools_width: 300.0, side_width: 410.0 }; + let panels = SidePanels { tools_side: ToolsSide::Right, rail_side: ToolsSide::Left, tools_width: 300.0, side_width: 410.0 }; let restored = restore(&json!({ "side_panels": panels.to_json(false) })); assert_eq!(restored, Restored { panels, tools_open: Some(false), side_closed: false }); } + + #[test] + fn rail_side_placement_and_restore() { + let mut panels = SidePanels::default(); + assert_eq!(panels.rail_side, ToolsSide::Right); + assert_eq!(panels.rail_edge(), Edge::End); + panels.rail_side = ToolsSide::Left; + assert_eq!(panels.rail_edge(), Edge::Start); + let restored = restore(&json!({"side_panels": {"version": 1, "rail_side": "left"}})); + assert_eq!(restored.panels.rail_side, ToolsSide::Left); + } } diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index e9956e5f95..cb8bafe642 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -54,6 +54,9 @@ pub(crate) use state::{EditorFocus, PageCx}; /// Acrobat's selection blue for annotation frames and handles (11-visual-spec §5). const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +/// Maximum stroke thickness offered by commenting controls, in PDF points. +pub const MAX_LINE_WIDTH: f64 = 100.0; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum CommentTool { Note, @@ -63,6 +66,7 @@ pub enum CommentTool { StrikeOut, Squiggly, Ink, + FreehandHighlight, Line, Arrow, Rectangle, @@ -80,7 +84,15 @@ pub enum CommentTool { /// The quick-bar flyout groups, in Acrobat's order: Comment ▸, Highlight ▸, Draw ▸. pub const GROUPS: [&[CommentTool]; 3] = [ &[CommentTool::Note, CommentTool::TextBox, CommentTool::Callout, CommentTool::Attach], - &[CommentTool::Highlight, CommentTool::Underline, CommentTool::StrikeOut, CommentTool::Squiggly, CommentTool::Caret, CommentTool::ReplaceText], + &[ + CommentTool::Highlight, + CommentTool::FreehandHighlight, + CommentTool::Underline, + CommentTool::StrikeOut, + CommentTool::Squiggly, + CommentTool::Caret, + CommentTool::ReplaceText, + ], &[ CommentTool::Ink, CommentTool::Line, @@ -94,7 +106,8 @@ pub const GROUPS: [&[CommentTool]; 3] = [ ], ]; -pub const ALL: [CommentTool; 19] = [ +pub const ALL: [CommentTool; 20] = [ + CommentTool::FreehandHighlight, CommentTool::Eraser, CommentTool::ReplaceText, CommentTool::Attach, @@ -117,6 +130,7 @@ pub const ALL: [CommentTool; 19] = [ ]; impl CommentTool { + /// The command registry identifier (`"comment.*"`) corresponding to this tool. pub fn command(self) -> &'static str { match self { Self::Note => "comment.note", @@ -125,6 +139,7 @@ impl CommentTool { Self::Underline => "comment.underline", Self::StrikeOut => "comment.strikeout", Self::Squiggly => "comment.squiggly", + Self::FreehandHighlight => "comment.freehand_highlight", Self::Ink => "comment.ink", Self::Line => "comment.line", Self::Arrow => "comment.arrow", @@ -141,10 +156,12 @@ impl CommentTool { } } + /// Resolve a [`CommentTool`] from its command registry identifier. pub fn from_command(id: &str) -> Option { ALL.into_iter().find(|t| t.command() == id) } + /// User-facing label for this commenting tool. pub fn label(self) -> &'static str { match self { Self::Note => "Add a sticky note", @@ -153,6 +170,7 @@ impl CommentTool { Self::Underline => "Underline", Self::StrikeOut => "Strikethrough", Self::Squiggly => "Squiggly underline", + Self::FreehandHighlight => "Freehand-Highlight", Self::Ink => "Draw", Self::Line => "Line", Self::Arrow => "Arrow", @@ -169,6 +187,7 @@ impl CommentTool { } } + /// Lucide icon name for this commenting tool. pub fn icon(self) -> &'static str { match self { Self::Note => "message-square-plus", @@ -177,6 +196,7 @@ impl CommentTool { Self::Underline => "underline", Self::StrikeOut => "strikethrough", Self::Squiggly => "spline", + Self::FreehandHighlight => "highlighter", Self::Ink => "pencil", Self::Line => "minus", Self::Arrow => "move-right", @@ -209,7 +229,7 @@ impl CommentTool { /// Tools that draw with a drag gesture. pub fn draws(self) -> bool { - matches!(self, Self::Ink | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval | Self::Eraser) + matches!(self, Self::Ink | Self::FreehandHighlight | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval | Self::Eraser) } /// Tools that place points one click at a time (double-click or Enter finishes). @@ -240,6 +260,7 @@ impl CommentTool { Self::Highlight | Self::Underline | Self::StrikeOut | Self::Squiggly => { Shape::TextMarkup { kind: self.markup().unwrap_or(Markup::Highlight), quads: Vec::new() } } + Self::FreehandHighlight => Shape::FreehandHighlight { strokes: Vec::new() }, Self::Ink => Shape::Ink { strokes: Vec::new() }, Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::None }, Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::OpenArrow }, @@ -318,9 +339,10 @@ impl CommentPrefs { } } + /// Clamp and store the stroke width (`tool.min_width()..=MAX_LINE_WIDTH`) for `tool`. pub fn set_width(&mut self, tool: CommentTool, w: f64) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { - s.width = w.clamp(tool.min_width(), 12.0); + s.width = if w.is_finite() { w.clamp(tool.min_width(), MAX_LINE_WIDTH) } else { s.width }; } } @@ -381,6 +403,7 @@ pub fn tool_for(a: &Annotation) -> Option { ("StrikeOut", Some("StrikeOutTextEdit")) => CommentTool::ReplaceText, ("StrikeOut", _) => CommentTool::StrikeOut, ("Squiggly", _) => CommentTool::Squiggly, + ("Ink", Some("FreehandHighlight")) => CommentTool::FreehandHighlight, ("Ink", _) => CommentTool::Ink, ("Line", _) => CommentTool::Line, ("Square", _) => CommentTool::Rectangle, diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 5e74fdf56e..a7ef6c92e1 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -140,7 +140,7 @@ fn on_link(cx: &PageCx<'_>, p: Pos2) -> bool { /// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a /// comment is moved, resized or drawn. pub(crate) fn describe(cx: &PageCx<'_>, view: &mut DocView) { - let freehand = matches!(cx.tool, QuickTool::Comment(CommentTool::Ink | CommentTool::Eraser)); + let freehand = matches!(cx.tool, QuickTool::Comment(CommentTool::Ink | CommentTool::FreehandHighlight | CommentTool::Eraser)); if matches!(cx.tool, QuickTool::Fill(_)) || freehand || view.comments.gesture.is_some() { return; } @@ -343,7 +343,7 @@ impl Gestures<'_> { { let p = clamp_to(page_rect, p); let u = cx.to_user(p); - if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { + if matches!(*tool, CommentTool::Ink | CommentTool::FreehandHighlight | CommentTool::Eraser) { let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); if last.distance(p) >= 1.5 { points.push(u); @@ -369,7 +369,7 @@ impl Gestures<'_> { // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a // single PDF point is visible and remains editable like any other drawing. - if tool == CommentTool::Ink + if matches!(tool, CommentTool::Ink | CommentTool::FreehandHighlight) && resp.clicked() && over_page && let Some(p) = pointer diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 52ea8d707d..b577fbbd29 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -330,7 +330,7 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m ui.set_min_width(180.0); let mut w = style.width; ui.label(egui::RichText::new(tl!("Line thickness")).font(theme::semibold(12.0))); - if ui.add(egui::Slider::new(&mut w, tool.min_width()..=12.0).step_by(0.5).suffix(" pt")).changed() { + if ui.add(egui::Slider::new(&mut w, tool.min_width()..=super::MAX_LINE_WIDTH).step_by(0.5).suffix(" pt")).changed() { prefs.set_width(tool, w); } }); diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index bfcd67982b..59b5ca773a 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -127,10 +127,10 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin { let style = cx.prefs.style(*tool); let zoom = cx.xf.zoom(); - let stroke = Stroke::new((style.width as f32 * zoom).max(1.0), color32(style.color)); + let stroke = Stroke::new((style.width as f32 * zoom).max(1.0), color32(style.color).gamma_multiply(style.opacity as f32)); let pts: Vec = points.iter().map(|p| cx.to_screen(*p)).collect(); match tool { - CommentTool::Ink => { + CommentTool::Ink | CommentTool::FreehandHighlight => { painter.add(egui::Shape::line(pts, stroke)); } CommentTool::Eraser => { @@ -186,6 +186,7 @@ pub(super) fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option= 2.0 && rect[3] - rect[1] >= 2.0; match tool { + CommentTool::FreehandHighlight if !points.is_empty() => Some(Shape::FreehandHighlight { strokes: vec![points.to_vec()] }), CommentTool::Ink if !points.is_empty() => Some(Shape::Ink { strokes: vec![points.to_vec()] }), CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line { from: first, @@ -209,7 +210,7 @@ pub(super) fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option f64 { - 3.0 * if width.is_finite() { width.clamp(0.5, 12.0) } else { 2.0 } + 3.0 * if width.is_finite() { width.clamp(0.5, super::MAX_LINE_WIDTH) } else { 2.0 } } /// Eraser: rub out drawings along `path` (user space); one undo step for all of them. It rubs diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index ab24352256..6cac912210 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -87,7 +87,10 @@ const SETTLE_MAX: usize = 16; impl CommentView { /// Remember a released drag drawing so it stays on screen until its page is re-rendered. pub(crate) fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) { - if !matches!(tool, CommentTool::Ink | CommentTool::Line | CommentTool::Arrow | CommentTool::Rectangle | CommentTool::Oval) { + if !matches!( + tool, + CommentTool::Ink | CommentTool::FreehandHighlight | CommentTool::Line | CommentTool::Arrow | CommentTool::Rectangle | CommentTool::Oval + ) { return; } self.settling.retain(|s| now - s.since < SETTLE_SECS); diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 91e796e43a..6110e8a6fc 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -1,5 +1,22 @@ use super::*; +#[test] +fn freehand_highlight_and_draw_accept_widths_up_to_100() { + assert_eq!(CommentTool::FreehandHighlight.group(), CommentTool::Highlight.group()); + assert_eq!(GROUPS.len(), 3); + let mut prefs = CommentPrefs::default(); + assert_eq!(prefs.style(CommentTool::FreehandHighlight).width, 14.0); + assert_eq!(prefs.style(CommentTool::FreehandHighlight).opacity, 0.35); + for tool in [CommentTool::Ink, CommentTool::FreehandHighlight] { + prefs.set_width(tool, 75.0); + assert_eq!(prefs.style(tool).width, 75.0); + prefs.set_width(tool, 200.0); + assert_eq!(prefs.style(tool).width, 100.0); + prefs.set_width(tool, f64::NAN); + assert_eq!(prefs.style(tool).width, 100.0); + } +} + /// A selected line is drawn where it is going: moved by its body (both ends follow the /// pointer), with an end dragged (that end alone), and as released until the page is drawn. #[test] diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 4c99ee1db8..70c3f775e9 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -434,6 +434,24 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok app.side_panels.tools_side = crate::side_panels::ToolsSide::Right; } }); + ui.horizontal(|ui| { + ui.label(tl!("Navigation rail side")); + let right = app.side_panels.rail_on_right(); + let left_btn = ui.selectable_label(!right, tl!("Left")); + left_btn.widget_info(|| { + egui::WidgetInfo::selected(egui::Role::Button, true, !right, format!("{}: {}", tl!("Navigation rail side"), tl!("Left"))) + }); + if left_btn.clicked() { + app.side_panels.rail_side = crate::side_panels::ToolsSide::Left; + } + let right_btn = ui.selectable_label(right, tl!("Right")); + right_btn.widget_info(|| { + egui::WidgetInfo::selected(egui::Role::Button, true, right, format!("{}: {}", tl!("Navigation rail side"), tl!("Right"))) + }); + if right_btn.clicked() { + app.side_panels.rail_side = crate::side_panels::ToolsSide::Right; + } + }); ui.add_space(8.0); crate::appearance::preferences_section(ui, app, t); ui.add_space(8.0); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 057663a76c..6805732422 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3092,3 +3092,159 @@ fn a_card_with_an_editor_open_out_of_view_is_drawn_at_its_size() { let after = h.get_by_label("Card 61").rect().top(); assert!(after > before + 20.0, "the cards below make room for card 1's editor: {before} → {after}"); } + +#[test] +fn freehand_highlighter_draws_translucent_yellow_ink() { + let mut h = harness(|app| app.set_option("quick", "freehand_highlight").unwrap()); + drag_pt(&mut h, (40.0, 100.0), (240.0, 100.0)); + + let c = comments(&h); + assert_eq!(c.len(), 1, "{c:?}"); + assert_eq!(c[0].subtype, "Ink"); + assert_eq!(c[0].intent.as_deref(), Some("FreehandHighlight")); + let props = h.state().session.get(h.state().views[0].id).unwrap().comment_props(0, c[0].index).expect("ink properties"); + assert_eq!(props.color, Some([1.0, 0.94, 0.0])); + assert!((props.opacity - 0.35).abs() < 0.001); + assert_eq!(props.width, Some(14.0)); + assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight)); + h.state_mut().comment_prefs.set_width(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight, 100.0); + drag_pt(&mut h, (40.0, 160.0), (240.0, 160.0)); + let c = comments(&h); + assert_eq!(c.len(), 2); + let props = h.state().session.get(h.state().views[0].id).unwrap().comment_props(0, c.iter().map(|a| a.index).max().unwrap()).unwrap(); + assert_eq!(props.width, Some(100.0)); + assert_eq!(h.state().views[0].comments.selected, None); +} + +#[test] +fn hotbar_picker_adds_and_runs_freehand_highlight() { + let mut h = harness(|_| {}); + h.get_by_label("Hotbar settings").click(); + h.run_steps(3); + field(&h, "Search all tools…").click(); + h.run_steps(2); + field(&h, "Search all tools…").type_text("freehand"); + h.run_steps(3); + h.get_by_role_and_label(Role::CheckBox, "Freehand-Highlight").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_some(), "picker stays open after adding a tool"); + h.get_by_role_and_label(Role::CheckBox, "Draw freehand").click(); + h.run_steps(3); + h.get_by_label("Close menu").click(); + h.run_steps(3); + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["tools"], serde_json::json!(["comment.freehand_highlight", "comment.ink"])); + h.state_mut().set_option("hotbar-side", "bottom").unwrap(); + h.run_steps(3); + // The new tool is available directly from its custom shortcut. + h.query_all_by_role_and_label(Role::Button, "Freehand-Highlight").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight)); +} + +#[test] +fn hotbar_picker_searches_translated_tool_labels() { + let mut h = harness(|app| { + app.set_option("language", "fr").unwrap(); + }); + h.get_by_label("Hotbar settings").click(); + h.run_steps(3); + field(&h, "Search all tools…").click(); + h.run_steps(2); + field(&h, "Search all tools…").type_text("Dessiner"); + h.run_steps(3); + h.get_by_role_and_label(Role::CheckBox, "Dessiner à main levée").click(); + h.run_steps(3); + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["tools"], serde_json::json!(["comment.ink"])); +} + +#[test] +fn hotbar_drag_moves_and_persists_position() { + let mut h = harness(|_| {}); + let before: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + let from = h.get_by_label("Drag hotbar").rect().center(); + drag(&mut h, from, from + egui::vec2(90.0, 90.0)); + let after: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_ne!(before["hotbar"]["position"], after["hotbar"]["position"]); + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + let saved: serde_json::Value = serde_json::from_str(&restored.persist()).unwrap(); + assert_eq!(saved["hotbar"], after["hotbar"]); +} + +#[test] +fn hotbar_vertical_size_and_docking() { + let mut h = harness(|app| { + app.set_option("hotbar-tools", "comment.ink,comment.freehand_highlight").unwrap(); + app.set_option("hotbar-side", "right").unwrap(); + }); + for size in [24, 48, 64] { + h.state_mut().set_option("hotbar-size", &size.to_string()).unwrap(); + h.run_steps(5); + let measured = h.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("custom-hotbar"))).unwrap(); + assert!(measured.x <= size as f32 + 24.0, "vertical bar too wide: {measured:?}"); + assert!(measured.x >= size as f32); + } + let from = h.get_by_label("Drag hotbar").rect().center(); + drag(&mut h, from, from - egui::vec2(90.0, 90.0)); + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["position"], serde_json::json!([1.0, 0.5])); + assert_eq!(saved["hotbar"]["dock"], "right"); + h.state_mut().set_option("hotbar-side", "float").unwrap(); + h.run_steps(3); + let from = h.get_by_label("Drag hotbar").rect().center(); + drag(&mut h, from, from - egui::vec2(90.0, 90.0)); + let moved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_ne!(saved["hotbar"]["position"], moved["hotbar"]["position"]); +} + +#[test] +fn hotbar_settings_reopen_even_with_remembered_collapsed_state() { + let mut h = harness(|_| {}); + let mut collapsed = + egui::collapsing_header::CollapsingState::load_with_default_open(&h.ctx, egui::Id::unique("hotbar-picker").with("collapsing"), false); + collapsed.set_open(false); + collapsed.store(&h.ctx); + h.get_by_label("Hotbar settings").click(); + h.run_steps(5); + assert!(h.query_by_label("Close menu").is_some()); + h.get_by_label("Hotbar settings").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_some(), "Hotbar settings always shows settings"); + h.get_by_label("Close menu").click(); + h.run_steps(3); + h.get_by_label("Hotbar settings").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_some()); +} + +#[test] +fn hotbar_full_side_strip_spans_each_edge_and_returns_to_float() { + let mut h = harness(|app| { + app.set_option("hotbar-side", "right").unwrap(); + app.set_option("hotbar-size", "48").unwrap(); + }); + h.get_by_label("Hotbar settings").click(); + h.run_steps(5); + h.get_by_label("Full-side strip").click(); + h.run_steps(3); + let enabled: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(enabled["hotbar"]["full_side"], true, "settings checkbox enables strip"); + h.get_by_label("Close menu").click(); + for side in ["right", "left", "top", "bottom"] { + h.state_mut().set_option("hotbar-side", side).unwrap(); + h.run_steps(5); + let size = h.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("custom-hotbar"))).unwrap(); + let (length, thickness) = if matches!(side, "left" | "right") { (size.y, size.x) } else { (size.x, size.y) }; + assert!(length > 300.0, "strip must fill the edge: {side} {size:?}"); + assert!(thickness <= 64.0, "strip must stay narrow: {size:?}"); + h.get_by_label("Hotbar settings"); + } + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["full_side"], true); + h.state_mut().set_option("hotbar-side", "float").unwrap(); + h.run_steps(5); + let size = h.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("custom-hotbar"))).unwrap(); + assert!(size.x < 150.0 && size.y < 150.0, "floating toolbar is compact: {size:?}"); +} diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 69be82e7a7..0dc431344e 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -346,6 +346,30 @@ fn closed_right_panel_stays_closed_for_a_document_asking_for_its_outline() { assert_eq!(h.state().right, None, "the closed right panel reopened after a restart"); } +#[test] +fn navigation_rail_toggles_between_right_and_left_and_persists() { + use pdfcraft_ui_egui::Dialog; + + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + assert_eq!(h.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Right); + h.get_by_label("Dock navigation rail on Left").click(); + h.run_steps(3); + assert_eq!(h.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); + let saved = h.state().persist(); + let mut h2 = harness(move |app| { + app.restore(&saved); + }); + assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); + h2.state_mut().dialog = Some(Dialog::Preferences); + h2.run_steps(3); + h2.get_by_label("Navigation rail side: Right").click(); + h2.run_steps(3); + assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Right); + h2.get_by_label("Navigation rail side: Left").click(); + h2.run_steps(3); + assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); +} + /// Read mode hides the tools, not the navigation panes: the rail's Bookmarks button opens the /// bookmarks when Read is the default workspace (#932), and the left tool pane stays hidden. #[test] @@ -1696,36 +1720,53 @@ fn the_edit_panel_keeps_the_add_update_remove_menu() { /// being drawn under the page number field. #[test] fn short_window_rail_buttons_scroll_clear_of_page_navigation() { - let (width, height) = (1000.0, 560.0); - let mut h = Harness::builder().with_size(egui::vec2(width, height)).build_eframe(|_cc| { - let mut app = PdfCraftApp::new(); - app.set_option("language", "en").unwrap(); - app.open_bytes("short.pdf", None, FIXTURE.to_vec()).unwrap(); - app - }); - crate::support::settle(&mut h); - let rail = |h: &Harness<'static, PdfCraftApp>, label: &str| { - h.query_all_by_label(label).map(|n| n.rect()).find(|r| r.left() >= width - 48.0).unwrap_or_else(|| panic!("{label} in the rail")) - }; - let page = h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(); - assert!(page.left() >= width - 48.0 && page.bottom() <= height, "page field {page:?} in the rail"); - assert!(rail(&h, "Comments").bottom() <= page.top(), "the first button sits above the page field"); - assert!(rail(&h, "Tags").top() >= page.top(), "the window is short enough that the buttons must scroll"); - let over = rail(&h, "Comments").center(); - h.event(egui::Event::PointerMoved(over)); - for _ in 0..8 { - h.event(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(0.0, -200.0), - phase: egui::TouchPhase::Move, - modifiers: egui::Modifiers::NONE, - source: egui::MouseWheelSource::Unknown, + use pdfcraft_ui_egui::side_panels::ToolsSide; + + for side in [ToolsSide::Right, ToolsSide::Left] { + let (width, height) = (1000.0, 560.0); + let mut h = Harness::builder().with_size(egui::vec2(width, height)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.side_panels.rail_side = side; + app.open_bytes("short.pdf", None, FIXTURE.to_vec()).unwrap(); + app }); - h.run_steps(4); + crate::support::settle(&mut h); + let in_rail = |r: egui::Rect| match side { + ToolsSide::Left => r.right() <= 48.0, + ToolsSide::Right => r.left() >= width - 48.0, + }; + let rail = |h: &Harness<'static, PdfCraftApp>, label: &str| { + h.query_all_by_label(label).map(|n| n.rect()).find(|r| in_rail(*r)).unwrap_or_else(|| panic!("{label} in the rail")) + }; + let page = h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(); + assert!(in_rail(page) && page.bottom() <= height, "page field {page:?} in the rail"); + let dock_label = match side { + ToolsSide::Right => "Dock navigation rail on Left", + ToolsSide::Left => "Dock navigation rail on Right", + }; + let dock = rail(&h, dock_label); + assert!(dock.top() >= page.bottom() && dock.bottom() <= height, "dock button {dock:?} stays below navigation and inside the window"); + assert!(rail(&h, "Comments").bottom() <= page.top(), "the first button sits above the page field"); + assert!(rail(&h, "Tags").top() >= page.top(), "the window is short enough that the buttons must scroll"); + let first = rail(&h, "Comments"); + let over = first.center(); + h.event(egui::Event::PointerMoved(over)); + for _ in 0..8 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -200.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(4); + } + let tags = rail(&h, "Tags"); + assert!(tags.top() >= first.top(), "Tags {tags:?} is fully visible in the scrolling area"); + assert!(tags.bottom() <= page.top(), "Tags {tags:?} scrolls into view above the page field {page:?}"); + assert_eq!(h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(), page, "navigation stays put"); } - let tags = rail(&h, "Tags"); - assert!(tags.bottom() <= page.top(), "Tags {tags:?} scrolls into view above the page field {page:?}"); - assert_eq!(h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(), page, "navigation stays put"); } /// A PDF with `n` empty 200×300 pt pages. diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index aef5ddae73..7f444bee0f 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2072,7 +2072,11 @@ fn long_view_menu_fits_a_short_window() { assert!(after <= 400.0, "the menu scrolls Side panels into view: {after}"); crate::support::menu_row(&h, "Side panels").hover(); h.run_steps(4); - h.get_all_by_label("Bookmarks").last().unwrap().click(); + // The navigation rail has a Bookmarks button too; click the submenu's radio item. + let bookmarks = h.get_by(|n| n.role() == egui::accesskit::Role::MenuItemRadio && n.label().as_deref() == Some("Bookmarks")); + let row = bookmarks.rect(); + assert!(row.top() >= 0.0 && row.bottom() <= 400.0, "the Bookmarks submenu row is onscreen: {row:?}"); + bookmarks.click(); h.run_steps(4); assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)); } diff --git a/docs/roadmap.md b/docs/roadmap.md index c5bc1ab7d0..59f478dea1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -237,7 +237,7 @@ total matches the parity estimate. | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Local customization: adjustable, floating or side-docked hotbar with optional full-side strip and persistent tool picker; freehand highlighting is grouped with Highlight. Estimates unchanged. Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 86eb0c66e7..f950e36a94 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -26,7 +26,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) |---|---|---|---| | Home view (recent files, recommended tools) | yes | yes | shipped | | All tools pane, tool sets | yes | yes, with milestone badges | shipped | -| Quick-action toolbar, customisable | yes | fixed | partial (`view.customize-quick-tools` planned) | +| Quick-action toolbar, customisable | yes | customizable floating/docked hotbar, adjustable size, persistent multi-select picker | partial (`view.customize-quick-tools`; no shortcut reordering) | | Command search | "Search tools" | command palette (⌘K), matches translated and English labels | shipped | | Document tabs | yes | yes, with unsaved marker | shipped | | Multiple windows, drag a tab out | yes | no | planned | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 401a0152ca..30ff931c20 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1808,8 +1808,13 @@ evidence = [ "crates/ui-egui/tests/it/pinned_tools.rs::home_shows_pinned_tools_section_and_placeholder", "crates/ui-egui/tests/it/pinned_tools.rs::pin_tool_dialog_workflow", "crates/ui-egui/tests/it/pinned_tools.rs::sidebar_pin_toggle_unpins_and_pins", + "crates/ui-egui/tests/it/comments.rs::hotbar_picker_adds_and_runs_freehand_highlight", + "crates/ui-egui/tests/it/comments.rs::hotbar_drag_moves_and_persists_position", + "crates/ui-egui/tests/it/comments.rs::hotbar_vertical_size_and_docking", + "crates/ui-egui/tests/it/comments.rs::hotbar_settings_reopen_even_with_remembered_collapsed_state", + "crates/ui-egui/tests/it/comments.rs::hotbar_full_side_strip_spans_each_edge_and_returns_to_float", ] -notes = "Pin and unpin tools on the Home page via tool card hover button, All Tools row toggle, or 'Pin a tool' search dialog. Pinned tool order persists across sessions. Floating quick-action toolbar customisation remains planned." +notes = "Pin and unpin tools on the Home page via tool card hover button, All Tools row toggle, or 'Pin a tool' search dialog. Pinned tool order persists across sessions. Customizable document hotbar searches the command registry, adds/removes tools, resizes buttons from 24 to 64 pixels, floats or docks on any side with an optional full-side chrome strip, and persists preferences (UI control: hotbar-tools, hotbar-size, hotbar-side, hotbar-full-side). Missing: hotbar shortcut reordering and full Acrobat customization-dialog parity." [[feature]] id = "view.restriction-notice" @@ -5824,8 +5829,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals"] -notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate (larger than a Group 4 or JBIG2 scan) and refers to no globals; globals another image still uses stay, and may hold the shapes of marks under the area (not where they were)" +evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals", "crates/redact/src/tests.rs::a_cleared_black_and_white_scan_is_stored_as_group_4", "crates/redact/src/tests.rs::globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file"] +notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate, or for a one-bit image of one component Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, and refers to no globals; every other image using a redacted image's globals is stored again without them (Flate, Group 4 or JBIG2, whichever is smallest), so they leave the file (one that can't be decoded refuses the redaction)" [[feature]] id = "protect.redact-vectors"