mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 11:37:59 -06:00
M5: Fill & Sign text reads across on rotated pages (#1710)
* M5: Fill & Sign text reads across on rotated pages Typed text, dates and text boxes ignored the page's /Rotate, so on a page stored sideways (common for scanned forms) the text came out sideways. The box now follows the displayed axes, the annotation records the turn in /PCTextRotate and its appearance is turned back with a form /Matrix, as stamps and image signatures already do. Callouts keep user-space axes. The headless fill_sign_add tool gets the same layout. generated with Claude Code * chore: credit alexanderalber in the About window generated with Claude Code
This commit is contained in:
@@ -129,3 +129,6 @@ sync_github_name = false
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
|
||||
@@ -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<Stream> {
|
||||
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<Stream> {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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<pdfcraft_sign::Graphic> {
|
||||
|
||||
@@ -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<u32> = 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<u8> {
|
||||
let content = "1 0 0 rg 0 0 40 40 re f";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user