diff --git a/contributors/people.toml b/contributors/people.toml index aa8dd7a15..eb0e008d4 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -129,3 +129,6 @@ sync_github_name = false [people.webdevtodayjason] display_name = "Jason Brashear" + +[people.alexanderalber] +real_name = "Alexander Alber" diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index a5394038d..0f0706428 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -269,6 +269,12 @@ fn stamp_turn(d: &Dict) -> i64 { d.get(b"PCStampRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 } +/// The page `/Rotate` a text box's or Fill & Sign text's lines are turned back by +/// (`/PCTextRotate`, written when it is added to a turned page). +fn text_turn(d: &Dict) -> i64 { + d.get(b"PCTextRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 +} + /// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that /// they can't, which would come out as `?`: what it is (for the message) and the character. The /// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's @@ -666,6 +672,19 @@ pub fn build(d: &Dict) -> Option { let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; // A callout: the leader line (arrowhead at its first point), and the text box inside // `/Rect` by `/RD`. + // Text added to a turned page is drawn upright in its displayed box, which the form + // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. + let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; + let (rect, matrix) = if turn == 0 { + (rect, None) + } 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], Some(pdfcraft_model::view_matrix_for(turn, rect))) + }; let full = rect; let mut rect = rect; if d.contains(b"CL") { @@ -751,7 +770,11 @@ pub fn build(d: &Dict) -> Option { let mut fonts = Dict::new(); fonts.set(b"Helv".to_vec(), Object::Dict(font)); res.set(b"Font".to_vec(), Object::Dict(fonts)); - return Some(form(full, &out, res)); + let mut stream = form(full, &out, res); + if let Some(m) = matrix { + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + return Some(stream); } _ => return None, } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index a25b35c3b..5621f4638 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1206,6 +1206,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } border(&mut d); rich_text(&mut d); + // The text is laid out for the page as displayed, like a stamp's label. + if !matches!(new.shape, Shape::Callout { .. }) { + 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"PCTextRotate".to_vec(), Object::Int(turn)); + } + } } _ => {} } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index d98c48723..10036d7e6 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -511,7 +511,8 @@ impl Automation { let text_at = |t: &str| { let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0); let h = size * 1.2 + 6.0; - Shape::Typewriter { rect: [at[0], at[1] - h, at[0] + w, at[1]], font_size: size } + let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); + Shape::Typewriter { rect, font_size: size } }; let (shape, contents) = match kind { "text" => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 19f18fea9..f170a07d1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -107,6 +107,19 @@ pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i6 let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); Some(Shape::TypedSignature { rect, contours }) } +/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). +pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + [at[0] + a * right + c * up, at[1] + b * right + d * up] +} + +/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, +/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a +/// non-finite point. +pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { + signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) +} + /// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types /// it. `None` for text with no outlines. pub fn typed_signature_graphic(text: &str) -> Option { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 99594cde2..133587202 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -196,6 +196,59 @@ fn rubber_stamps_point_left_as_displayed_on_rotated_pages() { } } +/// 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] +fn typed_text_reads_across_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 top-left of the text at (40, 60): a wide first line, then one letter below + // its start. + let at = info.view_to_user(40.0, 60.0); + let at = [f64::from(at[0]), f64::from(at[1])]; + let rect = text_box_at(degrees, at, 120.0, 2.0 * 12.0 * 1.2 + 6.0).unwrap(); + let shape = Shape::Typewriter { rect, font_size: 12.0 }; + let style = Style::default_for(&shape); + session + .apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + style, + shape, + contents: "MMMMMMMM +I" + .into(), + author: "Test".into(), + }), + ) + .unwrap(); + let check = |session: &Session, id: DocId| { + let rendered = render_page(session, id); + let dark = |x: u32, y: u32| stamp_pixel(&rendered, x, y)[..3].iter().all(|c| *c < 128); + let ink: Vec<(u32, u32)> = + (0..rendered.height).flat_map(|y| (0..rendered.width).map(move |x| (x, y))).filter(|&(x, y)| dark(x, y)).collect(); + assert!(!ink.is_empty(), "rotation {degrees}: no text drawn"); + let (x0, x1) = (ink.iter().map(|p| p.0).min().unwrap(), ink.iter().map(|p| p.0).max().unwrap()); + let (y0, y1) = (ink.iter().map(|p| p.1).min().unwrap(), ink.iter().map(|p| p.1).max().unwrap()); + // Rendered at scale 2: the text starts at the clicked point and runs to the right. + assert!((x1 - x0) > 2 * (y1 - y0), "rotation {degrees}: ink {x0}..{x1} x {y0}..{y1} should be wide, not tall"); + assert!(x0.abs_diff(80) < 16 && y0.abs_diff(120) < 16, "rotation {degrees}: text starts at ({x0}, {y0}), not near (80, 120)"); + // The lone "I" is on the second line under the start of the first, not upside down + // (above the end). + let mid = (y0 + y1) / 2; + let lower: Vec = ink.iter().filter(|p| p.1 > mid + 2).map(|p| p.0).collect(); + assert!(!lower.is_empty() && lower.iter().all(|x| *x < x0 + (x1 - x0) / 4), "rotation {degrees}: second line is not under the start"); + }; + 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); + } +} + /// A one-page PDF whose left half is red and whose right half is white, as displayed. fn half_red_page() -> Vec { let content = "1 0 0 rg 0 0 40 40 re f"; diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 6f3d8ab87..67d2c1ce9 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1252,11 +1252,17 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, } ComposerKind::TextBox => { view.comments.tool_done = true; - Some(new_comment(&cx, CommentTool::TextBox, Shape::TextBox { rect: text_box_rect(c.at, &text, 12.0), font_size: 12.0 }, text)) + 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, + )) } ComposerKind::Callout { point } => { view.comments.tool_done = true; - let rect = text_box_rect(c.at, &text, 10.0); + // A callout's leader and box stay in user-space axes (its appearance isn't turned). + let rect = text_box_rect(c.at, &text, 10.0, 0); let side = if point[0] < rect[0] { rect[0] } else if point[0] > rect[2] { @@ -1361,14 +1367,21 @@ pub(crate) fn caret_snap(text: &PageText, vx: f32, vy: f32) -> Option<([f32; 2], } /// A text box sized to its text (at most 300 pt wide), hanging from its top-left corner. -pub fn text_box_rect(at: [f64; 2], text: &str, size: f64) -> [f64; 4] { +/// The box for `text` at `size` with its displayed top-left corner at `at`, on a page turned by +/// `rotation` (its `/Rotate`): on a quarter-turned page it is taller than wide in user space. +pub fn text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64; 4] { use pdfcraft_engine::annot_text::{text_width, wrap}; let pad = 2.0; let longest = text.lines().map(|l| text_width(l, size)).fold(0.0, f64::max); let w = (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0); let lines = wrap(text, size, w - 2.0 * pad).len().max(1); let h = lines as f64 * size * 1.2 + 2.0 * pad + 2.0; - [at[0], at[1] - h, at[0] + w, at[1]] + pdfcraft_engine::text_box_at(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)) } /// Delete / Escape handling for comments (only while no text field has focus). @@ -1920,10 +1933,10 @@ mod tests { #[test] fn text_boxes_fit_their_text() { - let r = text_box_rect([100.0, 500.0], "Hi", 12.0); + let r = text_box_rect([100.0, 500.0], "Hi", 12.0, 0); assert_eq!((r[0], r[3]), (100.0, 500.0)); assert!(r[2] - r[0] < 60.0); - let long = text_box_rect([0.0, 500.0], &"word ".repeat(80), 12.0); + let long = text_box_rect([0.0, 500.0], &"word ".repeat(80), 12.0, 0); assert_eq!(long[2], 300.0); assert!(long[3] - long[1] > 50.0); } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index dc7226ef6..19cd043bf 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -509,7 +509,8 @@ impl PdfCraftApp { return true; } let author = self.comment_prefs.author.clone(); - let edit = crate::fill_sign::typed(tb.page, tb.at, &text, tb.size, &author); + let rotation = self.session.get(id).map_or(0, |doc| crate::comments::page_rotation(&doc.info, tb.page)); + let edit = crate::fill_sign::typed(tb.page, tb.at, &text, tb.size, rotation, &author); if self.apply_edit(edit) { if let Some(view) = self.views.get_mut(i) { view.fill_text = None; diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index d1b5f0e59..a541254d2 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -411,9 +411,10 @@ fn new(page: usize, shape: Shape, contents: String, author: &str) -> Edit { Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author: author.to_string() }) } -/// A typewriter annotation sized to its text. -pub fn typed(page: usize, at: [f64; 2], text: &str, size: f64, author: &str) -> Edit { - let rect = crate::comments::text_box_rect(at, text, size); +/// A typewriter annotation sized to its text, its displayed top-left at `at` on a page turned by +/// `rotation` (its `/Rotate`). +pub fn typed(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str) -> Edit { + let rect = crate::comments::text_box_rect(at, text, size, rotation); new(page, Shape::Typewriter { rect, font_size: size }, text.to_string(), author) } @@ -633,16 +634,20 @@ pub(crate) fn page_input( if !resp.clicked() { return None; } + // Typed text starts a little above the click, as displayed. + let rotation = i64::from(p.rotation); + let top = pdfcraft_engine::displayed_offset(rotation, at, 0.0, text_size * 0.6); match tool { FillTool::Text => { // Clicking away from an open box commits it: the page sees this click before the box // sees its lost focus, so replacing the box first would drop what was typed. - let open = - view.fill_text.replace(TypeBox { page, at: [at[0], at[1] + text_size * 0.6], text: String::new(), size: text_size, focus: true }); - open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| FillAction::Edit(Box::new(typed(tb.page, tb.at, tb.text.trim(), tb.size, author)))) + let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, focus: true }); + open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| { + FillAction::Edit(Box::new(typed(tb.page, tb.at, tb.text.trim(), tb.size, crate::comments::page_rotation(info, tb.page), author))) + }) } FillTool::Date => Some(match date_text { - Ok(text) => FillAction::Edit(Box::new(typed(page, [at[0], at[1] + text_size * 0.6], text, text_size, author))), + Ok(text) => FillAction::Edit(Box::new(typed(page, top, text, text_size, rotation, author))), Err(why) => FillAction::Refused(why.clone()), }), FillTool::Signature => match signature { @@ -719,7 +724,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, if commit { let tb = view.fill_text.take()?; if !tb.text.trim().is_empty() { - return Some(typed(tb.page, tb.at, tb.text.trim(), tb.size, author)); + return Some(typed(tb.page, tb.at, tb.text.trim(), tb.size, crate::comments::page_rotation(info, tb.page), author)); } } None diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 22ee2c66d..bf39b83f8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5244,8 +5244,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/automation.rs::fill_and_sign_through_tools"] -notes = "typewriter FreeText, 10 pt Helvetica" +evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages"] +notes = "typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet" [[feature]] id = "form.fill-sign-marks"