refactor(ui-egui): split sign_ui.rs into sign_ui/

New modules: dialog, panel.
This commit is contained in:
aiongg
2026-10-26 22:18:50 +00:00
parent ce5d36e470
commit 676b30d83c
3 changed files with 854 additions and 841 deletions
+10 -841
View File
@@ -14,6 +14,16 @@ use crate::page_pointer::PagePointer;
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, icons, widgets};
mod dialog;
mod panel;
#[cfg(test)]
use dialog::detail_lines;
use dialog::graphic_of;
pub(crate) use dialog::{appearance_labels, dialog};
pub(crate) use panel::{CertAction, banner, cert_viewer, panel, signature_status_style};
pub use panel::{CertTab, CertViewer, PanelAction, certificate_pem};
/// A digital ID the app knows about (Acrobat: Digital ID files). The file stays where it is;
/// its password is asked for at each signing.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -654,847 +664,6 @@ impl PdfCraftApp {
}
}
/// Draw the signing dialogs; returns `true` when the dialog should close.
pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
let Some(step) = app.sign_draft.as_ref().map(|d| d.step) else { return true };
match step {
SignStep::Choose => choose(ui, app, t),
SignStep::Configure => configure(ui, app, t),
SignStep::SignAs => sign_as(ui, app, t),
}
}
fn title(ui: &mut egui::Ui, text: &str) {
ui.label(egui::RichText::new(tl!(text)).font(theme::semibold(18.0)));
ui.add_space(4.0);
ui.separator();
ui.add_space(8.0);
}
fn error(ui: &mut egui::Ui, err: &Option<String>) {
if let Some(e) = err.as_ref().filter(|e| !e.is_empty()) {
ui.colored_label(Color32::from_rgb(0xD7, 0x37, 0x3F), tl!(e));
ui.add_space(4.0);
}
}
/// Draw the digital-ID chooser, including unusable certificates and removal confirmation.
fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
title(ui, "Sign with a Digital ID");
ui.label(tl!("Choose the digital ID that you want to use for signing:"));
ui.add_space(6.0);
let ids = app.digital_ids.clone();
let Some(d) = app.sign_draft.as_mut() else { return true };
let mut close = false;
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
for (i, e) in ids.iter().enumerate() {
let selected = d.selected == Some(i);
let usable = e.unusable.is_none();
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 52.0), egui::Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, usable, selected, &e.name));
let fill = if selected {
t.accent_soft
} else if resp.hovered() && usable {
t.hover
} else {
Color32::TRANSPARENT
};
ui.painter().rect_filled(rect, CornerRadius::same(6), fill);
// An unusable ID is greyed, with the reason where the issuer would be.
let (icon, name_color) = if usable { (t.accent, t.text) } else { (t.text_muted, t.text_muted) };
icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 15.0), vec2(20.0, 20.0)), "badge-check", 18.0, icon);
// Certificate names can be any length: cut both lines with "…" at the row's edge
// and show them whole on hover.
let line = |text: &str, font: egui::FontId, color: Color32| {
let mut job = egui::text::LayoutJob::simple_singleline(text.to_owned(), font, color);
job.wrap = egui::text::TextWrapping::truncate_at_width((rect.width() - 50.0).max(0.0));
ui.painter().layout_job(job)
};
let name = line(&e.name, theme::semibold(13.0), name_color);
let store = if e.path.starts_with("keychain:") {
tl!("Keychain · ").to_string()
} else if e.path.starts_with("windows:") {
tl!("Windows store · ").to_string()
} else {
String::new()
};
let sub = match &e.unusable {
Some(reason) => format!("{store}{reason}"),
None => format!(
"{store}{email}{issued}{issuer}{expires}{date}",
email = if e.email.is_empty() { String::new() } else { format!("{} · ", e.email) },
issued = tl!("Issued by: "),
issuer = e.issuer,
expires = tl!(", Expires: "),
date = e.expires,
),
};
let details = line(&sub, theme::regular(11.5), t.text_muted);
let elided = name.elided || details.elided;
ui.painter().galley(rect.min + vec2(40.0, 9.0), name, name_color);
ui.painter().galley(rect.min + vec2(40.0, 28.0), details, t.text_muted);
let resp = if elided { resp.on_hover_text(format!("{}\n{sub}", e.name)) } else { resp };
if !usable {
continue;
}
if resp.clicked() {
d.selected = Some(i);
d.confirm_remove = None;
}
if resp.double_clicked() {
d.step = SignStep::SignAs;
}
}
if ids.is_empty() {
ui.label(egui::RichText::new(tl!("No digital IDs yet.")).color(t.text_muted));
}
});
ui.add_space(10.0);
// Remove takes a digital ID file off the list after asking (#1396); OS key store IDs stay.
let mut remove = None;
if let Some(e) = d.confirm_remove.and_then(|i| ids.get(i)) {
ui.label(crate::i18n::fmt(tl!("Remove \"{name}\" from this list? The file itself is not deleted."), &[("name", &e.name)]));
ui.horizontal(|ui| {
if widgets::pill_button(ui, tl!("Remove"), true).clicked() {
remove = d.confirm_remove;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
d.confirm_remove = None;
}
});
ui.add_space(10.0);
}
let removable = d.selected.filter(|&i| ids.get(i).is_some_and(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:")));
ui.horizontal(|ui| {
if widgets::pill_button(ui, tl!("Configure New Digital ID"), false).clicked() {
d.step = SignStep::Configure;
d.error = None;
}
if d.confirm_remove.is_none() && ui.add_enabled_ui(removable.is_some(), |ui| widgets::pill_button(ui, tl!("Remove"), false)).inner.clicked() {
d.confirm_remove = removable;
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled_ui(d.selected.is_some(), |ui| widgets::pill_button(ui, tl!("Continue"), true)).inner.clicked() {
d.step = SignStep::SignAs;
d.error = None;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
});
if let Some(i) = remove {
app.remove_digital_id(i);
}
close
}
fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
title(ui, "Configure a Digital ID for Signing");
let Some(d) = app.sign_draft.as_mut() else { return true };
let mut close = false;
let mut go = false;
// Browse… is desktop-only.
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut browse = false;
widgets::radio_value(ui, &mut d.new_id.create, false, tl!("Use a Digital ID from a file"));
widgets::radio_value(ui, &mut d.new_id.create, true, tl!("Create a new Digital ID (self-signed, saved to a password-protected file)"));
ui.add_space(8.0);
let n = &mut d.new_id;
if n.create {
egui::Grid::new("new-id").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
for (label, value) in [
(tl!("Name"), &mut n.name),
(tl!("Organizational Unit"), &mut n.unit),
(tl!("Organization Name"), &mut n.organization),
(tl!("Email Address"), &mut n.email),
] {
let l = ui.label(label);
ui.add(crate::widgets::line(value).desired_width(260.0)).labelled_by(l.id);
ui.end_row();
}
let l = ui.label(tl!("Country/Region"));
ui.add(crate::widgets::line(&mut n.country).hint_text("US").char_limit(2).desired_width(60.0)).labelled_by(l.id);
ui.end_row();
ui.label(tl!("Key Algorithm"));
egui::ComboBox::from_id_salt("key-alg").selected_text(KEY_ALGORITHMS[n.key].0).show_ui(ui, |ui| {
for (i, (label, _)) in KEY_ALGORITHMS.iter().enumerate() {
ui.selectable_value(&mut n.key, i, *label);
}
});
ui.end_row();
let l = ui.label(tl!("Password"));
ui.add(crate::widgets::line(&mut n.password).password(true).desired_width(200.0)).labelled_by(l.id);
ui.end_row();
let l = ui.label(tl!("Confirm Password"));
ui.add(crate::widgets::line(&mut n.confirm).password(true).desired_width(200.0)).labelled_by(l.id);
ui.end_row();
});
ui.label(
egui::RichText::new(tl!("Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others."))
.small()
.color(t.text_muted),
);
} else {
egui::Grid::new("id-file").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
let l = ui.label(tl!("File (.p12, .pfx)"));
ui.horizontal(|ui| {
ui.add(crate::widgets::line(&mut n.file).desired_width(220.0)).labelled_by(l.id);
#[cfg(not(target_arch = "wasm32"))]
if ui.button(tl!("Browse…")).clicked() {
browse = true;
}
});
ui.end_row();
let l = ui.label(tl!("Password"));
ui.add(crate::widgets::line(&mut n.file_password).password(true).desired_width(200.0)).labelled_by(l.id);
ui.end_row();
});
}
ui.add_space(6.0);
error(ui, &d.error);
ui.horizontal(|ui| {
if !app.digital_ids.is_empty() && widgets::pill_button(ui, tl!("Back"), false).clicked() {
d.step = SignStep::Choose;
d.error = None;
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, if d.new_id.create { tl!("Save") } else { tl!("Continue") }, true).clicked() {
go = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
});
if go {
let create = app.sign_draft.as_ref().is_some_and(|d| d.new_id.create);
let result = if create { app.create_digital_id() } else { app.import_digital_id() };
let Some(d) = app.sign_draft.as_mut() else { return true };
match result {
Ok(i) => {
d.selected = Some(i);
d.step = SignStep::Choose;
d.error = None;
d.new_id = NewIdDraft::default();
}
Err(e) => d.error = Some(e),
}
}
// The picker answers on a later frame, into the draft if the same dialog is still open.
#[cfg(not(target_arch = "wasm32"))]
if browse {
let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Digital ID"), &["p12", "pfx"]);
let epoch = app.dialog_epoch();
app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| {
// Not one closed meanwhile, or opened again since.
if app.dialog_epoch() == epoch
&& let Some(d) = app.sign_draft.as_mut()
{
d.new_id.file = p.to_string_lossy().into_owned();
}
});
}
#[cfg(target_arch = "wasm32")]
let _ = browse;
close
}
/// The appearance preview: Acrobat's standard layout (name left, details right).
/// The saved signature or image `g` stands for, given the saved Fill & Sign `signature`.
fn graphic_of(signature: Option<&SavedSig>, g: &SignGraphic) -> Option<SavedSig> {
match g {
SignGraphic::Name => None,
SignGraphic::FillSign => signature.cloned(),
SignGraphic::Image(image) => Some(SavedSig::Image(image.clone())),
}
}
/// The words of a visible signature's labels in `lang`, and whether they fell back to English.
///
/// The labels are stamped into the document, so they are all in `lang` or all in English, never a
/// mix. English when `lang` is English; when it is written right to left (its catalog is kept in
/// display order, not the order a PDF stores text in); when one label has no translation; or when
/// the signature's font can't draw one (Chinese, Japanese, Cyrillic, Greek in Helvetica), which
/// signing would refuse. The English words are the sign crate's own, so they can't drift apart.
pub(crate) fn appearance_labels(lang: crate::i18n::Lang) -> (AppearanceLabels, bool) {
let english = AppearanceLabels::default();
if lang == crate::i18n::Lang::EN {
return (english, false);
}
let said = |s: &str| (!lang.rtl() && crate::i18n::has(lang, s)).then(|| crate::i18n::tr(lang, s).to_string());
let translated = (|| {
Some(AppearanceLabels {
signed_by: said(&english.signed_by)?,
distinguished_name: said(&english.distinguished_name)?,
reason: said(&english.reason)?,
location: said(&english.location)?,
date: said(&english.date)?,
})
})();
match translated.filter(|l| l.first_undrawable().is_none()) {
Some(labels) => (labels, false),
None => (english, true),
}
}
/// The detail lines of a visible signature, as `sign::pdf` stamps them (the distinguished name
/// aside, which the dialog doesn't know before the ID is opened): the label above the name, then
/// the reason, location and date, each after its label. `reason` and `location` are as typed.
fn detail_lines(a: &Appearance, labels: &AppearanceLabels, name: &str, reason: &str, location: &str, date: &str) -> Vec<String> {
let label = |l: &str, v: &str| if a.labels { format!("{l}{v}") } else { v.to_string() };
let mut lines = Vec::new();
if a.name {
if a.labels {
lines.push(labels.signed_by.clone());
}
lines.push(name.to_string());
}
if a.reason && !reason.trim().is_empty() {
lines.push(label(&labels.reason, reason.trim()));
}
if a.location && !location.trim().is_empty() {
lines.push(label(&labels.location, location.trim()));
}
if a.date {
lines.push(label(&labels.date, date));
}
lines.retain(|l| !l.is_empty());
lines
}
/// The signature as it will look: the graphic (or the name) on the left, the details beside it
/// (`lines`, from [`detail_lines`] with the labels signing will stamp).
fn preview(
ui: &mut egui::Ui,
t: &Tokens,
name: &str,
a: &Appearance,
lines: &[String],
graphic: Option<&SavedSig>,
cache: &mut Option<(SavedSig, egui::TextureHandle)>,
) {
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 96.0), egui::Sense::hover());
let bg = if t.dark() { Color32::from_gray(0xF4) } else { Color32::WHITE };
ui.painter().rect(rect, CornerRadius::same(4), bg, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let inner = rect.shrink(10.0);
// As in the signature itself: a graphic with no details beside it fills the box.
let alone = graphic.is_some() && lines.is_empty();
let half = Rect::from_min_size(inner.min, vec2(inner.width() * 0.5 - 6.0, inner.height()));
if let Some(g) = graphic {
crate::fill_sign::paint_saved(ui, if alone { inner } else { half }, g, cache, "sign-graphic");
} else if a.name {
let galley = ui.painter().layout(name.to_string(), theme::regular(22.0), Color32::BLACK, half.width());
ui.painter().galley(half.left_center() - vec2(0.0, galley.size().y / 2.0), galley, Color32::BLACK);
}
let x = if (a.name || graphic.is_some()) && !alone { inner.center().x + 6.0 } else { inner.left() };
let galley = ui.painter().layout(lines.join("\n"), theme::regular(11.0), Color32::from_gray(0x20), inner.right() - x);
ui.painter().galley(egui::pos2(x, inner.center().y - galley.size().y / 2.0), galley, Color32::BLACK);
}
fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
let ids = app.digital_ids.clone();
// The time as the signature will show it (signing takes the session clock then).
let date = sign::pdf::display_date(&app.session.signing_date());
let Some(d) = app.sign_draft.as_mut() else { return true };
let Some(entry) = d.selected.and_then(|i| ids.get(i)).cloned() else {
d.step = SignStep::Choose;
return false;
};
title(
ui,
&crate::i18n::fmt(
tl!("{verb} as \"{name}\""),
&[("verb", if d.certify.is_some() { tl!("Certify") } else { tl!("Sign") }), ("name", &entry.name)],
),
);
let in_os_key_store = entry.path.starts_with("keychain:") || entry.path.starts_with("windows:");
let mut close = false;
let mut go = false;
#[cfg(not(target_arch = "wasm32"))]
let mut pick = false;
if d.rect.is_some() || d.field.is_some() {
ui.label(egui::RichText::new(tl!("Appearance")).font(theme::semibold(12.5)));
let graphic = graphic_of(app.signature.as_ref(), &d.graphic);
let (labels, in_english) = appearance_labels(crate::i18n::current());
let lines = detail_lines(&d.appearance, &labels, &entry.name, &d.reason, &d.location, &date);
preview(ui, t, &entry.name, &d.appearance, &lines, graphic.as_ref(), &mut app.sign_graphic_preview);
if in_english && d.appearance.labels {
ui.label(
egui::RichText::new(tl!("The labels are in English: the signature's font can't draw them in this language."))
.small()
.color(t.text_muted),
);
}
ui.horizontal_wrapped(|ui| {
let a = &mut d.appearance;
ui.add(widgets::checkbox(&mut a.name, tl!("Name")));
ui.add(widgets::checkbox(&mut a.date, tl!("Date")));
ui.add(widgets::checkbox(&mut a.reason, tl!("Reason")));
ui.add(widgets::checkbox(&mut a.location, tl!("Location")));
ui.add(widgets::checkbox(&mut a.distinguished_name, tl!("Distinguished name")));
ui.add(widgets::checkbox(&mut a.labels, tl!("Labels")));
});
// Acrobat: Configure Signature Appearance, Text, Draw or Image.
ui.horizontal_wrapped(|ui| {
ui.label(tl!("Graphic"));
widgets::radio_value(ui, &mut d.graphic, SignGraphic::Name, tl!("Signer's name"));
ui.add_enabled_ui(app.signature.is_some(), |ui| {
if ui.add(widgets::radio(d.graphic == SignGraphic::FillSign, tl!("Fill & Sign signature"))).clicked() {
d.graphic = SignGraphic::FillSign;
}
});
ui.add_enabled_ui(d.image.is_some(), |ui| {
if ui.add(widgets::radio(matches!(d.graphic, SignGraphic::Image(_)), tl!("Image"))).clicked()
&& let Some(image) = &d.image
{
d.graphic = SignGraphic::Image(image.clone());
}
});
#[cfg(not(target_arch = "wasm32"))]
if widgets::pill_button(ui, tl!("Choose image…"), false).clicked() {
pick = true;
}
});
ui.add_space(6.0);
} else {
ui.label(egui::RichText::new(tl!("The signature will be invisible.")).color(t.text_muted));
}
egui::Grid::new("sign-as").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
if d.certify.is_some() {
ui.label(tl!("Permitted actions after certifying"));
let mut p = d.certify.unwrap_or(2);
let label = |p: u8| match p {
1 => tl!("No changes allowed"),
3 => tl!("Annotations, form fill-in, and digital signatures"),
_ => tl!("Form fill-in and digital signatures"),
};
egui::ComboBox::from_id_salt("certify-p").selected_text(label(p)).width(300.0).show_ui(ui, |ui| {
for v in [1u8, 2, 3] {
ui.selectable_value(&mut p, v, label(v));
}
});
d.certify = Some(p);
ui.end_row();
}
let l = ui.label(tl!("Reason"));
ui.add(crate::widgets::line(&mut d.reason).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id);
ui.end_row();
let l = ui.label(tl!("Location"));
ui.add(crate::widgets::line(&mut d.location).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id);
ui.end_row();
if in_os_key_store {
ui.label("");
ui.label(
egui::RichText::new(if entry.path.starts_with("windows:") {
tl!("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it.")
} else {
tl!("The key is in the macOS Keychain, which may ask to allow PdfCraft to use it.")
})
.small()
.color(t.text_muted),
);
} else {
let l = ui.label(tl!("Digital ID password"));
let r = ui.add(crate::widgets::line(&mut d.password).password(true).desired_width(200.0)).labelled_by(l.id);
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
go = true;
}
}
ui.end_row();
});
ui.add_space(6.0);
error(ui, &d.error);
ui.horizontal(|ui| {
if widgets::pill_button(ui, tl!("Back"), false).clicked() {
d.step = SignStep::Choose;
d.error = None;
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Sign"), true).clicked() {
go = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
});
#[cfg(not(target_arch = "wasm32"))]
if pick {
app.pick_sign_graphic_image();
}
if go {
match app.finish_signing() {
Ok(()) => {
app.sign_draft = None;
return true;
}
Err(e) => {
if let Some(d) = app.sign_draft.as_mut() {
d.error = Some(e);
}
}
}
}
close
}
/// The message bar for signed documents: (icon, colour, template, argument).
/// The template renders through [`crate::i18n::tr_fmt`] at the call site, so the bar follows
/// the UI language; names inside `argument` stay as they are.
pub(crate) fn banner(sigs: &[SignatureInfo]) -> Option<(&'static str, Color32, &'static str, String)> {
let signed: Vec<&SignatureInfo> = sigs.iter().filter(|s| s.signed).collect();
if signed.is_empty() {
return None;
}
// A valid certification heads the bar only when every signature is valid: it says nothing
// about the signatures added after it, so their problems come first.
if let Some(c) = signed.iter().find(|s| s.certify.is_some())
&& signed.iter().all(|s| s.status == SignatureStatus::Valid)
{
let by = c.certificate.as_ref().map(|x| {
let org = x.subject.organization().map(|o| format!(", {o}")).unwrap_or_default();
let who = format!("{}{org}", x.display_name());
let (template, issuer) = match x.issuer.common_name() {
Some(issuer) => ("{who}, certificate issued by {issuer}.", issuer),
None => ("{who}, certificate issued by an unknown issuer.", ""),
};
crate::i18n::fmt(tl!(template), &[("who", &who), ("issuer", issuer)])
});
return Some(("badge-check", Color32::from_rgb(0x14, 0x73, 0xE6), "Certified by {by}", by.unwrap_or_default()));
}
Some(if signed.iter().any(|s| s.status == SignatureStatus::Invalid) {
("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F), "At least one signature is invalid.", String::new())
} else if signed.iter().all(|s| s.status == SignatureStatus::Valid) {
("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78), "Signed and all signatures are valid.", String::new())
} else {
("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19), "At least one signature has problems.", String::new())
})
}
fn status_icon(s: &SignatureInfo) -> (&'static str, Color32) {
if !s.signed {
return ("pen-line", Color32::from_gray(0x80));
}
signature_status_style(s.status)
}
/// One look per signature verdict, shared by the Signatures panel and the page badges so
/// the two can't drift apart.
pub(crate) fn signature_status_style(status: SignatureStatus) -> (&'static str, Color32) {
match status {
SignatureStatus::Valid => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)),
SignatureStatus::Unknown => ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19)),
SignatureStatus::Invalid => ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F)),
}
}
/// What the Signatures panel asks the app to do.
#[derive(Clone, Debug)]
pub enum PanelAction {
Validate,
GoTo(usize),
Trust(Box<Certificate>),
ViewSigned {
len: usize,
revision: usize,
},
Sign(String),
ExportCertificate(Box<Certificate>),
/// Certificate Viewer for the signer's chain (signer first).
ViewCertificate(Vec<Certificate>),
}
/// The Signatures panel body.
pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expanded: &mut Vec<String>) -> Option<PanelAction> {
let mut action = None;
ui.horizontal(|ui| {
if widgets::pill_button(ui, tl!("Validate all"), false).clicked() {
action = Some(PanelAction::Validate);
}
});
ui.add_space(6.0);
if sigs.is_empty() {
ui.add_space(24.0);
ui.vertical_centered(|ui| {
ui.add(icons::image("signature", 32.0, t.text_faint));
ui.add_space(6.0);
ui.label(egui::RichText::new(tl!("This document has no signatures.")).color(t.text_muted));
});
return action;
}
let mut ordered: Vec<&SignatureInfo> = sigs.iter().collect();
ordered.sort_by_key(|s| (!s.signed, s.revision));
for s in ordered {
let open = expanded.contains(&s.field);
let (icon, color) = status_icon(s);
let (template, name) = match (s.signed, s.certify.is_some(), s.signer.as_deref()) {
(false, ..) => ("Unsigned signature field: {name}", s.field.as_str()),
(true, true, Some(signer)) => ("Rev. {rev}: Certified by {name}", signer),
(true, true, None) => ("Rev. {rev}: Certified by an unknown signer", ""),
(true, false, Some(signer)) => ("Rev. {rev}: Signed by {name}", signer),
(true, false, None) => ("Rev. {rev}: Signed by an unknown signer", ""),
};
let head = crate::i18n::fmt(tl!(template), &[("rev", &s.revision.to_string()), ("name", name)]);
let resp = ui
.horizontal(|ui| {
let chevron = if open { "chevron-down" } else { "chevron-right" };
let toggle = icons::button(ui, chevron, 20.0, false, if open { "Collapse" } else { "Expand" }).clicked();
ui.add(icons::image(icon, 16.0, color));
let l = ui.add(egui::Label::new(egui::RichText::new(&head).font(theme::semibold(12.5)).color(t.text)).sense(egui::Sense::click()));
toggle || l.clicked()
})
.inner;
if resp {
if open {
expanded.retain(|f| f != &s.field);
} else {
expanded.push(s.field.clone());
}
}
if !open {
continue;
}
egui::Frame::NONE.inner_margin(egui::Margin { left: 44, right: 4, top: 2, bottom: 8 }).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 3.0;
if !s.signed {
if widgets::pill_button(ui, tl!("Sign this field…"), true).clicked() {
action = Some(PanelAction::Sign(s.field.clone()));
}
return;
}
ui.label(
egui::RichText::new(match s.status {
SignatureStatus::Valid => tl!("Signature is valid:"),
SignatureStatus::Unknown => tl!("Signature validity is UNKNOWN:"),
SignatureStatus::Invalid => tl!("Signature is INVALID:"),
})
.font(theme::semibold(12.0)),
);
for detail in &s.details {
// Each detail is a whole sentence of ours plus what the signature itself says; said
// here in the panel's language, with the signature's own words left as they are.
let line = detail.said(&crate::i18n::words);
ui.add(egui::Label::new(egui::RichText::new(format!("• {line}")).font(theme::regular(11.5)).color(t.text_muted)).wrap());
}
ui.add_space(4.0);
ui.label(egui::RichText::new(tl!("Signature Details")).font(theme::semibold(12.0)));
let row = |ui: &mut egui::Ui, k: &str, v: &str| {
ui.add(egui::Label::new(egui::RichText::new(format!("{}: {v}", tl!(k))).font(theme::regular(11.5))).wrap());
};
if let Some(r) = &s.reason {
row(ui, "Reason", r);
}
if let Some(l) = &s.location {
row(ui, "Location", l);
}
if let Some(d) = &s.date {
row(ui, "Signing time", &sign::pdf::display_date(d));
}
if let Some(a) = &s.algorithm {
row(ui, "Algorithm", a);
}
if let Some(c) = &s.certificate {
row(ui, "Signer", &c.subject.display());
row(ui, "Issued by", &c.issuer.display());
row(ui, "Valid", &format!("{} to {}", c.not_before, c.not_after));
}
match s.page {
Some(p) => {
let link = ui.add(
egui::Label::new(
egui::RichText::new(crate::i18n::fmt(
tl!("Field: {field} on page {p}"),
&[("field", &s.field), ("p", &(p + 1).to_string())],
))
.font(theme::regular(11.5))
.color(t.accent_text)
.underline(),
)
.sense(egui::Sense::click()),
);
if link.clicked() {
action = Some(PanelAction::GoTo(p));
}
}
None => row(ui, "Field", &s.field),
}
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
// Trust exactly the signer shown above (s.certificate), not the chain top the
// document embedded: trusting an issuer or root is a separate, explicit step in
// the Certificate Viewer's Trust tab, which names that certificate.
if let Some(c) = s.certificate.as_ref()
&& s.status == SignatureStatus::Unknown
&& ui.button(tl!("Add to trusted certificates")).clicked()
{
action = Some(PanelAction::Trust(Box::new(c.clone())));
}
if ui.button(tl!("View signed version")).clicked() {
action = Some(PanelAction::ViewSigned { len: s.signed_len, revision: s.revision });
}
if let Some(c) = &s.certificate
&& ui.button(tl!("Show certificate…")).clicked()
{
let mut chain = vec![c.clone()];
chain.extend(s.chain.iter().filter(|x| x.raw != c.raw).cloned());
action = Some(PanelAction::ViewCertificate(chain));
}
if let Some(c) = &s.certificate
&& ui.button(tl!("Export certificate…")).clicked()
{
action = Some(PanelAction::ExportCertificate(Box::new(c.clone())));
}
});
});
}
action
}
/// Certificate Viewer: a chain (the end certificate first) and the tab shown.
#[derive(Clone, Debug)]
pub struct CertViewer {
pub chain: Vec<Certificate>,
pub selected: usize,
pub tab: CertTab,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CertTab {
Summary,
Details,
Trust,
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().join(" ")
}
fn key_usage(bits: u16) -> String {
const NAMES: [&str; 9] = [
"Digital Signature",
"Non-Repudiation",
"Key Encipherment",
"Data Encipherment",
"Key Agreement",
"Certificate Signing",
"CRL Signing",
"Encipher Only",
"Decipher Only",
];
let used: Vec<&str> = NAMES.iter().enumerate().filter(|(i, _)| bits & (1 << i) != 0).map(|(_, n)| tl!(*n)).collect();
if used.is_empty() { tl!("None").into() } else { used.join(", ") }
}
/// What the viewer asks for.
pub(crate) enum CertAction {
Trust(Box<Certificate>),
Export(Box<Certificate>),
}
/// The Certificate Viewer dialog: the chain on the left, the selected certificate's tabs.
pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Certificate], t: &Tokens) -> (bool, Option<CertAction>) {
title(ui, "Certificate Viewer");
ui.label(egui::RichText::new(tl!("This dialog shows the details of a certificate and its chain.")).color(t.text_muted));
ui.add_space(8.0);
let mut action = None;
v.selected = v.selected.min(v.chain.len().saturating_sub(1));
ui.horizontal_top(|ui| {
// The chain, root at the top (as Acrobat shows it).
ui.vertical(|ui| {
ui.set_width(200.0);
for (depth, i) in (0..v.chain.len()).rev().enumerate() {
let c = &v.chain[i];
ui.horizontal(|ui| {
ui.add_space(depth as f32 * 12.0);
if ui.selectable_label(v.selected == i, c.display_name()).clicked() {
v.selected = i;
}
});
}
});
ui.add_space(16.0);
ui.vertical(|ui| {
ui.set_width(440.0);
let Some(c) = v.chain.get(v.selected).cloned() else { return };
ui.horizontal(|ui| {
for (tab, label) in [(CertTab::Summary, tl!("Summary")), (CertTab::Details, tl!("Details")), (CertTab::Trust, tl!("Trust"))] {
if widgets::pill_button(ui, label, v.tab == tab).clicked() {
v.tab = tab;
}
}
});
ui.add_space(8.0);
let grid = |ui: &mut egui::Ui, rows: Vec<(&str, String)>| {
egui::Grid::new(("cert-rows", v.tab as u8)).num_columns(2).spacing([12.0, 5.0]).show(ui, |ui| {
for (k, val) in rows {
ui.label(egui::RichText::new(tl_ctx!("certificate", k)).color(t.text_muted));
ui.add(egui::Label::new(val).wrap());
ui.end_row();
}
});
};
match v.tab {
CertTab::Summary => grid(
ui,
vec![
("Issued to", c.subject.display()),
("Issued by", c.issuer.display()),
("Valid from", c.not_before.to_string()),
("Valid to", c.not_after.to_string()),
("Intended usage", c.key_usage.map(key_usage).unwrap_or_else(|| tl!("Any").to_string())),
],
),
CertTab::Details => grid(
ui,
vec![
("Version", "3".into()),
("Serial number", c.serial_hex()),
("Issuer", c.issuer.display()),
("Subject", c.subject.display()),
("Validity starts", c.not_before.to_string()),
("Validity ends", c.not_after.to_string()),
("Public key", c.public_key.describe()),
("Basic constraints", tl!(if c.is_ca { "Certificate authority" } else { "End entity" }).to_string()),
("Key usage", c.key_usage.map(key_usage).unwrap_or_else(|| tl!("Not present").to_string())),
("Self-signed", if c.is_self_signed() { tl!("Yes").to_string() } else { tl!("No").to_string() }),
("SHA-1 digest", hex(&sign::keys::DigestAlg::Sha1.digest(&[&c.raw]))),
("SHA-256 digest", hex(&sign::keys::DigestAlg::Sha256.digest(&[&c.raw]))),
],
),
CertTab::Trust => {
let is_trusted = trusted.iter().any(|x| x.raw == c.raw);
let anchored = v.chain.iter().any(|x| trusted.iter().any(|y| y.raw == x.raw));
ui.label(if is_trusted {
tl!("This certificate is in your list of trusted certificates.")
} else if anchored {
tl!("This certificate is trusted through a certificate above it in the chain.")
} else {
tl!("This certificate is not trusted. Signatures made with it show an unknown identity.")
});
ui.add_space(8.0);
if !is_trusted && widgets::pill_button(ui, tl!("Add to Trusted Certificates"), false).clicked() {
action = Some(CertAction::Trust(Box::new(c.clone())));
}
}
}
ui.add_space(8.0);
if widgets::pill_button(ui, tl!("Export…"), false).clicked() {
action = Some(CertAction::Export(Box::new(c.clone())));
}
});
});
ui.add_space(10.0);
let mut close = false;
ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| close = widgets::pill_button(ui, tl!("OK"), true).clicked()));
(close, action)
}
/// The text of an exported certificate (`.cer`, PEM).
pub fn certificate_pem(c: &Certificate) -> String {
sign::x509::to_pem(c)
}
#[cfg(test)]
mod tests {
use super::*;
+486
View File
@@ -0,0 +1,486 @@
use super::*;
/// Draw the signing dialogs; returns `true` when the dialog should close.
pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
let Some(step) = app.sign_draft.as_ref().map(|d| d.step) else { return true };
match step {
SignStep::Choose => choose(ui, app, t),
SignStep::Configure => configure(ui, app, t),
SignStep::SignAs => sign_as(ui, app, t),
}
}
pub(super) fn title(ui: &mut egui::Ui, text: &str) {
ui.label(egui::RichText::new(tl!(text)).font(theme::semibold(18.0)));
ui.add_space(4.0);
ui.separator();
ui.add_space(8.0);
}
fn error(ui: &mut egui::Ui, err: &Option<String>) {
if let Some(e) = err.as_ref().filter(|e| !e.is_empty()) {
ui.colored_label(Color32::from_rgb(0xD7, 0x37, 0x3F), tl!(e));
ui.add_space(4.0);
}
}
/// Draw the digital-ID chooser, including unusable certificates and removal confirmation.
fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
title(ui, "Sign with a Digital ID");
ui.label(tl!("Choose the digital ID that you want to use for signing:"));
ui.add_space(6.0);
let ids = app.digital_ids.clone();
let Some(d) = app.sign_draft.as_mut() else { return true };
let mut close = false;
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
for (i, e) in ids.iter().enumerate() {
let selected = d.selected == Some(i);
let usable = e.unusable.is_none();
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 52.0), egui::Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, usable, selected, &e.name));
let fill = if selected {
t.accent_soft
} else if resp.hovered() && usable {
t.hover
} else {
Color32::TRANSPARENT
};
ui.painter().rect_filled(rect, CornerRadius::same(6), fill);
// An unusable ID is greyed, with the reason where the issuer would be.
let (icon, name_color) = if usable { (t.accent, t.text) } else { (t.text_muted, t.text_muted) };
icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 15.0), vec2(20.0, 20.0)), "badge-check", 18.0, icon);
// Certificate names can be any length: cut both lines with "…" at the row's edge
// and show them whole on hover.
let line = |text: &str, font: egui::FontId, color: Color32| {
let mut job = egui::text::LayoutJob::simple_singleline(text.to_owned(), font, color);
job.wrap = egui::text::TextWrapping::truncate_at_width((rect.width() - 50.0).max(0.0));
ui.painter().layout_job(job)
};
let name = line(&e.name, theme::semibold(13.0), name_color);
let store = if e.path.starts_with("keychain:") {
tl!("Keychain · ").to_string()
} else if e.path.starts_with("windows:") {
tl!("Windows store · ").to_string()
} else {
String::new()
};
let sub = match &e.unusable {
Some(reason) => format!("{store}{reason}"),
None => format!(
"{store}{email}{issued}{issuer}{expires}{date}",
email = if e.email.is_empty() { String::new() } else { format!("{} · ", e.email) },
issued = tl!("Issued by: "),
issuer = e.issuer,
expires = tl!(", Expires: "),
date = e.expires,
),
};
let details = line(&sub, theme::regular(11.5), t.text_muted);
let elided = name.elided || details.elided;
ui.painter().galley(rect.min + vec2(40.0, 9.0), name, name_color);
ui.painter().galley(rect.min + vec2(40.0, 28.0), details, t.text_muted);
let resp = if elided { resp.on_hover_text(format!("{}\n{sub}", e.name)) } else { resp };
if !usable {
continue;
}
if resp.clicked() {
d.selected = Some(i);
d.confirm_remove = None;
}
if resp.double_clicked() {
d.step = SignStep::SignAs;
}
}
if ids.is_empty() {
ui.label(egui::RichText::new(tl!("No digital IDs yet.")).color(t.text_muted));
}
});
ui.add_space(10.0);
// Remove takes a digital ID file off the list after asking (#1396); OS key store IDs stay.
let mut remove = None;
if let Some(e) = d.confirm_remove.and_then(|i| ids.get(i)) {
ui.label(crate::i18n::fmt(tl!("Remove \"{name}\" from this list? The file itself is not deleted."), &[("name", &e.name)]));
ui.horizontal(|ui| {
if widgets::pill_button(ui, tl!("Remove"), true).clicked() {
remove = d.confirm_remove;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
d.confirm_remove = None;
}
});
ui.add_space(10.0);
}
let removable = d.selected.filter(|&i| ids.get(i).is_some_and(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:")));
ui.horizontal(|ui| {
if widgets::pill_button(ui, tl!("Configure New Digital ID"), false).clicked() {
d.step = SignStep::Configure;
d.error = None;
}
if d.confirm_remove.is_none() && ui.add_enabled_ui(removable.is_some(), |ui| widgets::pill_button(ui, tl!("Remove"), false)).inner.clicked() {
d.confirm_remove = removable;
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled_ui(d.selected.is_some(), |ui| widgets::pill_button(ui, tl!("Continue"), true)).inner.clicked() {
d.step = SignStep::SignAs;
d.error = None;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
});
if let Some(i) = remove {
app.remove_digital_id(i);
}
close
}
fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
title(ui, "Configure a Digital ID for Signing");
let Some(d) = app.sign_draft.as_mut() else { return true };
let mut close = false;
let mut go = false;
// Browse… is desktop-only.
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut browse = false;
widgets::radio_value(ui, &mut d.new_id.create, false, tl!("Use a Digital ID from a file"));
widgets::radio_value(ui, &mut d.new_id.create, true, tl!("Create a new Digital ID (self-signed, saved to a password-protected file)"));
ui.add_space(8.0);
let n = &mut d.new_id;
if n.create {
egui::Grid::new("new-id").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
for (label, value) in [
(tl!("Name"), &mut n.name),
(tl!("Organizational Unit"), &mut n.unit),
(tl!("Organization Name"), &mut n.organization),
(tl!("Email Address"), &mut n.email),
] {
let l = ui.label(label);
ui.add(crate::widgets::line(value).desired_width(260.0)).labelled_by(l.id);
ui.end_row();
}
let l = ui.label(tl!("Country/Region"));
ui.add(crate::widgets::line(&mut n.country).hint_text("US").char_limit(2).desired_width(60.0)).labelled_by(l.id);
ui.end_row();
ui.label(tl!("Key Algorithm"));
egui::ComboBox::from_id_salt("key-alg").selected_text(KEY_ALGORITHMS[n.key].0).show_ui(ui, |ui| {
for (i, (label, _)) in KEY_ALGORITHMS.iter().enumerate() {
ui.selectable_value(&mut n.key, i, *label);
}
});
ui.end_row();
let l = ui.label(tl!("Password"));
ui.add(crate::widgets::line(&mut n.password).password(true).desired_width(200.0)).labelled_by(l.id);
ui.end_row();
let l = ui.label(tl!("Confirm Password"));
ui.add(crate::widgets::line(&mut n.confirm).password(true).desired_width(200.0)).labelled_by(l.id);
ui.end_row();
});
ui.label(
egui::RichText::new(tl!("Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others."))
.small()
.color(t.text_muted),
);
} else {
egui::Grid::new("id-file").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
let l = ui.label(tl!("File (.p12, .pfx)"));
ui.horizontal(|ui| {
ui.add(crate::widgets::line(&mut n.file).desired_width(220.0)).labelled_by(l.id);
#[cfg(not(target_arch = "wasm32"))]
if ui.button(tl!("Browse…")).clicked() {
browse = true;
}
});
ui.end_row();
let l = ui.label(tl!("Password"));
ui.add(crate::widgets::line(&mut n.file_password).password(true).desired_width(200.0)).labelled_by(l.id);
ui.end_row();
});
}
ui.add_space(6.0);
error(ui, &d.error);
ui.horizontal(|ui| {
if !app.digital_ids.is_empty() && widgets::pill_button(ui, tl!("Back"), false).clicked() {
d.step = SignStep::Choose;
d.error = None;
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, if d.new_id.create { tl!("Save") } else { tl!("Continue") }, true).clicked() {
go = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
});
if go {
let create = app.sign_draft.as_ref().is_some_and(|d| d.new_id.create);
let result = if create { app.create_digital_id() } else { app.import_digital_id() };
let Some(d) = app.sign_draft.as_mut() else { return true };
match result {
Ok(i) => {
d.selected = Some(i);
d.step = SignStep::Choose;
d.error = None;
d.new_id = NewIdDraft::default();
}
Err(e) => d.error = Some(e),
}
}
// The picker answers on a later frame, into the draft if the same dialog is still open.
#[cfg(not(target_arch = "wasm32"))]
if browse {
let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Digital ID"), &["p12", "pfx"]);
let epoch = app.dialog_epoch();
app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| {
// Not one closed meanwhile, or opened again since.
if app.dialog_epoch() == epoch
&& let Some(d) = app.sign_draft.as_mut()
{
d.new_id.file = p.to_string_lossy().into_owned();
}
});
}
#[cfg(target_arch = "wasm32")]
let _ = browse;
close
}
/// The appearance preview: Acrobat's standard layout (name left, details right).
/// The saved signature or image `g` stands for, given the saved Fill & Sign `signature`.
pub(super) fn graphic_of(signature: Option<&SavedSig>, g: &SignGraphic) -> Option<SavedSig> {
match g {
SignGraphic::Name => None,
SignGraphic::FillSign => signature.cloned(),
SignGraphic::Image(image) => Some(SavedSig::Image(image.clone())),
}
}
/// The words of a visible signature's labels in `lang`, and whether they fell back to English.
///
/// The labels are stamped into the document, so they are all in `lang` or all in English, never a
/// mix. English when `lang` is English; when it is written right to left (its catalog is kept in
/// display order, not the order a PDF stores text in); when one label has no translation; or when
/// the signature's font can't draw one (Chinese, Japanese, Cyrillic, Greek in Helvetica), which
/// signing would refuse. The English words are the sign crate's own, so they can't drift apart.
pub(crate) fn appearance_labels(lang: crate::i18n::Lang) -> (AppearanceLabels, bool) {
let english = AppearanceLabels::default();
if lang == crate::i18n::Lang::EN {
return (english, false);
}
let said = |s: &str| (!lang.rtl() && crate::i18n::has(lang, s)).then(|| crate::i18n::tr(lang, s).to_string());
let translated = (|| {
Some(AppearanceLabels {
signed_by: said(&english.signed_by)?,
distinguished_name: said(&english.distinguished_name)?,
reason: said(&english.reason)?,
location: said(&english.location)?,
date: said(&english.date)?,
})
})();
match translated.filter(|l| l.first_undrawable().is_none()) {
Some(labels) => (labels, false),
None => (english, true),
}
}
/// The detail lines of a visible signature, as `sign::pdf` stamps them (the distinguished name
/// aside, which the dialog doesn't know before the ID is opened): the label above the name, then
/// the reason, location and date, each after its label. `reason` and `location` are as typed.
pub(super) fn detail_lines(a: &Appearance, labels: &AppearanceLabels, name: &str, reason: &str, location: &str, date: &str) -> Vec<String> {
let label = |l: &str, v: &str| if a.labels { format!("{l}{v}") } else { v.to_string() };
let mut lines = Vec::new();
if a.name {
if a.labels {
lines.push(labels.signed_by.clone());
}
lines.push(name.to_string());
}
if a.reason && !reason.trim().is_empty() {
lines.push(label(&labels.reason, reason.trim()));
}
if a.location && !location.trim().is_empty() {
lines.push(label(&labels.location, location.trim()));
}
if a.date {
lines.push(label(&labels.date, date));
}
lines.retain(|l| !l.is_empty());
lines
}
/// The signature as it will look: the graphic (or the name) on the left, the details beside it
/// (`lines`, from [`detail_lines`] with the labels signing will stamp).
fn preview(
ui: &mut egui::Ui,
t: &Tokens,
name: &str,
a: &Appearance,
lines: &[String],
graphic: Option<&SavedSig>,
cache: &mut Option<(SavedSig, egui::TextureHandle)>,
) {
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 96.0), egui::Sense::hover());
let bg = if t.dark() { Color32::from_gray(0xF4) } else { Color32::WHITE };
ui.painter().rect(rect, CornerRadius::same(4), bg, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let inner = rect.shrink(10.0);
// As in the signature itself: a graphic with no details beside it fills the box.
let alone = graphic.is_some() && lines.is_empty();
let half = Rect::from_min_size(inner.min, vec2(inner.width() * 0.5 - 6.0, inner.height()));
if let Some(g) = graphic {
crate::fill_sign::paint_saved(ui, if alone { inner } else { half }, g, cache, "sign-graphic");
} else if a.name {
let galley = ui.painter().layout(name.to_string(), theme::regular(22.0), Color32::BLACK, half.width());
ui.painter().galley(half.left_center() - vec2(0.0, galley.size().y / 2.0), galley, Color32::BLACK);
}
let x = if (a.name || graphic.is_some()) && !alone { inner.center().x + 6.0 } else { inner.left() };
let galley = ui.painter().layout(lines.join("\n"), theme::regular(11.0), Color32::from_gray(0x20), inner.right() - x);
ui.painter().galley(egui::pos2(x, inner.center().y - galley.size().y / 2.0), galley, Color32::BLACK);
}
fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
let ids = app.digital_ids.clone();
// The time as the signature will show it (signing takes the session clock then).
let date = sign::pdf::display_date(&app.session.signing_date());
let Some(d) = app.sign_draft.as_mut() else { return true };
let Some(entry) = d.selected.and_then(|i| ids.get(i)).cloned() else {
d.step = SignStep::Choose;
return false;
};
title(
ui,
&crate::i18n::fmt(
tl!("{verb} as \"{name}\""),
&[("verb", if d.certify.is_some() { tl!("Certify") } else { tl!("Sign") }), ("name", &entry.name)],
),
);
let in_os_key_store = entry.path.starts_with("keychain:") || entry.path.starts_with("windows:");
let mut close = false;
let mut go = false;
#[cfg(not(target_arch = "wasm32"))]
let mut pick = false;
if d.rect.is_some() || d.field.is_some() {
ui.label(egui::RichText::new(tl!("Appearance")).font(theme::semibold(12.5)));
let graphic = graphic_of(app.signature.as_ref(), &d.graphic);
let (labels, in_english) = appearance_labels(crate::i18n::current());
let lines = detail_lines(&d.appearance, &labels, &entry.name, &d.reason, &d.location, &date);
preview(ui, t, &entry.name, &d.appearance, &lines, graphic.as_ref(), &mut app.sign_graphic_preview);
if in_english && d.appearance.labels {
ui.label(
egui::RichText::new(tl!("The labels are in English: the signature's font can't draw them in this language."))
.small()
.color(t.text_muted),
);
}
ui.horizontal_wrapped(|ui| {
let a = &mut d.appearance;
ui.add(widgets::checkbox(&mut a.name, tl!("Name")));
ui.add(widgets::checkbox(&mut a.date, tl!("Date")));
ui.add(widgets::checkbox(&mut a.reason, tl!("Reason")));
ui.add(widgets::checkbox(&mut a.location, tl!("Location")));
ui.add(widgets::checkbox(&mut a.distinguished_name, tl!("Distinguished name")));
ui.add(widgets::checkbox(&mut a.labels, tl!("Labels")));
});
// Acrobat: Configure Signature Appearance, Text, Draw or Image.
ui.horizontal_wrapped(|ui| {
ui.label(tl!("Graphic"));
widgets::radio_value(ui, &mut d.graphic, SignGraphic::Name, tl!("Signer's name"));
ui.add_enabled_ui(app.signature.is_some(), |ui| {
if ui.add(widgets::radio(d.graphic == SignGraphic::FillSign, tl!("Fill & Sign signature"))).clicked() {
d.graphic = SignGraphic::FillSign;
}
});
ui.add_enabled_ui(d.image.is_some(), |ui| {
if ui.add(widgets::radio(matches!(d.graphic, SignGraphic::Image(_)), tl!("Image"))).clicked()
&& let Some(image) = &d.image
{
d.graphic = SignGraphic::Image(image.clone());
}
});
#[cfg(not(target_arch = "wasm32"))]
if widgets::pill_button(ui, tl!("Choose image…"), false).clicked() {
pick = true;
}
});
ui.add_space(6.0);
} else {
ui.label(egui::RichText::new(tl!("The signature will be invisible.")).color(t.text_muted));
}
egui::Grid::new("sign-as").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
if d.certify.is_some() {
ui.label(tl!("Permitted actions after certifying"));
let mut p = d.certify.unwrap_or(2);
let label = |p: u8| match p {
1 => tl!("No changes allowed"),
3 => tl!("Annotations, form fill-in, and digital signatures"),
_ => tl!("Form fill-in and digital signatures"),
};
egui::ComboBox::from_id_salt("certify-p").selected_text(label(p)).width(300.0).show_ui(ui, |ui| {
for v in [1u8, 2, 3] {
ui.selectable_value(&mut p, v, label(v));
}
});
d.certify = Some(p);
ui.end_row();
}
let l = ui.label(tl!("Reason"));
ui.add(crate::widgets::line(&mut d.reason).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id);
ui.end_row();
let l = ui.label(tl!("Location"));
ui.add(crate::widgets::line(&mut d.location).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id);
ui.end_row();
if in_os_key_store {
ui.label("");
ui.label(
egui::RichText::new(if entry.path.starts_with("windows:") {
tl!("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it.")
} else {
tl!("The key is in the macOS Keychain, which may ask to allow PdfCraft to use it.")
})
.small()
.color(t.text_muted),
);
} else {
let l = ui.label(tl!("Digital ID password"));
let r = ui.add(crate::widgets::line(&mut d.password).password(true).desired_width(200.0)).labelled_by(l.id);
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
go = true;
}
}
ui.end_row();
});
ui.add_space(6.0);
error(ui, &d.error);
ui.horizontal(|ui| {
if widgets::pill_button(ui, tl!("Back"), false).clicked() {
d.step = SignStep::Choose;
d.error = None;
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Sign"), true).clicked() {
go = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
});
#[cfg(not(target_arch = "wasm32"))]
if pick {
app.pick_sign_graphic_image();
}
if go {
match app.finish_signing() {
Ok(()) => {
app.sign_draft = None;
return true;
}
Err(e) => {
if let Some(d) = app.sign_draft.as_mut() {
d.error = Some(e);
}
}
}
}
close
}
+358
View File
@@ -0,0 +1,358 @@
use super::dialog::title;
use super::*;
/// The message bar for signed documents: (icon, colour, template, argument).
/// The template renders through [`crate::i18n::tr_fmt`] at the call site, so the bar follows
/// the UI language; names inside `argument` stay as they are.
pub(crate) fn banner(sigs: &[SignatureInfo]) -> Option<(&'static str, Color32, &'static str, String)> {
let signed: Vec<&SignatureInfo> = sigs.iter().filter(|s| s.signed).collect();
if signed.is_empty() {
return None;
}
// A valid certification heads the bar only when every signature is valid: it says nothing
// about the signatures added after it, so their problems come first.
if let Some(c) = signed.iter().find(|s| s.certify.is_some())
&& signed.iter().all(|s| s.status == SignatureStatus::Valid)
{
let by = c.certificate.as_ref().map(|x| {
let org = x.subject.organization().map(|o| format!(", {o}")).unwrap_or_default();
let who = format!("{}{org}", x.display_name());
let (template, issuer) = match x.issuer.common_name() {
Some(issuer) => ("{who}, certificate issued by {issuer}.", issuer),
None => ("{who}, certificate issued by an unknown issuer.", ""),
};
crate::i18n::fmt(tl!(template), &[("who", &who), ("issuer", issuer)])
});
return Some(("badge-check", Color32::from_rgb(0x14, 0x73, 0xE6), "Certified by {by}", by.unwrap_or_default()));
}
Some(if signed.iter().any(|s| s.status == SignatureStatus::Invalid) {
("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F), "At least one signature is invalid.", String::new())
} else if signed.iter().all(|s| s.status == SignatureStatus::Valid) {
("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78), "Signed and all signatures are valid.", String::new())
} else {
("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19), "At least one signature has problems.", String::new())
})
}
fn status_icon(s: &SignatureInfo) -> (&'static str, Color32) {
if !s.signed {
return ("pen-line", Color32::from_gray(0x80));
}
signature_status_style(s.status)
}
/// One look per signature verdict, shared by the Signatures panel and the page badges so
/// the two can't drift apart.
pub(crate) fn signature_status_style(status: SignatureStatus) -> (&'static str, Color32) {
match status {
SignatureStatus::Valid => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)),
SignatureStatus::Unknown => ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19)),
SignatureStatus::Invalid => ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F)),
}
}
/// What the Signatures panel asks the app to do.
#[derive(Clone, Debug)]
pub enum PanelAction {
Validate,
GoTo(usize),
Trust(Box<Certificate>),
ViewSigned {
len: usize,
revision: usize,
},
Sign(String),
ExportCertificate(Box<Certificate>),
/// Certificate Viewer for the signer's chain (signer first).
ViewCertificate(Vec<Certificate>),
}
/// The Signatures panel body.
pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expanded: &mut Vec<String>) -> Option<PanelAction> {
let mut action = None;
ui.horizontal(|ui| {
if widgets::pill_button(ui, tl!("Validate all"), false).clicked() {
action = Some(PanelAction::Validate);
}
});
ui.add_space(6.0);
if sigs.is_empty() {
ui.add_space(24.0);
ui.vertical_centered(|ui| {
ui.add(icons::image("signature", 32.0, t.text_faint));
ui.add_space(6.0);
ui.label(egui::RichText::new(tl!("This document has no signatures.")).color(t.text_muted));
});
return action;
}
let mut ordered: Vec<&SignatureInfo> = sigs.iter().collect();
ordered.sort_by_key(|s| (!s.signed, s.revision));
for s in ordered {
let open = expanded.contains(&s.field);
let (icon, color) = status_icon(s);
let (template, name) = match (s.signed, s.certify.is_some(), s.signer.as_deref()) {
(false, ..) => ("Unsigned signature field: {name}", s.field.as_str()),
(true, true, Some(signer)) => ("Rev. {rev}: Certified by {name}", signer),
(true, true, None) => ("Rev. {rev}: Certified by an unknown signer", ""),
(true, false, Some(signer)) => ("Rev. {rev}: Signed by {name}", signer),
(true, false, None) => ("Rev. {rev}: Signed by an unknown signer", ""),
};
let head = crate::i18n::fmt(tl!(template), &[("rev", &s.revision.to_string()), ("name", name)]);
let resp = ui
.horizontal(|ui| {
let chevron = if open { "chevron-down" } else { "chevron-right" };
let toggle = icons::button(ui, chevron, 20.0, false, if open { "Collapse" } else { "Expand" }).clicked();
ui.add(icons::image(icon, 16.0, color));
let l = ui.add(egui::Label::new(egui::RichText::new(&head).font(theme::semibold(12.5)).color(t.text)).sense(egui::Sense::click()));
toggle || l.clicked()
})
.inner;
if resp {
if open {
expanded.retain(|f| f != &s.field);
} else {
expanded.push(s.field.clone());
}
}
if !open {
continue;
}
egui::Frame::NONE.inner_margin(egui::Margin { left: 44, right: 4, top: 2, bottom: 8 }).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 3.0;
if !s.signed {
if widgets::pill_button(ui, tl!("Sign this field…"), true).clicked() {
action = Some(PanelAction::Sign(s.field.clone()));
}
return;
}
ui.label(
egui::RichText::new(match s.status {
SignatureStatus::Valid => tl!("Signature is valid:"),
SignatureStatus::Unknown => tl!("Signature validity is UNKNOWN:"),
SignatureStatus::Invalid => tl!("Signature is INVALID:"),
})
.font(theme::semibold(12.0)),
);
for detail in &s.details {
// Each detail is a whole sentence of ours plus what the signature itself says; said
// here in the panel's language, with the signature's own words left as they are.
let line = detail.said(&crate::i18n::words);
ui.add(egui::Label::new(egui::RichText::new(format!("• {line}")).font(theme::regular(11.5)).color(t.text_muted)).wrap());
}
ui.add_space(4.0);
ui.label(egui::RichText::new(tl!("Signature Details")).font(theme::semibold(12.0)));
let row = |ui: &mut egui::Ui, k: &str, v: &str| {
ui.add(egui::Label::new(egui::RichText::new(format!("{}: {v}", tl!(k))).font(theme::regular(11.5))).wrap());
};
if let Some(r) = &s.reason {
row(ui, "Reason", r);
}
if let Some(l) = &s.location {
row(ui, "Location", l);
}
if let Some(d) = &s.date {
row(ui, "Signing time", &sign::pdf::display_date(d));
}
if let Some(a) = &s.algorithm {
row(ui, "Algorithm", a);
}
if let Some(c) = &s.certificate {
row(ui, "Signer", &c.subject.display());
row(ui, "Issued by", &c.issuer.display());
row(ui, "Valid", &format!("{} to {}", c.not_before, c.not_after));
}
match s.page {
Some(p) => {
let link = ui.add(
egui::Label::new(
egui::RichText::new(crate::i18n::fmt(
tl!("Field: {field} on page {p}"),
&[("field", &s.field), ("p", &(p + 1).to_string())],
))
.font(theme::regular(11.5))
.color(t.accent_text)
.underline(),
)
.sense(egui::Sense::click()),
);
if link.clicked() {
action = Some(PanelAction::GoTo(p));
}
}
None => row(ui, "Field", &s.field),
}
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
// Trust exactly the signer shown above (s.certificate), not the chain top the
// document embedded: trusting an issuer or root is a separate, explicit step in
// the Certificate Viewer's Trust tab, which names that certificate.
if let Some(c) = s.certificate.as_ref()
&& s.status == SignatureStatus::Unknown
&& ui.button(tl!("Add to trusted certificates")).clicked()
{
action = Some(PanelAction::Trust(Box::new(c.clone())));
}
if ui.button(tl!("View signed version")).clicked() {
action = Some(PanelAction::ViewSigned { len: s.signed_len, revision: s.revision });
}
if let Some(c) = &s.certificate
&& ui.button(tl!("Show certificate…")).clicked()
{
let mut chain = vec![c.clone()];
chain.extend(s.chain.iter().filter(|x| x.raw != c.raw).cloned());
action = Some(PanelAction::ViewCertificate(chain));
}
if let Some(c) = &s.certificate
&& ui.button(tl!("Export certificate…")).clicked()
{
action = Some(PanelAction::ExportCertificate(Box::new(c.clone())));
}
});
});
}
action
}
/// Certificate Viewer: a chain (the end certificate first) and the tab shown.
#[derive(Clone, Debug)]
pub struct CertViewer {
pub chain: Vec<Certificate>,
pub selected: usize,
pub tab: CertTab,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CertTab {
Summary,
Details,
Trust,
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().join(" ")
}
fn key_usage(bits: u16) -> String {
const NAMES: [&str; 9] = [
"Digital Signature",
"Non-Repudiation",
"Key Encipherment",
"Data Encipherment",
"Key Agreement",
"Certificate Signing",
"CRL Signing",
"Encipher Only",
"Decipher Only",
];
let used: Vec<&str> = NAMES.iter().enumerate().filter(|(i, _)| bits & (1 << i) != 0).map(|(_, n)| tl!(*n)).collect();
if used.is_empty() { tl!("None").into() } else { used.join(", ") }
}
/// What the viewer asks for.
pub(crate) enum CertAction {
Trust(Box<Certificate>),
Export(Box<Certificate>),
}
/// The Certificate Viewer dialog: the chain on the left, the selected certificate's tabs.
pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Certificate], t: &Tokens) -> (bool, Option<CertAction>) {
title(ui, "Certificate Viewer");
ui.label(egui::RichText::new(tl!("This dialog shows the details of a certificate and its chain.")).color(t.text_muted));
ui.add_space(8.0);
let mut action = None;
v.selected = v.selected.min(v.chain.len().saturating_sub(1));
ui.horizontal_top(|ui| {
// The chain, root at the top (as Acrobat shows it).
ui.vertical(|ui| {
ui.set_width(200.0);
for (depth, i) in (0..v.chain.len()).rev().enumerate() {
let c = &v.chain[i];
ui.horizontal(|ui| {
ui.add_space(depth as f32 * 12.0);
if ui.selectable_label(v.selected == i, c.display_name()).clicked() {
v.selected = i;
}
});
}
});
ui.add_space(16.0);
ui.vertical(|ui| {
ui.set_width(440.0);
let Some(c) = v.chain.get(v.selected).cloned() else { return };
ui.horizontal(|ui| {
for (tab, label) in [(CertTab::Summary, tl!("Summary")), (CertTab::Details, tl!("Details")), (CertTab::Trust, tl!("Trust"))] {
if widgets::pill_button(ui, label, v.tab == tab).clicked() {
v.tab = tab;
}
}
});
ui.add_space(8.0);
let grid = |ui: &mut egui::Ui, rows: Vec<(&str, String)>| {
egui::Grid::new(("cert-rows", v.tab as u8)).num_columns(2).spacing([12.0, 5.0]).show(ui, |ui| {
for (k, val) in rows {
ui.label(egui::RichText::new(tl_ctx!("certificate", k)).color(t.text_muted));
ui.add(egui::Label::new(val).wrap());
ui.end_row();
}
});
};
match v.tab {
CertTab::Summary => grid(
ui,
vec![
("Issued to", c.subject.display()),
("Issued by", c.issuer.display()),
("Valid from", c.not_before.to_string()),
("Valid to", c.not_after.to_string()),
("Intended usage", c.key_usage.map(key_usage).unwrap_or_else(|| tl!("Any").to_string())),
],
),
CertTab::Details => grid(
ui,
vec![
("Version", "3".into()),
("Serial number", c.serial_hex()),
("Issuer", c.issuer.display()),
("Subject", c.subject.display()),
("Validity starts", c.not_before.to_string()),
("Validity ends", c.not_after.to_string()),
("Public key", c.public_key.describe()),
("Basic constraints", tl!(if c.is_ca { "Certificate authority" } else { "End entity" }).to_string()),
("Key usage", c.key_usage.map(key_usage).unwrap_or_else(|| tl!("Not present").to_string())),
("Self-signed", if c.is_self_signed() { tl!("Yes").to_string() } else { tl!("No").to_string() }),
("SHA-1 digest", hex(&sign::keys::DigestAlg::Sha1.digest(&[&c.raw]))),
("SHA-256 digest", hex(&sign::keys::DigestAlg::Sha256.digest(&[&c.raw]))),
],
),
CertTab::Trust => {
let is_trusted = trusted.iter().any(|x| x.raw == c.raw);
let anchored = v.chain.iter().any(|x| trusted.iter().any(|y| y.raw == x.raw));
ui.label(if is_trusted {
tl!("This certificate is in your list of trusted certificates.")
} else if anchored {
tl!("This certificate is trusted through a certificate above it in the chain.")
} else {
tl!("This certificate is not trusted. Signatures made with it show an unknown identity.")
});
ui.add_space(8.0);
if !is_trusted && widgets::pill_button(ui, tl!("Add to Trusted Certificates"), false).clicked() {
action = Some(CertAction::Trust(Box::new(c.clone())));
}
}
}
ui.add_space(8.0);
if widgets::pill_button(ui, tl!("Export…"), false).clicked() {
action = Some(CertAction::Export(Box::new(c.clone())));
}
});
});
ui.add_space(10.0);
let mut close = false;
ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| close = widgets::pill_button(ui, tl!("OK"), true).clicked()));
(close, action)
}
/// The text of an exported certificate (`.cer`, PEM).
pub fn certificate_pem(c: &Certificate) -> String {
sign::x509::to_pem(c)
}