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:
alexanderalber
2026-10-15 02:21:01 -04:00
committed by GitHub
parent 089e13c074
commit 00957b9f21
10 changed files with 138 additions and 19 deletions
+3
View File
@@ -129,3 +129,6 @@ sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"
[people.alexanderalber]
real_name = "Alexander Alber"
+24 -1
View File
@@ -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,
}
+7
View File
@@ -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));
}
}
}
_ => {}
}
+2 -1
View File
@@ -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" => {
+13
View File
@@ -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> {
+53
View File
@@ -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";
+19 -6
View File
@@ -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);
}
+2 -1
View File
@@ -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;
+13 -8
View File
@@ -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
+2 -2
View File
@@ -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"