mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
vjeko2404
Brandon Thomas
parent
9206254142
commit
f46268fbe4
Generated
+1
@@ -4347,6 +4347,7 @@ dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"rc2",
|
||||
"ripemd",
|
||||
"rsa",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user