mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 13:08:48 -06:00
Merge PR #3016: M5: sticky notes stay upright where they are placed on rotated pages (NoRotate, #2583)
Conflicts: - parity/acrobat-features.toml: comment.note: kept main's icon notes text, added the NoRotate sentence and the PR's three evidence entries. - vendor/README.md: kept main's rows; added the PR's hayro-interpret patch as (53) with its test (53).
This commit is contained in:
@@ -275,6 +275,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged.
|
||||
- **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged.
|
||||
- **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged.
|
||||
- **2026-10-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. 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.
|
||||
|
||||
@@ -51,6 +51,11 @@ deterministic; the engine supplies them.
|
||||
signature) and PdfCraft's rubber stamps counterrotate their appearance on rotated pages, so
|
||||
the picture or label reads upright as the page is displayed. Restyling a picture stamp keeps
|
||||
that appearance matrix. The rotation is the page's `/Rotate` at placement.
|
||||
- Sticky notes and file attachments are not counterrotated: they carry the NoRotate flag and
|
||||
their icon is drawn in an unturned 20 × 20 box. A viewer keeps them upright on a turned page,
|
||||
about the upper-left corner of `/Rect` (ISO 32000-2 §12.5.3). PdfCraft's renderer does so
|
||||
(vendored hayro-interpret patch 37), and `Summary::no_rotate` tells the UI where the icon is
|
||||
drawn.
|
||||
|
||||
## Not yet
|
||||
|
||||
|
||||
@@ -842,6 +842,17 @@ fn annotation_flags(doc: &Document, d: &Dict) -> i64 {
|
||||
d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether the renderer keeps the annotation upright on a turned page: the NoRotate flag, with
|
||||
/// `/F` read as the renderer reads it (a real truncated, a value that isn't a `u32` no flags).
|
||||
fn drawn_upright(doc: &Document, d: &Dict) -> bool {
|
||||
let f = match d.get(b"F").map(|f| doc.resolve(f)).as_deref() {
|
||||
Some(Object::Int(i)) => *i,
|
||||
Some(Object::Real(r)) => *r as i64,
|
||||
_ => 0,
|
||||
};
|
||||
u32::try_from(f).is_ok_and(|f| i64::from(f) & FLAG_NO_ROTATE != 0)
|
||||
}
|
||||
|
||||
/// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden).
|
||||
pub fn has_visible_fill_sign(doc: &Document) -> bool {
|
||||
let Ok(pages) = page_refs(doc) else { return false };
|
||||
@@ -2262,6 +2273,8 @@ pub struct Summary {
|
||||
pub intent: Option<String>,
|
||||
/// A Fill & Sign mark ([`is_fill_sign`]).
|
||||
pub fill_sign: bool,
|
||||
/// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`.
|
||||
pub no_rotate: 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]>,
|
||||
@@ -2344,6 +2357,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),
|
||||
no_rotate: drawn_upright(doc, d),
|
||||
points,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
//! Geometry follows the automation convention: points from the top-left of the displayed page,
|
||||
//! y down. It is converted to PDF user space (crop box, `/Rotate`) here.
|
||||
|
||||
use pdfcraft_engine::{
|
||||
Edit, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort,
|
||||
};
|
||||
use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort};
|
||||
use pdfcraft_render::{Annotation, PageInfo};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -81,13 +79,11 @@ fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] {
|
||||
}
|
||||
|
||||
/// The engine's `at` for a note or attachment icon whose top-left corner *as displayed* is the
|
||||
/// view point (x, y): the top-left (`[x0, y1]`) of the icon's square in user space. Converting
|
||||
/// the point alone is only right on unrotated pages — under `/Rotate` that corner of the
|
||||
/// displayed square is another corner of the user-space one, and the icon lands one icon-width
|
||||
/// away from where it was asked for.
|
||||
/// view point (x, y): the upper-left corner (`[x0, y1]`) of its `/Rect`. Notes and attachments
|
||||
/// are NoRotate, so on a turned page the icon is drawn upright with its top-left at that corner
|
||||
/// (ISO 32000-2 §12.5.3), and `comment_list` reports that square (`Annotation::shown_rect`).
|
||||
fn icon_anchor(p: &PageInfo, x: f64, y: f64) -> [f64; 2] {
|
||||
let r = rect_to_user(p, [x, y, x + NOTE_SIZE, y + NOTE_SIZE]);
|
||||
[r[0], r[3]]
|
||||
to_user(p, x, y)
|
||||
}
|
||||
|
||||
/// A user-space rectangle as the displayed-page rectangle every tool reports and accepts:
|
||||
@@ -183,8 +179,7 @@ impl Automation {
|
||||
"author": c.author,
|
||||
"contents": c.contents,
|
||||
"modified": c.modified,
|
||||
// Unchanged on a page the viewer could not read (#2660), as `doc_info` reports it.
|
||||
"rect": crate::view_rect(&doc.info, c.page, c.rect),
|
||||
"rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))),
|
||||
"color": c.color.map(hex),
|
||||
});
|
||||
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
|
||||
@@ -640,7 +635,12 @@ impl Automation {
|
||||
edits.push(Edit::FontSizeAnnotation { page, index, size });
|
||||
}
|
||||
if let Some(r) = a.nums::<4>("rect")? {
|
||||
edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) });
|
||||
// The rectangle as comment_list reports it: where the comment is drawn.
|
||||
let doc = self.doc(a)?;
|
||||
let rect = rect_to_user(&info, r);
|
||||
let rect =
|
||||
doc.info.annotations.iter().find(|c| c.page == page && c.index == index).map_or(rect, |c| c.rect_for_shown(rect, info.rotation));
|
||||
edits.push(Edit::ResizeAnnotation { page, index, rect });
|
||||
}
|
||||
if let Some([dx, dy]) = a.nums::<2>("move")? {
|
||||
let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy));
|
||||
|
||||
@@ -2616,7 +2616,8 @@ fn info(d: &Document) -> Value {
|
||||
// geometry-taking tools (#129).
|
||||
"annotations": i.annotations.iter().map(|a| json!({
|
||||
"page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents,
|
||||
"modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, "rect": view_rect(i, a.page, a.rect),
|
||||
"modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to,
|
||||
"rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))),
|
||||
})).collect::<Vec<_>>(),
|
||||
"fields": i.fields.iter().map(|f| json!({
|
||||
"name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1),
|
||||
|
||||
@@ -2908,6 +2908,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
|
||||
locked: s.locked,
|
||||
intent: s.intent,
|
||||
fill_sign: s.fill_sign,
|
||||
no_rotate: s.no_rotate,
|
||||
points: s.points,
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -196,6 +196,47 @@ fn rubber_stamps_point_left_as_displayed_on_rotated_pages() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2583, #1869: a sticky note placed on a turned page is drawn upright, with its top-left as
|
||||
/// displayed where it was placed (the corner its `/Rect` keeps, as the NoRotate flag it is saved
|
||||
/// with asks), exactly as on an unturned page, including after save. The comment list's
|
||||
/// `shown_rect`, which the UI hit-tests, is that square.
|
||||
#[test]
|
||||
fn sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages() {
|
||||
// The displayed 20 × 20 pt square whose top-left is (100, 40), in device pixels at scale 2.
|
||||
let crop = |rendered: &pdfcraft_render::RenderedPage| -> Vec<[u8; 4]> {
|
||||
(80..120).flat_map(|y| (200..240).map(move |x| (x, y))).map(|(x, y)| stamp_pixel(rendered, x, y)).collect()
|
||||
};
|
||||
let mut upright = None;
|
||||
for degrees in [0, 90, 180, 270] {
|
||||
let (mut session, id) = rotated_blank(degrees);
|
||||
let info = session.get(id).unwrap().info.pages[0].clone();
|
||||
let [x, y] = info.view_to_user(100.0, 40.0);
|
||||
let shape = Shape::Note { at: [f64::from(x), f64::from(y)], icon: NoteIcon::Comment };
|
||||
let style = Style::default_for(&shape);
|
||||
session.apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, style, shape, contents: "Turned".into(), author: "Test".into() })).unwrap();
|
||||
let mut check = |session: &Session, id: DocId| {
|
||||
let doc = session.get(id).unwrap();
|
||||
let info = &doc.info.pages[0];
|
||||
let note = &doc.info.annotations[0];
|
||||
assert!(note.no_rotate, "rotation {degrees}: notes are saved NoRotate");
|
||||
let shown = note.shown_rect(info.rotation);
|
||||
let (a, b) = (info.user_to_view(shown[0], shown[1]), info.user_to_view(shown[2], shown[3]));
|
||||
let shown = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])];
|
||||
assert!(shown.iter().zip([100.0, 40.0, 120.0, 60.0]).all(|(v, w)| (v - w).abs() < 0.01), "rotation {degrees}: shown at {shown:?}");
|
||||
let icon = crop(&render_page(session, id));
|
||||
let reference = upright.get_or_insert_with(|| icon.clone());
|
||||
assert!(reference.iter().filter(|c| c.iter().any(|v| *v < 200)).count() > 100, "the note's icon is drawn");
|
||||
let off = icon.iter().zip(reference.iter()).filter(|(a, b)| a.iter().zip(b.iter()).any(|(a, b)| a.abs_diff(*b) > 48)).count();
|
||||
assert!(off <= 8, "rotation {degrees}: {off} of the icon's {} pixels differ from the unturned page's", icon.len());
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// #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]
|
||||
|
||||
@@ -436,6 +436,10 @@ 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 NoRotate flag (`/F` bit 5), set on sticky notes and attachments: on a turned page the
|
||||
/// appearance stays upright, turned about the upper-left corner of `rect`
|
||||
/// ([`Self::shown_rect`]).
|
||||
pub no_rotate: 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]>,
|
||||
@@ -446,6 +450,58 @@ impl Annotation {
|
||||
pub fn is_mark(&self) -> bool {
|
||||
matches!(self.state.as_deref(), Some("Marked" | "Unmarked"))
|
||||
}
|
||||
|
||||
/// Where the comment is drawn on a page turned by `rotation` (`PageInfo::rotation`), in user
|
||||
/// space: `rect`, or for a NoRotate comment `rect` turned upright about its upper-left corner
|
||||
/// (see [`Self::no_rotate`]). Hit-testing, selection and anything placed beside the comment
|
||||
/// use this, so they match the drawing.
|
||||
pub fn shown_rect(&self, rotation: u16) -> [f32; 4] {
|
||||
self.shown(self.rect.map(f64::from), rotation).map(|v| v as f32)
|
||||
}
|
||||
|
||||
/// [`Self::shown_rect`] for this comment with its `/Rect` at `rect` (one being moved or
|
||||
/// resized).
|
||||
pub fn shown(&self, rect: [f64; 4], rotation: u16) -> [f64; 4] {
|
||||
if self.no_rotate { upright_rect(rect, rotation) } else { rect }
|
||||
}
|
||||
|
||||
/// The `/Rect` that shows this comment in the user-space box `shown`: the inverse of
|
||||
/// [`Self::shown`], for a box resized as drawn.
|
||||
pub fn rect_for_shown(&self, shown: [f64; 4], rotation: u16) -> [f64; 4] {
|
||||
if self.no_rotate { rect_from_upright(shown, rotation) } else { shown }
|
||||
}
|
||||
}
|
||||
|
||||
/// The user-space box a NoRotate annotation with `/Rect` `rect` is drawn in on a page turned
|
||||
/// clockwise by `rotation` (ISO 32000-2 §12.5.3): `rect` turned as far counterclockwise about its
|
||||
/// upper-left corner `(x0, y1)`, which stays where it is on the page. As displayed, the box's
|
||||
/// top-left is that corner, the rectangle's width runs across and its height down, as on an
|
||||
/// unturned page. `rect` itself for 0 or anything that isn't a quarter turn. The vendored
|
||||
/// renderer draws the appearance there (hayro-interpret patch 37).
|
||||
fn upright_rect(rect: [f64; 4], rotation: u16) -> [f64; 4] {
|
||||
let [x0, y0, x1, y1] = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
match rotation {
|
||||
90 => [x0, y1, x0 + h, y1 + w],
|
||||
180 => [x0 - w, y1, x0, y1 + h],
|
||||
270 => [x0 - h, y1 - w, x0, y1],
|
||||
_ => [x0, y0, x1, y1],
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/Rect` whose [`upright_rect`] is `shown`: `shown` turned back about the corner that
|
||||
/// stays put.
|
||||
fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] {
|
||||
let [s0, s1, s2, s3] = [shown[0].min(shown[2]), shown[1].min(shown[3]), shown[0].max(shown[2]), shown[1].max(shown[3])];
|
||||
let (sw, sh) = (s2 - s0, s3 - s1);
|
||||
// The upper-left corner of `/Rect` and the rectangle's width and height.
|
||||
let ((x0, y1), w, h) = match rotation {
|
||||
90 => ((s0, s1), sh, sw),
|
||||
180 => ((s2, s1), sw, sh),
|
||||
270 => ((s2, s3), sh, sw),
|
||||
_ => return [s0, s1, s2, s3],
|
||||
};
|
||||
[x0, y1 - h, x0 + w, y1]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -1356,6 +1412,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,
|
||||
no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0),
|
||||
points,
|
||||
};
|
||||
kept = kept.saturating_add(retained(&annotation));
|
||||
@@ -1864,6 +1921,17 @@ fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec<OutlineIt
|
||||
}
|
||||
}
|
||||
|
||||
/// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a
|
||||
/// `u32` counts as no flags.
|
||||
fn draw_flags(o: &Object) -> u32 {
|
||||
let f = match o {
|
||||
Object::Integer(i) => *i,
|
||||
Object::Real(r) => *r as i64,
|
||||
_ => 0,
|
||||
};
|
||||
u32::try_from(f).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn rect4(o: &Object) -> [f32; 4] {
|
||||
match o {
|
||||
Object::Array(a) if a.len() == 4 => {
|
||||
@@ -2319,6 +2387,54 @@ mod tests {
|
||||
assert_eq!(p.view_rect_to_quad([0.0, 0.0, 10.0, 5.0]), [10.0, 320.0, 20.0, 320.0, 10.0, 315.0, 20.0, 315.0]);
|
||||
}
|
||||
|
||||
/// #2583: a NoRotate comment is drawn upright with its top-left as displayed at the upper-left
|
||||
/// corner of `/Rect`, its width across and its height down; a box resized as drawn maps back to
|
||||
/// the `/Rect` that shows it. Other comments, and pages that aren't quarter-turned, keep `/Rect`.
|
||||
#[test]
|
||||
fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() {
|
||||
let mut note = super::Annotation {
|
||||
page: 0,
|
||||
subtype: "Text".into(),
|
||||
author: None,
|
||||
contents: None,
|
||||
modified: None,
|
||||
name: None,
|
||||
in_reply_to: None,
|
||||
rect: [40.0, 50.0, 70.0, 70.0],
|
||||
color: None,
|
||||
index: 0,
|
||||
state: None,
|
||||
quads: Vec::new(),
|
||||
locked: false,
|
||||
intent: None,
|
||||
fill_sign: false,
|
||||
no_rotate: true,
|
||||
points: Vec::new(),
|
||||
};
|
||||
let rect = note.rect.map(f64::from);
|
||||
for rotation in [0u16, 90, 180, 270, 45] {
|
||||
let (w, h) = if rotation % 180 == 90 { (140.0, 200.0) } else { (200.0, 140.0) };
|
||||
let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation };
|
||||
let shown = note.shown_rect(rotation);
|
||||
let (a, b) = (p.user_to_view(shown[0], shown[1]), p.user_to_view(shown[2], shown[3]));
|
||||
let [cx, cy] = p.user_to_view(40.0, 70.0);
|
||||
let want = [cx, cy, cx + 30.0, cy + 20.0];
|
||||
let got = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])];
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rotation}: shown {got:?}, want {want:?}");
|
||||
assert_eq!(note.rect_for_shown(note.shown(rect, rotation), rotation), rect, "{rotation}");
|
||||
// Moved by (5, -7) and resized to 50 × 10 as drawn, back to a /Rect.
|
||||
let resized = [shown[0] as f64 + 5.0, shown[1] as f64 - 7.0, 0.0, 0.0];
|
||||
let across = if rotation % 180 == 90 { [10.0, 50.0] } else { [50.0, 10.0] };
|
||||
let resized = [resized[0], resized[1], resized[0] + across[0], resized[1] + across[1]];
|
||||
let back = note.rect_for_shown(resized, rotation);
|
||||
assert_eq!((back[2] - back[0], back[3] - back[1]), (50.0, 10.0), "{rotation}: {back:?}");
|
||||
assert_eq!(note.shown(back, rotation), resized, "{rotation}");
|
||||
}
|
||||
note.no_rotate = false;
|
||||
assert_eq!(note.shown_rect(90), note.rect);
|
||||
assert_eq!(note.rect_for_shown([1.0, 2.0, 3.0, 4.0], 90), [1.0, 2.0, 3.0, 4.0]);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3175,6 +3175,91 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
/// #2583, #1869: a sticky note carries the NoRotate flag (`/F` bit 5), so on a page with
|
||||
/// `/Rotate` its icon stays upright as displayed, turned about the upper-left corner of its
|
||||
/// `/Rect`, which stays where it is on the page (ISO 32000-2 §12.5.3). It was drawn turned with
|
||||
/// the page, in `/Rect` as displayed (vendored hayro-interpret patch 37). A note without the
|
||||
/// flag still turns with the page. `Annotation::shown_rect` is where the UI hit-tests the
|
||||
/// note, and must be where it is drawn.
|
||||
#[test]
|
||||
fn no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages() {
|
||||
// A 30 × 20 appearance: red in its top-left quarter, blue elsewhere.
|
||||
let content = "0 0 1 rg 0 0 30 20 re f 1 0 0 rg 0 10 15 10 re f";
|
||||
for rotation in [0u16, 90, 180, 270] {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 140] /Rotate {rotation} /Annots [4 0 R 5 0 R] >>"),
|
||||
// NoRotate (and Print), as PdfCraft and Acrobat save notes.
|
||||
"<< /Type /Annot /Subtype /Text /F 20 /Rect [40 50 70 70] /AP << /N 6 0 R >> >>".to_string(),
|
||||
// The same appearance without the flag.
|
||||
"<< /Type /Annot /Subtype /Text /F 4 /Rect [120 50 150 70] /AP << /N 6 0 R >> >>".to_string(),
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 30 20] /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
let pdf = Arc::new(pdf);
|
||||
let info = crate::inspect(pdf.clone(), None).expect("opens");
|
||||
let page = &info.pages[0];
|
||||
assert_eq!(page.rotation, rotation);
|
||||
let mut r = PageRenderer::new(pdf, RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
// Displayed-page points are device pixels at scale 1.
|
||||
let px = |x: f32, y: f32| -> [u8; 4] {
|
||||
let (x, y) = (x.clamp(0.0, p.width as f32 - 1.0) as u32, y.clamp(0.0, p.height as f32 - 1.0) as u32);
|
||||
p.rgba[((y * p.width + x) * 4) as usize..][..4].try_into().unwrap()
|
||||
};
|
||||
let red: fn([u8; 4]) -> bool = |c| c[0] > 200 && c[1] < 60 && c[2] < 60;
|
||||
let blue: fn([u8; 4]) -> bool = |c| c[2] > 200 && c[0] < 60 && c[1] < 60;
|
||||
let paper: fn([u8; 4]) -> bool = |c| c.iter().all(|v| *v > 240);
|
||||
|
||||
// The NoRotate note: its upper-left corner (40, 70) is the top-left of an upright
|
||||
// 30 × 20 icon as displayed, red quarter top-left.
|
||||
let [cx, cy] = page.user_to_view(40.0, 70.0);
|
||||
for ((dx, dy), want, what) in [
|
||||
((7.5, 5.0), red, "red top-left quarter"),
|
||||
((22.5, 5.0), blue, "blue top-right"),
|
||||
((7.5, 15.0), blue, "blue bottom-left"),
|
||||
((22.5, 15.0), blue, "blue bottom-right"),
|
||||
((-3.0, 10.0), paper, "paper left of the icon"),
|
||||
((10.0, -3.0), paper, "paper above the icon"),
|
||||
((33.0, 10.0), paper, "paper right of its 30 pt width"),
|
||||
((10.0, 23.0), paper, "paper below its 20 pt height"),
|
||||
] {
|
||||
let c = px(cx + dx, cy + dy);
|
||||
assert!(want(c), "rotation {rotation}: NoRotate note at corner + ({dx}, {dy}) is {c:?}, want {what}");
|
||||
}
|
||||
let note = info.annotations.iter().find(|a| a.no_rotate).expect("the NoRotate note is listed");
|
||||
let shown = note.shown_rect(rotation);
|
||||
let (a, b) = (page.user_to_view(shown[0], shown[1]), page.user_to_view(shown[2], shown[3]));
|
||||
let box_ = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])];
|
||||
let want = [cx, cy, cx + 30.0, cy + 20.0];
|
||||
assert!(box_.iter().zip(want).all(|(v, w)| (v - w).abs() < 0.01), "rotation {rotation}: shown {box_:?}, drawn {want:?}");
|
||||
|
||||
// The note without the flag turns with the page: its red quarter stays at the
|
||||
// user-space top-left of /Rect, at the displayed top-left only when unturned.
|
||||
let plain = info.annotations.iter().find(|a| !a.no_rotate).expect("the plain note is listed");
|
||||
assert_eq!(plain.shown_rect(rotation), plain.rect);
|
||||
let [rx, ry] = page.user_to_view(127.5, 65.0);
|
||||
let [bx, by] = page.user_to_view(142.5, 55.0);
|
||||
assert!(red(px(rx, ry)) && blue(px(bx, by)), "rotation {rotation}: the plain note's appearance turns with the page");
|
||||
let (a, b) = (page.user_to_view(120.0, 50.0), page.user_to_view(150.0, 70.0));
|
||||
let corner = px(a[0].min(b[0]) + 4.0, a[1].min(b[1]) + 4.0);
|
||||
assert_eq!(red(corner), rotation == 0, "rotation {rotation}: displayed top-left of the plain note is {corner:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1968: the highlight appearance other editors write, a transparency group whose content is
|
||||
/// filled with `/BM /Multiply`, drew as an opaque block over the text. Every `/Group` form
|
||||
/// got a layer of its own, so the Multiply blended with that layer's transparent backdrop
|
||||
|
||||
@@ -5745,6 +5745,7 @@ mod tests {
|
||||
locked: false,
|
||||
intent: None,
|
||||
fill_sign,
|
||||
no_rotate: false,
|
||||
points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,8 +618,14 @@ impl PageCx<'_> {
|
||||
self.xf.norm_to_screen(v[0] / self.xf.pw, v[1] / self.xf.ph)
|
||||
}
|
||||
|
||||
/// The page's clockwise `/Rotate`.
|
||||
fn rotation(&self) -> u16 {
|
||||
self.info.pages.get(self.page).map_or(0, |p| p.rotation)
|
||||
}
|
||||
|
||||
/// Where a comment is drawn on screen (a sticky note stays upright on a turned page).
|
||||
fn screen_rect(&self, a: &Annotation) -> Rect {
|
||||
self.xf.user_rect(self.info, self.page, a.rect)
|
||||
self.xf.user_rect(self.info, self.page, a.shown_rect(self.rotation()))
|
||||
}
|
||||
|
||||
/// Top-level comments on this page, in paint order.
|
||||
@@ -688,16 +694,13 @@ impl PageCx<'_> {
|
||||
if let Some(Edit::ResizeAnnotation { page, index, rect }) = pending
|
||||
&& (*page, *index) == (self.page, a.index)
|
||||
{
|
||||
return self.xf.user_rect(self.info, self.page, rect.map(|v| v as f32));
|
||||
return self.xf.user_rect(self.info, self.page, a.shown(*rect, self.rotation()).map(|v| v as f32));
|
||||
}
|
||||
if let Some(Edit::MoveAnnotation { page, index, dx, dy }) = pending
|
||||
&& (*page, *index) == (self.page, a.index)
|
||||
{
|
||||
return self.xf.user_rect(
|
||||
self.info,
|
||||
self.page,
|
||||
[a.rect[0] + *dx as f32, a.rect[1] + *dy as f32, a.rect[2] + *dx as f32, a.rect[3] + *dy as f32],
|
||||
);
|
||||
let r = a.shown_rect(self.rotation());
|
||||
return self.xf.user_rect(self.info, self.page, [r[0] + *dx as f32, r[1] + *dy as f32, r[2] + *dx as f32, r[3] + *dy as f32]);
|
||||
}
|
||||
match (gesture, pointer) {
|
||||
(Some(Gesture::Move { page, index, from }), Some(p)) if (*page, *index) == (self.page, a.index) => r.translate(p - *from),
|
||||
@@ -1230,7 +1233,7 @@ fn select_input(
|
||||
let from_rect = cx.screen_rect(a);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift));
|
||||
let rect = cx.rect_to_user(r);
|
||||
let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation());
|
||||
if r.width() >= 4.0 && r.height() >= 4.0 {
|
||||
view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect });
|
||||
}
|
||||
@@ -1278,7 +1281,7 @@ fn select_input(
|
||||
cv.selected = Some((cx.page, a.index));
|
||||
cv.composer = Some(Composer {
|
||||
page: cx.page,
|
||||
at: [a.rect[2] as f64, a.rect[3] as f64],
|
||||
at: edit_anchor(cx.info, a),
|
||||
kind: ComposerKind::Edit(a.index),
|
||||
text: a.contents.clone().unwrap_or_default(),
|
||||
focus: true,
|
||||
@@ -1550,6 +1553,15 @@ fn erase(cx: &PageCx<'_>, path: &[[f64; 2]]) -> Option<Edit> {
|
||||
Some(Edit::Batch { label: "Erase".into(), edits })
|
||||
}
|
||||
|
||||
/// Where the composer for editing `a` opens: the top-right corner of the comment as drawn on the
|
||||
/// displayed page.
|
||||
fn edit_anchor(info: &DocInfo, a: &Annotation) -> [f64; 2] {
|
||||
let Some(p) = info.pages.get(a.page) else { return [f64::from(a.rect[2]), f64::from(a.rect[3])] };
|
||||
let r = a.shown_rect(p.rotation);
|
||||
let (u, v) = (p.user_to_view(r[0], r[1]), p.user_to_view(r[2], r[3]));
|
||||
p.view_to_user(u[0].max(v[0]), u[1].min(v[1])).map(f64::from)
|
||||
}
|
||||
|
||||
fn new_comment(cx: &PageCx<'_>, tool: CommentTool, shape: Shape, contents: String) -> Edit {
|
||||
Edit::AddAnnotation(NewAnnotation { page: cx.page, shape, style: cx.prefs.style(tool), contents, author: cx.prefs.author.clone() })
|
||||
}
|
||||
@@ -1851,7 +1863,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
|
||||
if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() {
|
||||
view.comments.composer = Some(Composer {
|
||||
page,
|
||||
at: [a.rect[2] as f64, a.rect[3] as f64],
|
||||
at: edit_anchor(info, a),
|
||||
kind: ComposerKind::Edit(index),
|
||||
text: a.contents.clone().unwrap_or_default(),
|
||||
focus: true,
|
||||
|
||||
@@ -264,7 +264,8 @@ pub(crate) fn show(
|
||||
continue;
|
||||
}
|
||||
let thread = replies_of(a);
|
||||
if let Some(e) = card(ui, t, a, thread, info, view, prefs, allowed, nav) {
|
||||
let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation);
|
||||
if let Some(e) = card(ui, t, a, rotation, thread, info, view, prefs, allowed, nav) {
|
||||
edit = Some(e);
|
||||
}
|
||||
}
|
||||
@@ -276,6 +277,8 @@ fn card(
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
a: &Annotation,
|
||||
// The clockwise `/Rotate` of the comment's page.
|
||||
rotation: u16,
|
||||
thread: &[&Annotation],
|
||||
info: &DocInfo,
|
||||
view: &mut DocView,
|
||||
@@ -442,7 +445,7 @@ fn card(
|
||||
if click.clicked() {
|
||||
view.comments.selected = Some(key);
|
||||
view.comments.reply.clear();
|
||||
*nav = Some(Nav::Flash(a.page, a.rect));
|
||||
*nav = Some(Nav::Flash(a.page, a.shown_rect(rotation)));
|
||||
}
|
||||
if click.double_clicked() && allowed {
|
||||
view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default());
|
||||
|
||||
@@ -675,6 +675,38 @@ fn sticky_note_composer_posts_and_returns_to_select() {
|
||||
assert_eq!(comments(&h).len(), 1);
|
||||
}
|
||||
|
||||
/// #2583: a sticky note is saved NoRotate, so on a turned page its icon is drawn upright from
|
||||
/// where it was placed (right and down), not where its `/Rect` lies as displayed. The Select tool
|
||||
/// picks it where it is drawn.
|
||||
#[test]
|
||||
fn a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn() {
|
||||
let mut h = harness(|app| {
|
||||
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
app.set_option("quick", "note").unwrap();
|
||||
});
|
||||
settle(&mut h);
|
||||
let page = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||||
// Screen pixels per point of the displayed page (300 × 200 pt turned to 200 × 300).
|
||||
let k = page.width() / 200.0;
|
||||
let placed = page.left_top() + egui::vec2(100.0 * k, 60.0 * k);
|
||||
click(&mut h, placed);
|
||||
field(&h, "Add a comment").type_text("Turned");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Post").click();
|
||||
h.run_steps(3);
|
||||
settle(&mut h);
|
||||
let c = comments(&h);
|
||||
assert_eq!(c.len(), 1);
|
||||
assert_eq!((c[0].subtype.as_str(), c[0].no_rotate), ("Text", true));
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||||
// Where the note's /Rect lies as displayed, left of where it was placed: nothing.
|
||||
click(&mut h, placed + egui::vec2(-10.0 * k, 10.0 * k));
|
||||
assert_eq!(h.state().views[0].comments.selected, None);
|
||||
// On the icon, drawn right and down from where it was placed.
|
||||
click(&mut h, placed + egui::vec2(10.0 * k, 10.0 * k));
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -3584,8 +3584,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.note"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured"]
|
||||
notes = "our own icon drawings for the seven standard /Name values of ISO 32000-2 Table 175 (Comment, Note, Help, Insert, Key, NewParagraph, Paragraph) and seven further names (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow), each its own shape, chosen in Properties or comment_add, and kept when the colour changes; another name draws as Note. With a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Not yet: opening the pop-ups a file saves open (/Open)."
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/it/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn"]
|
||||
notes = "our own icon drawings for the seven standard /Name values of ISO 32000-2 Table 175 (Comment, Note, Help, Insert, Key, NewParagraph, Paragraph) and seven further names (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow), each its own shape, chosen in Properties or comment_add, and kept when the colour changes; another name draws as Note. With a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Notes are saved with the NoRotate flag, and it is honoured: on a page with /Rotate the icon stays upright with its top-left where it was placed, and is selected and hovered there (#2583). Not yet: opening the pop-ups a file saves open (/Open); NoZoom (the icon scales with the zoom); printing and flattening still place the icon in its /Rect, turned with the page."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.highlight"
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+33
-1
@@ -21,7 +21,7 @@ use hayro_syntax::object::dict::keys::{
|
||||
ACTUAL_TEXT, ANNOTS, AP, AS, C, CA, F, MCID, N, OC, QUADPOINTS, RECT, SUBTYPE,
|
||||
};
|
||||
use hayro_syntax::object::{Array, Dict, Object, Rect, Stream, dict_or_stream};
|
||||
use hayro_syntax::page::{ContentReader, Page, Resources};
|
||||
use hayro_syntax::page::{ContentReader, Page, Resources, Rotation};
|
||||
use hayro_syntax::MAX_DECODED_STREAM;
|
||||
use kurbo::{Affine, BezPath, Point, Shape};
|
||||
use smallvec::smallvec;
|
||||
@@ -249,6 +249,13 @@ pub fn interpret_page<'a>(
|
||||
annot_rect.x0 - sx * transformed_rect.x0,
|
||||
annot_rect.y0 - sy * transformed_rect.y0,
|
||||
]);
|
||||
// PdfCraft patch (37): NoRotate (bit 5) keeps the appearance upright as the page is
|
||||
// displayed (ISO 32000-2 §12.5.3).
|
||||
let affine = if flags & 16 != 0 && no_rotate_applies(&annot) {
|
||||
no_rotate_turn(page.rotation(), &annot_rect) * affine
|
||||
} else {
|
||||
affine
|
||||
};
|
||||
|
||||
// PdfCraft patch: a /BBox or /Matrix that collapses the appearance box to a line
|
||||
// or a point leaves nothing to map onto /Rect: the scales above divide by zero,
|
||||
@@ -282,6 +289,31 @@ pub fn interpret_page<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (37): whether the NoRotate flag turns this annotation's appearance. Form fields
|
||||
/// and links are left in `/Rect`: PdfCraft takes their click targets from it.
|
||||
fn no_rotate_applies(annot: &Dict<'_>) -> bool {
|
||||
!matches!(
|
||||
annot.get::<hayro_syntax::object::Name<'_>>(SUBTYPE).as_deref(),
|
||||
Some(b"Widget" | b"Link")
|
||||
)
|
||||
}
|
||||
|
||||
/// PdfCraft patch (37): the user-space turn that keeps a NoRotate annotation upright on a page
|
||||
/// with `/Rotate`. The page is shown turned clockwise, so the appearance (already fitted to
|
||||
/// `/Rect`) is turned as far counterclockwise about the rectangle's upper-left corner, which stays
|
||||
/// where it is on the page (ISO 32000-2 §12.5.3). Its width then runs across the displayed page
|
||||
/// and its height down from that corner.
|
||||
fn no_rotate_turn(rotation: Rotation, rect: &kurbo::Rect) -> Affine {
|
||||
let (cos, sin) = match rotation {
|
||||
Rotation::None => return Affine::IDENTITY,
|
||||
Rotation::Horizontal => (0.0, 1.0),
|
||||
Rotation::Flipped => (-1.0, 0.0),
|
||||
Rotation::FlippedHorizontal => (0.0, -1.0),
|
||||
};
|
||||
let (x, y) = (rect.x0.min(rect.x1), rect.y0.max(rect.y1));
|
||||
Affine::new([cos, sin, -sin, cos, x - (cos * x - sin * y), y - (sin * x + cos * y)])
|
||||
}
|
||||
|
||||
/// PdfCraft patch: the normal appearance of a Highlight annotation that has none (ISO 32000-2
|
||||
/// §12.5.6.10), drawn as PdfCraft draws its own highlights (pdfcraft-annot): each /QuadPoints
|
||||
/// quadrilateral filled with /C at /CA opacity, blended with Multiply so the marked-up content
|
||||
|
||||
Reference in New Issue
Block a user