mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
Address review findings: context menu permissions, font submenu, parity notes, and lockfile
- Grey out restricted page context-menu actions (editing, annotation, assembly, printing) on protected PDFs. - Group installed fonts by family with search, max height, and scroll area in the format panel. Prevent row cutoff in font dropdown. - Document Unicode character conversion behavior for superscript and subscript in parity notes. - Revert unintended clipboard-win bump in Cargo.lock back to 5.4.1.
This commit is contained in:
Generated
+2
-2
@@ -953,9 +953,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.3"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
@@ -1020,25 +1020,31 @@ pub(crate) enum PageMenuAction {
|
||||
DocProperties,
|
||||
}
|
||||
|
||||
pub(crate) fn default_page_context_menu(
|
||||
ui: &mut egui::Ui,
|
||||
page: usize,
|
||||
at: [f64; 2],
|
||||
can_copy: bool,
|
||||
can_paste: bool,
|
||||
action: &mut Option<PageMenuAction>,
|
||||
) {
|
||||
pub(crate) struct PageMenuPerms {
|
||||
pub can_modify: bool,
|
||||
pub can_assemble: bool,
|
||||
pub can_annotate: bool,
|
||||
pub can_print: bool,
|
||||
pub can_copy: bool,
|
||||
pub can_paste: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64; 2], perms: PageMenuPerms, action: &mut Option<PageMenuAction>) {
|
||||
// Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if crate::icons::button(ui, "rotate-ccw", 24.0, false, tl!("Rotate Counterclockwise")).clicked() {
|
||||
*action = Some(PageMenuAction::RotateCCW(page));
|
||||
ui.close();
|
||||
}
|
||||
if crate::icons::button(ui, "printer", 24.0, false, tl!("Print")).clicked() {
|
||||
*action = Some(PageMenuAction::Print);
|
||||
ui.close();
|
||||
}
|
||||
ui.add_enabled_ui(perms.can_assemble, |ui| {
|
||||
if crate::icons::button(ui, "rotate-ccw", 24.0, false, tl!("Rotate Counterclockwise")).clicked() {
|
||||
*action = Some(PageMenuAction::RotateCCW(page));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(perms.can_print, |ui| {
|
||||
if crate::icons::button(ui, "printer", 24.0, false, tl!("Print")).clicked() {
|
||||
*action = Some(PageMenuAction::Print);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if crate::icons::button(ui, "mouse-pointer-2", 24.0, false, tl!("Select Tool")).clicked() {
|
||||
*action = Some(PageMenuAction::SelectTool);
|
||||
ui.close();
|
||||
@@ -1055,49 +1061,49 @@ pub(crate) fn default_page_context_menu(
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy")).shortcut_text("Ctrl+C")).clicked() {
|
||||
if ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Copy")).shortcut_text("Ctrl+C")).clicked() {
|
||||
*action = Some(PageMenuAction::Copy);
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() {
|
||||
if ui.add_enabled(perms.can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() {
|
||||
*action = Some(PageMenuAction::Paste(page, at));
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.button(tl!("Add comment")).clicked() {
|
||||
if ui.add_enabled(perms.can_annotate, egui::Button::new(tl!("Add comment"))).clicked() {
|
||||
*action = Some(PageMenuAction::AddComment(page, at));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Add bookmark")).clicked() {
|
||||
if ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Add bookmark"))).clicked() {
|
||||
*action = Some(PageMenuAction::AddBookmark(page));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Take a snapshot")).clicked() {
|
||||
if ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Take a snapshot"))).clicked() {
|
||||
*action = Some(PageMenuAction::TakeSnapshot);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.add(egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())).clicked() {
|
||||
if ui.add_enabled(perms.can_modify, egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())).clicked() {
|
||||
*action = Some(PageMenuAction::EditPdf);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Add text")).clicked() {
|
||||
if ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add text"))).clicked() {
|
||||
*action = Some(PageMenuAction::AddText);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Add image")).clicked() {
|
||||
if ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add image"))).clicked() {
|
||||
*action = Some(PageMenuAction::AddImage);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Delete page")).clicked() {
|
||||
if ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Delete page"))).clicked() {
|
||||
*action = Some(PageMenuAction::DeletePage(page));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Export this PDF")).clicked() {
|
||||
if ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Export this PDF"))).clicked() {
|
||||
*action = Some(PageMenuAction::ExportPdf);
|
||||
ui.close();
|
||||
}
|
||||
@@ -4682,8 +4688,16 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0]));
|
||||
let can_copy_items = can_copy && (view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image);
|
||||
let can_paste = can_paste_image;
|
||||
default_page_context_menu(ui, page, at, can_copy_items, can_paste, &mut page_action);
|
||||
let can_paste = can_paste_image && can_modify;
|
||||
let perms = PageMenuPerms {
|
||||
can_modify,
|
||||
can_assemble: doc.allows_assembly(),
|
||||
can_annotate: allowed,
|
||||
can_print: doc.allows_printing(),
|
||||
can_copy: can_copy_items,
|
||||
can_paste,
|
||||
};
|
||||
default_page_context_menu(ui, page, at, perms, &mut page_action);
|
||||
});
|
||||
if !resp.context_menu_opened() {
|
||||
ui.ctx().data_mut(|d| d.insert_persisted(crate::edit_text_ui::image_menu_id(), false));
|
||||
|
||||
@@ -77,6 +77,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras:
|
||||
widgets::section_title(ui, tl!("Format text"));
|
||||
|
||||
// The font, across the panel.
|
||||
ui.spacing_mut().combo_height = 360.0;
|
||||
let width = ui.available_width();
|
||||
let font_resp = egui::ComboBox::from_id_salt("format-font")
|
||||
.selected_text(fonts.shown.as_str())
|
||||
@@ -104,22 +105,74 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras:
|
||||
out.font = Some(FontChoice::Load);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ui.menu_button(tl!("Installed fonts…"), |ui| match crate::files::installed_font_files(ui.ctx()) {
|
||||
Some(list) if list.is_empty() => {
|
||||
ui.label(tl!("No openly licensed document fonts found. Load a font file."));
|
||||
}
|
||||
Some(list) => {
|
||||
for font in list {
|
||||
if ui.button(&font.face).clicked() {
|
||||
out.font = Some(FontChoice::Installed(font.path));
|
||||
ui.close();
|
||||
ui.menu_button(tl!("Installed fonts…"), |ui| {
|
||||
ui.set_min_width(260.0);
|
||||
match crate::files::installed_font_files(ui.ctx()) {
|
||||
Some(list) if list.is_empty() => {
|
||||
ui.label(tl!("No openly licensed document fonts found. Load a font file."));
|
||||
}
|
||||
Some(list) => {
|
||||
let search_id = ui.id().with("installed-font-search");
|
||||
let mut query = ui.data(|d| d.get_temp::<String>(search_id)).unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("🔍").size(12.0));
|
||||
let resp = ui
|
||||
.add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search…")).desired_width(ui.available_width() - 8.0));
|
||||
if resp.changed() {
|
||||
ui.data_mut(|d| d.insert_temp(search_id, query.clone()));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
let query_lower = query.trim().to_lowercase();
|
||||
let mut families: std::collections::BTreeMap<&str, Vec<&pdfcraft_engine::text_fonts::FontFile>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for font in &list {
|
||||
if query_lower.is_empty()
|
||||
|| font.family.to_lowercase().contains(&query_lower)
|
||||
|| font.face.to_lowercase().contains(&query_lower)
|
||||
{
|
||||
families.entry(&font.family).or_default().push(font);
|
||||
}
|
||||
}
|
||||
|
||||
if families.is_empty() {
|
||||
ui.label(tl!("No openly licensed document fonts found. Load a font file."));
|
||||
} else {
|
||||
egui::ScrollArea::vertical().max_height(350.0).show(ui, |ui| {
|
||||
for (family, faces) in families {
|
||||
if faces.len() == 1 {
|
||||
if let Some(&font) = faces.first() {
|
||||
let label =
|
||||
if font.face != font.family && !font.face.is_empty() { font.face.as_str() } else { family };
|
||||
if ui.button(label).clicked() {
|
||||
out.font = Some(FontChoice::Installed(font.path.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui.menu_button(family, |ui| {
|
||||
ui.set_min_width(180.0);
|
||||
for font in faces {
|
||||
let label =
|
||||
font.face.strip_prefix(family).map(str::trim).filter(|s| !s.is_empty()).unwrap_or(&font.face);
|
||||
if ui.button(label).clicked() {
|
||||
out.font = Some(FontChoice::Installed(font.path.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label(tl!("Finding document fonts…"));
|
||||
None => {
|
||||
ui.label(tl!("Finding document fonts…"));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
})
|
||||
.response;
|
||||
|
||||
@@ -3711,6 +3711,50 @@ fn default_page_context_menu_when_not_in_edit_mode() {
|
||||
assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::EditText);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_context_menu_items_disabled_on_protected_pdf() {
|
||||
let bytes = protected("", "owner", 0b0100);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("protected.pdf", None, bytes.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::Select);
|
||||
|
||||
let doc_id = h.state().views[0].id;
|
||||
let doc = h.state().session.get(doc_id).unwrap();
|
||||
assert!(!doc.allows_modification());
|
||||
assert!(!doc.allows_assembly());
|
||||
assert!(!doc.allows_annotation());
|
||||
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let c = r.center();
|
||||
// Right click on page in Select mode
|
||||
h.hover_at(c);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
|
||||
let is_disabled =
|
||||
|h: &mut Harness<'static, PdfCraftApp>, label: &str| h.query_by(|n| n.label().as_deref() == Some(label) && n.is_disabled()).is_some();
|
||||
|
||||
// Context menu is open; check restricted items are greyed out (disabled)
|
||||
assert!(is_disabled(&mut h, "Edit this PDF"), "Edit this PDF should be disabled on protected document");
|
||||
assert!(is_disabled(&mut h, "Add text"), "Add text should be disabled");
|
||||
assert!(is_disabled(&mut h, "Add image"), "Add image should be disabled");
|
||||
assert!(is_disabled(&mut h, "Delete page"), "Delete page should be disabled");
|
||||
assert!(is_disabled(&mut h, "Add comment"), "Add comment should be disabled");
|
||||
assert!(is_disabled(&mut h, "Add bookmark"), "Add bookmark should be disabled");
|
||||
|
||||
// View-only items should still be enabled
|
||||
assert!(!is_disabled(&mut h, "Select Tool"));
|
||||
assert!(!is_disabled(&mut h, "Hand Tool"));
|
||||
assert!(!is_disabled(&mut h, "Find text or tools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_edge_handle_stretch_and_crop_mode() {
|
||||
let mut png = Vec::new();
|
||||
|
||||
@@ -1947,11 +1947,11 @@ title = "Superscript and subscript"
|
||||
area = "C"
|
||||
tier = "P2"
|
||||
milestone = "M7"
|
||||
status = "planned"
|
||||
commands = []
|
||||
status = "partial"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
evidence = ["crates/ui-egui/src/edit_text_ui.rs::superscript_and_subscript_conversion"]
|
||||
notes = "Toggled in the Format text panel for the selection. Uses Unicode superscript and subscript forms (2 ↔ ²) rather than text rise (Ts) and font scaling, so the font must provide the corresponding glyphs and extracted text contains the Unicode characters."
|
||||
|
||||
[[feature]]
|
||||
id = "edit.paragraph-alignment"
|
||||
|
||||
Reference in New Issue
Block a user