M9: visible signatures read upright on rotated pages (#4067)

* M9: visible signatures read upright on rotated pages

A visible digital signature placed on a page with /Rotate was drawn in
user space, so its graphic and details turned with the page: on scans
stored landscape with /Rotate 270 the signature came out sideways.
Stamps (#1398) and Fill & Sign marks (#2392) were already corrected;
signatures were not.

A new signature widget on a turned page gets /MK /R equal to the page
rotation (inherited rotation included, via pdfcraft_model::pages), and
its appearance is laid out in the box as displayed (sides swapped for a
quarter turn) with a /Matrix from pdfcraft_model::view_matrix_for that
maps it exactly onto /Rect. Signing an existing field follows that
field's own /MK /R, so a field without one is drawn as before. Pages
that are not turned produce the same bytes as before.

Tests: sign crate (layout equals the upright box's, /MK /R, /Matrix,
inherited /Rotate, existing fields with and without /MK /R, malformed
/MK /R) and an engine render test on 0/90/180/270 pages with the
quadrant image. Both fail with the fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep one evidence/notes pair in the sign.digital parity entry

---------

Co-authored-by: vjeko2404 <vjekoslav@outlook.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Friday's for beer
2026-10-23 10:43:54 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 vjeko2404 Brandon Thomas
parent 9206254142
commit f46268fbe4
7 changed files with 181 additions and 9 deletions
Generated
+1
View File
@@ -4347,6 +4347,7 @@ dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"rc2",
"ripemd",
"rsa",
+25
View File
@@ -373,6 +373,31 @@ fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() {
}
}
/// A visible digital signature reads upright as the page is displayed on every page rotation:
/// its picture, alone in the box, shows its quadrants where they are in the image.
#[test]
fn visible_signatures_keep_displayed_orientation_on_rotated_pages() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
let picture = image::load_from_memory(&quadrant_png()).unwrap().to_rgba8();
let graphic = sign::Graphic::Image { width: picture.width(), height: picture.height(), rgba: Arc::new(picture.into_raw()) };
let bare = sign::Appearance { name: false, date: false, reason: false, location: false, distinguished_name: false, labels: false };
for degrees in [0, 90, 180, 270] {
// After the test certificate's start date (2026-10-02).
let mut session = Session::new().with_clock(|| 1_800_000_000);
let blank = session.create_blank(200.0, 300.0, 1).unwrap();
let id = session.open_new("contract.pdf", blank).unwrap();
session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap();
let info = session.get(id).unwrap().info.pages[0].clone();
// The picture's proportions (2:1) as displayed, centred at (100, 100).
let rect = displayed_rect(&info, 100.0, 100.0, 80.0, 40.0);
let opts = SignOptions { page: 0, rect: Some(rect), graphic: Some(graphic.clone()), appearance: bare.clone(), ..SignOptions::default() };
let signed = session.sign(id, &digital_id, opts).unwrap();
let reopened = session.open("signed.pdf", None, signed, None).unwrap();
assert_upright_quadrants(&session, reopened, degrees);
}
}
/// #1398: a PDF page used as a stamp stays upright, at its natural size and in an explicit box.
#[test]
fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() {
+1
View File
@@ -14,6 +14,7 @@ workspace = true
pdfcraft-cos = { workspace = true }
pdfcraft-annot = { workspace = true }
pdfcraft-fonts = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror.workspace = true
# ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code).
chrono = { workspace = true }
+4 -1
View File
@@ -22,7 +22,10 @@ for s in signatures(&doc, &bytes, &trust) { // li
An existing unsigned field or a new one (visible with Acrobat's name-and-details appearance,
or invisible); certification with DocMDP P=1/2/3. A visible signature can show a `Graphic`
where the large name would be: drawn strokes, filled outlines (a typed signature) or RGBA
pixels (an image XObject with a soft mask), checked and size-capped first. The document is written incrementally
pixels (an image XObject with a soft mask), checked and size-capped first. On a page shown
turned (`/Rotate`, inherited or not), a new visible signature reads upright as displayed: its
widget gets `/MK /R` and its appearance is laid out in the displayed box and turned back by a
`/Matrix`; an existing field follows its own `/MK /R`. The document is written incrementally
with a zero-filled `/Contents` and fixed-width `/ByteRange`, which are then patched in
place. Encrypted documents are refused for now.
- **Timestamps (PAdES B-T):** the `timestamp` module builds and parses RFC 3161 requests,
+44 -6
View File
@@ -672,6 +672,17 @@ fn page_index(doc: &Document, p: ObjRef) -> Option<usize> {
pdfcraft_annot::page_refs(doc).ok()?.iter().position(|r| *r == p)
}
/// A widget's `/MK /R` as a quarter turn (0, 90, 180 or 270); missing, malformed or not a
/// multiple of 90 counts as 0, as `pdfcraft-forms` reads it for the fields it draws.
fn mk_rotation(doc: &Document, widget: &Dict) -> i64 {
let r = widget
.get(b"MK")
.map(|mk| doc.resolve(mk))
.and_then(|mk| mk.as_dict().and_then(|mk| mk.get(b"R")).map(|r| doc.resolve(r)))
.and_then(|r| r.as_int().or_else(|| r.as_f64().filter(|v| v.is_finite() && v.abs() <= 1.0e6).map(|v| v.round() as i64)));
r.map(|r| r.rem_euclid(360)).filter(|q| matches!(q, 90 | 180 | 270)).unwrap_or(0)
}
/// Validate one signature dictionary.
pub fn validate(doc: &Document, bytes: &[u8], trust: &TrustStore, field: &str) -> Option<SignatureInfo> {
list(doc, bytes, trust).into_iter().find(|s| s.field == field)
@@ -2054,7 +2065,8 @@ fn sign_inner(
.field
.as_deref()
.map(|n| sig_fields(&doc).into_iter().find(|f| f.name == n).ok_or_else(|| SignError::Pdf(format!("there is no signature field {n:?}"))));
let (widget, rect) = match existing {
// `turn`: how far the appearance is turned to read upright (`/MK /R`, counterclockwise).
let (widget, rect, turn) = match existing {
Some(f) => {
let f = f?;
if f.signature(&doc).is_some() {
@@ -2067,7 +2079,9 @@ fn sign_inner(
doc.update_dict(fr, |d| d.set(b"V".to_vec(), Object::Ref(sig)))?;
let (wr, wd) = f.widgets.first().cloned().ok_or_else(|| SignError::Pdf("the field has no widget".into()))?;
let rect = nums(&doc, &wd, b"Rect").filter(|r| r.len() == 4).map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
(wr.unwrap_or(fr), rect.unwrap_or([0.0; 4]))
// The field's own rotation, as a form author set it: an existing field is not
// reoriented, so one without `/MK /R` is drawn as before.
(wr.unwrap_or(fr), rect.unwrap_or([0.0; 4]), mk_rotation(&doc, &wd))
}
None => {
let page = *pages.get(opts.page).ok_or_else(|| SignError::Pdf(format!("page {} does not exist", opts.page + 1)))?;
@@ -2087,6 +2101,14 @@ fn sign_inner(
w.set(b"P".to_vec(), Object::Ref(page));
// Print + Locked.
w.set(b"F".to_vec(), Object::Int(4 | 128));
// On a page shown turned, the signature reads upright as the page is displayed: the
// widget is turned back by the page's rotation (ISO 32000-2 §12.5.6.19, `/MK /R`).
let turn = pdfcraft_model::pages(&doc).get(opts.page).map_or(0, |p| p.rotation(&doc));
if turn != 0 {
let mut mk = Dict::new();
mk.set(b"R".to_vec(), Object::Int(turn));
w.set(b"MK".to_vec(), Object::Dict(mk));
}
let wr = doc.add(Object::Dict(w));
// Page /Annots (indirect arrays are updated in place).
let pd = doc.get(page).as_dict().cloned().unwrap_or_default();
@@ -2122,7 +2144,7 @@ fn sign_inner(
// Inline (or new) AcroForms stay in the catalog.
None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?,
}
(wr, rect)
(wr, rect, turn)
}
};
if opts.field.is_some() {
@@ -2136,7 +2158,7 @@ fn sign_inner(
}
}
// The appearance.
let ap = appearance(&mut doc, rect, &name, &id.certificate, opts)?;
let ap = appearance(&mut doc, rect, turn, &name, &id.certificate, opts)?;
let ap_ref = doc.add(Object::Stream(ap));
let mut apd = Dict::new();
apd.set(b"N".to_vec(), Object::Ref(ap_ref));
@@ -2264,9 +2286,15 @@ pub fn display_date(pdf: &str) -> String {
/// draw: signing invisibly, or leaving that part out, still works. A graphic doesn't change this:
/// the name it replaces on the left is still among the details. A label that is drawn
/// ([`AppearanceLabels`], the caller's words) must be drawable in Helvetica, and is refused otherwise.
fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Result<Stream, SignError> {
///
/// `turn` is the widget's `/MK /R` (0, 90, 180 or 270; anything else counts as 0): the layout is
/// drawn upright in the box as turned by it, and a `/Matrix` maps that box onto `rect`.
fn appearance(doc: &mut Document, rect: [f64; 4], turn: i64, name: &str, cert: &Certificate, opts: &SignOptions) -> Result<Stream, SignError> {
use pdfcraft_fonts::{first_undrawable, helvetica_width, inter_width, literal, undrawable_message, win_ansi, wrap, wrap_with};
let (w, h) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0));
let turn = if matches!(turn, 90 | 180 | 270) { turn } else { 0 };
let (rw, rh) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0));
// The upright layout box: a quarter turn swaps its sides.
let (w, h) = if turn % 180 == 0 { (rw, rh) } else { (rh, rw) };
let a = &opts.appearance;
let dn = cert.subject.display();
let mut needs_unicode = false;
@@ -2412,6 +2440,16 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|x| Object::Real(*x)).collect()));
if turn != 0 {
// Viewers fit the box, transformed by `/Matrix`, to the widget's `/Rect` (ISO 32000-2
// §12.5.5); this one lands on `rect` exactly, turned as `/MK /R` says.
let mut m = pdfcraft_model::view_matrix_for(turn, [rect[0], rect[1], rect[0] + rw, rect[1] + rh]);
if !m.iter().all(|x| x.is_finite()) {
// A caller's non-finite corner: the turn alone, which viewers fit to `/Rect` the same.
m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]);
}
d.set(b"Matrix".to_vec(), Object::Array(m.iter().map(|x| Object::Real(*x)).collect()));
}
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
if let Some(im) = image {
+104
View File
@@ -28,6 +28,11 @@ fn fixture_with(catalog: &[u8], more: &[&[u8]]) -> Vec<u8> {
b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>",
];
objs.extend_from_slice(more);
write_pdf(&objs)
}
/// A classic-xref PDF of `objs`, numbered from 1, with object 1 as the catalog.
fn write_pdf(objs: &[&[u8]]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
@@ -365,6 +370,105 @@ fn visible_signatures_show_a_drawn_typed_or_image_graphic() {
assert!(!invisible.windows(6).any(|w| w == b"/Image"));
}
/// [`fixture`]'s page and "Approval" widget with extra entries: `page` (a `/Rotate`, say) and
/// `widget` (an `/MK`); `pages` goes on the page tree node, where `/Rotate` is inheritable.
fn rotated_fixture(pages: &str, page: &str, widget: &str) -> Vec<u8> {
let pages = format!("<< /Type /Pages /Kids [3 0 R] /Count 1 {pages} >>");
let page = format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] {page} >>"
);
let widget = format!("<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 {widget} >>");
write_pdf(&[
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>",
pages.as_bytes(),
page.as_bytes(),
b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
widget.as_bytes(),
])
}
/// The signature field `name`'s widget and its normal appearance's dictionary and content.
fn widget_and_appearance(bytes: &[u8], name: &str) -> (pdfcraft_cos::Dict, pdfcraft_cos::Dict, String) {
let doc = open(bytes);
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
let form = doc.resolve(catalog.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
let fields = doc.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap();
let widget = fields
.iter()
.filter_map(|f| doc.resolve(f).as_dict().cloned())
.find(|f| matches!(f.get(b"T"), Some(Object::String(s)) if s.bytes == name.as_bytes()))
.unwrap();
let ap = doc.resolve(widget.get(b"AP").unwrap()).as_dict().cloned().unwrap();
let Object::Stream(s) = &*doc.resolve(ap.get(b"N").unwrap()) else { panic!("no normal appearance") };
(widget.clone(), s.dict.clone(), String::from_utf8_lossy(&s.decoded().unwrap()).into_owned())
}
fn numbers(d: &pdfcraft_cos::Dict, key: &[u8]) -> Option<Vec<f64>> {
d.get(key).and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|n| n.as_f64()).collect())
}
/// On a page shown turned, a visible signature reads upright as displayed: its appearance is the
/// one an unturned page gets in the box with the displayed proportions, turned back onto the
/// widget by `/MK /R` and the matching `/Matrix`.
#[test]
fn visible_signatures_read_upright_on_rotated_pages() {
use pdfcraft_sign::Appearance;
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
// 200 x 50 in user space: shown 50 wide and 200 tall on a page turned a quarter.
let rect = [20.0, 100.0, 220.0, 150.0];
let swapped = [20.0, 100.0, 70.0, 300.0];
let shown = Appearance { reason: true, location: true, ..Appearance::default() };
let sign = |bytes: &[u8], rect: [f64; 4]| {
let o = SignOptions { rect: Some(rect), graphic: Some(picture(false)), appearance: shown.clone(), ..opts() };
pdfcraft_sign::sign(&open(bytes), &id, &o).unwrap()
};
let upright = |rect: [f64; 4]| widget_and_appearance(&sign(&fixture(), rect), "Signature1");
for (pages, page, turn) in [
("", "", 0),
("", "/Rotate 90", 90),
("", "/Rotate 180", 180),
("", "/Rotate 270", 270),
("", "/Rotate -90", 270),
// Inherited from the page tree.
("/Rotate 270", "", 270),
// Not a quarter turn: drawn as unturned, as the page is.
("", "/Rotate 45", 0),
] {
let doc = rotated_fixture(pages, page, "");
let signed = sign(&doc, rect);
let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
assert_eq!((s.status, s.modification), (Status::Unknown, Modification::None), "{pages} {page}: {:?}", s.details);
let (widget, form, content) = widget_and_appearance(&signed, "Signature1");
let (_, expected_form, expected) = upright(if turn % 180 == 0 { rect } else { swapped });
assert_eq!(content, expected, "{pages} {page}: the layout is the upright box's");
assert_eq!(numbers(&form, b"BBox"), numbers(&expected_form, b"BBox"), "{pages} {page}");
let mk = widget.get(b"MK").and_then(|m| m.as_dict()).and_then(|m| m.int(b"R"));
let matrix = numbers(&form, b"Matrix");
if turn == 0 {
assert_eq!((mk, matrix), (None, None), "{pages} {page}: an unturned page's signature is unchanged");
} else {
assert_eq!(mk, Some(turn), "{pages} {page}");
assert_eq!(matrix, Some(pdfcraft_model::view_matrix_for(turn, rect).to_vec()), "{pages} {page}");
}
}
// An existing field keeps its own orientation: `/MK /R` turns it, whatever the page does.
let field = |bytes: &[u8]| {
let o = SignOptions { field: Some("Approval".into()), graphic: Some(picture(false)), appearance: shown.clone(), ..opts() };
widget_and_appearance(&pdfcraft_sign::sign(&open(bytes), &id, &o).unwrap(), "Approval")
};
let field_rect = [150.0, 20.0, 280.0, 70.0];
let (_, plain, plain_content) = field(&rotated_fixture("", "/Rotate 90", ""));
assert_eq!((numbers(&plain, b"Matrix"), numbers(&plain, b"BBox")), (None, Some(vec![0.0, 0.0, 130.0, 50.0])));
let (_, turned, turned_content) = field(&rotated_fixture("", "/Rotate 90", "/MK << /R 90 >>"));
assert_eq!(numbers(&turned, b"BBox"), Some(vec![0.0, 0.0, 50.0, 130.0]));
assert_eq!(numbers(&turned, b"Matrix"), Some(pdfcraft_model::view_matrix_for(90, field_rect).to_vec()));
assert_ne!(turned_content, plain_content);
// A malformed `/MK /R` counts as 0.
let (_, odd, _) = field(&rotated_fixture("", "", "/MK << /R (ninety) >>"));
assert_eq!(numbers(&odd, b"Matrix"), None);
}
#[test]
fn malformed_signature_graphics_are_refused_not_drawn() {
use pdfcraft_sign::Graphic;
+2 -2
View File
@@ -6001,8 +6001,8 @@ milestone = "M9"
status = "shipped"
commands = ["sign.digital"]
tools = ["sign_document"]
evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"]
notes = "drag a rectangle; Acrobat's name-and-details appearance"
evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/sign/tests/it/pdf.rs::visible_signatures_read_upright_on_rotated_pages", "crates/engine/src/tests.rs::visible_signatures_keep_displayed_orientation_on_rotated_pages"]
notes = "drag a rectangle; Acrobat's name-and-details appearance; reads upright on rotated pages (/MK /R)"
[[feature]]
id = "sign.sign-existing-field"