mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 06:51:50 -06:00
Merge origin/main into feat/jbig2-run-allowance
This commit is contained in:
Generated
+9
-9
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user