mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge r2 track t06: annotations-comments
# Conflicts: # crates/automation/tests/automation.rs # crates/render/src/raster.rs
This commit is contained in:
+7
-1
@@ -1,13 +1,14 @@
|
||||
# PdfCraft roadmap
|
||||
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
|
||||
> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
|
||||
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-20 - **Last updated:** 2026-10-20 - **Change:** minor (M6, #3502: form detection uses the existing content-stream boundary policy; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
|
||||
@@ -195,6 +196,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-19 (M2, radial luminosity masks):** Added synthetic radial-fade coverage for the existing masked-gradient fix (#2132): centre, midpoint, antialiased clip and per-pixel shading reference, including previews, scales and tiles. Removing the existing native-gradient mask guard reproduces an opaque midpoint; restoring it passes. No new renderer behavior or estimate change.
|
||||
- **2026-10-20 (M6, #3502):** Form detection preserves comments across content streams and does not add fields over rectangles hidden by those comments. Synthetic tests cover visible controls, graphics state, and saved field inventories. Estimates unchanged.
|
||||
- **2026-10-20 (M6, #2273 / PR #2279):** Rebased the field-highlight default and Appearance preference onto main, retaining secured-form parity evidence and updating secured AcroForm/XFA toggle expectations. Build and test validation delegated to GitHub CI. Estimates unchanged.
|
||||
- **2026-10-20 (M5, reshape lines):** A line or arrow could only be moved after it was drawn; its length and angle stayed. A selected line, connected lines, polygon or cloud now has a handle on each end or corner: dragging one reshapes it (Shift holds it to 45°), as one undo step, with the new shape drawn while dragging. `pdfcraft_annot::set_points`, `Edit::ReshapeAnnotation`, `comment_edit points`; comment lists carry the points. Estimates unchanged.
|
||||
- **2026-10-20 (M3, #3620):** Overlapping comment hover uses the original annotation index independently of spatial inspection order. A synthetic UI regression covers different annotation upper edges. Estimates unchanged.
|
||||
|
||||
- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged.
|
||||
|
||||
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
|
||||
|
||||
@@ -226,6 +231,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
|
||||
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
|
||||
- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
|
||||
- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged.
|
||||
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
|
||||
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
|
||||
|
||||
@@ -593,7 +593,28 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
// Only PdfCraft's own Fill & Sign marks are drawn here.
|
||||
let name = d.name(b"Name")?;
|
||||
let col = stroke.unwrap_or([0.0; 3]);
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
// A mark or stamp placed on a turned page is drawn upright in its displayed box, then
|
||||
// mapped into `/Rect` the way the page maps display space into user space. Viewers
|
||||
// fit that box to `/Rect`; the page rotation then shows it the right way up.
|
||||
let turn = stamp_turn(d);
|
||||
let draw = if turn == 0 {
|
||||
rect
|
||||
} else {
|
||||
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
|
||||
if dw < 1.0 || dh < 1.0 {
|
||||
return None;
|
||||
}
|
||||
[0.0, 0.0, dw, dh]
|
||||
};
|
||||
let turned = |mut stream: Stream| {
|
||||
if turn != 0 {
|
||||
let m = pdfcraft_model::view_matrix_for(turn, rect);
|
||||
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
|
||||
}
|
||||
stream
|
||||
};
|
||||
let [x0, y0, x1, y1] = draw;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let lw = w.min(h) * 0.12;
|
||||
match name {
|
||||
@@ -643,28 +664,12 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
}
|
||||
let kind = crate::StampKind::from_name(other)?;
|
||||
let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text);
|
||||
let turn = stamp_turn(d);
|
||||
let draw = if turn == 0 {
|
||||
rect
|
||||
} else {
|
||||
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
|
||||
if dw < 1.0 || dh < 1.0 {
|
||||
return None;
|
||||
}
|
||||
[0.0, 0.0, dw, dh]
|
||||
};
|
||||
let mut stream = stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res);
|
||||
if turn != 0 {
|
||||
// Upright in the stamp's displayed box, then mapped into `/Rect` the way
|
||||
// the page maps display space into user space. Viewers fit that box to
|
||||
// `/Rect`; the page rotation then shows the label the right way up.
|
||||
let m = pdfcraft_model::view_matrix_for(turn, rect);
|
||||
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
|
||||
}
|
||||
return Some(stream);
|
||||
return Some(turned(stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res)));
|
||||
}
|
||||
}
|
||||
if turn != 0 {
|
||||
return Some(turned(form(draw, c.as_bytes(), res)));
|
||||
}
|
||||
}
|
||||
b"FreeText" => {
|
||||
let (text_color, size) = parse_da(d);
|
||||
|
||||
@@ -125,6 +125,14 @@ impl FillMark {
|
||||
FillMark::Line => "Line",
|
||||
}
|
||||
}
|
||||
|
||||
/// The user-space box of the mark placed at `at` on a page turned by `rotation`: centred
|
||||
/// there and sized in displayed axes, so a line runs across the page as displayed (#2392).
|
||||
pub fn rect_at(self, at: [f64; 2], rotation: i64) -> [f64; 4] {
|
||||
let (w, h) = if self == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) };
|
||||
let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) };
|
||||
[at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0]
|
||||
}
|
||||
}
|
||||
|
||||
/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style).
|
||||
@@ -803,6 +811,27 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64
|
||||
Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn))
|
||||
}
|
||||
|
||||
/// Leave page `page` of (a copy of) a document showing only its annotation at `index`, without
|
||||
/// the page's content or other annotations, so a render draws that annotation alone: to lay it
|
||||
/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no
|
||||
/// normal appearance (`/AP /N`) for that render to draw.
|
||||
pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result<bool, AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let has_normal = {
|
||||
let obj = doc.resolve(&entry);
|
||||
obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")))
|
||||
};
|
||||
if !has_normal {
|
||||
return Ok(false);
|
||||
}
|
||||
set_annots(doc, p, vec![entry])?;
|
||||
doc.update_dict(p, |d| {
|
||||
d.remove(b"Contents");
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ── building ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Annotation flags (§12.5.3).
|
||||
@@ -1081,6 +1110,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Name".to_vec(), Object::name(mark.name()));
|
||||
border(&mut d);
|
||||
// Drawn upright for the page as displayed, as a stamp is (#2392).
|
||||
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
||||
if matches!(turn, 90 | 180 | 270) {
|
||||
d.set(b"PCStampRotate".to_vec(), Object::Int(turn));
|
||||
}
|
||||
}
|
||||
Shape::Stamp { stamp, by, .. } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
@@ -1355,8 +1389,22 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) ->
|
||||
apd.remove(b"D");
|
||||
apd.remove(b"R");
|
||||
apd.set(b"N".to_vec(), Object::Ref(ap));
|
||||
// A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`.
|
||||
// The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with
|
||||
// no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing.
|
||||
let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment"))
|
||||
.then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>())
|
||||
.flatten()
|
||||
.filter(|q| q.len() == 4 && finite(q))
|
||||
.map(|q| {
|
||||
let (x, top) = (q[0].min(q[2]), q[1].max(q[3]));
|
||||
[x, top - NOTE_SIZE, x + NOTE_SIZE, top]
|
||||
});
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"AP".to_vec(), Object::Dict(apd));
|
||||
if let Some(rect) = icon_rect {
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
}
|
||||
d.remove(b"AS");
|
||||
pdfcraft_fonts::set_undrawn_mark(d, missing);
|
||||
})?;
|
||||
@@ -1433,8 +1481,15 @@ pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
|
||||
/// redrawing it. See [`AppearanceCache`] for when entries stay valid.
|
||||
pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option<Document> {
|
||||
let mut copy: Option<Document> = None;
|
||||
// An annotation listed more than once, on one page or several, is drawn once. `has_normal`
|
||||
// reads `doc`, which never gets the appearance, so every repeat used to add another stream to
|
||||
// the copy, and the display bytes grew with the number of listings.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for page in page_refs(doc).ok()? {
|
||||
for r in annots(doc, page).iter().filter_map(Object::as_ref) {
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
let d = annot_dict(doc, r);
|
||||
if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) {
|
||||
continue;
|
||||
@@ -1774,6 +1829,48 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
|
||||
apply_text_box(doc, r, rect, meta, false)
|
||||
}
|
||||
|
||||
/// At most this many points reshape a comment (a drawn polygon has a handful).
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
|
||||
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two
|
||||
/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the
|
||||
/// points with room for the line's width and endings, as when the comment was drawn, and the
|
||||
/// appearance is redrawn. A measurement keeps the points it measured.
|
||||
pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.contains(b"Measure") {
|
||||
return Err(AnnotError::Invalid("a measurement's points are where it measured".into()));
|
||||
}
|
||||
if points.len() > MAX_POINTS {
|
||||
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
|
||||
}
|
||||
let style = Style { width: border_width_of(&d), ..Style::default() };
|
||||
let ends = line_endings_of(&d).unwrap_or_default();
|
||||
let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None));
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) {
|
||||
("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"),
|
||||
("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())),
|
||||
("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"),
|
||||
("Polygon", _) => {
|
||||
let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
|
||||
(Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices")
|
||||
}
|
||||
(other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))),
|
||||
};
|
||||
// Checks the points too: finite, and far enough apart to draw.
|
||||
let rect = rect_for(&shape, &style)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(key.to_vec(), num_array(&points.concat()));
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
|
||||
/// the line width and a little more) stays the same on each side, so the line isn't clipped.
|
||||
fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
|
||||
@@ -2082,6 +2179,9 @@ pub struct Summary {
|
||||
pub intent: Option<String>,
|
||||
/// A Fill & Sign mark ([`is_fill_sign`]).
|
||||
pub fill_sign: bool,
|
||||
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
|
||||
/// (`/Vertices`), in user space; empty for other comments.
|
||||
pub points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
@@ -2136,6 +2236,12 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
}
|
||||
}
|
||||
let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec();
|
||||
// Keep in step with `pdfcraft_render::inspect`.
|
||||
let points = match subtype.as_str() {
|
||||
"Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(),
|
||||
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
|
||||
out.push(Summary {
|
||||
page,
|
||||
@@ -2155,6 +2261,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||||
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
|
||||
fill_sign: is_fill_sign(doc, d),
|
||||
points,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,6 +991,29 @@ fn malformed_optional_interior_color_does_not_hide_shape_strokes() {
|
||||
assert!(list(&doc, 0)[0].get(b"AP").is_none(), "display generation leaves the source document unchanged");
|
||||
}
|
||||
|
||||
/// #3175: viewers draw a note without an appearance as a fixed-size icon at the upper-left corner
|
||||
/// of its `/Rect`. The display copy must not stretch the icon over a large `/Rect` or shrink it
|
||||
/// to nothing in one with no width.
|
||||
#[test]
|
||||
fn a_note_without_an_appearance_is_shown_at_icon_size() {
|
||||
let doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [-23 240 480 468] /C [1 0 0] /Open true >>",
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [310 380 310 392] /Name /Comment /F 4 >>",
|
||||
b"<< /Type /Annot /Subtype /FileAttachment /Rect [100 50 300 10] >>",
|
||||
]);
|
||||
let display = with_missing_appearances(&doc).expect("the notes get a display appearance");
|
||||
let rects = |doc: &Document| -> Vec<Vec<f64>> {
|
||||
let rect = |d: &Dict| d.get(b"Rect").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(Object::as_f64).collect::<Vec<f64>>());
|
||||
list(doc, 0).iter().map(|d| rect(d).unwrap_or_default()).collect()
|
||||
};
|
||||
assert_eq!(rects(&display), [vec![-23.0, 448.0, -3.0, 468.0], vec![310.0, 372.0, 330.0, 392.0], vec![100.0, 30.0, 120.0, 50.0]]);
|
||||
let original = [vec![-23.0, 240.0, 480.0, 468.0], vec![310.0, 380.0, 310.0, 392.0], vec![100.0, 50.0, 300.0, 10.0]];
|
||||
assert_eq!(rects(&doc), original, "the source document is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_line_ending_is_drawn() {
|
||||
let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow];
|
||||
@@ -1301,6 +1324,28 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() {
|
||||
assert_eq!(a, b, "the reused appearance is byte-identical");
|
||||
}
|
||||
|
||||
/// A comment that `/Annots` lists many times, and that a second page lists too, is drawn once in
|
||||
/// the display copy. Every listing used to add another appearance stream, so a short array of
|
||||
/// repeated references multiplied the display bytes the engine writes on each refresh.
|
||||
#[test]
|
||||
fn a_comment_listed_many_times_is_drawn_once_for_display() {
|
||||
let first = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", vec!["5 0 R"; 1_000].join(" "));
|
||||
let doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>",
|
||||
first.as_bytes(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] >>",
|
||||
]);
|
||||
let mut cache = AppearanceCache::default();
|
||||
let display = with_missing_appearances_cached(&doc, &mut cache).expect("the square needs an appearance");
|
||||
assert_eq!(display.object_numbers().len(), doc.object_numbers().len() + 1, "one appearance stream, not one per listing");
|
||||
assert_eq!((cache.builds(), cache.hits()), (1, 0));
|
||||
assert!(list(&display, 0).iter().chain(&list(&display, 1)).all(|d| d.contains(b"AP")), "every listing shows it");
|
||||
with_missing_appearances_cached(&doc, &mut cache).expect("a refresh");
|
||||
assert_eq!((cache.builds(), cache.hits()), (1, 1), "a refresh reuses the cached stream once");
|
||||
}
|
||||
|
||||
/// #1908: a text box's font size changes in `/DA` (colour kept), the rich text follows, the box
|
||||
/// grows to hold the bigger text but never shrinks, and bad sizes and other subtypes are refused.
|
||||
#[test]
|
||||
@@ -1382,3 +1427,50 @@ fn free_text_properties_show_legacy_or_absent_border_width() {
|
||||
.unwrap();
|
||||
assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0));
|
||||
}
|
||||
|
||||
/// A line's ends and the corners of connected lines and polygons can be moved after drawing:
|
||||
/// the points change, `/Rect` follows them (with room for an arrowhead), and the appearance is
|
||||
/// drawn anew. A line needs two ends far enough apart; a locked comment keeps its shape.
|
||||
#[test]
|
||||
fn lines_and_polygons_are_reshaped_by_their_points() {
|
||||
let mut doc = fixture();
|
||||
let l = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [100.0, 100.0], to: [200.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow }),
|
||||
&meta("l"),
|
||||
)
|
||||
.unwrap();
|
||||
let before = ap_content(&doc, &list(&doc, 0)[l]);
|
||||
set_points(&mut doc, 0, l, &[[100.0, 100.0], [100.0, 300.0]], &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[l];
|
||||
let ends: Vec<f64> = d.get(b"L").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
|
||||
assert_eq!(ends, [100.0, 100.0, 100.0, 300.0]);
|
||||
let r = rect(d);
|
||||
assert!(r[0] < 100.0 && r[2] > 100.0 && r[1] < 100.0 && r[3] > 300.0, "the rectangle holds the line and its arrowhead: {r:?}");
|
||||
assert!(r[2] - r[0] < 40.0, "and no more: {r:?}");
|
||||
assert_ne!(ap_content(&doc2, d), before, "drawn anew");
|
||||
assert_eq!(summaries(&doc2).into_iter().find(|s| s.index == l).unwrap().points, vec![[100.0, 100.0], [100.0, 300.0]]);
|
||||
// Two ends exactly, far enough apart.
|
||||
assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0], [1.2, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert!(matches!(set_points(&mut doc, 0, l, &[[f64::NAN, 1.0], [10.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
// A polygon's corner.
|
||||
let g = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Polygon { vertices: vec![[300.0, 300.0], [400.0, 300.0], [350.0, 380.0]], cloud: false }),
|
||||
&meta("g"),
|
||||
)
|
||||
.unwrap();
|
||||
set_points(&mut doc, 0, g, &[[300.0, 300.0], [450.0, 300.0], [350.0, 380.0], [300.0, 360.0]], &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[g];
|
||||
let v: Vec<f64> = d.get(b"Vertices").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
|
||||
assert_eq!(v.len(), 8);
|
||||
assert!(rect(d)[2] > 450.0);
|
||||
// Locked: refused, and nothing changes.
|
||||
set_locked(&mut doc, 0, l, true).unwrap();
|
||||
assert!(set_points(&mut doc, 0, l, &[[0.0, 0.0], [50.0, 50.0]], &meta("")).is_err());
|
||||
// Other comments have no points to move.
|
||||
let s = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("s")).unwrap();
|
||||
assert!(matches!(set_points(&mut doc, 0, s, &[[0.0, 0.0], [50.0, 50.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ impl Automation {
|
||||
let all = self.comments(a)?;
|
||||
let view = |c: &Annotation| -> Value {
|
||||
let p = &doc.info.pages[c.page.min(doc.info.pages.len().saturating_sub(1))];
|
||||
json!({
|
||||
let mut v = json!({
|
||||
"id": c.name,
|
||||
"page": c.page + 1,
|
||||
"index": c.index + 1,
|
||||
@@ -184,7 +184,14 @@ impl Automation {
|
||||
"modified": c.modified,
|
||||
"rect": rect_to_view(p, c.rect),
|
||||
"color": c.color.map(hex),
|
||||
})
|
||||
});
|
||||
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
|
||||
// comment_edit's `points` takes them.
|
||||
if !c.points.is_empty() {
|
||||
let round = |v: f32| (v * 100.0).round() / 100.0;
|
||||
v["points"] = json!(c.points.iter().map(|q| p.user_to_view(q[0], q[1]).map(round)).collect::<Vec<_>>());
|
||||
}
|
||||
v
|
||||
};
|
||||
let roots: Vec<Value> = all
|
||||
.iter()
|
||||
@@ -551,8 +558,7 @@ impl Automation {
|
||||
"line" => FillMark::Line,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown type {other:?}"))),
|
||||
};
|
||||
let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) };
|
||||
(Shape::Mark { rect: [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0], mark }, String::new())
|
||||
(Shape::Mark { rect: mark.rect_at(at, i64::from(info.rotation)), mark }, String::new())
|
||||
}
|
||||
};
|
||||
let style = Style::default_for(&shape);
|
||||
@@ -632,8 +638,23 @@ impl Automation {
|
||||
let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy));
|
||||
edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] });
|
||||
}
|
||||
if let Some(pts) = a.get("points") {
|
||||
let wrong = || ToolError::InvalidArgs("points must be an array of [x, y] points (a line's two ends, or every corner)".into());
|
||||
let points = pts
|
||||
.as_array()
|
||||
.ok_or_else(wrong)?
|
||||
.iter()
|
||||
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
|
||||
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
|
||||
_ => Err(wrong()),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
edits.push(Edit::ReshapeAnnotation { page, index, points });
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into()));
|
||||
return Err(ToolError::InvalidArgs(
|
||||
"nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(),
|
||||
));
|
||||
}
|
||||
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
|
||||
self.apply(a, edit)
|
||||
|
||||
@@ -803,7 +803,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
comment_ref(json!({
|
||||
"contents": { "type": "string" },
|
||||
"color": color(),
|
||||
@@ -813,6 +813,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"move": point(),
|
||||
"points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." },
|
||||
})),
|
||||
&["doc"],
|
||||
)),
|
||||
|
||||
@@ -4553,6 +4553,60 @@ fn typed_signatures_stay_upright_on_rotated_pages() {
|
||||
check(&mut a, reopened);
|
||||
}
|
||||
|
||||
/// #2392: a Fill & Sign checkmark and line look the same on a turned page as on an unturned one:
|
||||
/// the checkmark isn't upside down or sideways, and the line runs across as displayed.
|
||||
#[test]
|
||||
fn fill_sign_marks_stay_upright_on_rotated_pages() {
|
||||
let dir = workdir("marks-upright");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap();
|
||||
for (page, degrees) in [(2, 90), (3, 180), (4, 270)] {
|
||||
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees }));
|
||||
}
|
||||
for page in 1..=4 {
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "check", "at": [40, 60] }));
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "line", "at": [120, 60] }));
|
||||
}
|
||||
let check = |a: &mut Automation, doc: u64| {
|
||||
let mut upright = Vec::new();
|
||||
for page in 1..=4 {
|
||||
// 144 dpi: two pixels a point.
|
||||
let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 144 })).unwrap();
|
||||
let Content::Png { data, .. } = &output[0] else { panic!() };
|
||||
let pixels = image::load_from_memory(data).unwrap().to_rgba8();
|
||||
let dark: Vec<bool> = pixels.pixels().map(|p| p.0[..3].iter().all(|v| *v < 128)).collect();
|
||||
let width = pixels.width();
|
||||
// The line, 36 × 4 pt displayed, runs across.
|
||||
let mut b = [u32::MAX, u32::MAX, 0, 0];
|
||||
for (x, y, p) in pixels.enumerate_pixels() {
|
||||
if x.abs_diff(240) < 60 && y.abs_diff(120) < 60 && p.0[..3].iter().all(|v| *v < 128) {
|
||||
b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)];
|
||||
}
|
||||
}
|
||||
assert!(b[0] <= b[2], "page {page}: no line drawn");
|
||||
assert!(b[2] - b[0] > 60 && b[3] - b[1] < 10, "page {page}: the line should run across: {b:?}");
|
||||
if page == 1 {
|
||||
upright = dark;
|
||||
continue;
|
||||
}
|
||||
// Same pixels as the unturned page where both pages are (pages 2 and 4 are wider).
|
||||
let (mut differ, mut ink) = (0, 0);
|
||||
for y in 0..400 {
|
||||
for x in 0..400 {
|
||||
let (want, got) = (upright[(y * 400 + x) as usize], dark[(y * width + x) as usize]);
|
||||
ink += usize::from(want);
|
||||
differ += usize::from(want != got);
|
||||
}
|
||||
}
|
||||
assert!(ink > 0 && differ * 10 < ink, "page {page}: {differ} of {ink} ink pixels differ from the unturned page");
|
||||
}
|
||||
};
|
||||
check(&mut a, doc);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap();
|
||||
check(&mut a, reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
|
||||
let dir = workdir("comment-polish");
|
||||
@@ -7402,3 +7456,21 @@ fn a_bookmark_added_at_a_position_reports_the_same_position_back_on_any_page_rot
|
||||
near(&d["left"], Some(72.5), "reopened left");
|
||||
near(&d["top"], Some(120.25), "reopened top");
|
||||
}
|
||||
|
||||
/// `comment_edit points` reshapes a line, connected lines or a polygon, in one undo step, and
|
||||
/// `comment_list` reports the points it takes.
|
||||
#[test]
|
||||
fn comments_are_reshaped_through_the_tool() {
|
||||
let dir = workdir("reshape");
|
||||
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": "line", "from": [20, 40], "to": [120, 40] }));
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [120.0, 40.0]]));
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 1, "points": [[20, 40], [20, 140]] }));
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [20.0, 140.0]]));
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Reshape comment");
|
||||
assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": [[1, 2]] })).is_err(), "a line has two ends");
|
||||
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": "no" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
@@ -437,6 +437,27 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() {
|
||||
assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}");
|
||||
}
|
||||
|
||||
/// #3175: a note without an appearance is drawn as a fixed-size icon at the upper-left corner of
|
||||
/// its `/Rect`. A rectangle with no width (LaTeX `pdfcomment`) used to scale the icon to nothing
|
||||
/// while flattening still counted it as drawn and deleted the note.
|
||||
#[test]
|
||||
fn flattening_a_note_with_a_zero_width_rect_draws_a_fixed_size_icon() {
|
||||
let page = stream("", " ");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R] >>",
|
||||
&page,
|
||||
"<< /Type /Annot /Subtype /Text /Rect [50 60 50 72] /Contents (Zero width) /Name /Comment /F 4 >>",
|
||||
"<< /Type /Annot /Subtype /Text /Rect [10 20 150 120] /Contents (Large) /F 4 >>",
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 2, "both icons are drawn");
|
||||
let doc = reopen(&doc);
|
||||
let flat = streams(&doc, 0).pop().unwrap();
|
||||
assert!(flat.contains("q 1 0 0 1 50 52 cm /PCFl0 Do Q"), "{flat}");
|
||||
assert!(flat.contains("q 1 0 0 1 10 100 cm /PCFl1 Do Q"), "{flat}");
|
||||
}
|
||||
|
||||
/// #1470: a form field whose widget is on no page (left behind by page extraction or combining)
|
||||
/// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`,
|
||||
/// or other software keeps treating the flattened file as a form. A hidden widget on a page is
|
||||
|
||||
@@ -34,6 +34,7 @@ mod scan_colours;
|
||||
mod scan_fields;
|
||||
pub mod signature_image;
|
||||
pub mod signature_scan;
|
||||
mod stamp_preview;
|
||||
pub mod text_fonts;
|
||||
mod text_preview;
|
||||
pub mod word_count;
|
||||
@@ -41,6 +42,7 @@ pub mod xfa;
|
||||
|
||||
pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
|
||||
pub use stamp_preview::StampPreview;
|
||||
pub use text_preview::TextEditPreview;
|
||||
|
||||
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, split_ranges};
|
||||
@@ -282,6 +284,7 @@ fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::ReplaceText { .. }
|
||||
| Edit::EraseInk { .. }
|
||||
| Edit::MoveAnnotation { .. }
|
||||
| Edit::ReshapeAnnotation { .. }
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
@@ -1478,6 +1481,13 @@ pub enum Edit {
|
||||
dx: f64,
|
||||
dy: f64,
|
||||
},
|
||||
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's
|
||||
/// two ends, the others' corners).
|
||||
ReshapeAnnotation {
|
||||
page: usize,
|
||||
index: usize,
|
||||
points: Vec<[f64; 2]>,
|
||||
},
|
||||
/// Resize a rectangle, oval or text box.
|
||||
ResizeAnnotation {
|
||||
page: usize,
|
||||
@@ -1923,6 +1933,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Lock comment",
|
||||
"Unlock comment",
|
||||
"Move comment",
|
||||
"Reshape comment",
|
||||
"Resize comment",
|
||||
"Change comment properties",
|
||||
"Fill in {name}",
|
||||
@@ -2048,6 +2059,7 @@ impl Edit {
|
||||
Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"),
|
||||
Edit::LockAnnotation { .. } => Label::of("Unlock comment"),
|
||||
Edit::MoveAnnotation { .. } => Label::of("Move comment"),
|
||||
Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"),
|
||||
Edit::ResizeAnnotation { .. } => Label::of("Resize comment"),
|
||||
Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => {
|
||||
Label::of("Change comment properties")
|
||||
@@ -2204,6 +2216,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
| Edit::EraseInk { .. }
|
||||
| Edit::LockAnnotation { .. }
|
||||
| Edit::MoveAnnotation { .. }
|
||||
| Edit::ReshapeAnnotation { .. }
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
@@ -2470,6 +2483,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
}
|
||||
Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?,
|
||||
Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?,
|
||||
Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?,
|
||||
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
|
||||
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
|
||||
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?;
|
||||
@@ -2812,6 +2826,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
|
||||
locked: s.locked,
|
||||
intent: s.intent,
|
||||
fill_sign: s.fill_sign,
|
||||
points: s.points,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! A stamp while it is moved or resized: the page without it, and the stamp alone.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{SaveOptions, write_incremental};
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage};
|
||||
|
||||
use crate::{Document, guard};
|
||||
|
||||
/// Read-only layers for interactive movement and resizing: the page without the stamp, and the
|
||||
/// stamp alone on a transparent page. Moving or resizing a stamp only changes its `/Rect`, which
|
||||
/// its appearance is stretched to fit, so these two show the result before it is applied.
|
||||
/// Preparing them never changes the working bytes, dirty flag or undo history.
|
||||
pub struct StampPreview {
|
||||
page: usize,
|
||||
background: Arc<Vec<u8>>,
|
||||
stamp: Arc<Vec<u8>>,
|
||||
config: RenderConfig,
|
||||
}
|
||||
|
||||
impl StampPreview {
|
||||
/// One worker for the page beneath the stamp.
|
||||
pub fn background_renderer(&self) -> RenderPool {
|
||||
RenderPool::new(self.background.clone(), 1, self.config.clone())
|
||||
}
|
||||
|
||||
/// One worker for the stamp alone: its page has no content, no other annotations and no
|
||||
/// background, so only the stamp's own pixels are drawn.
|
||||
pub fn stamp_renderer(&self) -> RenderPool {
|
||||
RenderPool::new(self.stamp.clone(), 1, self.stamp_config())
|
||||
}
|
||||
|
||||
/// The page beneath the stamp, rendered synchronously with the renderer's normal pixel limits.
|
||||
pub fn render_background(&self, scale: f32) -> Result<RenderedPage, String> {
|
||||
self.render(&self.background, self.config.clone(), scale)
|
||||
}
|
||||
|
||||
/// The stamp alone on a transparent page, rendered like [`Self::render_background`].
|
||||
pub fn render_stamp(&self, scale: f32) -> Result<RenderedPage, String> {
|
||||
self.render(&self.stamp, self.stamp_config(), scale)
|
||||
}
|
||||
|
||||
fn stamp_config(&self) -> RenderConfig {
|
||||
RenderConfig { transparent: true, ..self.config.clone() }
|
||||
}
|
||||
|
||||
fn render(&self, bytes: &Arc<Vec<u8>>, config: RenderConfig, scale: f32) -> Result<RenderedPage, String> {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return Err("preview scale must be finite and positive".into());
|
||||
}
|
||||
guard(|| {
|
||||
let out = PageRenderer::new(bytes.clone(), config).render(RenderRequest { page: self.page, scale, ..Default::default() });
|
||||
if let Some(e) = &out.error {
|
||||
return Err(e.clone());
|
||||
}
|
||||
Ok(out)
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The layers that show the stamp at `(page, index)` moved or resized as it is dragged.
|
||||
/// `None` for an annotation that isn't a stamp, or one without an appearance to draw.
|
||||
pub fn stamp_preview(&self, page: usize, index: usize) -> Result<Option<StampPreview>, String> {
|
||||
guard(|| {
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
let is_stamp = self.info.annotations.iter().any(|a| a.page == page && a.index == index && a.subtype == "Stamp");
|
||||
if !is_stamp {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut stamp = editor.cos.clone();
|
||||
if !pdfcraft_annot::isolate_annotation(&mut stamp, page, index).map_err(|e| e.to_string())? {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut background = editor.cos.clone();
|
||||
pdfcraft_annot::delete_annotation(&mut background, page, index).map_err(|e| e.to_string())?;
|
||||
// The page beneath as it is displayed: comments without an appearance get theirs.
|
||||
let background = pdfcraft_annot::with_missing_appearances(&background).unwrap_or(background);
|
||||
let write = |cos: &pdfcraft_cos::Document| write_incremental(cos, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string());
|
||||
Ok(Some(StampPreview { page, background: write(&background)?, stamp: write(&stamp)?, config: self.config.clone() }))
|
||||
})?
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,59 @@ fn rubber_stamps_point_left_as_displayed_on_rotated_pages() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2392: Fill & Sign marks are drawn as displayed on every page rotation, including after save:
|
||||
/// a checkmark's long stroke rises to the right, and a line runs across.
|
||||
#[test]
|
||||
fn fill_sign_marks_are_upright_as_displayed_on_rotated_pages() {
|
||||
for degrees in [0, 90, 180, 270] {
|
||||
let (mut session, id) = rotated_blank(degrees);
|
||||
let info = session.get(id).unwrap().info.pages[0].clone();
|
||||
// Displayed boxes: a 40 pt checkmark centred at (50, 50), an 80 × 8 pt line at (130, 50).
|
||||
for (mark, vx, w, h) in [(FillMark::Check, 50.0, 40.0, 40.0), (FillMark::Line, 130.0, 80.0, 8.0)] {
|
||||
let shape = Shape::Mark { rect: displayed_rect(&info, vx, 50.0, w, h), mark };
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddAnnotation(NewAnnotation {
|
||||
page: 0,
|
||||
style: Style::default_for(&shape),
|
||||
shape,
|
||||
contents: String::new(),
|
||||
author: "Test".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let check = |session: &Session, id: DocId| {
|
||||
let rendered = render_page(session, id);
|
||||
// Dark pixels in a displayed box, rendered at scale 2.
|
||||
let ink = |x0: u32, y0: u32, x1: u32, y1: u32| -> Vec<(u32, u32)> {
|
||||
(y0 * 2..y1 * 2)
|
||||
.flat_map(|y| (x0 * 2..x1 * 2).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| stamp_pixel(&rendered, x, y)[..3].iter().all(|c| *c < 128))
|
||||
.collect()
|
||||
};
|
||||
let tick = ink(30, 30, 70, 70);
|
||||
assert!(!tick.is_empty(), "rotation {degrees}: no checkmark drawn");
|
||||
let top = tick.iter().min_by_key(|p| p.1).unwrap();
|
||||
let bottom = tick.iter().max_by_key(|p| p.1).unwrap();
|
||||
// The long stroke ends at the top right; the short one meets it low, left of centre.
|
||||
assert!(top.0 > bottom.0 + 20, "rotation {degrees}: highest ink {top:?} should be right of the lowest {bottom:?}");
|
||||
assert!(bottom.0 < 100 && top.0 > 110, "rotation {degrees}: checkmark {bottom:?} .. {top:?} is not upright");
|
||||
let line = ink(85, 40, 175, 60);
|
||||
assert!(!line.is_empty(), "rotation {degrees}: no line drawn");
|
||||
let (x0, x1) = (line.iter().map(|p| p.0).min().unwrap(), line.iter().map(|p| p.0).max().unwrap());
|
||||
let (y0, y1) = (line.iter().map(|p| p.1).min().unwrap(), line.iter().map(|p| p.1).max().unwrap());
|
||||
assert!((x1 - x0) > 10 * (y1 - y0 + 1), "rotation {degrees}: line {x0}..{x1} x {y0}..{y1} should run across");
|
||||
};
|
||||
check(&session, id);
|
||||
let saved = session.save_bytes(id).unwrap();
|
||||
let mut reopened = Session::new().with_clock(|| 1_700_000_000);
|
||||
let again = reopened.open("drawing.pdf", None, saved, None).unwrap();
|
||||
check(&reopened, again);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill & Sign text and text boxes read across as displayed on turned pages (scanned forms are
|
||||
/// often stored sideways with `/Rotate`), including after save.
|
||||
#[test]
|
||||
@@ -2554,6 +2607,60 @@ fn comment_text(s: &Session, id: DocId) -> Vec<String> {
|
||||
t
|
||||
}
|
||||
|
||||
/// A dragged stamp's layers: the page without it (its text and other comments kept) and the
|
||||
/// stamp alone on a transparent page. Preparing them changes nothing in the document.
|
||||
#[test]
|
||||
fn stamp_preview_layers_split_the_stamp_from_its_page() {
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap();
|
||||
s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
let (generation, dirty) = (doc.edit_generation(), doc.dirty);
|
||||
let preview = doc.stamp_preview(0, 0).unwrap().expect("the line is a stamp");
|
||||
let (stamp, background) = (preview.render_stamp(1.0).unwrap(), preview.render_background(1.0).unwrap());
|
||||
let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] {
|
||||
p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap()
|
||||
};
|
||||
let line = |x: u32, y: u32| (30..=130).contains(&x) && (59..=67).contains(&y);
|
||||
let (mut drawn, mut stray) = (0, Vec::new());
|
||||
for y in 1..300 {
|
||||
for x in 0..200 {
|
||||
match (line(x, y), px(&stamp, x, y)) {
|
||||
(true, [.., 255]) => drawn += 1,
|
||||
(false, v) if v != [0, 0, 0, 0] => stray.push((x, y)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(drawn > 100, "the line is drawn opaque: {drawn} pixels");
|
||||
assert!(stray.is_empty(), "only the line is drawn on its layer: {:?}", &stray[..stray.len().min(8)]);
|
||||
assert!((30..=130).all(|x| px(&background, x, 63) == [255, 255, 255, 255]), "the page beneath has no line");
|
||||
assert!((138..=142).any(|x| px(&background, x, 65) != [255, 255, 255, 255]), "other comments stay");
|
||||
assert!((20..120).any(|x| (150..170).any(|y| px(&background, x, y)[0] < 128)), "the page text stays");
|
||||
assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a rectangle isn't a stamp");
|
||||
assert!(doc.stamp_preview(0, 9).unwrap().is_none());
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add rectangle")), "the document is unchanged");
|
||||
|
||||
// Another app's comments without appearances: beneath a dragged stamp they are drawn as on
|
||||
// screen, and such a stamp has nothing to lay over the page.
|
||||
let bare = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Stamp /Rect [20 20 80 40] /Name /Draft >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let mut s = Session::new();
|
||||
let id = s.open("bare.pdf", None, Arc::new(bare.to_vec()), None).unwrap();
|
||||
s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
let background = doc.stamp_preview(0, 2).unwrap().expect("the line is a stamp").render_background(1.0).unwrap();
|
||||
assert!((138..=142).any(|x| px(&background, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn beneath");
|
||||
assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a stamp without an appearance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() {
|
||||
let (mut s, id) = session_with(1);
|
||||
|
||||
@@ -398,6 +398,9 @@ pub struct Annotation {
|
||||
/// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture
|
||||
/// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`).
|
||||
pub fill_sign: bool,
|
||||
/// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a
|
||||
/// cloud (their corners, `/Vertices`), in user space; empty for other comments.
|
||||
pub points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Annotation {
|
||||
@@ -1163,6 +1166,16 @@ impl<'a> Inspector<'a> {
|
||||
};
|
||||
let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM"));
|
||||
let fill_sign = self.is_fill_sign(d, &subtype);
|
||||
// Keep in step with `pdfcraft_annot::summaries`.
|
||||
let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" };
|
||||
let points = match d.get(key).map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => {
|
||||
let v: Vec<f32> = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
|
||||
let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec();
|
||||
if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts }
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
info.annotations.push(Annotation {
|
||||
page,
|
||||
subtype,
|
||||
@@ -1181,6 +1194,7 @@ impl<'a> Inspector<'a> {
|
||||
locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0,
|
||||
intent: self.name(d, b"IT"),
|
||||
fill_sign,
|
||||
points,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ use hayro::hayro_interpret::hayro_cmap::CidFamily;
|
||||
use hayro::hayro_interpret::{InterpreterSettings, InterpreterWarning};
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use hayro::vello_cpu::color::AlphaColor;
|
||||
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
|
||||
use hayro::{RenderCache, RenderSettings, render_into, render_size};
|
||||
|
||||
use crate::{Pixels, ReadingColors};
|
||||
@@ -68,6 +68,9 @@ pub struct RenderConfig {
|
||||
/// View ▸ Reading colours: solid text and line-art paints are remapped for readability and
|
||||
/// the page clears to the setting's background. Display only; the document never changes.
|
||||
pub reading_colors: ReadingColors,
|
||||
/// The page clears to transparent instead of white (or the reading background): for a page
|
||||
/// holding only what is laid over another raster, such as a comment while it is dragged.
|
||||
pub transparent: bool,
|
||||
}
|
||||
|
||||
impl RenderConfig {
|
||||
@@ -303,10 +306,10 @@ fn render_page<'a>(
|
||||
pdf: &'a Pdf,
|
||||
cache: &RenderCache<'a>,
|
||||
settings: &InterpreterSettings,
|
||||
reject_oversize: bool,
|
||||
reading: ReadingColors,
|
||||
config: &RenderConfig,
|
||||
req: RenderRequest,
|
||||
) -> Result<Output, (String, bool)> {
|
||||
let reading = request_reading(config, &req);
|
||||
if req.kind == RequestKind::Text {
|
||||
return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) {
|
||||
Ok(Some(t)) => Ok((0, 0, Pixels::default(), Some(Arc::new(t)))),
|
||||
@@ -327,6 +330,7 @@ fn render_page<'a>(
|
||||
Some(p) => (AlphaColor::from_rgb8(p.background[0], p.background[1], p.background[2]), Some(hayro::ColorReplacement::from(p))),
|
||||
None => (WHITE, None),
|
||||
};
|
||||
let bg_color = if config.transparent { TRANSPARENT } else { bg_color };
|
||||
let rs = match req.tile {
|
||||
// Tiles are bounded by construction, so the page itself may be arbitrarily large.
|
||||
Some(t) => {
|
||||
@@ -345,7 +349,7 @@ fn render_page<'a>(
|
||||
}
|
||||
None => {
|
||||
let scale = effective_scale(w, h, req.scale);
|
||||
if reject_oversize && (!(req.scale.is_finite() && req.scale > 0.0) || req.scale > scale) {
|
||||
if config.reject_oversize && (!(req.scale.is_finite() && req.scale > 0.0) || req.scale > scale) {
|
||||
return Err(format!(
|
||||
"requested page {} raster scale {req_scale} exceeds renderer limits (maximum scale {scale})",
|
||||
req.page + 1,
|
||||
@@ -469,7 +473,7 @@ impl PageRenderer {
|
||||
let cache = RenderCache::new();
|
||||
let warnings = Arc::new(Mutex::new(Vec::new()));
|
||||
let settings = warning_settings(&self.settings, warnings.clone());
|
||||
let r = render_page(pdf, &cache, &settings, self.config.reject_oversize, request_reading(&self.config, &req), req);
|
||||
let r = render_page(pdf, &cache, &settings, &self.config, req);
|
||||
let warnings = take_warnings(&warnings);
|
||||
if matches!(r, Err((_, true))) {
|
||||
self.pdf = parse(&self.bytes, self.config.password.as_deref());
|
||||
@@ -1199,7 +1203,7 @@ fn worker(
|
||||
panic!("injected page panic");
|
||||
}
|
||||
match pdf {
|
||||
Some(pdf) => render_page(pdf, cache, &settings, config.reject_oversize, request_reading(&config, &req), req),
|
||||
Some(pdf) => render_page(pdf, cache, &settings, &config, req),
|
||||
None => Err(("the document could not be parsed".into(), false)),
|
||||
}
|
||||
}))
|
||||
@@ -6814,6 +6818,46 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
/// A transparent page leaves what nothing paints see-through, so it can be laid over another
|
||||
/// raster: with reading colours on, and in a tile, too. Painted pixels stay premultiplied.
|
||||
#[test]
|
||||
fn transparent_pages_paint_only_their_content() {
|
||||
let content = "0 0 1 rg 10 10 30 20 re f /GS gs 0.5 g 60 10 30 20 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.4\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R /Resources << /ExtGState << /GS << /ca 0.5 >> >> >> >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF",
|
||||
content.len()
|
||||
)
|
||||
.into_bytes();
|
||||
let pixel = |page: &RenderedPage, x: u32, y: u32| page.rgba[((y * page.width + x) * 4) as usize..][..4].to_vec();
|
||||
for reading_colors in [ReadingColors::default(), ReadingColors::WHITE_ON_BLACK] {
|
||||
let config = RenderConfig { transparent: true, reading_colors, ..Default::default() };
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.clone()), config.clone());
|
||||
let page = r.render(RenderRequest { page: 0, scale: 2.0, ..Default::default() });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
assert_eq!(pixel(&page, 5, 5), [0, 0, 0, 0], "{reading_colors:?}: nothing painted, nothing shown");
|
||||
assert_eq!(pixel(&page, 50, 60)[3], 255, "{reading_colors:?}: the opaque rectangle covers");
|
||||
assert_eq!(pixel(&page, 150, 60), [64, 64, 64, 128], "{reading_colors:?}: half-transparent grey, premultiplied");
|
||||
let tile = r.render(RenderRequest { page: 0, scale: 2.0, tile: Some(Tile { x: 0, y: 40, w: 100, h: 60 }), ..Default::default() });
|
||||
assert_eq!((pixel(&tile, 5, 5), pixel(&tile, 50, 20)), (pixel(&page, 5, 45), pixel(&page, 50, 60)), "{reading_colors:?}");
|
||||
let pool = RenderPool::new(Arc::new(pdf.clone()), 1, config);
|
||||
pool.set_queue(vec![RenderRequest { page: 0, scale: 2.0, ..Default::default() }]);
|
||||
let pooled = loop {
|
||||
if let Some(p) = pool.try_recv() {
|
||||
break p;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!(pooled.rgba, page.rgba, "{reading_colors:?}: the pool renders the same");
|
||||
}
|
||||
let opaque = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { page: 0, scale: 2.0, ..Default::default() });
|
||||
assert_eq!(pixel(&opaque, 5, 5), [255, 255, 255, 255], "pages are still white by default");
|
||||
}
|
||||
|
||||
/// Reading colours replace *document* colours, never annotation colours (issue #1548): a
|
||||
/// highlight keeps its `/C`, so on a dark background the content under it shows through the
|
||||
/// Multiply blend in the highlight's colour instead of being darkened into invisibility.
|
||||
@@ -7150,9 +7194,9 @@ endstream endobj
|
||||
let pdf = parse(&Arc::new(pdf), None).expect("parses");
|
||||
let cache = RenderCache::new();
|
||||
let settings = InterpreterSettings::default();
|
||||
let preview = render_page(&pdf, &cache, &settings, false, ReadingColors::default(), req(true)).expect("preview renders");
|
||||
let fin = render_page(&pdf, &cache, &settings, false, ReadingColors::default(), req(false)).expect("final renders");
|
||||
let fresh = render_page(&pdf, &RenderCache::new(), &settings, false, ReadingColors::default(), req(false)).expect("final renders");
|
||||
let preview = render_page(&pdf, &cache, &settings, &RenderConfig::default(), req(true)).expect("preview renders");
|
||||
let fin = render_page(&pdf, &cache, &settings, &RenderConfig::default(), req(false)).expect("final renders");
|
||||
let fresh = render_page(&pdf, &RenderCache::new(), &settings, &RenderConfig::default(), req(false)).expect("final renders");
|
||||
assert_eq!(rgb_at(&preview.2, w, 35, 35), [0, 0, 255]);
|
||||
assert_eq!(&fin.2[..], &fresh.2[..], "a final render after a preview matches one with a fresh cache");
|
||||
}
|
||||
|
||||
@@ -1301,6 +1301,21 @@ impl DocView {
|
||||
self.current = page;
|
||||
}
|
||||
|
||||
/// Reveal a user-space rectangle without changing zoom, centred where the scroll bounds allow.
|
||||
pub(crate) fn reveal_rect(&mut self, page: usize, rect: [f32; 4], info: &DocInfo) {
|
||||
self.go_to_page(page);
|
||||
self.goto_point = None;
|
||||
let Some(p) = info.pages.get(self.current) else { return };
|
||||
if !rect.iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
// Halve before adding so even large finite coordinates cannot overflow the midpoint.
|
||||
let x = rect[0] * 0.5 + rect[2] * 0.5;
|
||||
let y = rect[1] * 0.5 + rect[3] * 0.5;
|
||||
let [fx, fy] = p.dest_fraction(Some(x), Some(y), self.rotation);
|
||||
self.goto_point = Some((self.current, fx, fy, vec2(self.viewport_w, self.viewport_h) / 2.0));
|
||||
}
|
||||
|
||||
/// Go to a destination, a bookmark's or a link's (ISO 32000-2 §12.3.2.2): its page, scrolled
|
||||
/// and zoomed as `dest` asks, as Acrobat does. `/XYZ` puts (left, top) at the window's
|
||||
/// top-left at its zoom (null or 0 keeps the zoom); `/Fit`, `/FitH` and `/FitV` switch to fit
|
||||
@@ -2284,6 +2299,13 @@ pub struct PageXform {
|
||||
}
|
||||
|
||||
impl PageXform {
|
||||
/// Screen pixels per page point. The view's rotation swaps the rectangle's sides, so a page
|
||||
/// turned a quarter shows its width down the screen.
|
||||
pub(crate) fn zoom(&self) -> f32 {
|
||||
let across = if self.rot % 180 == 90 { self.rect.height() } else { self.rect.width() };
|
||||
across / self.pw.max(1.0)
|
||||
}
|
||||
|
||||
/// Draw an image in the PDF user-space `rect` as a custom stamp's appearance draws it: turned
|
||||
/// back by `turn` (the page /Rotate it was placed for), so with the page's own rotation it
|
||||
/// reads upright as displayed.
|
||||
@@ -3729,16 +3751,22 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// freehand, where it would cover the next stroke (#429).
|
||||
let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser));
|
||||
let gesturing = view.comments.gesture.is_some() || freehand;
|
||||
for a in info.annotations.iter().filter(|a| a.page == i && !gesturing) {
|
||||
let sr = xf.user_rect(info, i, a.rect);
|
||||
let hidden = prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty);
|
||||
if sr.contains(p)
|
||||
&& hover_text.is_none()
|
||||
&& !hidden
|
||||
&& let Some(text) = annotation_hover_text(a)
|
||||
{
|
||||
hover_text = Some((p, text));
|
||||
}
|
||||
// Inspection sorts annotations spatially, but the renderer paints in /Annots
|
||||
// order. Keep the shared list intact and choose the last eligible raw index.
|
||||
if hover_text.is_none()
|
||||
&& !gesturing
|
||||
&& let Some((_, text)) = info
|
||||
.annotations
|
||||
.iter()
|
||||
.filter(|a| a.page == i)
|
||||
.filter(|a| {
|
||||
!(prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty))
|
||||
})
|
||||
.filter(|a| pcx.screen_rects(a).any(|r| r.contains(p)))
|
||||
.filter_map(|a| annotation_hover_text(a).map(|text| (a.index, text)))
|
||||
.max_by_key(|(index, _)| *index)
|
||||
{
|
||||
hover_text = Some((p, text));
|
||||
}
|
||||
}
|
||||
for (mp, mr, mc) in &view.compare_marks {
|
||||
@@ -5653,6 +5681,7 @@ mod tests {
|
||||
locked: false,
|
||||
intent: None,
|
||||
fill_sign,
|
||||
points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5730,6 +5759,18 @@ mod tests {
|
||||
assert_eq!(badges.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_zoom_reads_the_side_the_page_width_is_on() {
|
||||
// A 200 x 100 pt page at 2 pixels per point: 400 x 200 on screen, or 200 x 400 turned.
|
||||
let xf = |rot: u16, size: Vec2| PageXform { rect: Rect::from_min_size(Pos2::ZERO, size), rot, pw: 200.0, ph: 100.0 };
|
||||
for rot in [0, 180] {
|
||||
assert_eq!(xf(rot, vec2(400.0, 200.0)).zoom(), 2.0, "{rot}");
|
||||
}
|
||||
for rot in [90, 270] {
|
||||
assert_eq!(xf(rot, vec2(200.0, 400.0)).zoom(), 2.0, "{rot}: the width runs down the screen");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_tabs_read_like_their_source_plus_the_suffix() {
|
||||
let snap = Snapshot { kind: SnapshotKind::SignedVersion, revision: 1, from: "report.pdf".into() };
|
||||
|
||||
+281
-43
@@ -347,6 +347,7 @@ pub fn tool_for(a: &Annotation) -> Option<CommentTool> {
|
||||
("FreeText", _) => CommentTool::TextBox,
|
||||
("Highlight", _) => CommentTool::Highlight,
|
||||
("Underline", _) => CommentTool::Underline,
|
||||
("StrikeOut", Some("StrikeOutTextEdit")) => CommentTool::ReplaceText,
|
||||
("StrikeOut", _) => CommentTool::StrikeOut,
|
||||
("Squiggly", _) => CommentTool::Squiggly,
|
||||
("Ink", _) => CommentTool::Ink,
|
||||
@@ -385,12 +386,19 @@ pub enum Gesture {
|
||||
Move { page: usize, index: usize, from: Pos2 },
|
||||
/// Resizing a comment by one of its handles: (dx, dy) ∈ {-1, 0, 1}² says which sides move.
|
||||
Resize { page: usize, index: usize, handle: (i8, i8), from: Pos2, aspect_ratio: Option<f32> },
|
||||
/// Dragging point `point` of a line, connected lines, a polygon or a cloud (its `points`
|
||||
/// before the drag, in user space) to where the pointer is.
|
||||
Reshape { page: usize, index: usize, point: usize, points: Vec<[f64; 2]> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ComposerKind {
|
||||
Note,
|
||||
TextBox,
|
||||
/// A text box; `size` is the box dragged out (displayed width and height, in user space
|
||||
/// units), `None` after a click: then the box fits its text.
|
||||
TextBox {
|
||||
size: Option<[f64; 2]>,
|
||||
},
|
||||
/// A callout whose arrow touches `point`; the composer's anchor is the text box's top-left.
|
||||
Callout {
|
||||
point: [f64; 2],
|
||||
@@ -521,6 +529,19 @@ pub struct CommentView {
|
||||
pub context_at: Option<(usize, [f64; 2])>,
|
||||
/// A one-shot tool just finished; the app returns to the Select tool unless pinned.
|
||||
pub tool_done: bool,
|
||||
/// What the inline editor (`editing`) does with the keyboard the next time it is drawn.
|
||||
pub(crate) editor_focus: Option<EditorFocus>,
|
||||
}
|
||||
|
||||
/// A one-off change to the keyboard focus of the Comments panel's inline editor. The editor
|
||||
/// never asks for the keyboard on its own: when it did so on every frame, it took the keyboard
|
||||
/// back after every click elsewhere, and the tool keys (V, H) were typed into it (#2669).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum EditorFocus {
|
||||
/// The editor just opened: it takes the keyboard, once.
|
||||
Take,
|
||||
/// Another comment was added: the editor keeps its text but gives the keyboard back.
|
||||
Release,
|
||||
}
|
||||
|
||||
impl CommentView {
|
||||
@@ -537,6 +558,25 @@ impl CommentView {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Open the inline editor on comment (page, index) with `text`. It takes the keyboard once,
|
||||
/// as it opens; after that the keyboard goes wherever the user clicks.
|
||||
pub(crate) fn open_editor(&mut self, page: usize, index: usize, text: String) {
|
||||
self.editing = Some((page, index, text));
|
||||
self.editor_focus = Some(EditorFocus::Take);
|
||||
}
|
||||
|
||||
/// A comment was just added. The editor open on another one must not keep the keyboard, or
|
||||
/// the tool keys pressed next are typed into it (#2669): an empty editor closes, and one
|
||||
/// holding text stays open with it, without the keyboard.
|
||||
pub(crate) fn comment_added(&mut self) {
|
||||
if self.editing.as_ref().is_some_and(|(_, _, text)| !text.is_empty()) {
|
||||
self.editor_focus = Some(EditorFocus::Release);
|
||||
} else {
|
||||
self.editing = None;
|
||||
self.editor_focus = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Comments panel order (Acrobat: Sort by page, author, date, type, colour).
|
||||
@@ -588,21 +628,19 @@ impl PageCx<'_> {
|
||||
}
|
||||
|
||||
/// Where a comment is on screen: one rectangle, or one per marked line for text markup.
|
||||
fn screen_rects(&self, a: &Annotation) -> Vec<Rect> {
|
||||
if is_markup(&a.subtype) && !a.quads.is_empty() {
|
||||
a.quads
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let xs = [q[0], q[2], q[4], q[6]];
|
||||
let ys = [q[1], q[3], q[5], q[7]];
|
||||
let (x0, x1) = (xs.iter().copied().fold(f32::MAX, f32::min), xs.iter().copied().fold(f32::MIN, f32::max));
|
||||
let (y0, y1) = (ys.iter().copied().fold(f32::MAX, f32::min), ys.iter().copied().fold(f32::MIN, f32::max));
|
||||
self.xf.user_rect(self.info, self.page, [x0, y0, x1, y1])
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![self.screen_rect(a)]
|
||||
}
|
||||
pub(crate) fn screen_rects<'b>(&'b self, a: &'b Annotation) -> impl Iterator<Item = Rect> + 'b {
|
||||
let quads = if is_markup(&a.subtype) { a.quads.as_slice() } else { &[] };
|
||||
let fallback = quads.is_empty().then(|| self.screen_rect(a));
|
||||
quads
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let xs = [q[0], q[2], q[4], q[6]];
|
||||
let ys = [q[1], q[3], q[5], q[7]];
|
||||
let (x0, x1) = (xs.iter().copied().fold(f32::MAX, f32::min), xs.iter().copied().fold(f32::MIN, f32::max));
|
||||
let (y0, y1) = (ys.iter().copied().fold(f32::MAX, f32::min), ys.iter().copied().fold(f32::MIN, f32::max));
|
||||
self.xf.user_rect(self.info, self.page, [x0, y0, x1, y1])
|
||||
})
|
||||
.chain(fallback)
|
||||
}
|
||||
|
||||
/// A redaction mark's areas: one per marked quad, or its rectangle.
|
||||
@@ -624,7 +662,7 @@ impl PageCx<'_> {
|
||||
|
||||
/// The topmost comment under `p`.
|
||||
fn hit(&self, p: Pos2) -> Option<&Annotation> {
|
||||
self.comments().filter(|a| self.screen_rects(a).iter().any(|r| r.expand(3.0).contains(p))).last()
|
||||
self.comments().filter(|a| self.screen_rects(a).any(|r| r.expand(3.0).contains(p))).last()
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, index: usize) -> Option<&Annotation> {
|
||||
@@ -681,6 +719,42 @@ fn resizable(a: &Annotation) -> bool {
|
||||
matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp" | "Ink") && a.intent.as_deref() != Some("FreeTextCallout")
|
||||
}
|
||||
|
||||
/// Lines, connected lines, polygons and clouds: their ends and corners have handles of their own
|
||||
/// (measurements keep the points they measured, and a locked comment its shape).
|
||||
fn reshapable(a: &Annotation) -> bool {
|
||||
matches!(a.subtype.as_str(), "Line" | "PolyLine" | "Polygon")
|
||||
&& !a.points.is_empty()
|
||||
&& !a.locked
|
||||
&& !a.intent.as_deref().is_some_and(|i| i.ends_with("Dimension"))
|
||||
}
|
||||
|
||||
/// Whether `tool` picks up a line's end and corner handles: the Select and Fill & Sign tools.
|
||||
/// The drawing tools draw from where they are pressed, so a new line starts at the end of the
|
||||
/// one just drawn rather than dragging it.
|
||||
fn grabs_points(tool: QuickTool) -> bool {
|
||||
!matches!(tool, QuickTool::Comment(t) if t.draws() || t.clicks_points())
|
||||
}
|
||||
|
||||
/// The point of `a` (an end or a corner) whose handle is at `p` on screen.
|
||||
fn point_handle(cx: &PageCx<'_>, a: &Annotation, p: Pos2) -> Option<usize> {
|
||||
a.points.iter().position(|q| cx.to_screen([f64::from(q[0]), f64::from(q[1])]).distance(p) <= 7.0)
|
||||
}
|
||||
|
||||
/// `points` with point `i` moved to `to`; with Shift, held to the nearest 45° from the point
|
||||
/// before it (a line's other end; the first corner turns about the second).
|
||||
pub(crate) fn reshaped(points: &[[f64; 2]], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> {
|
||||
let mut out = points.to_vec();
|
||||
let anchor = if i == 0 { points.get(1) } else { points.get(i - 1) };
|
||||
let to = match anchor {
|
||||
Some(a) if shift => pdfcraft_engine::measure::snap::constrain(*a, to),
|
||||
_ => to,
|
||||
};
|
||||
if let Some(p) = out.get_mut(i) {
|
||||
*p = to;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const HANDLES: [(i8, i8); 8] = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)];
|
||||
|
||||
fn handle_pos(r: Rect, (hx, hy): (i8, i8)) -> Pos2 {
|
||||
@@ -953,11 +1027,43 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
|
||||
if over_page {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
// A text box also takes a drag, as in Acrobat: the box is drawn at that size and its
|
||||
// text wraps inside it (#2669).
|
||||
if tool == CommentTool::TextBox {
|
||||
if resp.drag_started()
|
||||
&& pressed_here
|
||||
&& let Some(o) = origin
|
||||
{
|
||||
cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] });
|
||||
cv.selected = None;
|
||||
}
|
||||
if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut()
|
||||
&& *page == cx.page
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
points.truncate(1);
|
||||
points.push(cx.to_user(clamp_to(page_rect, p)));
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone()
|
||||
&& page == cx.page
|
||||
{
|
||||
cv.gesture = None;
|
||||
if let (Some(first), Some(last)) = (points.first(), points.last()) {
|
||||
let r = Rect::from_two_pos(cx.to_screen(*first), cx.to_screen(*last));
|
||||
// A drag of a few pixels is a click: the box fits its text.
|
||||
let drawn =
|
||||
(r.width() >= 6.0 && r.height() >= 6.0).then(|| cx.info.pages.get(page)).flatten().map(|p| drawn_box(p, *first, *last));
|
||||
let (at, size) = drawn.map_or((*first, None), |(at, size)| (at, Some(size)));
|
||||
cv.composer = Some(Composer { page, at, kind: ComposerKind::TextBox { size }, text: String::new(), focus: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.clicked()
|
||||
&& over_page
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox };
|
||||
let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } };
|
||||
cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true });
|
||||
cv.selected = None;
|
||||
}
|
||||
@@ -993,7 +1099,7 @@ pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool
|
||||
return false;
|
||||
}
|
||||
let cv = &view.comments;
|
||||
if matches!(cv.gesture, Some(Gesture::Resize { page, .. }) if page == cx.page) {
|
||||
if matches!(cv.gesture, Some(Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) {
|
||||
return true;
|
||||
}
|
||||
// A polygon being clicked out keeps its clicks, even near an old shape's handle.
|
||||
@@ -1009,7 +1115,8 @@ fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotati
|
||||
if cx.hidden {
|
||||
return false;
|
||||
}
|
||||
if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) {
|
||||
if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some(p) = press_point(ui, cx) else { return false };
|
||||
@@ -1025,12 +1132,10 @@ fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option<Pos2> {
|
||||
|
||||
/// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`.
|
||||
fn on_handle(cx: &PageCx<'_>, view: &DocView, p: Pos2, pick: impl Fn(&Annotation) -> bool) -> bool {
|
||||
view.comments
|
||||
.selected
|
||||
.filter(|(page, _)| *page == cx.page)
|
||||
.and_then(|(_, i)| cx.get(i))
|
||||
.filter(|a| pick(a) && cx.allowed && resizable(a))
|
||||
.is_some_and(|a| HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0))
|
||||
view.comments.selected.filter(|(page, _)| *page == cx.page).and_then(|(_, i)| cx.get(i)).filter(|a| pick(a) && cx.allowed).is_some_and(|a| {
|
||||
(resizable(a) && HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0))
|
||||
|| (grabs_points(cx.tool) && reshapable(a) && point_handle(cx, a, p).is_some())
|
||||
})
|
||||
}
|
||||
|
||||
fn clamp_to(r: Rect, p: Pos2) -> Pos2 {
|
||||
@@ -1055,9 +1160,14 @@ fn select_input(
|
||||
let r = cx.screen_rect(a);
|
||||
HANDLES.into_iter().find(|h| handle_pos(r, *h).distance(p) <= 7.0)
|
||||
};
|
||||
// Ends and corners of a selected line, connected lines, polygon or cloud.
|
||||
let point_at =
|
||||
|p: Pos2| -> Option<usize> { selected.filter(|a| cx.allowed && grabs_points(cx.tool) && reshapable(a)).and_then(|a| point_handle(cx, a, p)) };
|
||||
let mut consumed = false;
|
||||
if let Some(p) = pointer {
|
||||
if let Some(h) = handle_at(p) {
|
||||
if point_at(p).is_some() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
} else if let Some(h) = handle_at(p) {
|
||||
ui.ctx().set_cursor_icon(match h {
|
||||
(0, _) => egui::CursorIcon::ResizeVertical,
|
||||
(_, 0) => egui::CursorIcon::ResizeHorizontal,
|
||||
@@ -1073,7 +1183,11 @@ fn select_input(
|
||||
&& pressed_here
|
||||
&& let Some(o) = origin
|
||||
{
|
||||
if let (Some(h), Some(a)) = (handle_at(o), selected) {
|
||||
if let (Some(point), Some(a)) = (point_at(o), selected) {
|
||||
let points = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect();
|
||||
cv.gesture = Some(Gesture::Reshape { page: cx.page, index: a.index, point, points });
|
||||
consumed = true;
|
||||
} else if let (Some(h), Some(a)) = (handle_at(o), selected) {
|
||||
// On screen, the image is also turned by the view's rotation.
|
||||
let turned = !cx.xf.rot.is_multiple_of(180);
|
||||
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio });
|
||||
@@ -1095,7 +1209,7 @@ fn select_input(
|
||||
consumed = true;
|
||||
}
|
||||
}
|
||||
if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) {
|
||||
if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) {
|
||||
consumed = true;
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
@@ -1122,6 +1236,14 @@ fn select_input(
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => {
|
||||
cv.gesture = None;
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0);
|
||||
if moved {
|
||||
view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1232,6 +1354,9 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
|
||||
return paint_gesture(painter, cx, view);
|
||||
}
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
if cx.allowed && grabs_points(cx.tool) && reshapable(a) {
|
||||
return paint_points(painter, cx, view, a, pointer, shift);
|
||||
}
|
||||
let r = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref()).expand(2.0);
|
||||
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle);
|
||||
if cx.allowed && resizable(a) {
|
||||
@@ -1244,6 +1369,56 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
|
||||
paint_gesture(painter, cx, view);
|
||||
}
|
||||
|
||||
/// A selected line, connected lines, polygon or cloud: a handle on each end or corner and, while
|
||||
/// one is dragged (or its edit is on its way), the new shape over the old.
|
||||
/// The points a selected line, polygon or cloud is drawn with while it changes: an end or corner
|
||||
/// being dragged, the whole comment being moved by its body, or the edit just released (until the
|
||||
/// page is drawn again). `None` while it isn't changing.
|
||||
fn changing_points(
|
||||
gesture: Option<&Gesture>,
|
||||
pending: Option<&Edit>,
|
||||
(page, index): (usize, usize),
|
||||
points: &[[f64; 2]],
|
||||
to_user: impl Fn(Pos2) -> [f64; 2],
|
||||
pointer: Option<Pos2>,
|
||||
shift: bool,
|
||||
) -> Option<Vec<[f64; 2]>> {
|
||||
let shifted = |dx: f64, dy: f64| points.iter().map(|q| [q[0] + dx, q[1] + dy]).collect();
|
||||
match (gesture, pending) {
|
||||
(Some(Gesture::Reshape { page: p, index: i, point, points: from }), _) if (*p, *i) == (page, index) => {
|
||||
pointer.map(|at| reshaped(from, *point, to_user(at), shift))
|
||||
}
|
||||
(Some(Gesture::Move { page: p, index: i, from }), _) if (*p, *i) == (page, index) => pointer.map(|at| {
|
||||
let (a, b) = (to_user(*from), to_user(at));
|
||||
shifted(b[0] - a[0], b[1] - a[1])
|
||||
}),
|
||||
(_, Some(Edit::ReshapeAnnotation { page: p, index: i, points: to })) if (*p, *i) == (page, index) => Some(to.clone()),
|
||||
(_, Some(Edit::MoveAnnotation { page: p, index: i, dx, dy })) if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_points(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, a: &Annotation, pointer: Option<Pos2>, shift: bool) {
|
||||
let cv = &view.comments;
|
||||
let points: Vec<[f64; 2]> = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect();
|
||||
let changing = changing_points(cv.gesture.as_ref(), view.pending_edit.as_ref(), (cx.page, a.index), &points, |p| cx.to_user(p), pointer, shift);
|
||||
let changed = changing.is_some();
|
||||
let points = changing.unwrap_or(points);
|
||||
let screen: Vec<Pos2> = points.iter().map(|q| cx.to_screen(*q)).collect();
|
||||
if changed {
|
||||
let stroke = Stroke::new(1.5, SELECT_BLUE);
|
||||
painter.add(if a.subtype == "Polygon" {
|
||||
egui::Shape::closed_line(screen.clone(), stroke)
|
||||
} else {
|
||||
egui::Shape::line(screen.clone(), stroke)
|
||||
});
|
||||
}
|
||||
for c in screen {
|
||||
painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE));
|
||||
}
|
||||
paint_gesture(painter, cx, view);
|
||||
}
|
||||
|
||||
fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
|
||||
let now = painter.ctx().input(|i| i.time);
|
||||
for s in view.comments.settling.iter().filter(|s| s.page == cx.page && now - s.since < SETTLE_SECS) {
|
||||
@@ -1260,7 +1435,7 @@ fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
|
||||
fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, points: &[[f64; 2]]) {
|
||||
{
|
||||
let style = cx.prefs.style(*tool);
|
||||
let zoom = cx.xf.rect.width() / cx.xf.pw.max(1.0);
|
||||
let zoom = cx.xf.zoom();
|
||||
let stroke = Stroke::new((style.width as f32 * zoom).max(1.0), color32(style.color));
|
||||
let pts: Vec<Pos2> = points.iter().map(|p| cx.to_screen(*p)).collect();
|
||||
match tool {
|
||||
@@ -1268,7 +1443,9 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin
|
||||
painter.add(egui::Shape::line(pts, stroke));
|
||||
}
|
||||
CommentTool::Eraser => {
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(12.0, Color32::from_gray(128).gamma_multiply(0.35))));
|
||||
// As wide as what it rubs out (see `erase`).
|
||||
let width = (2.0 * eraser_radius_px(style.width)) as f32;
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(width, Color32::from_gray(128).gamma_multiply(0.35))));
|
||||
}
|
||||
CommentTool::Line | CommentTool::Arrow if pts.len() == 2 => {
|
||||
painter.line_segment([pts[0], pts[1]], stroke);
|
||||
@@ -1299,6 +1476,9 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin
|
||||
painter.circle_stroke(*first, 5.0, Stroke::new(1.0, SELECT_BLUE));
|
||||
}
|
||||
}
|
||||
CommentTool::TextBox if pts.len() == 2 => {
|
||||
painter.rect_stroke(Rect::from_two_pos(pts[0], pts[1]), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Inside);
|
||||
}
|
||||
CommentTool::Callout if pts.len() == 2 => {
|
||||
painter.line_segment([pts[0], pts[1]], stroke);
|
||||
painter.rect_stroke(Rect::from_min_size(pts[1], vec2(160.0, 40.0) * zoom), CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
|
||||
@@ -1334,10 +1514,23 @@ fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option<Shape> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Eraser: rub out drawings along `path` (user space); one undo step for all of them.
|
||||
/// The eraser's radius on screen, in pixels, from its thickness setting (#3747): three pixels
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
/// Eraser: rub out drawings along `path` (user space); one undo step for all of them. It rubs
|
||||
/// as wide on screen as its preview, whatever the zoom.
|
||||
fn erase(cx: &PageCx<'_>, path: &[[f64; 2]]) -> Option<Edit> {
|
||||
let zoom = (cx.xf.rect.width() / cx.xf.pw.max(1.0)) as f64;
|
||||
let radius = (6.0 / zoom).max(1.0);
|
||||
let zoom = f64::from(cx.xf.zoom());
|
||||
// A page with no size on screen has nothing to rub out (and no finite radius).
|
||||
if !(zoom.is_finite() && zoom > 0.0) {
|
||||
return None;
|
||||
}
|
||||
// Exactly the preview's width: no floor, which at a high zoom rubbed past it.
|
||||
let radius = eraser_radius_px(cx.prefs.style(CommentTool::Eraser).width) / zoom;
|
||||
let b = path.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]);
|
||||
let mut hits: Vec<usize> = cx
|
||||
.comments()
|
||||
@@ -1377,7 +1570,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
let c = view.comments.composer.as_mut()?;
|
||||
let title = match c.kind {
|
||||
ComposerKind::Note => "Sticky note",
|
||||
ComposerKind::TextBox => "Text box",
|
||||
ComposerKind::TextBox { .. } => "Text box",
|
||||
ComposerKind::Callout { .. } => "Callout",
|
||||
ComposerKind::Caret { .. } => "Inserted text",
|
||||
ComposerKind::Replace => "Replacement text",
|
||||
@@ -1393,7 +1586,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let hint = match c.kind {
|
||||
ComposerKind::TextBox | ComposerKind::Callout { .. } => tl!("Type text"),
|
||||
ComposerKind::TextBox { .. } | ComposerKind::Callout { .. } => tl!("Type text"),
|
||||
ComposerKind::Caret { .. } => tl!("Text to insert"),
|
||||
ComposerKind::Replace => tl!("Replacement text"),
|
||||
_ => tl!("Add a comment"),
|
||||
@@ -1444,14 +1637,15 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
view.comments.tool_done = true;
|
||||
Some(new_comment(&cx, CommentTool::Note, Shape::Note { at: c.at, icon: NoteIcon::Comment }, text))
|
||||
}
|
||||
ComposerKind::TextBox => {
|
||||
ComposerKind::TextBox { size } => {
|
||||
view.comments.tool_done = true;
|
||||
Some(new_comment(
|
||||
&cx,
|
||||
CommentTool::TextBox,
|
||||
Shape::TextBox { rect: text_box_rect(c.at, &text, 12.0, page_rotation(info, page)), font_size: 12.0 },
|
||||
text,
|
||||
))
|
||||
let rotation = page_rotation(info, page);
|
||||
let border = prefs.style(CommentTool::TextBox).width;
|
||||
let rect = match (size, info.pages.get(page)) {
|
||||
(Some(size), Some(p)) => drawn_text_box_rect(p, c.at, size, &text, 12.0, border),
|
||||
_ => text_box_rect(c.at, &text, 12.0, rotation),
|
||||
};
|
||||
Some(new_comment(&cx, CommentTool::TextBox, Shape::TextBox { rect, font_size: 12.0 }, text))
|
||||
}
|
||||
ComposerKind::Callout { point } => {
|
||||
view.comments.tool_done = true;
|
||||
@@ -1573,6 +1767,32 @@ pub fn text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64
|
||||
pdfcraft_engine::text_box_at(rotation, at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]])
|
||||
}
|
||||
|
||||
/// The box dragged between user-space points `a` and `b`: its displayed top-left corner (user
|
||||
/// space) and its displayed width and height. Measured in the page's view space, so the view's
|
||||
/// own rotation and zoom (View > Rotate View) do not turn or scale it.
|
||||
fn drawn_box(page: &PageInfo, a: [f64; 2], b: [f64; 2]) -> ([f64; 2], [f64; 2]) {
|
||||
let (a, b) = (page.user_to_view(a[0] as f32, a[1] as f32), page.user_to_view(b[0] as f32, b[1] as f32));
|
||||
let at = page.view_to_user(a[0].min(b[0]), a[1].min(b[1]));
|
||||
([f64::from(at[0]), f64::from(at[1])], [f64::from((a[0] - b[0]).abs()), f64::from((a[1] - b[1]).abs())])
|
||||
}
|
||||
|
||||
/// A text box dragged out `size` (displayed width and height) from its displayed top-left corner
|
||||
/// `at`, with a border `border` wide: the text wraps to its width inside the border, as its
|
||||
/// appearance lays it out. When the text needs more lines the box grows down, and moves up to stay
|
||||
/// on the page; text that cannot fit even so keeps the drawn height and is clipped.
|
||||
pub fn drawn_text_box_rect(page: &PageInfo, at: [f64; 2], size: [f64; 2], text: &str, font_size: f64, border: f64) -> [f64; 4] {
|
||||
use pdfcraft_engine::annot_text::wrap;
|
||||
let pad = 2.0 + border.max(0.0);
|
||||
let [w, drawn] = size;
|
||||
let lines = wrap(text, font_size, (w - 2.0 * pad).max(1.0)).len().max(1);
|
||||
let needed = lines as f64 * font_size * 1.2 + 2.0 * pad + 2.0;
|
||||
let [vx, vy] = page.user_to_view(at[0] as f32, at[1] as f32).map(f64::from);
|
||||
let bottom = f64::from(page.height);
|
||||
let (top, h) = if needed <= drawn || needed > bottom { (vy, drawn) } else { (vy.min(bottom - needed).max(0.0), needed) };
|
||||
let at = page.view_to_user(vx as f32, top as f32).map(f64::from);
|
||||
pdfcraft_engine::text_box_at(i64::from(page.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]])
|
||||
}
|
||||
|
||||
/// The `/Rotate` of `page` (0 when it is unknown).
|
||||
pub(crate) fn page_rotation(info: &DocInfo, page: usize) -> i64 {
|
||||
info.pages.get(page).map_or(0, |p| i64::from(p.rotation))
|
||||
@@ -1952,6 +2172,24 @@ pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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]
|
||||
fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() {
|
||||
let line = [[10.0, 10.0], [50.0, 10.0]];
|
||||
let to_user = |p: Pos2| [f64::from(p.x), f64::from(p.y)];
|
||||
let moving = Gesture::Move { page: 0, index: 3, from: pos2(20.0, 10.0) };
|
||||
let at = Some(pos2(25.0, 30.0));
|
||||
assert_eq!(changing_points(Some(&moving), None, (0, 3), &line, to_user, at, false), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
|
||||
assert_eq!(changing_points(Some(&moving), None, (0, 4), &line, to_user, at, false), None, "another comment's move");
|
||||
let released = Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 };
|
||||
assert_eq!(changing_points(None, Some(&released), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
|
||||
let dragging = Gesture::Reshape { page: 0, index: 3, point: 1, points: line.to_vec() };
|
||||
let to = Some(pos2(60.0, 10.0));
|
||||
assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, false), Some(vec![[10.0, 10.0], [60.0, 10.0]]));
|
||||
assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, false), None, "not changing");
|
||||
}
|
||||
use pdfcraft_render::TextGlyph;
|
||||
|
||||
/// One glyph per character, `w` points wide, on a line of `em`-point text whose baseline is at `y`, drawn
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! current page; cards grouped by page. Selecting a card selects the comment on the page and
|
||||
//! opens its reply box and "…" menu (Edit, Set status, Delete). Status replies show as a badge.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::{Align, Color32, CornerRadius, Layout, Sense, Stroke, vec2};
|
||||
use pdfcraft_engine::{Edit, NoteIcon, ReviewState, Shape};
|
||||
use pdfcraft_render::{Annotation, DocInfo};
|
||||
@@ -33,9 +35,10 @@ pub fn subtype_label(s: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The comment type as the panel names it, telling callouts and clouds apart by `/IT`.
|
||||
/// The comment type as the panel names it, telling callouts, clouds and replaced text apart by `/IT`.
|
||||
pub fn kind_label(a: &Annotation) -> &str {
|
||||
match a.intent.as_deref() {
|
||||
Some("StrikeOutTextEdit") => "Replace text",
|
||||
Some("FreeTextCallout") => "Callout",
|
||||
Some("PolygonCloud") => "Cloud",
|
||||
Some("FreeTextTypeWriter") => "Typewriter",
|
||||
@@ -154,12 +157,18 @@ pub(crate) fn show(
|
||||
return edit;
|
||||
}
|
||||
let query = view.comments.search.clone().filter(|q| !q.trim().is_empty());
|
||||
let replies_of = |a: &Annotation| -> Vec<&Annotation> {
|
||||
match &a.name {
|
||||
Some(nm) => info.annotations.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(),
|
||||
None => Vec::new(),
|
||||
let mut replies: HashMap<&str, Vec<&Annotation>> = HashMap::new();
|
||||
let mut page_counts: HashMap<usize, usize> = HashMap::new();
|
||||
// Keep global name matching and source reply order. Page badges count roots before filtering.
|
||||
for a in &info.annotations {
|
||||
if let Some(parent) = a.in_reply_to.as_deref() {
|
||||
replies.entry(parent).or_default().push(a);
|
||||
} else {
|
||||
let count = page_counts.entry(a.page).or_default();
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
let replies_of = |a: &Annotation| -> &[&Annotation] { a.name.as_deref().and_then(|name| replies.get(name)).map(Vec::as_slice).unwrap_or(&[]) };
|
||||
let cv = &view.comments;
|
||||
let status_of =
|
||||
|a: &Annotation| replies_of(a).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into());
|
||||
@@ -172,7 +181,7 @@ pub(crate) fn show(
|
||||
.filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default()))
|
||||
.filter(|a| !cv.hidden_statuses.contains(&status_of(a)))
|
||||
.filter(|a| !cv.hidden_colors.contains(&color_key(a)))
|
||||
.filter(|a| !cv.hidden_checks.contains(&check_label(is_marked(&replies_of(a))).to_string()))
|
||||
.filter(|a| !cv.hidden_checks.contains(&check_label(is_marked(replies_of(a))).to_string()))
|
||||
.collect();
|
||||
let sort = cv.sort;
|
||||
match sort {
|
||||
@@ -221,7 +230,7 @@ pub(crate) fn show(
|
||||
}
|
||||
} else if a.page != page {
|
||||
page = a.page;
|
||||
let n = info.annotations.iter().filter(|x| x.page == page && x.in_reply_to.is_none()).count();
|
||||
let n = page_counts.get(&page).copied().unwrap_or_default();
|
||||
let open = !view.comments.collapsed_pages.contains(&page);
|
||||
ui.add_space(6.0);
|
||||
let clicked = ui
|
||||
@@ -255,7 +264,7 @@ pub(crate) fn show(
|
||||
continue;
|
||||
}
|
||||
let thread = replies_of(a);
|
||||
if let Some(e) = card(ui, t, a, &thread, info, view, prefs, allowed, nav) {
|
||||
if let Some(e) = card(ui, t, a, thread, info, view, prefs, allowed, nav) {
|
||||
edit = Some(e);
|
||||
}
|
||||
}
|
||||
@@ -324,8 +333,10 @@ fn card(
|
||||
ui.set_width(ui.available_width());
|
||||
if editing && let Some((_, _, text)) = view.comments.editing.as_mut() {
|
||||
let r = ui.add(crate::widgets::block(text).desired_rows(2).desired_width(f32::INFINITY).id_salt(("comment-edit", key)));
|
||||
if !r.has_focus() && !ui.memory(|m| m.focused().is_some()) {
|
||||
r.request_focus();
|
||||
match view.comments.editor_focus.take() {
|
||||
Some(crate::comments::EditorFocus::Take) => r.request_focus(),
|
||||
Some(crate::comments::EditorFocus::Release) => r.surrender_focus(),
|
||||
None => {}
|
||||
}
|
||||
let submit = crate::comments::submit_key(ui, &r, false);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -434,7 +445,7 @@ fn card(
|
||||
*nav = Some(Nav::Flash(a.page, a.rect));
|
||||
}
|
||||
if click.double_clicked() && allowed {
|
||||
view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default()));
|
||||
view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default());
|
||||
}
|
||||
let menu_id = ui.id().with(("card-menu", key));
|
||||
if open_menu {
|
||||
@@ -450,7 +461,7 @@ fn card(
|
||||
ui.set_min_width(160.0);
|
||||
crate::comments::attachment_items(ui, view, info, a);
|
||||
if ui.button(tl_ctx!("comment menu", "Edit")).clicked() {
|
||||
view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default()));
|
||||
view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default());
|
||||
ui.close();
|
||||
}
|
||||
ui.menu_button(tl!("Set status"), |ui| {
|
||||
|
||||
@@ -128,6 +128,9 @@ impl PdfCraftApp {
|
||||
} else {
|
||||
view.document_changed(info);
|
||||
}
|
||||
if matches!(edit, Edit::AddAnnotation(_)) {
|
||||
view.comments.comment_added();
|
||||
}
|
||||
// Keep the pages the user acted on selected, where they now are.
|
||||
match edit {
|
||||
Edit::MovePages { pages, to } => {
|
||||
@@ -159,9 +162,9 @@ impl PdfCraftApp {
|
||||
// unless a note typed into another card is still unsaved.
|
||||
if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest)
|
||||
&& a.contents.is_empty()
|
||||
&& view.comments.editing.as_ref().is_none_or(|(_, _, text)| text.is_empty())
|
||||
&& view.comments.editing.is_none()
|
||||
{
|
||||
view.comments.editing = Some((a.page, n, String::new()));
|
||||
view.comments.open_editor(a.page, n, String::new());
|
||||
}
|
||||
}
|
||||
Edit::DeleteAnnotation { .. } => view.comments.selected = None,
|
||||
@@ -982,6 +985,7 @@ fn comment_page(edit: &Edit) -> Option<usize> {
|
||||
| Edit::ReplyToAnnotation { page, .. }
|
||||
| Edit::SetAnnotationStatus { page, .. }
|
||||
| Edit::MoveAnnotation { page, .. }
|
||||
| Edit::ReshapeAnnotation { page, .. }
|
||||
| Edit::ResizeAnnotation { page, .. }
|
||||
| Edit::StyleAnnotation { page, .. }
|
||||
| Edit::FillAnnotation { page, .. }
|
||||
|
||||
@@ -676,8 +676,7 @@ pub(crate) fn page_input(
|
||||
},
|
||||
mark => {
|
||||
let mark = mark.mark()?;
|
||||
let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) };
|
||||
let rect = [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0];
|
||||
let rect = mark.rect_at(at, rotation);
|
||||
Some(FillAction::Edit(Box::new(new(page, Shape::Mark { rect, mark }, String::new(), author))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2763,3 +2763,5 @@ certificate Version الإصدار
|
||||
Thumbnail size حجم الصور المصغرة
|
||||
Smaller thumbnails تصغير الصور المصغرة
|
||||
Larger thumbnails تكبير الصور المصغرة
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment إعادة تشكيل التعليق
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2450,3 +2450,5 @@ animation effects Off Изключени
|
||||
Thumbnail size Размер на миниатюрите
|
||||
Smaller thumbnails По-малки миниатюри
|
||||
Larger thumbnails По-големи миниатюри
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Промяна на формата на коментара
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -2561,3 +2561,5 @@ animation effects Off Vypnuto
|
||||
Thumbnail size Velikost miniatur
|
||||
Smaller thumbnails Menší miniatury
|
||||
Larger thumbnails Větší miniatury
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Změnit tvar komentáře
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2488,3 +2488,5 @@ animation effects Off Aus
|
||||
Thumbnail size Miniaturgröße
|
||||
Smaller thumbnails Kleinere Miniaturen
|
||||
Larger thumbnails Größere Miniaturen
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Kommentar umformen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -2493,3 +2493,5 @@ animation effects Off Ανενεργά
|
||||
Thumbnail size Μέγεθος μικρογραφιών
|
||||
Smaller thumbnails Μικρότερες μικρογραφίες
|
||||
Larger thumbnails Μεγαλύτερες μικρογραφίες
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Αλλαγή σχήματος σχολίου
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 479 and column 9.
|
@@ -2627,3 +2627,5 @@ animation effects Off Desactivados
|
||||
Thumbnail size Tamaño de las miniaturas
|
||||
Smaller thumbnails Miniaturas más pequeñas
|
||||
Larger thumbnails Miniaturas más grandes
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Cambiar la forma del comentario
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -2487,3 +2487,5 @@ animation effects Off Désactivés
|
||||
Thumbnail size Taille des vignettes
|
||||
Smaller thumbnails Réduire les vignettes
|
||||
Larger thumbnails Agrandir les vignettes
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Modifier la forme du commentaire
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -2486,3 +2486,5 @@ animation effects Off Ki
|
||||
Thumbnail size Bélyegképek mérete
|
||||
Smaller thumbnails Kisebb bélyegképek
|
||||
Larger thumbnails Nagyobb bélyegképek
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Megjegyzés alakjának módosítása
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -2699,3 +2699,5 @@ certificate SHA-256 digest Impronta SHA-256
|
||||
Thumbnail size Dimensione miniature
|
||||
Smaller thumbnails Miniature più piccole
|
||||
Larger thumbnails Miniature più grandi
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Modifica la forma del commento
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2487,3 +2487,5 @@ animation effects Off オフ
|
||||
Thumbnail size サムネイルのサイズ
|
||||
Smaller thumbnails サムネイルを小さく
|
||||
Larger thumbnails サムネイルを大きく
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment コメントの形状を変更
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2485,3 +2485,5 @@ animation effects Off Өшірулі
|
||||
Thumbnail size Нобай өлшемі
|
||||
Smaller thumbnails Кішірек нобайлар
|
||||
Larger thumbnails Үлкенірек нобайлар
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Пікір пішінін өзгерту
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 856 and column 9.
|
@@ -2737,3 +2737,5 @@ theme Appearance Išvaizda
|
||||
Thumbnail size Miniatiūrų dydis
|
||||
Smaller thumbnails Mažesnės miniatiūros
|
||||
Larger thumbnails Didesnės miniatiūros
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Keisti komentaro formą
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 404 and column 9.
|
@@ -2463,3 +2463,5 @@ animation effects Off Wyłączone
|
||||
Thumbnail size Rozmiar miniatur
|
||||
Smaller thumbnails Mniejsze miniatury
|
||||
Larger thumbnails Większe miniatury
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Zmień kształt komentarza
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2501,3 +2501,5 @@ animation effects Off Desativados
|
||||
Thumbnail size Tamanho das miniaturas
|
||||
Smaller thumbnails Miniaturas menores
|
||||
Larger thumbnails Miniaturas maiores
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Alterar a forma do comentário
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2400,3 +2400,5 @@ animation effects Off Выкл
|
||||
Thumbnail size Размер миниатюр
|
||||
Smaller thumbnails Уменьшить миниатюры
|
||||
Larger thumbnails Увеличить миниатюры
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Изменить форму комментария
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2492,3 +2492,5 @@ animation effects Off Izklopljeni
|
||||
Thumbnail size Velikost sličic
|
||||
Smaller thumbnails Manjše sličice
|
||||
Larger thumbnails Večje sličice
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Spremeni obliko komentarja
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2396,3 +2396,5 @@ animation effects Off ఆఫ్
|
||||
Thumbnail size థంబ్నెయిల్ పరిమాణం
|
||||
Smaller thumbnails చిన్న థంబ్నెయిల్లు
|
||||
Larger thumbnails పెద్ద థంబ్నెయిల్లు
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment వ్యాఖ్య ఆకారాన్ని మార్చు
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -2483,3 +2483,5 @@ animation effects Off Kapalı
|
||||
Thumbnail size Küçük resim boyutu
|
||||
Smaller thumbnails Küçük resimleri küçült
|
||||
Larger thumbnails Küçük resimleri büyüt
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Yorumun şeklini değiştir
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 855 and column 9.
|
@@ -2509,3 +2509,5 @@ animation effects Off Вимкнено
|
||||
Thumbnail size Розмір мініатюр
|
||||
Smaller thumbnails Зменшити мініатюри
|
||||
Larger thumbnails Збільшити мініатюри
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Змінити форму коментаря
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2739,3 +2739,5 @@ key Space Space
|
||||
Thumbnail size Kích thước hình thu nhỏ
|
||||
Smaller thumbnails Giảm kích thước hình thu nhỏ
|
||||
Larger thumbnails Tăng kích thước hình thu nhỏ
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment Đổi hình dạng nhận xét
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 491 and column 12.
|
@@ -2451,3 +2451,5 @@ animation effects Off 关
|
||||
Thumbnail size 缩略图大小
|
||||
Smaller thumbnails 缩小缩略图
|
||||
Larger thumbnails 放大缩略图
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment 更改注释形状
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2437,3 +2437,5 @@ animation effects Off 關
|
||||
Thumbnail size 縮圖大小
|
||||
Smaller thumbnails 縮小縮圖
|
||||
Larger thumbnails 放大縮圖
|
||||
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
|
||||
Reshape comment 變更註解形狀
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -990,7 +990,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
},
|
||||
Some(Nav::Flash(p, r)) => {
|
||||
let v = &mut app.views[index];
|
||||
v.go_to_page(p);
|
||||
if let Some(doc) = app.session.get(id) {
|
||||
v.reveal_rect(p, r, &doc.info);
|
||||
} else {
|
||||
v.go_to_page(p);
|
||||
}
|
||||
v.flash = Some((p, r, 0.0));
|
||||
}
|
||||
None => {}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
//! Cached page/image layers: a signature follows each pointer frame without editing the PDF.
|
||||
//! Cached page and stamp layers: a stamp (an image signature, a Fill & Sign mark, any other stamp)
|
||||
//! follows each pointer frame, moved or resized, without editing the PDF.
|
||||
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions};
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions, pos2, vec2};
|
||||
use pdfcraft_engine::{Document, Edit};
|
||||
use pdfcraft_render::{RenderPool, RenderRequest};
|
||||
use pdfcraft_render::{PageInfo, RenderPool, RenderRequest, Tile};
|
||||
|
||||
use crate::QuickTool;
|
||||
use crate::comments::{CommentView, Gesture, PageCx};
|
||||
|
||||
/// The longest side, in pixels, a stamp's own layer is rendered at.
|
||||
const MAX_STAMP_SIDE: f32 = 2048.0;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SignatureDrag {
|
||||
key: Option<(u64, usize, usize)>,
|
||||
@@ -15,10 +19,7 @@ pub(crate) struct SignatureDrag {
|
||||
}
|
||||
|
||||
struct Layers {
|
||||
image: TextureHandle,
|
||||
opacity: f32,
|
||||
/// The page /Rotate the PDF draws the image turned back by.
|
||||
turn: i64,
|
||||
front: Front,
|
||||
renderer: RenderPool,
|
||||
background: Option<TextureHandle>,
|
||||
tag: Option<u64>,
|
||||
@@ -27,6 +28,29 @@ struct Layers {
|
||||
settling: bool,
|
||||
}
|
||||
|
||||
/// What is drawn over the page without the stamp.
|
||||
enum Front {
|
||||
/// An image signature's own image, sharp at every zoom.
|
||||
Image {
|
||||
texture: TextureHandle,
|
||||
opacity: f32,
|
||||
/// The page /Rotate the PDF draws the image turned back by.
|
||||
turn: i64,
|
||||
},
|
||||
/// Any other stamp, rendered alone. Moving or resizing a stamp only changes its `/Rect`,
|
||||
/// which its appearance is stretched to fit, so its pixels are stretched with the frame.
|
||||
Stamp(Box<StampLayer>),
|
||||
}
|
||||
|
||||
struct StampLayer {
|
||||
renderer: RenderPool,
|
||||
/// The stamp's rectangle (user space) in the document it is rendered from.
|
||||
from: [f32; 4],
|
||||
tag: Option<u64>,
|
||||
/// The rendered stamp: its tag, and the view-space box its pixels cover.
|
||||
pixels: Option<(u64, TextureHandle, [f32; 4])>,
|
||||
}
|
||||
|
||||
impl SignatureDrag {
|
||||
pub fn prepare(&mut self, ctx: &egui::Context, doc: &Document, selected: Option<(usize, usize)>, scale: f32) {
|
||||
let key = selected.map(|(p, i)| (doc.edit_generation(), p, i));
|
||||
@@ -35,38 +59,35 @@ impl SignatureDrag {
|
||||
self.aspect_ratio = None;
|
||||
self.layers = None;
|
||||
if let Some((_, page, index)) = key
|
||||
&& doc.info.annotations.iter().any(|a| a.page == page && a.index == index && a.subtype == "Stamp" && !a.locked)
|
||||
&& let Ok(Some(preview)) = doc.image_signature_preview(page, index)
|
||||
&& let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && a.subtype == "Stamp" && !a.locked)
|
||||
{
|
||||
let [width, height] = preview.image.size();
|
||||
let ratio = width as f32 / height as f32;
|
||||
// As displayed, the image is turned by the page's rotation less the turn it is drawn back by.
|
||||
let shown = doc.info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) - preview.turn;
|
||||
self.aspect_ratio = Some(if shown.rem_euclid(180) == 0 { ratio } else { ratio.recip() });
|
||||
self.layers = Some(Layers {
|
||||
opacity: preview.opacity,
|
||||
turn: preview.turn,
|
||||
image: ctx.load_texture(
|
||||
if let Ok(Some(preview)) = doc.image_signature_preview(page, index) {
|
||||
let [width, height] = preview.image.size();
|
||||
let ratio = width as f32 / height as f32;
|
||||
// As displayed, the image is turned by the page's rotation less the turn it is drawn back by.
|
||||
let shown = doc.info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) - preview.turn;
|
||||
self.aspect_ratio = Some(if shown.rem_euclid(180) == 0 { ratio } else { ratio.recip() });
|
||||
let texture = ctx.load_texture(
|
||||
"signature-drag-image",
|
||||
ColorImage::from_rgba_unmultiplied(preview.image.size(), preview.image.rgba()),
|
||||
TextureOptions::LINEAR,
|
||||
),
|
||||
renderer: preview.renderer(),
|
||||
background: None,
|
||||
tag: None,
|
||||
received_tag: None,
|
||||
settling: false,
|
||||
});
|
||||
);
|
||||
let front = Front::Image { texture, opacity: preview.opacity, turn: preview.turn };
|
||||
self.layers = Some(Layers::new(front, preview.renderer()));
|
||||
} else if let Ok(Some(preview)) = doc.stamp_preview(page, index) {
|
||||
let stamp = StampLayer { renderer: preview.stamp_renderer(), from: a.rect, tag: None, pixels: None };
|
||||
self.layers = Some(Layers::new(Front::Stamp(Box::new(stamp)), preview.background_renderer()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (Some((_, page, _)), Some(layers)) = (self.key, self.layers.as_mut()) else { return };
|
||||
// The image stays sharp at every zoom. The background matches the page view up to one
|
||||
// whole-page texture; past that the view tiles and this preview stays at that cap.
|
||||
// The background matches the page view up to one whole-page texture; past that the view
|
||||
// tiles and this preview stays at that cap.
|
||||
let Some(p) = doc.info.pages.get(page) else { return };
|
||||
let scale = scale.min(crate::canvas::BASE_SIDE / p.width.max(p.height).max(1.0));
|
||||
let tag = (scale * 1000.0) as u64;
|
||||
let background_scale = scale.min(crate::canvas::BASE_SIDE / p.width.max(p.height).max(1.0));
|
||||
let tag = (background_scale * 1000.0) as u64;
|
||||
if layers.tag != Some(tag) {
|
||||
layers.renderer.set_queue(vec![RenderRequest { page, scale, tag, preview: true, ..Default::default() }]);
|
||||
layers.renderer.set_queue(vec![RenderRequest { page, scale: background_scale, tag, preview: true, ..Default::default() }]);
|
||||
layers.tag = Some(tag);
|
||||
}
|
||||
if let Some(out) = layers.renderer.try_recv()
|
||||
@@ -85,7 +106,34 @@ impl SignatureDrag {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if layers.received_tag != Some(tag) {
|
||||
let mut pending = layers.received_tag != Some(tag);
|
||||
// The stamp itself is small: it stays sharp at every zoom.
|
||||
if let Front::Stamp(stamp) = &mut layers.front {
|
||||
let Some((scale, tile)) = stamp_tile(p, stamp.from, scale) else {
|
||||
// Nothing to lay over the page: the frame alone follows the pointer, as before.
|
||||
self.layers = None;
|
||||
return;
|
||||
};
|
||||
let tag = (scale * 1000.0) as u64;
|
||||
if stamp.tag != Some(tag) {
|
||||
stamp.renderer.set_queue(vec![RenderRequest { page, scale, tag, tile: Some(tile), ..Default::default() }]);
|
||||
stamp.tag = Some(tag);
|
||||
}
|
||||
if let Some(out) = stamp.renderer.try_recv()
|
||||
&& out.request.tag == tag
|
||||
{
|
||||
let (Some(t), None) = (out.request.tile, &out.error) else {
|
||||
self.layers = None;
|
||||
return;
|
||||
};
|
||||
let s = out.request.scale;
|
||||
let area = [t.x as f32 / s, t.y as f32 / s, t.x.saturating_add(t.w) as f32 / s, t.y.saturating_add(t.h) as f32 / s];
|
||||
let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba);
|
||||
stamp.pixels = Some((tag, ctx.load_texture("signature-drag-stamp", image, TextureOptions::LINEAR), area));
|
||||
}
|
||||
pending |= stamp.pixels.as_ref().is_none_or(|(received, ..)| *received != tag);
|
||||
}
|
||||
if pending {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
@@ -100,7 +148,7 @@ impl SignatureDrag {
|
||||
self.key.filter(|(_, p, i)| (*p, *i) == (page, index)).and(self.aspect_ratio)
|
||||
}
|
||||
|
||||
/// Movement/resizing only change this signature's geometry, so its background is reusable.
|
||||
/// Movement/resizing only change this stamp's geometry, so its layers are reusable.
|
||||
pub fn committed(&mut self, edit: &Edit, generation: u64) {
|
||||
if let Edit::MoveAnnotation { page, index, .. } | Edit::ResizeAnnotation { page, index, .. } = edit
|
||||
&& self.contains(*page, *index)
|
||||
@@ -133,18 +181,66 @@ impl SignatureDrag {
|
||||
return;
|
||||
}
|
||||
let (Some(background), Some(a), Some(p)) = (&layers.background, cx.get(index), cx.info.pages.get(page)) else { return };
|
||||
if let Front::Stamp(stamp) = &layers.front
|
||||
&& stamp.pixels.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let (pointer, shift) = painter.ctx().input(|i| (i.pointer.hover_pos(), i.modifiers.shift));
|
||||
let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, pending);
|
||||
let rect = cx.rect_to_user(rect);
|
||||
let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect));
|
||||
cx.xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0);
|
||||
cx.xf.paint_user_image(
|
||||
&painter,
|
||||
layers.image.id(),
|
||||
p,
|
||||
rect,
|
||||
layers.turn,
|
||||
egui::Color32::from_white_alpha((layers.opacity * 255.0).round() as u8),
|
||||
);
|
||||
match &layers.front {
|
||||
Front::Image { texture, opacity, turn } => cx.xf.paint_user_image(
|
||||
&painter,
|
||||
texture.id(),
|
||||
p,
|
||||
cx.rect_to_user(rect),
|
||||
*turn,
|
||||
egui::Color32::from_white_alpha((opacity * 255.0).round() as u8),
|
||||
),
|
||||
Front::Stamp(stamp) => {
|
||||
let Some((_, texture, area)) = &stamp.pixels else { return };
|
||||
// Where the stamp's pixels were rendered, stretched as its box is from `from` to `rect`.
|
||||
let from = cx.xf.user_rect(cx.info, page, stamp.from);
|
||||
if from.width() < 0.01 || from.height() < 0.01 {
|
||||
return;
|
||||
}
|
||||
let stretch = vec2(rect.width() / from.width(), rect.height() / from.height());
|
||||
let mut mesh = egui::Mesh::with_texture(texture.id());
|
||||
for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] {
|
||||
let (x, y) = (area[0] + u * (area[2] - area[0]), area[1] + v * (area[3] - area[1]));
|
||||
let at = cx.xf.norm_to_screen(x / cx.xf.pw, y / cx.xf.ph);
|
||||
let pos = rect.min + (at - from.min) * stretch;
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos, uv: pos2(u, v), color: egui::Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Layers {
|
||||
fn new(front: Front, renderer: RenderPool) -> Self {
|
||||
Self { front, renderer, background: None, tag: None, received_tag: None, settling: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// The scale and tile of the page raster that hold a stamp at `rect` (user space), a pixel to
|
||||
/// spare around it for its anti-aliased edges: at `scale`, or less to keep it within
|
||||
/// [`MAX_STAMP_SIDE`]. `None` for a page or stamp that has no finite box.
|
||||
fn stamp_tile(p: &PageInfo, rect: [f32; 4], scale: f32) -> Option<(f32, Tile)> {
|
||||
let [a, b] = [p.user_to_view(rect[0], rect[1]), p.user_to_view(rect[2], rect[3])];
|
||||
let (x0, y0, x1, y1) = (a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1]));
|
||||
let scale = scale.min(MAX_STAMP_SIDE / (x1 - x0).max(y1 - y0).max(1.0));
|
||||
let (width, height) = ((p.width * scale).ceil(), (p.height * scale).ceil());
|
||||
if ![scale, x0, y0, x1, y1, width, height].iter().all(|v| v.is_finite()) || scale <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// Float to integer casts saturate, and each edge stays on the page.
|
||||
let (left, top) = (((x0 * scale).floor() - 1.0).max(0.0).min(width), ((y0 * scale).floor() - 1.0).max(0.0).min(height));
|
||||
let (right, bottom) = (((x1 * scale).ceil() + 1.0).min(width).max(left + 1.0), ((y1 * scale).ceil() + 1.0).min(height).max(top + 1.0));
|
||||
Some((scale, Tile { x: left as u32, y: top as u32, w: (right - left) as u32, h: (bottom - top) as u32 }))
|
||||
}
|
||||
|
||||
@@ -26,10 +26,14 @@ trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
|
||||
harness_with_bytes(TEXT_FIXTURE.to_vec(), setup)
|
||||
}
|
||||
|
||||
fn harness_with_bytes(bytes: Vec<u8>, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
app.open_bytes("text.pdf", None, bytes).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("author", "Tester").unwrap();
|
||||
setup(&mut app);
|
||||
@@ -100,6 +104,53 @@ fn comments(h: &Harness<'static, PdfCraftApp>) -> Vec<Annotation> {
|
||||
s.session.get(s.views[0].id).unwrap().info.annotations.clone()
|
||||
}
|
||||
|
||||
/// #3418: choosing a sidebar comment must reveal the annotation, not just its page's top.
|
||||
#[test]
|
||||
fn sidebar_comment_centres_the_annotation_at_high_zoom() {
|
||||
for (layout, rotation) in [("continuous", 0), ("single", 0), ("two-page", 0), ("continuous", 90), ("continuous", 180), ("continuous", 270)] {
|
||||
let mut h = harness(move |app| {
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
app.set_option("layout", layout).unwrap();
|
||||
app.set_option("zoom", "800").unwrap();
|
||||
app.views[0].rotation = rotation;
|
||||
let shape = pdfcraft_engine::Shape::Note { at: [150.0, 90.0], icon: pdfcraft_engine::NoteIcon::Comment };
|
||||
assert!(app.apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 1,
|
||||
style: pdfcraft_engine::Style::default_for(&shape),
|
||||
shape,
|
||||
contents: "Reveal this note".into(),
|
||||
author: "Tester".into(),
|
||||
})));
|
||||
app.views[0].comments.selected = None;
|
||||
app.set_option("page", "1").unwrap();
|
||||
});
|
||||
h.get_by_label("Reveal this note").click();
|
||||
h.run_steps(8);
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((1, 0)), "the sidebar card was selected");
|
||||
let a = &comments(&h)[0];
|
||||
let view = &h.state().views[0];
|
||||
let page = view.page_screen_rect(1).expect("the selected comment's page is drawn");
|
||||
let (u, v) = ((a.rect[0] + a.rect[2]) / 600.0, 1.0 - (a.rect[1] + a.rect[3]) / 400.0);
|
||||
let (u, v) = match rotation {
|
||||
90 => (1.0 - v, u),
|
||||
180 => (1.0 - u, 1.0 - v),
|
||||
270 => (v, 1.0 - u),
|
||||
_ => (u, v),
|
||||
};
|
||||
let centre = pos2(page.left() + u * page.width(), page.top() + v * page.height());
|
||||
let viewport = view.viewport_rect();
|
||||
assert!(centre.distance(viewport.center()) < 3.0, "{layout}, {rotation}°: comment at {centre:?}, viewport {viewport:?}");
|
||||
assert!((view.zoom - 8.0).abs() < 0.01, "revealing a comment preserves zoom");
|
||||
if layout == "continuous"
|
||||
&& rotation == 0
|
||||
&& let Ok(dir) = std::env::var("PDFCRAFT_SHOTS")
|
||||
{
|
||||
settle(&mut h);
|
||||
h.render().unwrap().save(format!("{dir}/comment-centred.png")).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_over_text_with_the_highlighter_highlights_it() {
|
||||
let mut h = harness(|app| app.set_option("quick", "highlight").unwrap());
|
||||
@@ -233,6 +284,99 @@ fn a_new_highlight_opens_its_note_for_typing() {
|
||||
assert_eq!(h.state().views[0].comments.editing, Some((0, 1, "typed".into())));
|
||||
}
|
||||
|
||||
/// The Comments panel's editor for a comment's note (the reply field is the other multiline one).
|
||||
fn note_editor<'a>(h: &'a Harness<'static, PdfCraftApp>) -> egui_kittest::Node<'a> {
|
||||
h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.placeholder() != Some("Add a reply")).next().expect("note editor")
|
||||
}
|
||||
|
||||
/// The text in the open note editor.
|
||||
fn note(h: &Harness<'static, PdfCraftApp>) -> Option<String> {
|
||||
h.state().views[0].comments.editing.as_ref().map(|(_, _, text)| text.clone())
|
||||
}
|
||||
|
||||
/// A letter key as a keyboard sends it: the key goes down, types its letter and comes up.
|
||||
fn type_key(h: &mut Harness<'static, PdfCraftApp>, key: egui::Key, letter: &str) {
|
||||
h.key_down(key);
|
||||
h.event(egui::Event::Text(letter.into()));
|
||||
h.key_up(key);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Highlight glyphs `from..to` of page 1's text with the highlighter.
|
||||
fn highlight_text(h: &mut Harness<'static, PdfCraftApp>, from: usize, to: usize) {
|
||||
let (a, b) = {
|
||||
let v = &h.state().views[0];
|
||||
(v.glyph_screen_pos(0, from).expect("text layer"), v.glyph_screen_pos(0, to).expect("glyph"))
|
||||
};
|
||||
drag(h, a, b);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_the_page_gives_the_keyboard_back_from_a_highlight_note() {
|
||||
// #2669: the note editor took the keyboard back on every frame, so after a click on the
|
||||
// page V and H were typed into the note instead of picking a tool.
|
||||
let mut h = harness(|app| {
|
||||
app.set_option("quick", "highlight").unwrap();
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
});
|
||||
highlight_text(&mut h, 4, 14);
|
||||
assert!(note_editor(&h).is_focused(), "a new highlight's note takes the keyboard straight away");
|
||||
h.event(egui::Event::Text("Note".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(note(&h).as_deref(), Some("Note"), "typing goes into the note");
|
||||
// A click on an empty part of the page gives the keyboard back to the page.
|
||||
click_pt(&mut h, (250.0, 20.0));
|
||||
type_key(&mut h, egui::Key::V, "v");
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select, "V picks the Select tool");
|
||||
assert_eq!(note(&h).as_deref(), Some("Note"), "V is not typed into the note");
|
||||
type_key(&mut h, egui::Key::H, "h");
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Hand, "H picks the Hand tool");
|
||||
assert_eq!(note(&h).as_deref(), Some("Note"), "nor is H");
|
||||
// Clicking back into the note types into it again, and the letters are text there.
|
||||
note_editor(&h).click();
|
||||
h.run_steps(2);
|
||||
type_key(&mut h, egui::Key::V, "v");
|
||||
assert_eq!(note(&h).as_deref(), Some("Notev"), "the note takes keys while the user types in it");
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Hand, "typed letters don't pick tools");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_another_comment_takes_the_keyboard_from_a_highlight_note() {
|
||||
// #2669: a highlight's note kept the keyboard while rectangles and ovals were drawn (a drag
|
||||
// is not a click, so the editor never let go), and V and H were typed into it.
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
let mut h = harness(|app| {
|
||||
app.set_option("quick", "highlight").unwrap();
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
});
|
||||
highlight_text(&mut h, 4, 14);
|
||||
assert!(note_editor(&h).is_focused());
|
||||
// The tools are picked without a click, as a shortcut would: the drawing alone must take
|
||||
// the keyboard back. An empty note closes.
|
||||
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
|
||||
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
|
||||
assert_eq!(comments(&h).len(), 2);
|
||||
assert_eq!(h.state().views[0].comments.editing, None, "the empty note closed");
|
||||
type_key(&mut h, egui::Key::H, "h");
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Hand, "H picks the Hand tool");
|
||||
// A note with text in it stays open with its text, but lets the keyboard go.
|
||||
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Highlight);
|
||||
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].comments.editing, Some((0, 2, String::new())), "the new area highlight's note opens");
|
||||
assert!(note_editor(&h).is_focused());
|
||||
h.event(egui::Event::Text("Note".into()));
|
||||
h.run_steps(2);
|
||||
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
|
||||
drag_pt(&mut h, (40.0, 30.0), (260.0, 8.0));
|
||||
assert_eq!(comments(&h).len(), 4);
|
||||
assert_eq!(h.state().views[0].comments.editing, Some((0, 2, "Note".into())), "a note with text stays open");
|
||||
type_key(&mut h, egui::Key::H, "h");
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Hand, "H picks the Hand tool");
|
||||
assert_eq!(note(&h).as_deref(), Some("Note"), "H is not typed into the note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line() {
|
||||
let mut h = harness(|app| {
|
||||
@@ -530,6 +674,117 @@ fn sticky_note_composer_posts_and_returns_to_select() {
|
||||
assert_eq!(comments(&h).len(), 1);
|
||||
}
|
||||
|
||||
/// The rectangle of the comment whose text starts with `text`.
|
||||
fn rect_of(h: &Harness<'static, PdfCraftApp>, text: &str) -> [f32; 4] {
|
||||
let c = comments(h);
|
||||
c.iter().find(|a| a.contents.as_deref().is_some_and(|t| t.starts_with(text))).unwrap_or_else(|| panic!("no comment {text:?}: {c:?}")).rect
|
||||
}
|
||||
|
||||
/// Post `text` in the open text box composer.
|
||||
fn post_text(h: &mut Harness<'static, PdfCraftApp>, text: &str) {
|
||||
field(h, "Type text").type_text(text);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Post").click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn near(r: [f32; 4], want: [f32; 4]) -> bool {
|
||||
r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1.5)
|
||||
}
|
||||
|
||||
/// #2669: a drag with Add a text box draws the box at that size, and the text wraps inside it;
|
||||
/// a click (or a drag of a few pixels) still makes a box sized to its text.
|
||||
#[test]
|
||||
fn a_text_box_is_sized_by_dragging() {
|
||||
let mut h = harness(|app| app.set_option("quick", "freetext").unwrap());
|
||||
let (from, to) = (at(&h, 40.0, 150.0), at(&h, 200.0, 100.0));
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
h.hover_at(to);
|
||||
h.run_steps(2);
|
||||
assert!(
|
||||
matches!(&h.state().views[0].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Draw { points, .. }) if points.len() == 2),
|
||||
"the box shows while it is dragged"
|
||||
);
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
post_text(&mut h, "Drawn");
|
||||
let r = rect_of(&h, "Drawn");
|
||||
assert!(near(r, [40.0, 100.0, 200.0, 150.0]), "{r:?}");
|
||||
|
||||
// Too much text for the drawn box: it keeps its width and grows down, moving up so that it
|
||||
// stays on the page (whose bottom is at 0).
|
||||
h.state_mut().set_option("quick", "freetext").unwrap();
|
||||
drag_pt(&mut h, (20.0, 80.0), (120.0, 70.0));
|
||||
post_text(&mut h, &"word ".repeat(20));
|
||||
let r = rect_of(&h, "word word");
|
||||
assert!((r[0] - 20.0).abs() < 1.5 && (r[2] - 120.0).abs() < 1.5 && r[1].abs() < 0.5 && r[3] > 90.0, "{r:?}");
|
||||
|
||||
// More text than the page can hold: the box keeps the drawn size and the text is clipped.
|
||||
h.state_mut().set_option("quick", "freetext").unwrap();
|
||||
drag_pt(&mut h, (150.0, 190.0), (250.0, 180.0));
|
||||
post_text(&mut h, &"longerwords ".repeat(16));
|
||||
let r = rect_of(&h, "longerwords");
|
||||
assert!(near(r, [150.0, 180.0, 250.0, 190.0]), "{r:?}");
|
||||
|
||||
// Drags of two and four pixels are clicks: the box fits its text.
|
||||
let fitted = pdfcraft_ui_egui::comments::text_box_rect([0.0, 0.0], "Hi", 12.0, 0);
|
||||
for (text, x, d) in [("Hi", 200.0, 2.0), ("Hi again", 270.0, 4.0)] {
|
||||
h.state_mut().set_option("quick", "freetext").unwrap();
|
||||
let p = at(&h, x, 40.0);
|
||||
drag(&mut h, p, p + egui::vec2(d, d));
|
||||
post_text(&mut h, text);
|
||||
let r = rect_of(&h, text).map(f64::from);
|
||||
let fitted = if text == "Hi" { fitted } else { pdfcraft_ui_egui::comments::text_box_rect([0.0, 0.0], text, 12.0, 0) };
|
||||
assert!((r[2] - r[0] - (fitted[2] - fitted[0])).abs() < 1.0 && (r[3] - r[1] - (fitted[3] - fitted[1])).abs() < 1.0, "{text}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2669: with the view turned (View > Rotate View) the box lands where it was drawn, not
|
||||
/// transposed or scaled by the turned page's other side.
|
||||
#[test]
|
||||
fn a_dragged_text_box_follows_a_rotated_view() {
|
||||
for rotation in [90u16, 270] {
|
||||
let mut h = harness(|app| app.set_option("quick", "freetext").unwrap());
|
||||
while h.state().views[0].rotation != rotation {
|
||||
h.state_mut().views[0].rotate_view(true);
|
||||
}
|
||||
h.state_mut().set_option("zoom", "150").unwrap();
|
||||
h.run_steps(3);
|
||||
settle(&mut h);
|
||||
let screen = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| {
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||||
let (u, v) = (x / 300.0, (200.0 - y) / 200.0);
|
||||
let (a, b) = if rotation == 90 { (1.0 - v, u) } else { (v, 1.0 - u) };
|
||||
pos2(r.left() + a * r.width(), r.top() + b * r.height())
|
||||
};
|
||||
let (from, to) = (screen(&h, 40.0, 150.0), screen(&h, 200.0, 100.0));
|
||||
drag(&mut h, from, to);
|
||||
post_text(&mut h, "Turned");
|
||||
let r = rect_of(&h, "Turned");
|
||||
assert!(near(r, [40.0, 100.0, 200.0, 150.0]), "view turned {rotation}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2669: the text of a drawn box wraps inside its border, as its appearance draws it, so a thick
|
||||
/// border does not cut off the last line.
|
||||
#[test]
|
||||
fn a_dragged_text_box_wraps_inside_its_border() {
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
let mut h = harness(|app| {
|
||||
app.set_option("quick", "freetext").unwrap();
|
||||
app.comment_prefs.set_width(CommentTool::TextBox, 3.0);
|
||||
});
|
||||
// Wide enough for "word word" inside a 2 pt padding, not inside 2 + 3 pt.
|
||||
let w = pdfcraft_engine::annot_text::text_width("word word", 12.0) as f32 + 6.0;
|
||||
drag_pt(&mut h, (20.0, 190.0), (20.0 + w, 180.0));
|
||||
post_text(&mut h, "word word");
|
||||
let r = rect_of(&h, "word word");
|
||||
assert!((r[3] - r[1] - (2.0 * 14.4 + 2.0 * 5.0 + 2.0)).abs() < 1.0, "two lines inside the border: {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_are_selected_moved_and_deleted_with_the_select_tool() {
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
@@ -552,6 +807,58 @@ fn comments_are_selected_moved_and_deleted_with_the_select_tool() {
|
||||
assert_eq!(comments(&h).len(), 1);
|
||||
}
|
||||
|
||||
/// A line's ends have handles: dragging one with the Select tool changes the line's length and
|
||||
/// angle (Shift holds it to 45°), as one undo step. The line tool keeps drawing from an end: a
|
||||
/// new line starts there rather than dragging the one just drawn.
|
||||
#[test]
|
||||
fn line_ends_are_dragged_to_change_length_and_angle() {
|
||||
let mut h = harness(|app| app.set_option("quick", "line").unwrap());
|
||||
drag_pt(&mut h, (40.0, 100.0), (140.0, 100.0));
|
||||
let line = comments(&h).into_iter().find(|a| a.subtype == "Line").expect("a line");
|
||||
assert_eq!(line.points.len(), 2);
|
||||
let ends = |h: &Harness<'static, PdfCraftApp>| comments(h).into_iter().find(|a| a.index == line.index).unwrap().points;
|
||||
// With the line tool, a drag from the end just drawn draws another line.
|
||||
let first = ends(&h);
|
||||
drag_pt(&mut h, (first[1][0], first[1][1]), (140.0, 40.0));
|
||||
assert_eq!(comments(&h).iter().filter(|a| a.subtype == "Line").count(), 2, "a second line");
|
||||
assert_eq!(ends(&h), first, "the first one stays");
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
click_pt(&mut h, (90.0, 100.0));
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, line.index)));
|
||||
// Drag the right end up and further right: longer, and turned.
|
||||
let [_, end] = [line.points[0], line.points[1]];
|
||||
drag_pt(&mut h, (end[0], end[1]), (200.0, 160.0));
|
||||
let p = ends(&h);
|
||||
assert!((p[0][0] - 40.0).abs() < 1.0 && (p[0][1] - 100.0).abs() < 1.0, "the other end stays: {p:?}");
|
||||
assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "the dragged end follows: {p:?}");
|
||||
let id = h.state().views[0].id;
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Reshape comment"));
|
||||
// Shift: held to the nearest 45° from the other end (39° here).
|
||||
let a = at(&h, 200.0, 160.0);
|
||||
let b = at(&h, 150.0, 190.0);
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
|
||||
h.drag_at(a);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(a + (b - a) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(b);
|
||||
h.run_steps(3);
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
let p = ends(&h);
|
||||
let (dx, dy) = (p[1][0] - p[0][0], p[1][1] - p[0][1]);
|
||||
assert!(dx > 50.0 && (dx - dy).abs() < 0.5, "45°: {p:?}");
|
||||
// Undo puts the line back as it was before the drag.
|
||||
h.state_mut().undo();
|
||||
h.run_steps(2);
|
||||
let p = ends(&h);
|
||||
assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() {
|
||||
// #2338: the new shape showed its handles, but only the Select tool answered them.
|
||||
@@ -777,6 +1084,131 @@ fn the_panel_filters_and_sorts() {
|
||||
assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added");
|
||||
}
|
||||
|
||||
// Contributor-original PDF. Two roots deliberately share /NM. Their replies are on
|
||||
// another page, with dates opposite to source order. An unnamed root has no thread.
|
||||
const REVIEW_FIXTURE: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (shared) /T (Alpha) /Contents (First root) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [60 140 80 160] /NM (shared) /T (Beta) /Contents (Duplicate root) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [100 140 120 160] /T (Gamma) /Contents (Unnamed root) >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (late) /T (Delta) /Contents (Late root) >> endobj
|
||||
9 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (first-reply) /IRT 5 0 R /T (Reader) /Contents (Needle first reply) /M (D:20260102000000) >> endobj
|
||||
10 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Review /State /Rejected /Contents (Old review) /M (D:20260102000000) >> endobj
|
||||
11 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Marked /State /Marked /M (D:20260102000000) >> endobj
|
||||
12 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (second-reply) /IRT 5 0 R /T (Reader) /Contents (Second reply) /M (D:20260101000000) >> endobj
|
||||
13 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Review /State /Accepted /Contents (Status-only token) /M (D:20260101000000) >> endobj
|
||||
14 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Marked /State /Unmarked /M (D:20260101000000) >> endobj
|
||||
15 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (late-reply) /IRT 8 0 R /T (Reader) /Contents (Late thread reply) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
#[test]
|
||||
fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts() {
|
||||
let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| {
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
app.views[0].comments.search = Some("needle".into());
|
||||
app.views[0].comments.hidden_authors.push("Beta".into());
|
||||
app.views[0].comments.selected = Some((0, 0));
|
||||
});
|
||||
h.get_by_label("First root");
|
||||
h.get_by_label("Page 1");
|
||||
h.get_by_label("3"); // All three page roots count, including the two filtered out.
|
||||
assert_eq!(h.query_all_by_label("Duplicate root").count(), 0);
|
||||
assert_eq!(h.query_all_by_label("Unnamed root").count(), 0);
|
||||
assert!(
|
||||
h.get_by_label("Needle first reply").rect().top() < h.get_by_label("Second reply").rect().top(),
|
||||
"replies follow source order, not dates"
|
||||
);
|
||||
h.get_by_label("Accepted");
|
||||
h.get_by_label("Mark with checkmark"); // The later Unmarked reply wins independently.
|
||||
assert_eq!(h.query_all_by_label("Status-only token").count(), 0, "status text is searchable but is not a displayed reply");
|
||||
|
||||
h.state_mut().views[0].comments.hidden_authors.clear();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Duplicate root");
|
||||
assert_eq!(h.query_all_by_label("Needle first reply").count(), 2, "duplicate names share the same global thread");
|
||||
h.state_mut().views[0].comments.search = Some("status-only token".into());
|
||||
h.state_mut().views[0].comments.hidden_statuses.push("Rejected".into());
|
||||
h.state_mut().views[0].comments.hidden_checks.push("Checked".into());
|
||||
h.run_steps(2);
|
||||
h.get_by_label("First root");
|
||||
h.get_by_label("Duplicate root");
|
||||
|
||||
h.state_mut().views[0].comments.hidden_statuses.push("Accepted".into());
|
||||
h.run_steps(2);
|
||||
h.get_by_label("No comments match.");
|
||||
h.state_mut().views[0].comments.hidden_statuses.clear();
|
||||
h.state_mut().views[0].comments.hidden_checks.push("Unchecked".into());
|
||||
h.run_steps(2);
|
||||
h.get_by_label("No comments match.");
|
||||
|
||||
h.state_mut().views[0].comments.search = Some("unnamed root".into());
|
||||
h.state_mut().views[0].comments.hidden_checks.clear();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Unnamed root");
|
||||
assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "an unnamed root has no replies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_reveals_edits_and_reopens_a_late_thread_without_changing_its_replies() {
|
||||
let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| {
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
app.views[0].comments.collapsed_pages = vec![0, 1];
|
||||
});
|
||||
assert_eq!(h.query_all_by_label("Late root").count(), 0);
|
||||
h.state_mut().views[0].comments.selected = Some((1, 0));
|
||||
h.state_mut().views[0].comments.reveal = true;
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Late root");
|
||||
h.get_by_label("Late thread reply");
|
||||
assert_eq!(h.state().views[0].comments.collapsed_pages, vec![0]);
|
||||
h.get_by_label("More").click();
|
||||
h.run_steps(2);
|
||||
h.query_all_by_label("Edit").last().expect("comment menu edit, after the toolbar tab").click();
|
||||
h.run_steps(2);
|
||||
let editor = h
|
||||
.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.placeholder() != Some("Add a reply"))
|
||||
.next()
|
||||
.expect("note editor");
|
||||
editor.click();
|
||||
h.run_steps(1);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.event(egui::Event::Text("Late root edited exactly".into()));
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Save").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((1, 0)));
|
||||
let before = comments(&h);
|
||||
let late = before.iter().find(|a| a.name.as_deref() == Some("late")).unwrap();
|
||||
assert_eq!(late.contents.as_deref(), Some("Late root edited exactly"));
|
||||
let id = h.state().views[0].id;
|
||||
let saved = h.state().session.save_bytes(id).unwrap().as_ref().clone();
|
||||
let mut reopened = harness_with_bytes(saved, |app| {
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
app.views[0].comments.search = Some("late thread reply".into());
|
||||
});
|
||||
h.get_by_label("Late root edited exactly");
|
||||
reopened.get_by_label("Late root edited exactly");
|
||||
reopened.get_by_label("Late thread reply");
|
||||
assert_eq!(reopened.query_all_by_label("First root").count(), 0);
|
||||
let after = comments(&reopened);
|
||||
assert_eq!(after.len(), before.len());
|
||||
for (original, saved) in before.iter().zip(&after) {
|
||||
assert_eq!(
|
||||
(&saved.name, &saved.contents, &saved.in_reply_to, &saved.state),
|
||||
(&original.name, &original.contents, &original.in_reply_to, &original.state)
|
||||
);
|
||||
}
|
||||
assert_eq!(after.iter().find(|a| a.name.as_deref() == Some("late-reply")).unwrap().in_reply_to.as_deref(), Some("late"));
|
||||
reopened.state_mut().views[0].comments.sort = pdfcraft_ui_egui::comments::SortBy::Author;
|
||||
reopened.run_steps(2);
|
||||
assert_eq!(reopened.query_all_by_label("Delta").count(), 2, "the author group and its card remain visible after sorting");
|
||||
reopened.get_by_label("Late root edited exactly");
|
||||
}
|
||||
|
||||
/// #1554: a page group's chevron folds that page's cards away and back, and the choice holds.
|
||||
#[test]
|
||||
fn page_groups_collapse_and_expand() {
|
||||
@@ -1231,6 +1663,13 @@ fn replacing_text_strikes_it_and_adds_a_caret() {
|
||||
assert_eq!(caret.in_reply_to, strike.name, "grouped with the strikeout");
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Replace text"));
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||||
// The strikeout is the Replace text tool's, not Strikethrough's (#3947): the panel names it
|
||||
// so, and Make Current Properties Default updates the Replace text tool.
|
||||
assert_eq!(pdfcraft_ui_egui::comments::tool_for(strike), Some(pdfcraft_ui_egui::comments::CommentTool::ReplaceText));
|
||||
h.state_mut().set_option("panel", "comments").unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Replace text").is_some(), "the panel names the comment Replace text");
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Strikethrough").is_none(), "not Strikethrough");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1395,6 +1834,69 @@ fn erasing_part_of_a_drawing() {
|
||||
assert!(comments(&h).is_empty(), "{:?}", comments(&h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eraser_rubs_as_wide_as_its_thickness_setting() {
|
||||
// #3747: the eraser's size changed nothing; it always rubbed 6 pixels either side.
|
||||
let stroke_near = |width: f64| {
|
||||
let mut h = harness(|app| app.set_option("quick", "ink").unwrap());
|
||||
drag_pt(&mut h, (40.0, 60.0), (260.0, 60.0));
|
||||
assert_eq!(comments(&h).len(), 1);
|
||||
let zoom = h.state().views[0].page_screen_rect(0).expect("page 1 on screen").width() / 300.0;
|
||||
let app = h.state_mut();
|
||||
app.comment_prefs.set_width(pdfcraft_ui_egui::comments::CommentTool::Eraser, width);
|
||||
app.set_option("quick", "eraser").unwrap();
|
||||
// Along the whole drawing, 20 pixels above it: out of reach of the default eraser
|
||||
// (6 pixels), within reach of the largest (36).
|
||||
let y = 60.0 + 20.0 / zoom;
|
||||
drag_pt(&mut h, (30.0, y), (270.0, y));
|
||||
comments(&h).len()
|
||||
};
|
||||
assert_eq!(stroke_near(2.0), 1, "the default eraser passes by");
|
||||
assert_eq!(stroke_near(12.0), 0, "the largest rubs the drawing out");
|
||||
}
|
||||
|
||||
/// The widths of the eraser preview's strokes drawn in the last frame.
|
||||
fn eraser_preview_widths(h: &Harness<'static, PdfCraftApp>) -> Vec<f32> {
|
||||
fn walk(shape: &egui::Shape, out: &mut Vec<f32>) {
|
||||
let preview = egui::Color32::from_gray(128).gamma_multiply(0.35);
|
||||
match shape {
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)),
|
||||
egui::Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(preview) => out.push(p.stroke.width),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for clipped in &h.output().shapes {
|
||||
walk(&clipped.shape, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eraser_preview_is_as_wide_as_it_rubs() {
|
||||
// #3747: while the eraser is held, its preview stroke is as wide as what it rubs out
|
||||
// (twice its radius, three pixels per point of thickness): 12 pixels by default, 72 at 12 pt.
|
||||
for (width, across) in [(2.0, 12.0), (12.0, 72.0)] {
|
||||
let mut h = harness(move |app| {
|
||||
app.comment_prefs.set_width(pdfcraft_ui_egui::comments::CommentTool::Eraser, width);
|
||||
app.set_option("quick", "eraser").unwrap();
|
||||
});
|
||||
let (from, to) = (at(&h, 60.0, 100.0), at(&h, 240.0, 100.0));
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
for k in 1..=3 {
|
||||
h.hover_at(from + (to - from) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert_eq!(eraser_preview_widths(&h), [across], "{width} pt: the preview while held");
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
assert!(eraser_preview_widths(&h).is_empty(), "{width} pt: gone once released");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hovering_a_comment_shows_its_author_and_text() {
|
||||
// A square comment by Ada over the first page's text.
|
||||
@@ -1457,6 +1959,162 @@ fn preference_hides_author_only_highlight_hover_previews() {
|
||||
assert!(h.query_by_label("Ada").is_none(), "author-only hover must be suppressed");
|
||||
}
|
||||
|
||||
/// Contributor-original synthetic markup, with the first highlight ending on the next line.
|
||||
fn markup_hover_fixture(subtype: &str, rotation: u16, extra: &str, refs: &str) -> Vec<u8> {
|
||||
let content = "BT /F1 14 Tf 20 150 Td (Before the marked words) Tj 0 -40 Td (Marked words then other words) Tj ET\n";
|
||||
String::from_utf8_lossy(TEXT_FIXTURE)
|
||||
.replace("/Contents 5 0 R /Resources", &format!("/Contents 5 0 R /Rotate {rotation} /Annots [8 0 R {refs}] /Resources"))
|
||||
.replace("/Length 55", &format!("/Length {}", content.len()))
|
||||
.replace("BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj ET\n", content)
|
||||
.replace(
|
||||
"trailer",
|
||||
&format!(
|
||||
"8 0 obj << /Type /Annot /Subtype /{subtype} /Rect [20 105 250 165] \
|
||||
/QuadPoints [120 165 250 165 120 145 250 145 20 125 100 125 20 105 100 105] \
|
||||
/C [1 1 0] /T (Ada) /Contents (First multiline comment) >> endobj\n{extra}\ntrailer"
|
||||
),
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn markup_hover_harness(pdf: Vec<u8>, view_rotation: u16) -> Harness<'static, PdfCraftApp> {
|
||||
// Give the fixture a complete xref so byte comparisons do not measure repair-on-save.
|
||||
let doc = pdfcraft_cos::Document::open(std::sync::Arc::new(pdf)).expect("synthetic fixture parses");
|
||||
let pdf = pdfcraft_cos::write_full(&doc, &Default::default()).expect("writes complete fixture");
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("markup.pdf", None, pdf).expect("opens synthetic markup");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "none").unwrap();
|
||||
app.views[0].rotation = view_rotation;
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
/// Includes the document's crop/rotation and the independent Rotate View transform.
|
||||
fn hover_markup_at(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) {
|
||||
let p = {
|
||||
let app = h.state();
|
||||
let v = &app.views[0];
|
||||
let info = &app.session.get(v.id).unwrap().info;
|
||||
let page = &info.pages[0];
|
||||
let xf = pdfcraft_ui_egui::canvas::PageXform {
|
||||
rect: v.page_screen_rect(0).expect("page on screen"),
|
||||
rot: v.rotation,
|
||||
pw: page.width,
|
||||
ph: page.height,
|
||||
};
|
||||
xf.user_rect(info, 0, [x, y, x, y]).center()
|
||||
};
|
||||
h.hover_at(p);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_markup_hover_stays_on_marked_lines() {
|
||||
// #3541: /Rect encloses both lines, including text before/after the selection and the gap.
|
||||
for subtype in ["Highlight", "Underline", "StrikeOut", "Squiggly"] {
|
||||
for (page_rotation, view_rotation) in [(0, 0), (90, 0), (180, 0), (270, 0), (0, 90), (0, 180), (0, 270), (90, 270)] {
|
||||
let pdf = markup_hover_fixture(subtype, page_rotation, "", "");
|
||||
let mut h = markup_hover_harness(pdf, view_rotation);
|
||||
let original = h.state().session.get(h.state().views[0].id).unwrap().saved_bytes();
|
||||
for (x, y, marked) in
|
||||
[(160.0, 155.0, true), (60.0, 115.0, true), (60.0, 155.0, false), (180.0, 115.0, false), (60.0, 135.0, false), (60.0, 75.0, false)]
|
||||
{
|
||||
hover_markup_at(&mut h, x, y);
|
||||
assert_eq!(
|
||||
h.query_by_label_contains("First multiline comment").is_some(),
|
||||
marked,
|
||||
"{subtype}, page/view rotation {page_rotation}/{view_rotation}, user point ({x}, {y})"
|
||||
);
|
||||
}
|
||||
let app = h.state();
|
||||
let id = app.views[0].id;
|
||||
let doc = app.session.get(id).unwrap();
|
||||
assert!(!doc.dirty, "hovering never edits the document");
|
||||
assert_eq!(doc.can_undo(), None);
|
||||
assert_eq!(app.session.save_bytes(id).unwrap().as_slice(), original.as_slice());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neighboring_markup_hover_changes_to_its_own_comment() {
|
||||
// #3620: the second highlight is in the first one's /Rect, but outside its marked lines.
|
||||
let other = "9 0 obj << /Type /Annot /Subtype /Highlight /Rect [160 105 240 125] \
|
||||
/QuadPoints [160 125 240 125 160 105 240 105] /Contents (Neighbor comment) >> endobj";
|
||||
let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, other, "9 0 R"), 0);
|
||||
for (x, y, first) in [(160.0, 155.0, true), (180.0, 115.0, false), (60.0, 115.0, true)] {
|
||||
hover_markup_at(&mut h, x, y);
|
||||
assert_eq!(h.query_by_label_contains("First multiline comment").is_some(), first);
|
||||
assert_eq!(h.query_by_label_contains("Neighbor comment").is_some(), !first);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_markup_hover_shows_the_topmost_comment() {
|
||||
// A later annotation can also cover a genuinely marked part of the older highlight.
|
||||
let other = "9 0 obj << /Type /Annot /Subtype /Highlight /Rect [150 145 200 165] \
|
||||
/QuadPoints [150 165 200 165 150 145 200 145] /Contents (Topmost comment) >> endobj";
|
||||
let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, other, "9 0 R"), 0);
|
||||
hover_markup_at(&mut h, 170.0, 155.0);
|
||||
h.get_by_label_contains("Topmost comment");
|
||||
assert!(h.query_by_label_contains("First multiline comment").is_none());
|
||||
hover_markup_at(&mut h, 230.0, 155.0);
|
||||
h.get_by_label_contains("First multiline comment");
|
||||
assert!(h.query_by_label_contains("Topmost comment").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_markup_hover_uses_annotation_index_with_different_top_edges() {
|
||||
// Inspection sorts by position, while /Annots determines paint order. The later
|
||||
// highlight wins the overlap whether its upper edge is above or below the first's.
|
||||
for top in [175, 160] {
|
||||
let other = format!(
|
||||
"9 0 obj << /Type /Annot /Subtype /Highlight /Rect [150 145 200 {top}] \
|
||||
/QuadPoints [150 {top} 200 {top} 150 145 200 145] /Contents (Topmost comment) >> endobj"
|
||||
);
|
||||
let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, &other, "9 0 R"), 0);
|
||||
hover_markup_at(&mut h, 170.0, 155.0);
|
||||
h.get_by_label_contains("Topmost comment");
|
||||
assert!(h.query_by_label_contains("First multiline comment").is_none(), "later annotation with top {top} owns the overlap");
|
||||
hover_markup_at(&mut h, 230.0, 155.0);
|
||||
h.get_by_label_contains("First multiline comment");
|
||||
assert!(h.query_by_label_contains("Topmost comment").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markup_hover_skips_popup_containers() {
|
||||
// /Popup holds the parent's comment window; it is not a second comment in paint order.
|
||||
let popup = "9 0 obj << /Type /Annot /Subtype /Popup /Parent 8 0 R /Rect [150 145 200 165] >> endobj";
|
||||
let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, popup, "9 0 R"), 0);
|
||||
hover_markup_at(&mut h, 170.0, 155.0);
|
||||
h.get_by_label_contains("First multiline comment");
|
||||
assert!(h.query_by_label("Popup").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markup_hover_without_quads_keeps_the_rectangle_fallback() {
|
||||
for quads in ["", "/QuadPoints [20 105 250 165]"] {
|
||||
let pdf = markup_hover_fixture("Highlight", 0, "", "");
|
||||
let pdf = String::from_utf8(pdf)
|
||||
.unwrap()
|
||||
.replace("/QuadPoints [120 165 250 165 120 145 250 145 20 125 100 125 20 105 100 105]", quads)
|
||||
.into_bytes();
|
||||
let mut h = markup_hover_harness(pdf, 0);
|
||||
assert!(comments(&h)[0].quads.is_empty());
|
||||
hover_markup_at(&mut h, 180.0, 115.0);
|
||||
h.get_by_label_contains("First multiline comment");
|
||||
hover_markup_at(&mut h, 270.0, 115.0);
|
||||
assert!(h.query_by_label_contains("First multiline comment").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_squiggly_tool_marks_selected_text() {
|
||||
let mut h = harness(|_| {});
|
||||
|
||||
@@ -101,6 +101,29 @@ fn rendered_ink(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) -> image:
|
||||
panic!("signature ink did not render at ({x}, {y})");
|
||||
}
|
||||
|
||||
/// #2392: the Line tool's line runs across the page as displayed, on turned pages too (its box was
|
||||
/// sized in the page's own axes, so a quarter turn stood it on end).
|
||||
#[test]
|
||||
fn the_line_mark_runs_across_turned_pages() {
|
||||
for degrees in [0, 90, 180, 270] {
|
||||
let mut h = harness();
|
||||
{
|
||||
let s = h.state_mut();
|
||||
let id = s.views[0].id;
|
||||
s.session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees }).unwrap();
|
||||
}
|
||||
h.run_steps(4);
|
||||
assert!(h.state_mut().execute("sign.fill.line"));
|
||||
click(&mut h, 150.0, 350.0);
|
||||
let s = h.state();
|
||||
let info = &s.session.get(s.views[0].id).unwrap().info;
|
||||
let r = info.annotations.last().expect("a line was placed").rect;
|
||||
let (a, b) = (info.pages[0].user_to_view(r[0], r[1]), info.pages[0].user_to_view(r[2], r[3]));
|
||||
let (w, h) = ((a[0] - b[0]).abs(), (a[1] - b[1]).abs());
|
||||
assert!(w > 4.0 * h, "rotation {degrees}: the line's displayed box is {w} × {h}, not across");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_marks_and_date() {
|
||||
let mut h = harness();
|
||||
@@ -1296,6 +1319,74 @@ fn drawn_signatures_resize_from_their_corners() {
|
||||
assert_rect(rects(&h)[0], now);
|
||||
}
|
||||
|
||||
/// Whether black ink is drawn within 3 pixels of `p`.
|
||||
fn ink_near(pixels: &image::RgbaImage, p: Pos2) -> bool {
|
||||
(-3..=3).any(|dy| (-3..=3).any(|dx| pixels.get_pixel((p.x as i32 + dx) as u32, (p.y as i32 + dy) as u32).0[..3].iter().all(|v| *v < 60)))
|
||||
}
|
||||
|
||||
/// A Fill & Sign line, like every stamp, is stretched with its frame while a handle is dragged:
|
||||
/// it grows and shrinks with the pointer instead of keeping its old length until the release.
|
||||
#[test]
|
||||
fn lines_grow_and_shrink_with_their_handles_before_release() {
|
||||
let _gpu = gpu();
|
||||
for (document_rotation, view_rotation) in [(0, 0), (90, 0), (0, 90), (90, 270)] {
|
||||
let rot = (document_rotation, view_rotation);
|
||||
let mut h = harness();
|
||||
if document_rotation != 0 {
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: document_rotation });
|
||||
}
|
||||
while h.state().views[0].rotation != view_rotation {
|
||||
h.state_mut().views[0].rotate_view(true);
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert!(h.state_mut().execute("sign.fill.line"));
|
||||
click(&mut h, 100.0, 250.0);
|
||||
assert_rect(rects(&h)[0], [82.0, 248.0, 118.0, 252.0]);
|
||||
// In line mode a click on the line picks it up, with its handles.
|
||||
click(&mut h, 100.0, 250.0);
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "{rot:?}");
|
||||
let generation = h.state().session.get(h.state().views[0].id).unwrap().edit_generation();
|
||||
|
||||
// The handle at the line's end, dragged out to 160.
|
||||
h.drag_at(at(&h, 118.0, 250.0));
|
||||
h.run_steps(1);
|
||||
h.hover_at(at(&h, 160.0, 250.0));
|
||||
// Wait only for the one-time layers. Later pointer frames are immediate.
|
||||
let mut grown = h.render().unwrap();
|
||||
for _ in 0..50 {
|
||||
if ink_near(&grown, at(&h, 150.0, 250.0)) {
|
||||
break;
|
||||
}
|
||||
h.run_steps(2);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
grown = h.render().unwrap();
|
||||
}
|
||||
assert!(ink_near(&grown, at(&h, 150.0, 250.0)), "{rot:?}: the line grows with the handle before release");
|
||||
assert!(ink_near(&grown, at(&h, 90.0, 250.0)), "{rot:?}: from where it starts");
|
||||
|
||||
// Back in, shorter than it was, still without letting go.
|
||||
h.hover_at(at(&h, 100.0, 250.0));
|
||||
h.run_steps(2);
|
||||
let shrunk = h.render().unwrap();
|
||||
assert!(ink_near(&shrunk, at(&h, 90.0, 250.0)), "{rot:?}: the line still starts where it did");
|
||||
for x in [110.0, 150.0] {
|
||||
assert!(!ink_near(&shrunk, at(&h, x, 250.0)), "{rot:?}: the line shrinks with the handle before release (ink at {x})");
|
||||
}
|
||||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||||
assert_eq!((doc.edit_generation(), doc.can_undo()), (generation, Some("Add line")), "{rot:?}: pointer frames never edit the document");
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||||
grown.save(format!("{dir}/line-live-grow-{document_rotation}-{view_rotation}.png")).unwrap();
|
||||
shrunk.save(format!("{dir}/line-live-shrink-{document_rotation}-{view_rotation}.png")).unwrap();
|
||||
}
|
||||
h.drop_at(at(&h, 100.0, 250.0));
|
||||
h.run_steps(3);
|
||||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||||
assert_eq!(doc.can_undo(), Some("Resize comment"), "{rot:?}");
|
||||
let [now] = rects(&h)[..] else { panic!("{rot:?}: still one line: {:?}", rects(&h)) };
|
||||
assert!(now.iter().zip([82.0, 248.0, 100.0, 252.0]).all(|(a, b)| (a - b).abs() < 0.01), "{rot:?}: {now:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A signature placed over a form field is picked up by the Select tool: the field under it
|
||||
/// doesn't take the press. Away from the signature, a click still fills the field.
|
||||
#[test]
|
||||
|
||||
@@ -28,7 +28,7 @@ fn overloaded() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two budget tests take turns: each starts render workers, and measuring one while the other
|
||||
/// The budget tests take turns: each starts render workers, and measuring one while another
|
||||
/// runs would charge it for the other's work on a small CI machine.
|
||||
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
@@ -75,6 +75,10 @@ fn big(n: usize) -> Vec<u8> {
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||||
objs.push("<< /Type /Annot /Subtype /Square /Rect [72 72 144 144] /C [1 0 0] /T (Ada) /Contents (Check) >>".into());
|
||||
}
|
||||
pdf(objs)
|
||||
}
|
||||
|
||||
fn pdf(objs: Vec<String>) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
@@ -90,6 +94,56 @@ fn big(n: usize) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Named roots exercise thread lookups even when none of the comments has replies.
|
||||
fn named_comments(n: usize) -> Vec<u8> {
|
||||
let annots: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 4)).collect();
|
||||
let mut objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Annots [{}] >>", annots.join(" ")),
|
||||
];
|
||||
for i in 0..n {
|
||||
objs.push(format!(
|
||||
"<< /Type /Annot /Subtype /Square /Rect [72 72 144 144] /C [1 0 0] /T (Ada) /NM (root-{i}) /Contents (Review note {i}) >>"
|
||||
));
|
||||
}
|
||||
pdf(objs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_comments_with_ten_thousand_named_roots_stay_within_the_frame_budget() {
|
||||
if overloaded() {
|
||||
return;
|
||||
}
|
||||
let _turn = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let bytes = named_comments(10_000);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("review.pdf", None, bytes).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
app.views[0].comments.collapsed_pages = vec![0];
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
{
|
||||
let state = h.state();
|
||||
let info = &state.session.get(state.views[0].id).unwrap().info;
|
||||
assert_eq!(info.pages.len(), 1);
|
||||
assert_eq!(info.annotations.len(), 10_000);
|
||||
for a in &info.annotations {
|
||||
assert_eq!(a.name.as_deref(), Some(format!("root-{}", a.index).as_str()));
|
||||
assert!(a.in_reply_to.is_none());
|
||||
assert!(a.state.is_none());
|
||||
}
|
||||
assert_eq!(state.views[0].comments.collapsed_pages, [0]);
|
||||
}
|
||||
// Folding removes card layout cost, so this measures filtering and thread lookup work.
|
||||
let avg = best_average("collapsed named comments", 30, |_| h.step());
|
||||
assert!(avg < BUDGET, "collapsed comments: average frame {avg:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_a_500_page_document_stays_within_the_frame_budget() {
|
||||
if overloaded() {
|
||||
|
||||
@@ -312,9 +312,7 @@ fn xfdf_annots(doc: &Document, out: &mut String) {
|
||||
}
|
||||
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
let fl: Vec<&str> = FLAGS.iter().filter(|(b, _)| flags & b != 0).map(|(_, s)| *s).collect();
|
||||
if !fl.is_empty() {
|
||||
let _ = write!(attrs, " flags=\"{}\"", fl.join(","));
|
||||
}
|
||||
let _ = write!(attrs, " flags=\"{}\"", fl.join(","));
|
||||
if let Some(c) = hex(&nums_of(doc, d.get(b"C"))) {
|
||||
let _ = write!(attrs, " color=\"{c}\"");
|
||||
}
|
||||
|
||||
@@ -308,6 +308,37 @@ fn xfdf_round_trips_comments_and_values() {
|
||||
assert!(!only.contains("<fields>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfdf_round_trip_keeps_zero_flags() {
|
||||
let square = |doc: &Document| {
|
||||
let page = pdfcraft_model::pages(doc)[0].obj;
|
||||
let annots = doc.get(page).as_dict().unwrap().get(b"Annots").map(|a| doc.resolve(a)).unwrap();
|
||||
annots
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Object::as_ref)
|
||||
.find(|r| doc.get(*r).as_dict().unwrap().name(b"Subtype") == Some(b"Square"))
|
||||
.unwrap()
|
||||
};
|
||||
let mut src = blank();
|
||||
let shape = Shape::Rectangle { rect: [10.0, 10.0, 100.0, 60.0] };
|
||||
add_annotation(
|
||||
&mut src,
|
||||
&NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "A".into() },
|
||||
&Meta { id: "s1".into(), date: None },
|
||||
)
|
||||
.unwrap();
|
||||
let annot = square(&src);
|
||||
src.update_dict(annot, |d| d.set(b"F".to_vec(), Object::Int(0))).unwrap();
|
||||
let xfdf = export_xfdf(&src, true, false, "a.pdf");
|
||||
let mut dst = blank();
|
||||
import(&mut dst, xfdf.as_bytes()).unwrap();
|
||||
let imported = square(&dst);
|
||||
let flags = dst.get(imported).as_dict().unwrap().get(b"F").and_then(|f| dst.resolve(f).as_int());
|
||||
assert_eq!(flags, Some(0), "flags after round trip: {xfdf}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_round_trips_values_and_comments() {
|
||||
let src = filled();
|
||||
|
||||
+7
-1
@@ -4,6 +4,7 @@
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-20 - **Last updated:** 2026-10-20 - **Change:** minor (M6, #3502: form detection uses the existing content-stream boundary policy; estimates unchanged)
|
||||
@@ -17,6 +18,8 @@
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: objects in one large object stream are read in linear time; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -123,6 +126,7 @@ total matches the parity estimate.
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
|
||||
@@ -137,7 +141,7 @@ total matches the parity estimate.
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: the Comments panel groups replies by name and counts all page roots once per frame (#2669, item 5); stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
@@ -214,6 +218,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
| 2026-10-20 | minor | M6: `form_calculation_order` reads, checks, fixes and sets the calculation order; an edited formula keeps its place in `/CO`. Estimates unchanged. |
|
||||
| 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
|
||||
| 2026-10-20 | minor | M3/#3620: overlapping comment hover follows original annotation indices independently of spatial inspection sorting; a synthetic differing-upper-edge regression covers paint order. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged |
|
||||
@@ -224,6 +229,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged |
|
||||
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
|
||||
| 2026-10-16 | minor | M5 #2669, item 5: the Comments panel groups replies and counts all page roots once per frame. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. |
|
||||
| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
|
||||
|
||||
@@ -1190,8 +1190,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::hovering_a_comment_shows_its_author_and_text"]
|
||||
notes = "a popup with the author and the comment text, as Acrobat shows"
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/comments.rs::preference_hides_author_only_highlight_hover_previews"]
|
||||
notes = "A popup with the author and comment text. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620), using the original /Annots index independently of spatial inspection order. Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool."
|
||||
|
||||
[[feature]]
|
||||
id = "view.page-size-hover"
|
||||
@@ -3716,8 +3716,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.eraser"]
|
||||
tools = []
|
||||
evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/comments.rs::erasing_part_of_a_drawing"]
|
||||
notes = "cuts strokes where the eraser passes; an emptied drawing is deleted; one undo step"
|
||||
evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/comments.rs::erasing_part_of_a_drawing", "crates/ui-egui/tests/comments.rs::the_eraser_rubs_as_wide_as_its_thickness_setting"]
|
||||
notes = "cuts strokes where the eraser passes; an emptied drawing is deleted; one undo step. Its thickness setting sizes it on screen (3 to 72 pixels across; the default 2 pt is 12), preview included (#3747)"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.line"
|
||||
@@ -3971,6 +3971,18 @@ tools = ["comment_delete"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"]
|
||||
notes = "deletes the pop-up and the reply thread"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.reshape"
|
||||
title = "Change a line's ends and a polygon's corners after drawing"
|
||||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["comment_edit"]
|
||||
evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/automation.rs::comments_are_reshaped_through_the_tool"]
|
||||
notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it to 45° from the point before), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.edit-comment-text"
|
||||
title = "Edit comment text"
|
||||
|
||||
Reference in New Issue
Block a user