Merge origin/main into feat/jbig2-run-allowance

This commit is contained in:
FredApps
2026-11-01 23:50:04 +01:00
47 changed files with 1496 additions and 107 deletions
Generated
+9 -9
View File
@@ -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",
+7 -7
View File
@@ -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" }
+1
View File
@@ -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.
+5 -1
View File
@@ -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");
}
+17 -9
View File
@@ -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 } => {
+36 -3
View File
@@ -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<[f64; 2]>> = Vec::with_capacity(list.len());
for s in list {
let pts: Vec<f64> = s.as_array()?.iter().map(Object::as_f64).collect::<Option<_>>()?;
strokes.push(pts.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect());
}
let inner = ink_bounds(&strokes)?;
let vals: Vec<f64> = 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 {
+56
View File
@@ -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<f64> {
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:?}");
}
}
+7 -1
View File
@@ -355,6 +355,10 @@ pub enum Shape {
start: LineEnding,
end: LineEnding,
},
/// Translucent freehand highlighting, stored as ink with a distinct intent.
FreehandHighlight {
strokes: Vec<Vec<[f64; 2]>>,
},
/// Freehand strokes (Draw tool).
Ink {
strokes: Vec<Vec<[f64; 2]>>,
@@ -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),
+3 -2
View File
@@ -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::<Result<Vec<_>>>()
})
.collect::<Result<Vec<_>>>()?;
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"));
+1 -1
View File
@@ -754,7 +754,7 @@ pub fn tools() -> Vec<ToolDef> {
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(),
+21
View File
@@ -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" }));
+11
View File
@@ -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<ObjRef> {
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
+3
View File
@@ -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"),
+1
View File
@@ -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",
+1
View File
@@ -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",
+2 -1
View File
@@ -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 }
+9 -6
View File
@@ -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).
+73 -3
View File
@@ -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<Stream> {
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))
}
+73
View File
@@ -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<usize, RedactError> {
let mut globals = HashSet::new();
let mut seen = HashSet::new();
let mut stack: Vec<ObjRef> = 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<ObjRef> = 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<ObjRef> = 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,
+364 -5
View File
@@ -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<Vec<u8>> {
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<Stream> {
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<u8> {
@@ -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<u8> {
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<u8> = (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<Vec<u8>> = (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<Vec<u8>> {
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<u8> = segments.concat();
let page_two: Vec<u8> = 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:?}");
}
}
+1
View File
@@ -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,
+19
View File
@@ -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;
+5
View File
@@ -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::<serde_json::Value>(json) else { return };
if let Ok(mut bar) = serde_json::from_value::<crate::hotbar::Hotbar>(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 {
+1
View File
@@ -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);
+15 -1
View File
@@ -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)];
+7 -2
View File
@@ -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);
+2
View File
@@ -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" => {
+5
View File
@@ -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
+2
View File
@@ -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,
+1 -1
View File
@@ -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() {
+5 -2
View File
@@ -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;
}
+372
View File
@@ -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<String>,
/// 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<String>,
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<String> = 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<usize>) {
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::<egui::Vec2>(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);
}
}
+2
View File
@@ -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<QuickTool>,
/// 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,
+45 -7
View File
@@ -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::<ToolsSide>(v.clone()).ok()) {
panels.tools_side = side;
}
if let Some(side) = value.get("rail_side").and_then(|v| serde_json::from_value::<ToolsSide>(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<Pane> 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);
}
}
+27 -4
View File
@@ -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<Self> {
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<CommentTool> {
("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,
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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);
}
});
+4 -3
View File
@@ -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<Pos2> = 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<Shap
let rect = [first[0].min(last[0]), first[1].min(last[1]), first[0].max(last[0]), first[1].max(last[1])];
let big = rect[2] - rect[0] >= 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<Shap
/// per point, so the default 2 pt keeps the 12-pixel eraser it always had, and the thickness
/// slider's 0.5–12 pt gives 3 to 72 pixels across. A setting that isn't a number is the default.
fn eraser_radius_px(width: f64) -> 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
+4 -1
View File
@@ -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);
@@ -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]
+18
View File
@@ -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);
+156
View File
@@ -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::Vec2>(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::Vec2>(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::Vec2>(egui::Id::unique("custom-hotbar"))).unwrap();
assert!(size.x < 150.0 && size.y < 150.0, "floating toolbar is compact: {size:?}");
}
+69 -28
View File
@@ -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.
+5 -1
View File
@@ -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));
}
+1 -1
View File
@@ -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<br>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.<br>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<br>Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).<br>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<br>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) |
+1 -1
View File
@@ -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 |
+8 -3
View File
@@ -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"