From 069f0e92978c83ea82415e47542e321d0fc6491e Mon Sep 17 00:00:00 2001 From: nonamexishere Date: Tue, 25 Aug 2026 02:51:37 +0300 Subject: [PATCH 1/2] feat(edit): add, edit and remove PDF URI and GoTo links Detect existing allowlisted URI and in-document GoTo annotations, show them on the Edit PDF canvas, and write real /Link dictionaries on save after overlay. Unsupported actions are left in place. Uses the #34 staged-output gate. Fixes #35. --- CHANGELOG.md | 1 + src-tauri/src/commands/render.rs | 13 + src-tauri/src/lib.rs | 1 + src-tauri/src/pdf_engine/edit_links.rs | 1270 +++++++++++++++++ src-tauri/src/pdf_engine/edit_overlay.rs | 151 +- .../src/pdf_engine/edit_overlay_integ.rs | 1 + src-tauri/src/pdf_engine/mod.rs | 1 + src/components/pdf/editor/EditorOverlay.tsx | 30 +- src/components/pdf/editor/ObjectInspector.tsx | 127 +- src/components/pdf/editor/ObjectList.tsx | 7 + src/components/pdf/editor/PdfEditorCanvas.tsx | 20 +- src/components/pdf/editor/useEditSession.ts | 16 + src/features/edit-pdf/EditPdfPage.tsx | 60 +- src/lib/editor/EDIT_MODEL.md | 3 + src/lib/editor/editReducer.ts | 36 +- src/lib/editor/index.ts | 3 + src/lib/editor/linkObject.test.ts | 23 + src/lib/editor/remapPages.ts | 9 + src/lib/editor/types.ts | 16 +- src/lib/tauriCommands.ts | 6 + src/lib/types.ts | 11 + 21 files changed, 1745 insertions(+), 60 deletions(-) create mode 100644 src-tauri/src/pdf_engine/edit_links.rs create mode 100644 src/lib/editor/linkObject.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c40aa33..e8c6273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable project changes should be documented here. ### Added - Edit PDF: staged output is checked with `qpdf --check` and reopened for page boxes, per-page content identity, and catalog data before the destination file is replaced. A failed check leaves the original and any existing destination untouched. `qpdf --check` warnings (exit 3) still publish and appear on the completed job update. +- Edit PDF: add, edit, and remove URI (`https` / `http` / `mailto`) and in-document GoTo link annotations. Unsupported actions are left unchanged. The original file is never overwritten. ### Fixed diff --git a/src-tauri/src/commands/render.rs b/src-tauri/src/commands/render.rs index b198ba2..fd72d55 100644 --- a/src-tauri/src/commands/render.rs +++ b/src-tauri/src/commands/render.rs @@ -122,6 +122,19 @@ pub async fn pdf_outline(input_path: String) -> Result Result, AppError> { + tauri::async_runtime::spawn_blocking(move || { + crate::pdf_engine::edit_links::list_pdf_links_cmd(&input_path) + }) + .await + .map_err(|e| AppError::io("Could not read the links.", e))? +} + /// Visually compare two pages; returns a diff-overlay image + changed percent. #[tauri::command] pub async fn diff_pages( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5374b0b..2c4b062 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -91,6 +91,7 @@ pub fn run() { commands::render::pdf_text, commands::render::page_pdf, commands::render::pdf_outline, + commands::render::list_pdf_links, commands::render::diff_pages, commands::render::office_available, commands::render::office_to_pdf, diff --git a/src-tauri/src/pdf_engine/edit_links.rs b/src-tauri/src/pdf_engine/edit_links.rs new file mode 100644 index 0000000..7dba516 --- /dev/null +++ b/src-tauri/src/pdf_engine/edit_links.rs @@ -0,0 +1,1270 @@ +//! PDF `/Link` annotations for Edit PDF (issue #35). +//! +//! Shared classifier + list/apply surface. Bodies are fail-today stubs so +//! tests compile and fail on assertions until impl lands. + +use crate::error::AppError; +use lopdf::{Dictionary, Document, Object, ObjectId}; +use serde::Serialize; +use std::collections::HashMap; +use std::path::Path; + +/// Outline-sized load gate — same bound as [`super::outline`]. +const MAX_LINK_BYTES: u64 = 400 * 1024 * 1024; +const MAX_LINKS: usize = 5000; + +/// Classification of a link action (URI / in-document GoTo / leave-alone). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkActionClass { + Uri, + GoTo, + Unsupported, +} + +/// Supported session/list action: allowlisted URI or 0-based dest page. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkAction { + Uri { uri: String }, + GoTo { dest_page_index: u32 }, +} + +/// A supported `/Link` listed from a source PDF. +/// +/// `rect` is unrotated user space `[x, y, w, h]` (same as `EditObject.rect`). +#[derive(Debug, Clone, PartialEq)] +pub struct ListedLink { + pub page_index: u32, + pub rect: [f64; 4], + pub action: LinkAction, +} + +/// A session-owned supported link to write onto a staged dest PDF. +#[derive(Debug, Clone, PartialEq)] +pub struct SessionLink { + pub page_index: u32, + pub rect: [f64; 4], + pub action: LinkAction, +} + +/// IPC DTO for `list_pdf_links` (paths in, JSON out). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PdfLinkDto { + pub page_index: u32, + pub rect: PdfRectDto, + pub action: PdfLinkActionDto, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PdfRectDto { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PdfLinkActionDto { + Uri { + uri: String, + }, + Goto { + #[serde(rename = "destPageIndex")] + dest_page_index: u32, + }, +} + +/// Write allowlist: `https` / `http` / `mailto` only (frozen #35 policy). +pub fn uri_is_allowed(uri: &str) -> bool { + let Some(scheme) = uri_scheme(uri) else { + return false; + }; + matches!(scheme, "https" | "http" | "mailto") +} + +/// Classify a PDF link action. +/// +/// `s` is the `/S` name (`URI`, `GoTo`, `Launch`, `GoToR`, `JavaScript`, …). +/// `uri` is `/URI` when `s` is `URI`. `dest_is_named` is true when `/D` is a +/// name or string (named dest — unsupported, leave). +pub fn classify_link_action(s: &str, uri: Option<&str>, dest_is_named: bool) -> LinkActionClass { + if s.eq_ignore_ascii_case("URI") { + return match uri { + Some(u) if uri_is_allowed(u) => LinkActionClass::Uri, + _ => LinkActionClass::Unsupported, + }; + } + if s.eq_ignore_ascii_case("GoTo") { + return if dest_is_named { + LinkActionClass::Unsupported + } else { + LinkActionClass::GoTo + }; + } + LinkActionClass::Unsupported +} + +/// List supported URI / in-document GoTo `/Link` annots. +/// +/// Rects are the written unrotated `/Rect` values as `[x, y, w, h]`, not +/// display-swapped for `/Rotate`. +pub fn list_link_annots(path: &Path) -> Result, AppError> { + let meta = match std::fs::metadata(path) { + Ok(m) => m, + Err(_) => return Ok(Vec::new()), + }; + if meta.len() > MAX_LINK_BYTES { + return Ok(Vec::new()); + } + let doc = match Document::load(path) { + Ok(d) => d, + Err(_) => return Ok(Vec::new()), + }; + let page_index_of = page_index_map(&doc); + let mut out = Vec::new(); + let pages = doc.get_pages(); + let mut nums: Vec = pages.keys().copied().collect(); + nums.sort_unstable(); + for num in nums { + if out.len() >= MAX_LINKS { + break; + } + let Some(&page_id) = pages.get(&num) else { + continue; + }; + for raw in page_annot_objects(&doc, page_id) { + if out.len() >= MAX_LINKS { + break; + } + let Some(listed) = listed_from_annot(&doc, &raw, num.saturating_sub(1), &page_index_of) + else { + continue; + }; + out.push(listed); + } + } + Ok(out) +} + +/// Replace the dest page's supported-Link set with `links`. +/// +/// Copy through every non-Link annot and every unsupported Link. Reject +/// writing a non-allowlisted URI with an actionable [`AppError`]. +pub fn apply_link_annots(staged: &Path, links: &[SessionLink]) -> Result<(), AppError> { + for link in links { + if let LinkAction::Uri { uri } = &link.action { + if !uri_is_allowed(uri) { + return Err(unsafe_uri_error(uri)); + } + } + } + + let mut doc = Document::load(staged) + .map_err(|e| AppError::engine_failed(format!("Could not read the staged PDF: {e}")))?; + let pages = doc.get_pages(); + let page_index_of = page_index_map(&doc); + let mut page_ids: Vec<(u32, ObjectId)> = pages.iter().map(|(&n, &id)| (n, id)).collect(); + page_ids.sort_by_key(|(n, _)| *n); + + let mut by_page: HashMap> = HashMap::new(); + for link in links { + if (link.page_index as usize) >= page_ids.len() { + return Err(AppError::new( + "BAD_EDIT", + "Link page is out of range", + format!( + "A link points at page {}, but this PDF has {} page{}.", + link.page_index + 1, + page_ids.len(), + if page_ids.len() == 1 { "" } else { "s" } + ), + ) + .with_suggestion("Pick a page that exists in the document.")); + } + by_page.entry(link.page_index).or_default().push(link); + } + + for (num, page_id) in &page_ids { + let page_index = num.saturating_sub(1); + let existing = page_annot_objects(&doc, *page_id); + let mut kept: Vec = Vec::new(); + for raw in existing { + if annot_is_supported_link(&doc, &raw, &page_index_of) { + continue; + } + kept.push(raw); + } + let session = by_page.get(&page_index).map(Vec::as_slice).unwrap_or(&[]); + for link in session { + let annot_id = add_session_link(&mut doc, link, &page_ids)?; + kept.push(annot_id.into()); + } + set_page_annots(&mut doc, *page_id, kept)?; + } + + doc.save(staged) + .map_err(|e| AppError::io("Could not write link annotations.", e))?; + Ok(()) +} + +/// True when dest will still have any annot after apply (leftover ∪ added). +pub fn expected_dest_has_annots(staged: &Path, links: &[SessionLink]) -> Result { + if !links.is_empty() { + return Ok(true); + } + dest_has_leftover_annots(staged) +} + +/// True when any dest annot is a supported URI/GoTo Link (apply would rewrite). +pub fn dest_has_supported_links(staged: &Path) -> Result { + let doc = Document::load(staged) + .map_err(|e| AppError::engine_failed(format!("Could not read the staged PDF: {e}")))?; + let page_index_of = page_index_map(&doc); + for id in doc.get_pages().values() { + for raw in page_annot_objects(&doc, *id) { + if annot_is_supported_link(&doc, &raw, &page_index_of) { + return Ok(true); + } + } + } + Ok(false) +} + +/// Command helper: size-gated list as JSON DTOs. +pub fn list_pdf_links_cmd(path: &str) -> Result, AppError> { + let listed = list_link_annots(Path::new(path))?; + Ok(listed.into_iter().map(listed_to_dto).collect()) +} + +pub fn unsafe_uri_error(uri: &str) -> AppError { + AppError::new( + "UNSAFE_URI", + "This link is not allowed", + format!("OffPDF cannot write a link to \"{uri}\"."), + ) + .with_suggestion("Use an https, http, or mailto address.") +} + +fn listed_to_dto(link: ListedLink) -> PdfLinkDto { + PdfLinkDto { + page_index: link.page_index, + rect: PdfRectDto { + x: link.rect[0], + y: link.rect[1], + w: link.rect[2], + h: link.rect[3], + }, + action: match link.action { + LinkAction::Uri { uri } => PdfLinkActionDto::Uri { uri }, + LinkAction::GoTo { dest_page_index } => PdfLinkActionDto::Goto { dest_page_index }, + }, + } +} + +fn uri_scheme(uri: &str) -> Option<&str> { + let s = uri.trim(); + let colon = s.find(':')?; + let scheme = &s[..colon]; + if scheme.is_empty() { + return None; + } + let ok = scheme.bytes().enumerate().all(|(i, b)| { + if i == 0 { + b.is_ascii_alphabetic() + } else { + b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.' + } + }); + if !ok { + return None; + } + // Lowercase compare without allocating when already ASCII-lower. + // Callers only match a tiny allowlist; a small String is fine. + None.or_else(|| { + let lower = scheme.to_ascii_lowercase(); + // Leak-free: compare via a local then map back is awkward; return owned via match in caller. + // Re-parse by returning a static when it matches. + match lower.as_str() { + "https" => Some("https"), + "http" => Some("http"), + "mailto" => Some("mailto"), + "javascript" => Some("javascript"), + "file" => Some("file"), + "data" => Some("data"), + "ftp" => Some("ftp"), + "vbscript" => Some("vbscript"), + _ => Some("other"), + } + }) +} + +fn dest_has_leftover_annots(staged: &Path) -> Result { + let doc = Document::load(staged) + .map_err(|e| AppError::engine_failed(format!("Could not read the staged PDF: {e}")))?; + let page_index_of = page_index_map(&doc); + for id in doc.get_pages().values() { + for raw in page_annot_objects(&doc, *id) { + if !annot_is_supported_link(&doc, &raw, &page_index_of) { + // Any leftover object (non-Link or unsupported Link). Empty Annots + // arrays still count as leftover only if they hold an object. + if resolve_dict(&doc, &raw).is_some() { + return Ok(true); + } + } + } + } + Ok(false) +} + +fn page_index_map(doc: &Document) -> HashMap { + let mut map = HashMap::new(); + for (num, id) in doc.get_pages() { + map.insert(id, num.saturating_sub(1)); + } + map +} + +fn page_annot_objects(doc: &Document, page_id: ObjectId) -> Vec { + let Ok(page) = doc.get_dictionary(page_id) else { + return Vec::new(); + }; + match page.get(b"Annots") { + Ok(Object::Array(a)) => a.clone(), + Ok(Object::Reference(r)) => doc + .get_object(*r) + .ok() + .and_then(|o| o.as_array().ok()) + .cloned() + .unwrap_or_default(), + _ => Vec::new(), + } +} + +fn resolve_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> { + match obj { + Object::Dictionary(d) => Some(d), + Object::Reference(id) => doc.get_dictionary(*id).ok(), + _ => None, + } +} + +fn as_name(obj: &Object) -> Option { + match obj { + Object::Name(n) => Some(String::from_utf8_lossy(n).into_owned()), + _ => None, + } +} + +fn pdf_string(obj: &Object) -> Option { + match obj { + Object::String(b, _) => Some(String::from_utf8_lossy(b).into_owned()), + _ => { + let s = lopdf::decode_text_string(obj).ok()?; + if s.is_empty() { + None + } else { + Some(s) + } + } + } +} + +fn as_nums(obj: &Object) -> Option> { + let Object::Array(a) = obj else { + return None; + }; + Some( + a.iter() + .filter_map(|o| match o { + Object::Integer(i) => Some(*i as f64), + Object::Real(r) => Some(*r as f64), + _ => None, + }) + .collect(), + ) +} + +fn dest_is_named(doc: &Document, dest: &Object) -> bool { + match dest { + Object::Array(_) => false, + Object::Name(_) | Object::String(_, _) => true, + Object::Reference(r) => match doc.get_object(*r) { + Ok(Object::Array(_)) => false, + Ok(Object::Name(_) | Object::String(_, _)) => true, + _ => true, + }, + _ => true, + } +} + +fn dest_page_index( + doc: &Document, + dest: &Object, + page_index_of: &HashMap, +) -> Option { + let arr = match dest { + Object::Array(a) => a, + Object::Reference(r) => doc.get_object(*r).ok()?.as_array().ok()?, + _ => return None, + }; + let page_id = arr.first()?.as_reference().ok()?; + page_index_of.get(&page_id).copied() +} + +struct ParsedLink<'a> { + class: LinkActionClass, + uri: Option, + dest_page_index: Option, + rect: [f64; 4], + _annot: &'a Dictionary, +} + +fn parse_link_annot<'a>( + doc: &'a Document, + raw: &'a Object, + page_index_of: &HashMap, +) -> Option> { + let annot = resolve_dict(doc, raw)?; + let subtype = annot.get(b"Subtype").ok().and_then(as_name)?; + if !subtype.eq_ignore_ascii_case("Link") { + return None; + } + let rect = annot + .get(b"Rect") + .ok() + .and_then(as_nums) + .and_then(|v| { + (v.len() == 4).then_some({ + let x0 = v[0]; + let y0 = v[1]; + let x1 = v[2]; + let y1 = v[3]; + [x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()] + }) + }) + .unwrap_or([0.0, 0.0, 0.0, 0.0]); + + if let Some(action_obj) = annot.get(b"A").ok() { + let action = resolve_dict(doc, action_obj)?; + let s = action.get(b"S").ok().and_then(as_name).unwrap_or_default(); + let uri = action.get(b"URI").ok().and_then(pdf_string); + let dest = action.get(b"D").ok(); + let named = dest.map(|d| dest_is_named(doc, d)).unwrap_or(false); + let class = classify_link_action(&s, uri.as_deref(), named); + let dest_page = dest.and_then(|d| dest_page_index(doc, d, page_index_of)); + return Some(ParsedLink { + class, + uri, + dest_page_index: dest_page, + rect, + _annot: annot, + }); + } + + if let Some(dest) = annot.get(b"Dest").ok() { + let named = dest_is_named(doc, dest); + let class = classify_link_action("GoTo", None, named); + let dest_page = dest_page_index(doc, dest, page_index_of); + return Some(ParsedLink { + class, + uri: None, + dest_page_index: dest_page, + rect, + _annot: annot, + }); + } + + Some(ParsedLink { + class: LinkActionClass::Unsupported, + uri: None, + dest_page_index: None, + rect, + _annot: annot, + }) +} + +fn listed_from_annot( + doc: &Document, + raw: &Object, + page_index: u32, + page_index_of: &HashMap, +) -> Option { + let parsed = parse_link_annot(doc, raw, page_index_of)?; + match parsed.class { + LinkActionClass::Uri => { + let uri = parsed.uri?; + Some(ListedLink { + page_index, + rect: parsed.rect, + action: LinkAction::Uri { uri }, + }) + } + LinkActionClass::GoTo => { + let dest_page_index = parsed.dest_page_index?; + Some(ListedLink { + page_index, + rect: parsed.rect, + action: LinkAction::GoTo { dest_page_index }, + }) + } + LinkActionClass::Unsupported => None, + } +} + +fn annot_is_supported_link( + doc: &Document, + raw: &Object, + page_index_of: &HashMap, +) -> bool { + // Same set list_link_annots hydrates: allowlisted URI or resolvable + // in-document GoTo. Unresolvable /D arrays stay leftover and copy through. + listed_from_annot(doc, raw, 0, page_index_of).is_some() +} + +fn add_session_link( + doc: &mut Document, + link: &SessionLink, + page_ids: &[(u32, ObjectId)], +) -> Result { + let [x, y, w, h] = link.rect; + let rect = Object::Array(vec![ + Object::Real(x as f32), + Object::Real(y as f32), + Object::Real((x + w) as f32), + Object::Real((y + h) as f32), + ]); + let mut action = Dictionary::new(); + match &link.action { + LinkAction::Uri { uri } => { + action.set("S", "URI"); + action.set("URI", Object::string_literal(uri.as_str())); + } + LinkAction::GoTo { dest_page_index } => { + let dest_1 = dest_page_index + 1; + let dest_id = page_ids + .iter() + .find(|(n, _)| *n == dest_1) + .map(|(_, id)| *id) + .ok_or_else(|| { + AppError::new( + "BAD_EDIT", + "Link destination is out of range", + format!( + "A link points at page {dest_1}, but this PDF has {} page{}.", + page_ids.len(), + if page_ids.len() == 1 { "" } else { "s" } + ), + ) + .with_suggestion("Pick a page that exists in the document.") + })?; + action.set("S", "GoTo"); + action.set( + "D", + Object::Array(vec![dest_id.into(), Object::Name(b"Fit".to_vec())]), + ); + } + } + let mut annot = Dictionary::new(); + annot.set("Type", "Annot"); + annot.set("Subtype", "Link"); + annot.set("Rect", rect); + annot.set( + "Border", + Object::Array(vec![ + Object::Integer(0), + Object::Integer(0), + Object::Integer(0), + ]), + ); + annot.set("A", Object::Dictionary(action)); + Ok(doc.add_object(Object::Dictionary(annot))) +} + +fn set_page_annots( + doc: &mut Document, + page_id: ObjectId, + annots: Vec, +) -> Result<(), AppError> { + let page = doc + .get_object_mut(page_id) + .and_then(|o| o.as_dict_mut()) + .map_err(|e| AppError::engine_failed(format!("Could not update page annotations: {e}")))?; + if annots.is_empty() { + page.remove(b"Annots"); + } else { + page.set("Annots", Object::Array(annots)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use lopdf::{Dictionary, Document, Object, ObjectId, Stream}; + use std::path::{Path, PathBuf}; + + struct Scratch(PathBuf); + + impl Scratch { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "offpdf-links-{}-{}-{}", + name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn file(&self, name: &str) -> PathBuf { + self.0.join(name) + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn box_obj(b: [i64; 4]) -> Object { + Object::Array(b.into_iter().map(Object::Integer).collect()) + } + + fn name(s: &str) -> Object { + Object::Name(s.as_bytes().to_vec()) + } + + struct PdfFix { + doc: Document, + page_ids: Vec, + } + + impl PdfFix { + fn new(n_pages: usize, rotate: i64, crop: Option<[i64; 4]>) -> Self { + let mut doc = Document::with_version("1.5"); + let pages_id = doc.new_object_id(); + let mut page_ids = Vec::with_capacity(n_pages); + for _ in 0..n_pages { + let content_id = doc.add_object(Object::Stream(Stream::new( + Dictionary::new(), + b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET".to_vec(), + ))); + let mut page = Dictionary::new(); + page.set("Type", "Page"); + page.set("Parent", pages_id); + page.set("MediaBox", box_obj([0, 0, 612, 792])); + if let Some(c) = crop { + page.set("CropBox", box_obj(c)); + } + if rotate != 0 { + page.set("Rotate", Object::Integer(rotate)); + } + page.set("Contents", content_id); + page_ids.push(doc.add_object(Object::Dictionary(page))); + } + + let mut pages = Dictionary::new(); + pages.set("Type", "Pages"); + pages.set( + "Kids", + Object::Array(page_ids.iter().copied().map(Object::from).collect()), + ); + pages.set("Count", Object::Integer(n_pages as i64)); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", "Catalog"); + catalog.set("Pages", pages_id); + let catalog_id = doc.add_object(Object::Dictionary(catalog)); + doc.trailer.set("Root", catalog_id); + Self { doc, page_ids } + } + + fn push_annot(&mut self, page_index: usize, dict: Dictionary) { + let annot_id = self.doc.add_object(Object::Dictionary(dict)); + let page_id = self.page_ids[page_index]; + let mut arr = match self.doc.get_dictionary(page_id).ok().and_then(|p| { + p.get(b"Annots").ok().and_then(|o| match o { + Object::Array(a) => Some(a.clone()), + _ => None, + }) + }) { + Some(a) => a, + None => Vec::new(), + }; + arr.push(annot_id.into()); + if let Ok(Object::Dictionary(page)) = self.doc.get_object_mut(page_id) { + page.set("Annots", Object::Array(arr)); + } + } + + fn add_link_uri(&mut self, page_index: usize, rect: [i64; 4], uri: &str) { + let mut action = Dictionary::new(); + action.set("S", name("URI")); + action.set("URI", Object::string_literal(uri)); + let mut annot = Dictionary::new(); + annot.set("Type", "Annot"); + annot.set("Subtype", "Link"); + annot.set("Rect", box_obj(rect)); + annot.set("A", Object::Dictionary(action)); + self.push_annot(page_index, annot); + } + + fn add_link_goto(&mut self, page_index: usize, rect: [i64; 4], dest_page: usize) { + let dest_id = self.page_ids[dest_page]; + let mut action = Dictionary::new(); + action.set("S", name("GoTo")); + action.set("D", Object::Array(vec![dest_id.into(), name("Fit")])); + let mut annot = Dictionary::new(); + annot.set("Type", "Annot"); + annot.set("Subtype", "Link"); + annot.set("Rect", box_obj(rect)); + annot.set("A", Object::Dictionary(action)); + self.push_annot(page_index, annot); + } + + fn add_highlight(&mut self, page_index: usize, rect: [i64; 4], contents: &str) { + let mut annot = Dictionary::new(); + annot.set("Type", "Annot"); + annot.set("Subtype", "Highlight"); + annot.set("Rect", box_obj(rect)); + annot.set("Contents", Object::string_literal(contents)); + self.push_annot(page_index, annot); + } + + fn save(&mut self, path: &Path) { + self.doc.save(path).expect("write link fixture"); + } + } + + struct InspectedAnnot { + subtype: String, + rect: [f64; 4], + action_s: Option, + uri: Option, + dest_page_index: Option, + contents: Option, + has_annots_key: bool, + } + + fn as_name(obj: &Object) -> Option { + match obj { + Object::Name(n) => Some(String::from_utf8_lossy(n).into_owned()), + _ => None, + } + } + + fn as_nums(obj: &Object) -> Option> { + let Object::Array(a) = obj else { + return None; + }; + Some( + a.iter() + .filter_map(|o| match o { + Object::Integer(i) => Some(*i as f64), + Object::Real(r) => Some(*r as f64), + _ => None, + }) + .collect(), + ) + } + + fn pdf_string(obj: &Object) -> Option { + match obj { + Object::String(b, _) => Some(String::from_utf8_lossy(b).into_owned()), + _ => { + let s = lopdf::decode_text_string(obj).ok()?; + if s.is_empty() { + None + } else { + Some(s) + } + } + } + } + + fn resolve_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> { + match obj { + Object::Dictionary(d) => Some(d), + Object::Reference(id) => doc.get_dictionary(*id).ok(), + _ => None, + } + } + + fn page_index_of(doc: &Document, id: ObjectId) -> Option { + doc.get_pages() + .iter() + .find_map(|(num, pid)| (*pid == id).then_some(*num - 1)) + } + + fn page_has_annots_key(path: &Path, page_1based: u32) -> bool { + let doc = Document::load(path).expect("load dest"); + let Some(id) = doc.get_pages().get(&page_1based).copied() else { + return false; + }; + doc.get_dictionary(id) + .ok() + .and_then(|p| p.get(b"Annots").ok()) + .is_some() + } + + fn inspect_page_annots(path: &Path, page_1based: u32) -> Vec { + let doc = Document::load(path).expect("load dest"); + let Some(id) = doc.get_pages().get(&page_1based).copied() else { + return Vec::new(); + }; + let Ok(page) = doc.get_dictionary(id) else { + return Vec::new(); + }; + let has_annots_key = page.get(b"Annots").is_ok(); + let annot_objs: Vec = match page.get(b"Annots") { + Ok(Object::Array(a)) => a.clone(), + Ok(Object::Reference(r)) => doc + .get_object(*r) + .ok() + .and_then(|o| o.as_array().ok()) + .cloned() + .unwrap_or_default(), + _ => Vec::new(), + }; + annot_objs + .iter() + .filter_map(|obj| { + let annot = resolve_dict(&doc, obj)?; + let subtype = annot + .get(b"Subtype") + .ok() + .and_then(as_name) + .unwrap_or_default(); + let rect = annot + .get(b"Rect") + .ok() + .and_then(as_nums) + .and_then(|v| (v.len() == 4).then_some([v[0], v[1], v[2], v[3]])) + .unwrap_or([0.0, 0.0, 0.0, 0.0]); + let action = annot.get(b"A").ok().and_then(|a| resolve_dict(&doc, a)); + let action_s = action.and_then(|a| a.get(b"S").ok().and_then(as_name)); + let uri = action.and_then(|a| a.get(b"URI").ok().and_then(pdf_string)); + let dest_page_index = action.and_then(|a| { + let dest = a.get(b"D").ok()?; + let arr = match dest { + Object::Array(v) => v, + Object::Reference(r) => doc.get_object(*r).ok()?.as_array().ok()?, + _ => return None, + }; + let first = arr.first()?; + let page_id = first.as_reference().ok()?; + page_index_of(&doc, page_id) + }); + let contents = annot.get(b"Contents").ok().and_then(pdf_string); + Some(InspectedAnnot { + subtype, + rect, + action_s, + uri, + dest_page_index, + contents, + has_annots_key, + }) + }) + .collect() + } + + fn rects_near(a: [f64; 4], b: [f64; 4]) -> bool { + a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 0.5) + } + + fn xywh_to_pdf(r: [f64; 4]) -> [f64; 4] { + [r[0], r[1], r[0] + r[2], r[1] + r[3]] + } + + fn assert_actionable(err: &AppError, what: &str) { + assert!( + !err.title.trim().is_empty(), + "{what}: AppError must have a title; got {err:?}" + ); + assert!( + !err.message.trim().is_empty(), + "{what}: AppError must have a message; got {err:?}" + ); + assert!( + err.suggestion + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false), + "{what}: AppError must have a suggestion; got {err:?}" + ); + } + + const URI_RECT_XYWH: [f64; 4] = [100.0, 200.0, 80.0, 40.0]; + const GOTO_RECT_XYWH: [f64; 4] = [200.0, 300.0, 80.0, 60.0]; + const URI_RECT_PDF: [i64; 4] = [100, 200, 180, 240]; + const GOTO_RECT_PDF: [i64; 4] = [200, 300, 280, 360]; + const HIGHLIGHT_RECT_PDF: [i64; 4] = [50, 50, 150, 80]; + + // --- L3 allowlist / classifier ----------------------------------------- + + #[test] + fn uri_allowlist_https_http_mailto_only() { + let allowed = [ + "https://example.com/a", + "http://example.com/b", + "mailto:user@example.com", + ]; + let rejected = [ + "javascript:alert(1)", + "file:///tmp/x", + "data:text/html,hi", + "ftp://example.com/f", + "vbscript:msgbox", + "myapp:custom", + ]; + for uri in allowed { + assert!(uri_is_allowed(uri), "L3: {uri} must be allowed"); + } + for uri in rejected { + assert!(!uri_is_allowed(uri), "L3: {uri} must be rejected"); + } + } + + #[test] + fn classify_allowlisted_uri_and_explicit_goto_are_supported() { + assert_eq!( + classify_link_action("URI", Some("https://example.com"), false), + LinkActionClass::Uri, + "L3: https URI must be Uri" + ); + assert_eq!( + classify_link_action("URI", Some("http://example.com"), false), + LinkActionClass::Uri, + "L3: http URI must be Uri" + ); + assert_eq!( + classify_link_action("URI", Some("mailto:a@b.com"), false), + LinkActionClass::Uri, + "L3: mailto URI must be Uri" + ); + assert_eq!( + classify_link_action("GoTo", None, false), + LinkActionClass::GoTo, + "L3: explicit GoTo must be GoTo" + ); + } + + #[test] + fn classify_javascript_file_data_uri_are_unsupported() { + for uri in ["javascript:alert(1)", "file:///tmp/x", "data:text/plain,x"] { + assert_eq!( + classify_link_action("URI", Some(uri), false), + LinkActionClass::Unsupported, + "L3: classify {uri} must be Unsupported" + ); + } + } + + #[test] + fn classify_launch_gotor_js_named_dest_are_unsupported() { + assert_eq!( + classify_link_action("Launch", None, false), + LinkActionClass::Unsupported, + "L3: classify Launch must be Unsupported" + ); + assert_eq!( + classify_link_action("GoToR", None, false), + LinkActionClass::Unsupported, + "L3: classify GoToR must be Unsupported" + ); + assert_eq!( + classify_link_action("JavaScript", None, false), + LinkActionClass::Unsupported, + "L3: classify JavaScript action must be Unsupported" + ); + assert_eq!( + classify_link_action("GoTo", None, true), + LinkActionClass::Unsupported, + "L3: classify named dest must be Unsupported" + ); + } + + #[test] + fn apply_rejected_uri_is_app_error_and_leaves_dest() { + let scratch = Scratch::new("l3-write"); + let dest = scratch.file("dest.pdf"); + PdfFix::new(1, 0, None).save(&dest); + let before = std::fs::read(&dest).unwrap(); + + for uri in [ + "javascript:alert(1)", + "file:///tmp/x", + "data:text/html,hi", + "ftp://example.com/f", + "vbscript:msgbox", + "myapp:custom", + ] { + std::fs::write(&dest, &before).unwrap(); + let links = [SessionLink { + page_index: 0, + rect: URI_RECT_XYWH, + action: LinkAction::Uri { + uri: uri.to_string(), + }, + }]; + let err = apply_link_annots(&dest, &links) + .expect_err(&format!("L3: writing {uri} must be AppError")); + assert_actionable(&err, &format!("L3 {uri}")); + assert_eq!( + std::fs::read(&dest).unwrap(), + before, + "L3: dest bytes must stay unchanged after rejected {uri}" + ); + } + } + + // --- L1 list ----------------------------------------------------------- + + #[test] + fn list_uri_and_goto_rects_are_unrotated_on_rotate_90_crop() { + let scratch = Scratch::new("l1"); + let src = scratch.file("src.pdf"); + let mut pdf = PdfFix::new(1, 90, Some([72, 72, 540, 720])); + pdf.add_link_uri(0, URI_RECT_PDF, "https://example.com/uri"); + pdf.add_link_goto(0, GOTO_RECT_PDF, 0); + pdf.save(&src); + + let listed = list_link_annots(&src).expect("L1: list_link_annots"); + assert!( + !listed.is_empty(), + "L1: list_link_annots must return the URI and GoTo on a Rotate 90 Crop≠Media page; got empty" + ); + + let uri = listed.iter().find( + |l| matches!(&l.action, LinkAction::Uri { uri } if uri == "https://example.com/uri"), + ); + let goto = listed + .iter() + .find(|l| matches!(&l.action, LinkAction::GoTo { dest_page_index: 0 })); + let uri = uri.expect("L1: listed set must include the URI link"); + let goto = goto.expect("L1: listed set must include the GoTo link"); + assert_eq!(uri.page_index, 0); + assert_eq!(goto.page_index, 0); + assert!( + rects_near(uri.rect, URI_RECT_XYWH), + "L1: listed URI rect must be unrotated /Rect as [x,y,w,h], not display-swapped; got {:?}", + uri.rect + ); + assert!( + rects_near(goto.rect, GOTO_RECT_XYWH), + "L1: listed GoTo rect must be unrotated /Rect as [x,y,w,h], not display-swapped; got {:?}", + goto.rect + ); + } + + // --- L2 apply add ------------------------------------------------------ + + #[test] + fn apply_writes_uri_and_goto_link_annots() { + let scratch = Scratch::new("l2"); + let dest = scratch.file("dest.pdf"); + PdfFix::new(2, 0, None).save(&dest); + + let links = [ + SessionLink { + page_index: 0, + rect: URI_RECT_XYWH, + action: LinkAction::Uri { + uri: "https://example.com/new".into(), + }, + }, + SessionLink { + page_index: 0, + rect: GOTO_RECT_XYWH, + action: LinkAction::GoTo { dest_page_index: 1 }, + }, + ]; + apply_link_annots(&dest, &links).expect("L2: apply_link_annots"); + + let annots = inspect_page_annots(&dest, 1); + let uri = annots.iter().find(|a| { + a.subtype == "Link" + && a.action_s.as_deref() == Some("URI") + && a.uri.as_deref() == Some("https://example.com/new") + }); + let goto = annots.iter().find(|a| { + a.subtype == "Link" + && a.action_s.as_deref() == Some("GoTo") + && a.dest_page_index == Some(1) + }); + assert!( + uri.is_some(), + "L2: dest page /Annots must have /Subtype /Link /A /S /URI; got {:?}", + annots + .iter() + .map(|a| (&a.subtype, &a.action_s, &a.uri)) + .collect::>() + ); + assert!( + goto.is_some(), + "L2: dest page /Annots must have /Subtype /Link /A /S /GoTo dest page 1; got {:?}", + annots + .iter() + .map(|a| (&a.subtype, &a.action_s, a.dest_page_index)) + .collect::>() + ); + let uri = uri.unwrap(); + let goto = goto.unwrap(); + assert!( + rects_near(uri.rect, xywh_to_pdf(URI_RECT_XYWH)), + "L2: URI /Rect must be unrotated [x y x+w y+h]; got {:?}", + uri.rect + ); + assert!( + rects_near(goto.rect, xywh_to_pdf(GOTO_RECT_XYWH)), + "L2: GoTo /Rect must be unrotated [x y x+w y+h]; got {:?}", + goto.rect + ); + } + + // --- L4 survival (keepGreen-after-impl if no-op leaves dest == copy) ---- + + #[test] + fn apply_kept_uri_copies_through_highlight() { + let scratch = Scratch::new("l4"); + let dest = scratch.file("dest.pdf"); + let mut pdf = PdfFix::new(1, 0, None); + pdf.add_link_uri(0, URI_RECT_PDF, "https://keep.example/"); + pdf.add_highlight(0, HIGHLIGHT_RECT_PDF, "keep-me"); + pdf.save(&dest); + + let links = [SessionLink { + page_index: 0, + rect: URI_RECT_XYWH, + action: LinkAction::Uri { + uri: "https://keep.example/".into(), + }, + }]; + apply_link_annots(&dest, &links).expect("L4: apply_link_annots"); + + let annots = inspect_page_annots(&dest, 1); + assert!( + annots.iter().any(|a| { + a.subtype == "Link" + && a.action_s.as_deref() == Some("URI") + && a.uri.as_deref() == Some("https://keep.example/") + }), + "L4: dest must still have the session URI; got {:?}", + annots + .iter() + .map(|a| (&a.subtype, &a.uri)) + .collect::>() + ); + assert!( + annots + .iter() + .any(|a| a.subtype == "Highlight" && a.contents.as_deref() == Some("keep-me")), + "L4: dest must copy through the non-Link Highlight; got {:?}", + annots + .iter() + .map(|a| (&a.subtype, &a.contents)) + .collect::>() + ); + } + + // --- L6 first Annots key ----------------------------------------------- + + #[test] + fn apply_first_link_creates_annots_key() { + let scratch = Scratch::new("l6"); + let dest = scratch.file("dest.pdf"); + PdfFix::new(1, 0, None).save(&dest); + assert!( + !page_has_annots_key(&dest, 1), + "fixture must start with no /Annots" + ); + + let links = [SessionLink { + page_index: 0, + rect: URI_RECT_XYWH, + action: LinkAction::Uri { + uri: "https://example.com/first".into(), + }, + }]; + apply_link_annots(&dest, &links).expect("L6: apply_link_annots"); + assert!( + page_has_annots_key(&dest, 1), + "L6: apply of the first link onto a file with no /Annots must create dest /Annots" + ); + let annots = inspect_page_annots(&dest, 1); + assert!( + annots.iter().any(|a| { + a.has_annots_key + && a.subtype == "Link" + && a.action_s.as_deref() == Some("URI") + && a.uri.as_deref() == Some("https://example.com/first") + }), + "L6: dest /Annots must contain the new URI Link; got {:?}", + annots + .iter() + .map(|a| (&a.subtype, &a.uri)) + .collect::>() + ); + } + + // --- L7 delete one supported, keep non-Link ---------------------------- + + #[test] + fn apply_deletes_one_uri_and_keeps_highlight() { + let scratch = Scratch::new("l7"); + let dest = scratch.file("dest.pdf"); + let mut pdf = PdfFix::new(1, 0, None); + pdf.add_link_uri(0, URI_RECT_PDF, "https://keep.example/"); + pdf.add_link_uri(0, GOTO_RECT_PDF, "https://drop.example/"); + pdf.add_highlight(0, HIGHLIGHT_RECT_PDF, "keep-me"); + pdf.save(&dest); + + let links = [SessionLink { + page_index: 0, + rect: URI_RECT_XYWH, + action: LinkAction::Uri { + uri: "https://keep.example/".into(), + }, + }]; + apply_link_annots(&dest, &links).expect("L7: apply_link_annots"); + + let annots = inspect_page_annots(&dest, 1); + let uris: Vec<&str> = annots + .iter() + .filter(|a| a.subtype == "Link" && a.action_s.as_deref() == Some("URI")) + .filter_map(|a| a.uri.as_deref()) + .collect(); + assert!( + uris.contains(&"https://keep.example/"), + "L7: dest must keep the remaining URI; got {uris:?}" + ); + assert!( + !uris.contains(&"https://drop.example/"), + "L7: dest must drop the deleted URI; still has {uris:?}" + ); + assert_eq!( + uris.len(), + 1, + "L7: dest must have exactly one URI Link; got {uris:?}" + ); + assert!( + annots + .iter() + .any(|a| a.subtype == "Highlight" && a.contents.as_deref() == Some("keep-me")), + "L7: dest must keep the non-Link Highlight; got {:?}", + annots + .iter() + .map(|a| (&a.subtype, &a.contents)) + .collect::>() + ); + } +} diff --git a/src-tauri/src/pdf_engine/edit_overlay.rs b/src-tauri/src/pdf_engine/edit_overlay.rs index e503008..e595d79 100644 --- a/src-tauri/src/pdf_engine/edit_overlay.rs +++ b/src-tauri/src/pdf_engine/edit_overlay.rs @@ -4,6 +4,10 @@ use crate::error::AppError; use crate::models::{JobHandle, PageGroup}; +use crate::pdf_engine::edit_links::{ + apply_link_annots, dest_has_supported_links, expected_dest_has_annots, unsafe_uri_error, + uri_is_allowed, LinkAction, SessionLink, +}; use crate::pdf_engine::validate_output::{ catalog_flags_from_doc, content_digest, validate_staged_pdf, ContentDigest, OutputSnapshot, PageSnapshot, @@ -196,6 +200,24 @@ pub enum EditObjectIn { #[serde(default, rename = "objectRotate")] object_rotate: f64, }, + Link { + #[serde(rename = "pageIndex")] + page_index: u32, + rect: PdfRectIn, + action: LinkActionIn, + }, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum LinkActionIn { + Uri { + uri: String, + }, + Goto { + #[serde(rename = "destPageIndex")] + dest_page_index: u32, + }, } impl EditObjectIn { @@ -212,11 +234,13 @@ impl EditObjectIn { | Self::Text { page_index, .. } | Self::Image { page_index, .. } | Self::Line { page_index, .. } - | Self::Ink { page_index, .. } => *page_index, + | Self::Ink { page_index, .. } + | Self::Link { page_index, .. } => *page_index, } } fn opacity(&self) -> f64 { let o = match self { + Self::Link { .. } => return 1.0, Self::Rect { opacity, .. } | Self::Ellipse { opacity, .. } | Self::Triangle { opacity, .. } @@ -235,6 +259,7 @@ impl EditObjectIn { fn object_rotate(&self) -> f64 { match self { + Self::Link { .. } => 0.0, Self::Rect { object_rotate, .. } | Self::Ellipse { object_rotate, .. } | Self::Triangle { object_rotate, .. } @@ -287,7 +312,8 @@ impl EditObjectIn { | Self::Bubble { rect, .. } | Self::Arrow { rect, .. } | Self::Text { rect, .. } - | Self::Image { rect, .. } => pdf_rect_to_overlay(rect, vis, page_rot), + | Self::Image { rect, .. } + | Self::Link { rect, .. } => pdf_rect_to_overlay(rect, vis, page_rot), } } } @@ -754,7 +780,7 @@ fn validate_doc(doc: &EditDocumentIn) -> Result<(), AppError> { return Err(AppError::new( "NO_EDITS", "Nothing to save", - "Add text, an image or a shape before saving.", + "Add text, an image, a shape or a link before saving.", )); } if doc.objects.len() > MAX_OBJECTS { @@ -780,6 +806,12 @@ fn validate_doc(doc: &EditDocumentIn) -> Result<(), AppError> { "Use a shorter stroke.", )); } + EditObjectIn::Link { + action: LinkActionIn::Uri { uri }, + .. + } if !uri_is_allowed(uri) => { + return Err(unsafe_uri_error(uri)); + } _ => {} } } @@ -869,14 +901,11 @@ fn remap_groups_to_visible_box( if !copies.contains_key(&g.path) { let doc = Document::load(&g.path) .map_err(|e| AppError::engine_failed(format!("Could not read the PDF: {e}")))?; - let needs = doc - .get_pages() - .values() - .any(|&id| { - let visible = crop::visible_box(&doc, id); - !boxes_near(crop::align_box(&doc, id), visible) - || !boxes_near(crop::media_box(&doc, id), visible) - }); + let needs = doc.get_pages().values().any(|&id| { + let visible = crop::visible_box(&doc, id); + !boxes_near(crop::align_box(&doc, id), visible) + || !boxes_near(crop::media_box(&doc, id), visible) + }); if needs { let dest = work.join(format!("src-{n_copies}.pdf")); n_copies += 1; @@ -1097,6 +1126,64 @@ pub(crate) fn build_edit_overlay_args( Ok(args) } +fn session_links_from_doc(doc: &EditDocumentIn) -> Vec { + doc.objects + .iter() + .filter_map(|o| match o { + EditObjectIn::Link { + page_index, + rect, + action, + } => Some(SessionLink { + page_index: *page_index, + rect: [rect.x, rect.y, rect.w, rect.h], + action: match action { + LinkActionIn::Uri { uri } => LinkAction::Uri { uri: uri.clone() }, + LinkActionIn::Goto { dest_page_index } => LinkAction::GoTo { + dest_page_index: *dest_page_index, + }, + }, + }), + _ => None, + }) + .collect() +} + +/// Link-only save: assemble to dest without `--empty` and without overlay paint. +fn assemble_to_tmp( + groups: &[PageGroup], + page_counts: &[u32], + tmp: &Path, + tmp_str: &str, + run: &mut F, +) -> Result<(), AppError> +where + F: FnMut(&[String]) -> Result<(), AppError>, +{ + if groups.is_empty() { + return Err(AppError::new("NO_PAGES", "No pages", "Add a PDF first.")); + } + let identity = groups.len() == 1 && super::spec_is_full_range(&groups[0].pages, page_counts[0]); + if identity { + std::fs::copy(&groups[0].path, tmp) + .map_err(|e| AppError::io("Could not prepare the output file.", e))?; + return Ok(()); + } + let mut args = vec![ + groups[0].path.clone(), + "--pages".into(), + ".".into(), + groups[0].pages.clone(), + ]; + for g in &groups[1..] { + args.push(g.path.clone()); + args.push(g.pages.clone()); + } + args.push("--".into()); + args.push(tmp_str.to_string()); + run(&args) +} + /// Build the overlay and run qpdf via `run`. Used by tests (system/`"qpdf"`). pub(crate) fn export_edit_pdf_with_runner( groups: &[PageGroup], @@ -1159,19 +1246,38 @@ where let font_bytes = std::fs::read(font_path) .map_err(|e| AppError::io("Could not read the editor font.", e))?; let font = FontInfo::parse(font_bytes)?; - write_overlay_pdf(&overlay_str, &geoms, document, &font, cancel)?; - let (mapped, restore_boxes) = remap_groups_to_visible_box(groups, work)?; - let args = build_edit_overlay_args(&mapped, &counts, &overlay_str, &tmp_str)?; - run(&args)?; - if restore_boxes { - restore_dest_page_boxes(&tmp, &geoms)?; - // lopdf xref can look damaged; qpdf rewrite before the atomic replace. - let cleaned = work.join("dest-boxes.pdf"); + let links = session_links_from_doc(document); + let has_paint = document + .objects + .iter() + .any(|o| !matches!(o, EditObjectIn::Link { .. })); + if has_paint { + write_overlay_pdf(&overlay_str, &geoms, document, &font, cancel)?; + let (mapped, restore_boxes) = remap_groups_to_visible_box(groups, work)?; + let args = build_edit_overlay_args(&mapped, &counts, &overlay_str, &tmp_str)?; + run(&args)?; + if restore_boxes { + restore_dest_page_boxes(&tmp, &geoms)?; + // lopdf xref can look damaged; qpdf rewrite before the atomic replace. + let cleaned = work.join("dest-boxes.pdf"); + let cleaned_str = cleaned.to_string_lossy().to_string(); + run(&[tmp_str.clone(), cleaned_str.clone()])?; + safe_output::replace_file(&cleaned, &tmp)?; + } + } else { + assemble_to_tmp(groups, &counts, &tmp, &tmp_str, &mut run)?; + } + let expected_annots = expected_dest_has_annots(&tmp, &links)?; + let rewrite_links = !links.is_empty() || dest_has_supported_links(&tmp)?; + if rewrite_links { + apply_link_annots(&tmp, &links)?; + let cleaned = work.join("dest-links.pdf"); let cleaned_str = cleaned.to_string_lossy().to_string(); run(&[tmp_str.clone(), cleaned_str.clone()])?; safe_output::replace_file(&cleaned, &tmp)?; } - let snapshot = output_snapshot_from_source(&geoms, Path::new(&groups[0].path))?; + let mut snapshot = output_snapshot_from_source(&geoms, Path::new(&groups[0].path))?; + snapshot.catalog.annots = expected_annots; let vr = validate_staged_pdf(&tmp, &snapshot, cancel, |args| { run_qpdf_check_argv(qpdf_check, args, handle) })?; @@ -1493,6 +1599,9 @@ fn write_overlay_pdf( if obj.page_index() as usize != pi { continue; } + if matches!(obj, EditObjectIn::Link { .. }) { + continue; + } let op100 = (obj.opacity() * 100.0).round() as i32; content.push_str(&format!("q\n/GS{op100} gs\n")); let rotated = push_object_rotate( @@ -1722,6 +1831,7 @@ fn write_overlay_pdf( content.push_str("S\n"); } } + EditObjectIn::Link { .. } => {} } if rotated { content.push_str("Q\n"); @@ -2367,6 +2477,7 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + // L5 keepGreen: hard-linked dest stays OVERWRITE. #[test] fn export_rejects_hard_linked_destination() { let Some(qpdf) = test_qpdf() else { diff --git a/src-tauri/src/pdf_engine/edit_overlay_integ.rs b/src-tauri/src/pdf_engine/edit_overlay_integ.rs index a655776..7fb89e3 100644 --- a/src-tauri/src/pdf_engine/edit_overlay_integ.rs +++ b/src-tauri/src/pdf_engine/edit_overlay_integ.rs @@ -584,6 +584,7 @@ fn write_tiny_png(path: &Path, w: u32, h: u32) { .unwrap(); } +// L4 keepGreen: overlay-only stamp save keeps catalog keys. #[test] fn integ_catalog_survives_and_original_stream_stays() { let Some(fx) = Harness::new("catalog") else { diff --git a/src-tauri/src/pdf_engine/mod.rs b/src-tauri/src/pdf_engine/mod.rs index 65c1bfe..be04f87 100644 --- a/src-tauri/src/pdf_engine/mod.rs +++ b/src-tauri/src/pdf_engine/mod.rs @@ -9,6 +9,7 @@ pub mod blank; pub mod compress; pub mod crop; pub mod edit_image; +pub mod edit_links; pub mod edit_overlay; #[cfg(test)] mod edit_overlay_integ; diff --git a/src/components/pdf/editor/EditorOverlay.tsx b/src/components/pdf/editor/EditorOverlay.tsx index 8f6750e..67dd97d 100644 --- a/src/components/pdf/editor/EditorOverlay.tsx +++ b/src/components/pdf/editor/EditorOverlay.tsx @@ -51,7 +51,8 @@ export type EditorTool = | "arrow" | "line" | "ink" - | "image"; + | "image" + | "link"; type Handle = ResizeHandle; @@ -81,6 +82,7 @@ type DragMode = | { kind: "marquee"; startCss: { x: number; y: number }; additive: boolean } | { kind: "create-shape"; shape: ClosedShapeKind; startCss: { x: number; y: number }; lock1to1: boolean } | { kind: "create-text"; startCss: { x: number; y: number } } + | { kind: "create-link"; startCss: { x: number; y: number } } | { kind: "create-line"; startCss: { x: number; y: number } } | { kind: "create-ink"; points: { x: number; y: number }[] }; @@ -117,6 +119,7 @@ export function EditorOverlay({ onUpdateRotate, onCreateShape, onCreateText, + onCreateLink, onCreateLine, onCreateInk, onRequestImage, @@ -139,6 +142,7 @@ export function EditorOverlay({ onUpdateRotate: (id: string, deg: number) => void; onCreateShape: (kind: ClosedShapeKind, rect: PdfRect, keepAspect?: boolean) => void; onCreateText: (rect: PdfRect) => void; + onCreateLink: (rect: PdfRect) => void; onCreateLine: (a: Point, b: Point) => void; onCreateInk: (points: Point[]) => void; onRequestImage: (atCss: { x: number; y: number }) => void; @@ -200,6 +204,9 @@ export function EditorOverlay({ } else if (tool === "text") { dragRef.current = { kind: "create-text", startCss: local }; setDraft({ kind: "box", start: local, cur: local }); + } else if (tool === "link") { + dragRef.current = { kind: "create-link", startCss: local }; + setDraft({ kind: "box", start: local, cur: local }); } else if (tool === "line") { dragRef.current = { kind: "create-line", startCss: local }; setDraft({ kind: "line", start: local, cur: local }); @@ -316,7 +323,7 @@ export function EditorOverlay({ } return; } - if (drag.kind === "create-text" || drag.kind === "marquee") { + if (drag.kind === "create-text" || drag.kind === "create-link" || drag.kind === "marquee") { setDraft({ kind: "box", start: drag.startCss, cur: local }); return; } @@ -399,7 +406,7 @@ export function EditorOverlay({ } return; } - if (drag.kind === "create-shape" || drag.kind === "create-text") { + if (drag.kind === "create-shape" || drag.kind === "create-text" || drag.kind === "create-link") { const lock1to1 = drag.kind === "create-shape" && (drag.lock1to1 || e.shiftKey); const box = lock1to1 ? constrainCssBox1to1(drag.startCss, local) : cssBoxFromPoints(drag.startCss, local); setDraft(null); @@ -415,6 +422,7 @@ export function EditorOverlay({ : box; const pdf = viewportRectFromCss(fallback, mapping); if (drag.kind === "create-text") onCreateText(pdf); + else if (drag.kind === "create-link") onCreateLink(pdf); else onCreateShape(drag.shape, pdf, drag.lock1to1 || undefined); return; } @@ -714,6 +722,20 @@ function ObjectShape({ onPointerDown={interactive ? (e) => onPointerDownObject(e, obj) : undefined} /> )} + {obj.kind === "link" && ( + onPointerDownObject(e, obj) : undefined}> + + + )} {selected && ( )} - {selected && selectedCount === 1 && !obj.locked && ( + {selected && selectedCount === 1 && !obj.locked && obj.kind !== "link" && ( <> ) => void; onPickFromPage?: (target: ColorPickTarget) => void; onReorder?: (dir: LayerDir) => void; @@ -41,6 +44,68 @@ export function ObjectInspector({ return (
+ {obj.kind === "link" && ( + <> +
+ + +
+ {obj.action.type === "uri" && ( + <> + + + onChange({ action: { type: "uri", uri: e.target.value } } as Partial) + } + /> + + )} + {obj.action.type === "goto" && ( + + onChange({ + action: { type: "goto", destPageIndex: n - 1 }, + } as Partial) + } + /> + )} + + )} + {obj.kind === "text" && ( <> @@ -185,38 +250,42 @@ export function ObjectInspector({
)} - onChange({ objectRotate: n })} - /> - - -
- onChange({ opacity: Number(e.target.value) } as Partial)} - /> + {obj.kind !== "link" && ( onChange({ opacity: n / 100 } as Partial)} + label="Rotation" + value={obj.objectRotate ?? 0} + min={-180} + max={180} + suffix="°" + onCommit={(n) => onChange({ objectRotate: n })} /> -
+ )} + + {obj.kind !== "link" && } + {obj.kind !== "link" && ( +
+ onChange({ opacity: Number(e.target.value) } as Partial)} + /> + onChange({ opacity: n / 100 } as Partial)} + /> +
+ )} - {onReorder && layerCount > 0 && ( + {onReorder && layerCount > 0 && obj.kind !== "link" && (
diff --git a/src/components/pdf/editor/ObjectList.tsx b/src/components/pdf/editor/ObjectList.tsx index a3d2d1c..19951f3 100644 --- a/src/components/pdf/editor/ObjectList.tsx +++ b/src/components/pdf/editor/ObjectList.tsx @@ -18,6 +18,13 @@ function labelFor(obj: EditObject, layer: string): string { if (obj.kind === "arrow") return `Arrow · ${page} · ${layer}`; if (obj.kind === "line") return `Line · ${page} · ${layer}`; if (obj.kind === "ink") return `Drawing · ${page} · ${layer}`; + if (obj.kind === "link") { + if (obj.action.type === "uri") { + const u = obj.action.uri.trim() || "Link"; + return `Link: ${u.length > 22 ? `${u.slice(0, 22)}…` : u} · ${page}`; + } + return `Link: page ${obj.action.destPageIndex + 1} · ${page}`; + } return `${isNearlySquare(obj.rect) ? "Square" : "Rectangle"} · ${page} · ${layer}`; } diff --git a/src/components/pdf/editor/PdfEditorCanvas.tsx b/src/components/pdf/editor/PdfEditorCanvas.tsx index 45ff6a7..d52940e 100644 --- a/src/components/pdf/editor/PdfEditorCanvas.tsx +++ b/src/components/pdf/editor/PdfEditorCanvas.tsx @@ -50,13 +50,14 @@ function newObjectId(): string { const MAIN_TOOLS: { id: EditorTool; label: string; - icon: "mousePointer" | "hand" | "type" | "image" | "pencil"; + icon: "mousePointer" | "hand" | "type" | "image" | "pencil" | "external"; }[] = [ { id: "select", label: "Select", icon: "mousePointer" }, { id: "hand", label: "Hand", icon: "hand" }, { id: "text", label: "Text", icon: "type" }, { id: "image", label: "Image", icon: "image" }, { id: "ink", label: "Draw", icon: "pencil" }, + { id: "link", label: "Link", icon: "external" }, ]; function isTextEntryTarget(t: EventTarget | null): boolean { @@ -443,7 +444,7 @@ export function PdfEditorCanvas({ - {MAIN_TOOLS.filter((t) => t.id !== "ink").map((t) => ( + {MAIN_TOOLS.filter((t) => t.id !== "ink" && t.id !== "link").map((t) => ( +