diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 5f68ab96..ba1a5601 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -15,6 +15,7 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; pub mod appearance; +pub mod links; #[cfg(test)] mod tests; diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs new file mode 100644 index 00000000..70ab267f --- /dev/null +++ b/crates/annot/src/links.rs @@ -0,0 +1,344 @@ +//! Links (ISO 32000-2 §12.5.6.5): create, edit and remove link annotations with Acrobat's Link +//! Properties (appearance and a go-to-page or open-a-web-page action). + +use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; + +use crate::{AnnotError, annots, page_ref, page_refs, set_annots}; + +/// Where a link goes. +#[derive(Clone, Debug, PartialEq)] +pub enum LinkAction { + /// A page of this document (0-based), shown fitting the window (`/Fit`). + Page(usize), + Uri(String), + /// Something PrintCraft doesn't edit yet (named destination, JavaScript…): kept as is. + Other(String), +} + +/// Acrobat's Link Properties ▸ Appearance. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LinkStyle { + /// A visible rectangle (`/Border` width > 0) or invisible. + pub visible: bool, + pub color: [f64; 3], + /// 1 thin, 2 medium, 3 thick. + pub width: f64, + pub dashed: bool, + pub underline: bool, + /// Highlight style when clicked: None, Invert, Outline, Inset (`/H` N, I, O, P). + pub highlight: Highlight, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Highlight { + None, + Invert, + Outline, + Inset, +} + +impl Highlight { + pub const ALL: [Highlight; 4] = [Highlight::None, Highlight::Invert, Highlight::Outline, Highlight::Inset]; + + fn code(self) -> &'static str { + match self { + Highlight::None => "N", + Highlight::Invert => "I", + Highlight::Outline => "O", + Highlight::Inset => "P", + } + } + + pub fn label(self) -> &'static str { + match self { + Highlight::None => "None", + Highlight::Invert => "Invert", + Highlight::Outline => "Outline", + Highlight::Inset => "Inset", + } + } +} + +impl Default for LinkStyle { + fn default() -> Self { + // Acrobat's defaults for a new link: invisible rectangle, invert highlight. + LinkStyle { visible: false, color: [0.0, 0.0, 1.0], width: 1.0, dashed: false, underline: false, highlight: Highlight::Invert } + } +} + +/// A link on a page. +#[derive(Clone, Debug, PartialEq)] +pub struct LinkItem { + pub page: usize, + /// Index in the page's `/Annots`. + pub index: usize, + pub obj: Option, + pub rect: [f64; 4], + pub action: LinkAction, + pub style: LinkStyle, +} + +fn nums(doc: &Document, o: Option<&Object>) -> Vec { + o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() +} + +fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { + let dest_page = |dest: &Object| -> Option { + let a = doc.resolve(dest); + let first = a.as_array()?.first()?.clone(); + match first { + Object::Ref(r) => pages.iter().position(|p| *p == r), + Object::Int(i) => usize::try_from(i).ok(), + _ => None, + } + }; + if let Some(dest) = d.get(b"Dest") { + return dest_page(dest).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page); + } + let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) }; + match a.name(b"S") { + Some(b"URI") => LinkAction::Uri( + a.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())).unwrap_or_default(), + ), + Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page), + Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()), + None => LinkAction::Other("no action".into()), + } +} + +fn style_of(doc: &Document, d: &Dict) -> LinkStyle { + let bs = d.get(b"BS").and_then(|b| doc.resolve(b).as_dict().cloned()); + let width = match &bs { + Some(b) => b.get(b"W").and_then(Object::as_f64).unwrap_or(1.0), + None => nums(doc, d.get(b"Border")).get(2).copied().unwrap_or(1.0), + }; + let s = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default(); + let c = nums(doc, d.get(b"C")); + LinkStyle { + visible: width > 0.0, + color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0, 0.0, 1.0] }, + width: if width > 0.0 { width } else { 1.0 }, + dashed: s == b"D", + underline: s == b"U", + highlight: match d.name(b"H") { + Some(b"N") => Highlight::None, + Some(b"O") => Highlight::Outline, + Some(b"P") => Highlight::Inset, + _ => Highlight::Invert, + }, + } +} + +/// Every link in the document. +pub fn list(doc: &Document) -> Vec { + let Ok(pages) = page_refs(doc) else { return Vec::new() }; + let mut out = Vec::new(); + for (pi, p) in pages.iter().enumerate() { + for (k, a) in annots(doc, *p).iter().enumerate() { + let Some(d) = doc.resolve(a).as_dict().cloned() else { continue }; + if d.name(b"Subtype") != Some(b"Link") { + continue; + } + let r = nums(doc, d.get(b"Rect")); + if r.len() != 4 { + continue; + } + out.push(LinkItem { + page: pi, + index: k, + obj: a.as_ref(), + rect: [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])], + action: action_of(doc, &d, &pages), + style: style_of(doc, &d), + }); + } + } + out +} + +fn write_style(d: &mut Dict, s: &LinkStyle) { + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(if s.visible { s.width.clamp(0.5, 12.0) } else { 0.0 })); + bs.set( + b"S".to_vec(), + Object::name(if s.dashed { + "D" + } else if s.underline { + "U" + } else { + "S" + }), + ); + if s.dashed { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + d.set(b"BS".to_vec(), Object::Dict(bs)); + d.remove(b"Border"); + d.set(b"C".to_vec(), Object::Array(s.color.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect())); + d.set(b"H".to_vec(), Object::name(s.highlight.code())); +} + +fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> { + match a { + LinkAction::Page(p) => { + let target = page_ref(doc, *p)?; + d.remove(b"A"); + d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")])); + } + LinkAction::Uri(u) => { + let u = u.trim(); + if u.is_empty() { + return Err(AnnotError::Invalid("type the web address first".into())); + } + d.remove(b"Dest"); + let mut act = Dict::new(); + act.set(b"S".to_vec(), Object::name("URI")); + // URIs are 7-bit ASCII strings (§12.6.4.8). + act.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec())); + d.set(b"A".to_vec(), Object::Dict(act)); + } + LinkAction::Other(_) => {} + } + Ok(()) +} + +/// Add a link over `rect` (user space). Returns its index in the page's `/Annots`. +pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result { + let p = page_ref(doc, page)?; + let r = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < 2.0 || r[3] - r[1] < 2.0 { + return Err(AnnotError::Invalid("the link area is too small".into())); + } + if matches!(action, LinkAction::Other(_)) { + return Err(AnnotError::Invalid("choose a page or a web address".into())); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"P".to_vec(), Object::Ref(p)); + d.set(b"F".to_vec(), Object::Int(4)); + write_style(&mut d, style); + write_action(doc, &mut d, action)?; + let obj = doc.add(Object::Dict(d)); + let mut list = annots(doc, p); + list.push(Object::Ref(obj)); + let index = list.len() - 1; + set_annots(doc, p, list)?; + Ok(index) +} + +fn link_at(doc: &Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { + let p = page_ref(doc, page)?; + let a = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let r = a.as_ref().ok_or_else(|| AnnotError::Invalid("an inline link can't be edited".into()))?; + if doc.get(r).as_dict().and_then(|d| d.name(b"Subtype").map(<[u8]>::to_vec)).as_deref() != Some(b"Link") { + return Err(AnnotError::Invalid("that annotation isn't a link".into())); + } + Ok((p, r)) +} + +/// Link Properties: change a link's area, action and/or appearance. +pub fn set( + doc: &mut Document, + page: usize, + index: usize, + rect: Option<[f64; 4]>, + action: Option<&LinkAction>, + style: Option<&LinkStyle>, +) -> Result<(), AnnotError> { + let (_, r) = link_at(doc, page, index)?; + let mut d = doc.get(r).as_dict().cloned().unwrap_or_default(); + if let Some(rect) = rect { + let rr = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + if rr[2] - rr[0] < 2.0 || rr[3] - rr[1] < 2.0 { + return Err(AnnotError::Invalid("the link area is too small".into())); + } + d.set(b"Rect".to_vec(), Object::Array(rr.iter().map(|v| Object::Real(*v)).collect())); + } + if let Some(s) = style { + write_style(&mut d, s); + } + if let Some(a) = action { + write_action(doc, &mut d, a)?; + } + doc.set(r, Object::Dict(d)); + Ok(()) +} + +/// Delete one link. +pub fn delete(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { + let (p, _) = link_at(doc, page, index)?; + let mut list = annots(doc, p); + list.remove(index); + set_annots(doc, p, list) +} + +/// Remove every link on `pages` (all when `None`). Returns how many went. +pub fn remove_all(doc: &mut Document, pages: Option<&[usize]>) -> Result { + let refs = page_refs(doc)?; + let mut n = 0; + for (pi, p) in refs.iter().enumerate() { + if pages.is_some_and(|ps| !ps.contains(&pi)) { + continue; + } + let list = annots(doc, *p); + let kept: Vec = list + .iter() + .filter(|a| doc.resolve(a).as_dict().and_then(|d| d.name(b"Subtype").map(<[u8]>::to_vec)).as_deref() != Some(b"Link")) + .cloned() + .collect(); + if kept.len() != list.len() { + n += list.len() - kept.len(); + set_annots(doc, *p, kept)?; + } + } + Ok(n) +} + +/// Find web addresses in text: `http(s)://…`, `www.…` and bare e-mail addresses become +/// `mailto:`. Returns character ranges and the URI for each. +pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { + let text: String = chars.iter().collect(); + let lower = text.to_lowercase(); + let lc: Vec = lower.chars().collect(); + let mut out = Vec::new(); + let url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c); + let mut i = 0; + while i < lc.len() { + let at = |s: &str| lc[i..].iter().take(s.chars().count()).copied().eq(s.chars()); + let boundary = i == 0 || !lc[i - 1].is_alphanumeric(); + if boundary && (at("http://") || at("https://") || at("www.")) { + let mut j = i; + while j < chars.len() && url_char(chars[j]) { + j += 1; + } + // Trailing punctuation belongs to the sentence. + while j > i && ".,;:!?".contains(chars[j - 1]) { + j -= 1; + } + let s: String = chars[i..j].iter().collect(); + if s.len() > 6 && s.contains('.') { + let uri = if s.to_lowercase().starts_with("www.") { format!("http://{s}") } else { s }; + out.push((i..j, uri)); + i = j; + continue; + } + } + i += 1; + } + out +} + +/// Create links for found URLs: `(page, quads, uri)` with quads in user space (one link per +/// line rectangle). Returns how many links were added. +pub fn add_many(doc: &mut Document, links: &[(usize, Vec<[f64; 4]>, String)], style: &LinkStyle) -> Result { + let mut n = 0; + for (page, rects, uri) in links { + for r in rects { + add(doc, *page, *r, &LinkAction::Uri(uri.clone()), style)?; + n += 1; + } + } + Ok(n) +} diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index d26360f2..bc7ed51c 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -372,3 +372,39 @@ fn stamps_draw_their_label_and_by_line() { assert_eq!(StampKind::from_name(b"Approved"), Some(StampKind::Approved), "standard names"); assert!(StampKind::ALL.iter().all(|k| k.size().0 >= 80.0)); } + +#[test] +fn links_are_added_edited_listed_and_removed() { + use crate::links::{self, Highlight, LinkAction, LinkStyle}; + let mut doc = fixture(); + let before = links::list(&doc).len(); + let i = links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri("https://example.org".into()), &LinkStyle::default()).unwrap(); + let style = LinkStyle { visible: true, color: [1.0, 0.0, 0.0], width: 2.0, dashed: true, underline: false, highlight: Highlight::Outline }; + links::add(&mut doc, 1, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page(0), &style).unwrap(); + let doc = reopen(&doc); + let all = links::list(&doc); + assert_eq!(all.len(), before + 2); + let web = all.iter().find(|l| l.page == 0 && l.index == i).unwrap(); + assert_eq!( + (web.action.clone(), web.style.visible, web.style.highlight), + (LinkAction::Uri("https://example.org".into()), false, Highlight::Invert) + ); + let page = all.iter().find(|l| l.page == 1 && l.action == LinkAction::Page(0)).unwrap().clone(); + assert_eq!(page.style, style); + let mut doc = doc; + links::set(&mut doc, 1, page.index, Some([0.0, 0.0, 50.0, 20.0]), Some(&LinkAction::Uri("mailto:ada@example.org".into())), None).unwrap(); + let changed = links::list(&doc).into_iter().find(|l| l.page == 1 && l.index == page.index).unwrap(); + assert_eq!((changed.rect, changed.action), ([0.0, 0.0, 50.0, 20.0], LinkAction::Uri("mailto:ada@example.org".into()))); + links::delete(&mut doc, 1, page.index).unwrap(); + assert!(links::delete(&mut doc, 0, 999).is_err(), "no such annotation"); + let n = links::remove_all(&mut doc, None).unwrap(); + assert!(n >= 1 && links::list(&doc).is_empty()); + assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small"); +} + +#[test] +fn urls_are_found_in_text() { + let t: Vec = "See https://example.org/a?b=1, or www.rust-lang.org. Not a.b or http:/x.".chars().collect(); + let found: Vec = crate::links::find_urls(&t).into_iter().map(|(_, u)| u).collect(); + assert_eq!(found, ["https://example.org/a?b=1", "http://www.rust-lang.org"]); +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index b697ba67..7d370b3b 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -12,6 +12,7 @@ mod comments; mod content; mod forms; +mod links; #[cfg(feature = "mcp")] pub mod mcp; mod printing; @@ -290,6 +291,12 @@ impl Automation { "redact_clear" => self.redact_clear(&a)?, "doc_hidden_info" => self.doc_hidden_info(&a)?, "printers" => self.printers()?, + "link_list" => self.link_list(&a)?, + "link_add" => self.link_add(&a)?, + "link_edit" => self.link_edit(&a)?, + "link_delete" => self.link_delete(&a)?, + "links_from_urls" => self.links_from_urls(&a)?, + "links_remove" => self.links_remove(&a)?, "content_list" => self.content_list(&a)?, "page_add_text" => self.page_add_text(&a)?, "page_add_image" => self.page_add_image(&a)?, diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs new file mode 100644 index 00000000..17fdee94 --- /dev/null +++ b/crates/automation/src/links.rs @@ -0,0 +1,134 @@ +//! Link tools: list, add, edit and delete links; create links from URLs in the text; remove +//! all links. Rectangles are points from the top-left of the displayed page. + +use printcraft_engine::{Edit, LinkAction, LinkHighlight, LinkStyle}; +use serde_json::{Value, json}; + +use crate::comments::parse_color; +use crate::{Args, Automation, Result, ToolError, failed}; + +fn bad(m: impl Into) -> ToolError { + ToolError::InvalidArgs(m.into()) +} + +impl Automation { + fn view_rect(&self, a: &Args, page: usize) -> Result> { + let Some(v) = a.get("rect") else { return Ok(None) }; + let r: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = <[f64; 4]>::try_from(r).map_err(|_| bad("rect must be 4 numbers"))?; + let p = &self.doc(a)?.info.pages[page]; + let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); + Ok(Some([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])) + } + + fn link_action(&self, a: &Args) -> Result> { + match (a.opt_str("url")?, a.opt_int("to_page")?) { + (Some(_), Some(_)) => Err(bad("pass url or to_page, not both")), + (Some(u), None) => Ok(Some(LinkAction::Uri(u.to_string()))), + (None, Some(p)) => { + let n = self.doc(a)?.info.pages.len() as i64; + if p < 1 || p > n { + return Err(bad(format!("to_page must be 1–{n}"))); + } + Ok(Some(LinkAction::Page(p as usize - 1))) + } + (None, None) => Ok(None), + } + } + + fn link_style(&self, a: &Args, base: LinkStyle) -> Result { + let mut s = base; + if let Some(v) = a.opt_bool("visible")? { + s.visible = v; + } + if let Some(c) = a.opt_str("color")? { + s.color = parse_color(c)?; + } + if let Some(w) = a.opt_num("width")? { + s.width = w.clamp(1.0, 3.0); + } + if let Some(h) = a.opt_str("highlight")? { + s.highlight = match h { + "none" => LinkHighlight::None, + "invert" => LinkHighlight::Invert, + "outline" => LinkHighlight::Outline, + "inset" => LinkHighlight::Inset, + o => return Err(bad(format!("unknown highlight {o:?}"))), + }; + } + Ok(s) + } + + pub(crate) fn link_list(&self, a: &Args) -> Result { + let doc = self.doc(a)?; + let items: Vec = doc + .links + .iter() + .map(|l| { + let p = &doc.info.pages[l.page]; + let (v0, v1) = (p.user_to_view(l.rect[0] as f32, l.rect[1] as f32), p.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); + let rect = [v0[0].min(v1[0]), v0[1].min(v1[1]), v0[0].max(v1[0]), v0[1].max(v1[1])].map(|x| (x * 100.0).round() / 100.0); + let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible }); + match &l.action { + LinkAction::Page(p) => v["to_page"] = json!(p + 1), + LinkAction::Uri(u) => v["url"] = json!(u), + LinkAction::Other(o) => v["other"] = json!(o), + } + v + }) + .collect(); + Ok(json!({ "count": items.len(), "links": items })) + } + + pub(crate) fn link_add(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let rect = self.view_rect(a, page)?.ok_or_else(|| bad("link_add needs rect"))?; + let action = self.link_action(a)?.ok_or_else(|| bad("pass url or to_page"))?; + let style = self.link_style(a, LinkStyle::default())?; + self.apply(a, Edit::AddLink { page, rect, action, style }) + } + + fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle)> { + let page = self.page(a)?; + let index = a.int("index")?; + let doc = self.doc(a)?; + let l = doc + .links + .iter() + .find(|l| l.page == page && l.index as i64 + 1 == index) + .ok_or_else(|| failed(format!("page {} has no link {index} (see link_list)", page + 1)))?; + Ok((page, l.index, l.style)) + } + + pub(crate) fn link_edit(&mut self, a: &Args) -> Result { + let (page, index, style) = self.link_index(a)?; + let rect = self.view_rect(a, page)?; + let action = self.link_action(a)?; + let new_style = self.link_style(a, style)?; + self.apply(a, Edit::SetLink { page, index, rect, action, style: (new_style != style).then_some(new_style) }) + } + + pub(crate) fn link_delete(&mut self, a: &Args) -> Result { + let (page, index, _) = self.link_index(a)?; + self.apply(a, Edit::DeleteLink { page, index }) + } + + pub(crate) fn links_from_urls(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let found = self.session.find_urls(id); + if found.is_empty() { + return Err(failed("no unlinked web addresses were found in the text")); + } + let urls: Vec = found.iter().map(|f| f.2.clone()).collect(); + let mut out = self.apply(a, Edit::AddLinks { links: found, style: LinkStyle::default() })?; + out["created"] = json!(urls); + Ok(out) + } + + pub(crate) fn links_remove(&mut self, a: &Args) -> Result { + let before = self.doc(a)?.links.len(); + let mut out = self.apply(a, Edit::RemoveLinks { pages: None })?; + out["removed"] = json!(before); + Ok(out) + } +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 9facd1e8..033993c5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -410,6 +410,39 @@ pub fn tools() -> Vec { t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), + t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url or to_page).") + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), + t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.") + .cmd("edit.link") + .with(schema( + json!({ + "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + "url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 }, + "visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] }, + }), + &["doc", "page", "rect"], + )), + t("link_edit", "Edit a link", "Change a link's rect, destination (url or to_page) or appearance. Undoable.") + .with(schema( + json!({ + "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + "url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 }, + "visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] }, + }), + &["doc", "page", "index"], + )), + t("link_delete", "Delete a link", "Delete one link (page, index from link_list). Undoable.") + .destructive() + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), + t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links. Returns the URLs. Undoable.") + .cmd("edit.links_from_urls") + .with(schema(json!({ "doc": doc() }), &["doc"])), + t("links_remove", "Remove all links", "Remove every link in the document. Undoable.") + .destructive() + .cmd("edit.remove_links") + .with(schema(json!({ "doc": doc() }), &["doc"])), t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 996d101f..ea73077c 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -852,3 +852,26 @@ fn organizing_with_filters_bookmark_splits_and_extract_options() { assert_eq!(e["original"]["pages"], 1); assert!(matches!(a.call("page_extract", &json!({ "doc": doc, "pages": [1], "separate": true })), Err(ToolError::InvalidArgs(_)))); } + +#[test] +fn links_through_tools() { + let dir = workdir("links"); + std::fs::write(dir.join("notes.txt"), "Docs at https://example.org/docs and www.rust-lang.org").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "notes.txt" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "links_from_urls", json!({ "doc": doc })); + assert_eq!(r["created"], json!(["https://example.org/docs", "http://www.rust-lang.org"])); + ok(&mut a, "link_add", json!({ "doc": doc, "page": 1, "rect": [72, 300, 200, 320], "to_page": 1, "visible": true, "color": "red" })); + let list = ok(&mut a, "link_list", json!({ "doc": doc })); + assert_eq!(list["count"], 3); + let added = list["links"].as_array().unwrap().iter().find(|l| l["to_page"] == 1).unwrap().clone(); + assert_eq!(added["rect"], json!([72.0, 300.0, 200.0, 320.0])); + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": added["index"], "url": "https://printcraft.dev" })); + let list = ok(&mut a, "link_list", json!({ "doc": doc })); + assert!(list["links"].as_array().unwrap().iter().any(|l| l["url"] == "https://printcraft.dev")); + ok(&mut a, "link_delete", json!({ "doc": doc, "page": 1, "index": added["index"] })); + let r = ok(&mut a, "links_remove", json!({ "doc": doc })); + assert_eq!(r["removed"], 2); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": doc }))["count"], 0); + assert!(matches!(a.call("link_add", &json!({ "doc": doc, "page": 1, "rect": [0, 0, 50, 20] })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index a548bc67..46fa69b0 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -124,7 +124,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Image", "image-plus", "edit.image", Ready), item("Header and footer", "heading", "edit.header_footer", Ready), item("Watermark", "stamp", "edit.watermark", Ready), - item("Link", "link-2", "edit.link", Planned("M7")), + item("Link", "link-2", "edit.link", Ready), item("Bates numbering", "hash", "edit.bates", Ready), item("Background", "palette", "edit.background", Ready), item("Attach file", "paperclip", "edit.attach", Planned("M12")), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5477b42f..91a8ac9a 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -189,6 +189,9 @@ pub const COMMANDS: &[CommandSpec] = &[ c("form.tab_order.column", "Tab order: by columns", None, None, HasFields, "columns-3"), c("form.tab_order.structure", "Tab order: by document structure", None, None, HasFields, "list"), c("edit.text", "Add text", None, None, Modification, "type"), + c("edit.link", "Add or edit links", None, None, Modification, "link-2"), + c("edit.links_from_urls", "Create links from URLs", None, None, Modification, "link-2"), + c("edit.remove_links", "Remove all links", None, None, Modification, "trash-2"), c("edit.bates", "Add Bates numbering…", None, None, Modification, "hash"), c("edit.image", "Add image…", None, None, Modification, "image-plus"), c("redact.mark", "Redact text and images", None, None, Modification, "rectangle-horizontal"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6438f32..0c2d9d96 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -31,6 +31,7 @@ pub use printcraft_forms::{ /// Comment geometry helpers (text-box line breaking) for frontends. pub use printcraft_annot::appearance as annot_text; +pub use printcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; pub use printcraft_annot::{ FillMark, Markup, NewAnnotation, NoteIcon, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, }; @@ -130,6 +131,8 @@ pub struct Document { pub marks: Vec, /// Text and images added with Edit a PDF ▸ Add content (still editable). pub added: Vec, + /// Link annotations, for Edit a PDF ▸ Link. + pub links: Vec, editor: Option, config: RenderConfig, } @@ -611,6 +614,34 @@ pub enum Edit { }, /// Remove redaction marks without applying them. ClearRedactions, + /// Edit a PDF ▸ Link: a new link over `rect` (user space). + AddLink { + page: usize, + rect: [f64; 4], + action: LinkAction, + style: LinkStyle, + }, + /// Link Properties (`index` in the page's `/Annots`). + SetLink { + page: usize, + index: usize, + rect: Option<[f64; 4]>, + action: Option, + style: Option, + }, + DeleteLink { + page: usize, + index: usize, + }, + /// Remove all links (on `pages`, or everywhere). + RemoveLinks { + pages: Option>, + }, + /// Create links from URLs in the text: (page, line rects in user space, URI). + AddLinks { + links: Vec<(usize, Vec<[f64; 4]>, String)>, + style: LinkStyle, + }, /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). ImportData { name: String, @@ -690,6 +721,11 @@ impl Edit { Edit::ClearRedactions => "Remove redaction marks".into(), Edit::RemoveHidden { .. } => "Remove hidden information".into(), Edit::ImportData { name, .. } => format!("Import {name}"), + Edit::AddLink { .. } => "Add link".into(), + Edit::SetLink { .. } => "Change link properties".into(), + Edit::DeleteLink { .. } => "Delete link".into(), + Edit::RemoveLinks { .. } => "Remove all links".into(), + Edit::AddLinks { links, .. } => plural("Create link", links.len()), Edit::Sanitize => "Sanitize document".into(), Edit::Flatten { comments: true, fields: false } => "Flatten comments".into(), Edit::Flatten { comments: false, fields: true } => "Flatten form fields".into(), @@ -801,6 +837,11 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::ClearRedactions | Edit::RemoveHidden { .. } | Edit::ImportData { .. } + | Edit::AddLink { .. } + | Edit::SetLink { .. } + | Edit::DeleteLink { .. } + | Edit::RemoveLinks { .. } + | Edit::AddLinks { .. } | Edit::Sanitize | Edit::SetFieldProps { .. } | Edit::DeleteField { .. } @@ -986,6 +1027,23 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::ClearRedactions => { printcraft_redact::clear_marks(doc, None)?; } + Edit::AddLink { page, rect, action, style } => { + printcraft_annot::links::add(doc, *page, *rect, action, style)?; + } + Edit::SetLink { page, index, rect, action, style } => { + printcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())? + } + Edit::DeleteLink { page, index } => printcraft_annot::links::delete(doc, *page, *index)?, + Edit::RemoveLinks { pages } => { + if printcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { + return Err(EditError::Edit(printcraft_edit::EditError::Invalid("there are no links to remove".into()))); + } + } + Edit::AddLinks { links, style } => { + if printcraft_annot::links::add_many(doc, links, style)? == 0 { + return Err(EditError::Edit(printcraft_edit::EditError::Invalid("no web addresses were found".into()))); + } + } Edit::ImportData { bytes, .. } => { printcraft_xfdf::import(doc, bytes)?; } @@ -1214,6 +1272,7 @@ impl Session { let form = editor.as_ref().map(|e| printcraft_forms::fields(&e.cos)).unwrap_or_default(); let marks = editor.as_ref().map(|e| printcraft_edit::marks_present(&e.cos)).unwrap_or_default(); let added = editor.as_ref().map(|e| printcraft_edit::list_added(&e.cos)).unwrap_or_default(); + let links = editor.as_ref().map(|e| printcraft_annot::links::list(&e.cos)).unwrap_or_default(); self.next_id += 1; let id = DocId(self.next_id); self.docs.push(Document { @@ -1231,6 +1290,7 @@ impl Session { form: Arc::new(form), marks, added, + links, editor, config, }); @@ -1376,6 +1436,7 @@ impl Session { doc.form = Arc::new(printcraft_forms::fields(&editor.cos)); doc.marks = printcraft_edit::marks_present(&editor.cos); doc.added = printcraft_edit::list_added(&editor.cos); + doc.links = printcraft_annot::links::list(&editor.cos); doc.bytes = bytes.clone(); doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone()); Ok(()) @@ -1565,6 +1626,51 @@ impl Session { self.split(id, &printcraft_organize::SplitBy::Before(cuts)) } + /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle + /// per line in user space, URI). Text that already has a link over it is skipped. + pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; + let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut out = Vec::new(); + for page in 0..doc.info.pages.len() { + let res = + r.render(printcraft_render::RenderRequest { page, kind: printcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let Some(text) = res.text else { continue }; + let found: std::cell::RefCell, String)>> = Default::default(); + let hits = text.find_with(|chars| { + let urls = printcraft_annot::links::find_urls(chars); + let ranges = urls.iter().map(|u| u.0.clone()).collect(); + *found.borrow_mut() = urls; + ranges + }); + for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { + let rects: Vec<[f64; 4]> = text + .line_rects(glyphs) + .into_iter() + .map(|v| { + let q = doc.info.pages[page].view_rect_to_quad(v); + let xs = [q[0], q[2], q[4], q[6]]; + let ys = [q[1], q[3], q[5], q[7]]; + [ + xs.iter().copied().fold(f64::MAX, f64::min), + ys.iter().copied().fold(f64::MAX, f64::min), + xs.iter().copied().fold(f64::MIN, f64::max), + ys.iter().copied().fold(f64::MIN, f64::max), + ] + }) + .filter(|r| { + !doc.links.iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) + }) + .collect(); + if !rects.is_empty() { + out.push((page, rects, uri)); + } + } + } + out + } + /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { let Some(doc) = self.get(id) else { return Vec::new() }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 189080cc..bf97a9ea 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -961,3 +961,45 @@ fn split_by_size_and_bookmarks_and_page_filters() { assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Landscape), Vec::::new()); assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Portrait), [0, 2, 4]); } + +#[test] +fn links_from_urls_and_link_edits() { + // One page whose text contains a web address. + let mut objs: Vec> = + vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec()]; + objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); + objs.push(b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec()); + let body = b"BT /F1 12 Tf 20 100 Td (Visit www.example.org today) Tj ET"; + objs.push([format!("<< /Length {} >>\nstream\n", body.len()).into_bytes(), body.to_vec(), b"\nendstream".to_vec()].concat()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new(); + let id = s.open("u.pdf", None, Arc::new(out), None).unwrap(); + let found = s.find_urls(id); + assert_eq!(found.len(), 1); + assert_eq!(found[0].2, "http://www.example.org"); + s.apply(id, Edit::AddLinks { links: found, style: LinkStyle::default() }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.links.len(), 1); + assert_eq!(d.info.links.len(), 1, "the viewer follows it"); + assert!(s.find_urls(id).is_empty(), "already linked"); + // Link Properties and delete. + let l = s.get(id).unwrap().links[0].clone(); + s.apply(id, Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page(0)), style: None }).unwrap(); + assert_eq!(s.get(id).unwrap().links[0].action, LinkAction::Page(0)); + s.apply(id, Edit::DeleteLink { page: 0, index: l.index }).unwrap(); + assert!(s.get(id).unwrap().links.is_empty()); + assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 78c0fcf8..3f8618bc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -143,6 +143,8 @@ pub struct DocView { pub prepare: crate::prepare::PrepareView, /// Edit a PDF: the selected added item, the text being typed. pub content: crate::content_ui::ContentView, + /// Edit a PDF ▸ Link tool state. + pub links: crate::link_ui::LinkView, /// Redact tool: the box being drawn, and a mark to add (page, quads) for the app to style. pub redact_drag: crate::redact_ui::AreaDrag, pub pending_redaction: Option<(usize, Vec<[f64; 8]>)>, @@ -224,6 +226,7 @@ impl DocView { forms: Default::default(), prepare: Default::default(), content: Default::default(), + links: Default::default(), redact_drag: None, pending_redaction: None, crop_drag: None, @@ -888,7 +891,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { QuickTool::Comment(t) => t.markup().is_some(), QuickTool::Select => !preparing, QuickTool::Redact => true, - QuickTool::Hand | QuickTool::Crop | QuickTool::Fill(_) | QuickTool::Field(_) | QuickTool::AddText | QuickTool::Stamp(_) => false, + QuickTool::Hand | QuickTool::Crop | QuickTool::Fill(_) | QuickTool::Field(_) | QuickTool::AddText | QuickTool::Stamp(_) | QuickTool::Link => { + false + } }; let prefs = &app.comment_prefs; let allowed = doc.allows_annotation(); @@ -915,6 +920,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { view.prepare.selected = None; } let added = doc.added.clone(); + let doc_links = if tool == QuickTool::Link { doc.links.clone() } else { Vec::new() }; let mut content_done = false; if editing_content { crate::content_ui::after_refresh(view, &added); @@ -1102,7 +1108,8 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { content_done |= o.done; o.consumed || tool == QuickTool::AddText }; - let consumed = on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view); + let consumed = on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); // Text layer: find matches, selection, I-beam and drag-to-select. let to_screen = |g: [f32; 4]| xf.view_rect(g); @@ -1180,6 +1187,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if editing_content { crate::content_ui::paint_page(ui, painter, &xf, i, info, &added, view); } + if tool == QuickTool::Link { + crate::link_ui::paint(ui, painter, &xf, i, info, &doc_links, view); + } if preparing { crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view); } else { @@ -1336,6 +1346,11 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if editing_content { crate::content_ui::keys(ui.ctx(), view); } + if tool == QuickTool::Link { + crate::link_ui::keys(ui.ctx(), view); + } else { + view.links.selected = None; + } if stamp_placed { tool = QuickTool::Select; } @@ -1370,6 +1385,12 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((name, w)) = field_props { app.open_field_props(&name, w); } + if let Some((page, rect)) = app.views[index].links.open_new.take() { + app.open_link_props(page, Some(rect), None); + } + if let Some((page, i)) = app.views[index].links.open_existing.take() { + app.open_link_props(page, None, Some(i)); + } if let Some((page, quads)) = app.views[index].pending_redaction.take() { let author = app.comment_prefs.author.clone(); app.views[index].pending_edit = Some(app.redact_prefs.mark(page, quads, &author)); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 21e9a21b..408ff026 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -228,6 +228,14 @@ impl PrintCraftApp { self.left_open = true; } "edit.image" => self.add_image_dialog(), + "edit.link" => { + self.quick_tool = crate::QuickTool::Link; + self.notify("Drag a rectangle to create a link; double-click a link to edit it"); + } + "edit.links_from_urls" => self.links_from_urls(), + "edit.remove_links" => { + self.apply_edit(Edit::RemoveLinks { pages: None }); + } "redact.mark" => { self.quick_tool = crate::QuickTool::Redact; self.left = crate::LeftPanel::Tool("redact"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 668634de..69246540 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -540,6 +540,7 @@ impl Host for crate::PrintCraftApp { crate::QuickTool::Crop => "crop".to_string(), crate::QuickTool::Redact => "redact".to_string(), crate::QuickTool::AddText => "add-text".to_string(), + crate::QuickTool::Link => "link".to_string(), crate::QuickTool::Stamp(k) => format!("stamp-{}", k.name().trim_start_matches("PC").to_ascii_lowercase()), crate::QuickTool::Fill(f) => format!("fill-{}", f.command().trim_start_matches("sign.fill.")), crate::QuickTool::Field(f) => format!("field-{}", f.command().trim_start_matches("form.add.")), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7ab84ad3..b2fe8fbf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -28,6 +28,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let mut split_ready: Option = None; let mut extract_now = false; let mut rotate_now = false; + let mut link_now: Option = None; let mut recover: Option = None; let mut number_now: Option = None; let mut apply_number = false; @@ -342,6 +343,19 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { close = ok || cancel; return; } + Dialog::LinkProps => { + let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let Some(d) = app.link_draft.as_mut() else { + close = true; + return; + }; + let (ok, cancel) = crate::link_ui::body(ui, d, pages, &t); + if ok { + link_now = Some(crate::link_ui::edit_for(d)); + } + close = ok || cancel; + return; + } Dialog::Extract => { let count = app.active_ids().map_or(0, |(i, _)| app.views[i].target_pages().len()); ui.label(egui::RichText::new("Extract pages").font(theme::semibold(18.0))); @@ -749,6 +763,10 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { if extract_now { app.extract_selection(); } + if let Some(e) = link_now { + app.apply_edit(e); + app.link_draft = None; + } if rotate_now { app.rotate_with_draft(); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e8974cd5..1afdddce 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,8 @@ pub mod marks { pub use crate::marks_ui::{MarksDraft, PageRange, Subset}; } mod content_ui; +mod link_ui; +pub use link_ui::LinkDraft; mod dialogs; mod editing; mod files; @@ -97,6 +99,8 @@ pub enum QuickTool { AddText, /// Add a stamp: click to place this stamp. Stamp(printcraft_engine::StampKind), + /// Edit a PDF ▸ Link: draw link areas, select and edit links. + Link, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -132,6 +136,8 @@ pub enum Dialog { Print, /// File ▸ Revert confirmation. Revert, + /// Link Properties. + LinkProps, /// Organize ▸ Extract (options), Rotate Pages. Extract, RotatePages, @@ -251,6 +257,7 @@ pub struct PrintCraftApp { pub redact_search: RedactSearchDraft, pub hidden_draft: HiddenDraft, pub print_draft: PrintDraft, + pub link_draft: Option, /// The style new text gets (Edit a PDF ▸ Format text). pub text_style: printcraft_engine::AddedText, /// The Replace Pages dialog's state. @@ -334,6 +341,7 @@ impl PrintCraftApp { redact_search: RedactSearchDraft::default(), hidden_draft: HiddenDraft::default(), print_draft: PrintDraft::default(), + link_draft: None, text_style: content_ui::default_style(), replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, @@ -715,6 +723,7 @@ impl PrintCraftApp { "crop" => QuickTool::Crop, "redact" => QuickTool::Redact, "add-text" => QuickTool::AddText, + "link" => QuickTool::Link, stamp if stamp.starts_with("stamp-") => QuickTool::Stamp( printcraft_engine::StampKind::ALL .into_iter() diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs new file mode 100644 index 00000000..d91d2884 --- /dev/null +++ b/crates/ui-egui/src/link_ui.rs @@ -0,0 +1,289 @@ +//! Edit a PDF ▸ Link: drag a rectangle to create a link, then choose its appearance and action +//! (go to a page, open a web page) in Link Properties; with the Link tool, existing links show +//! their outlines, a click selects one, a double-click edits it and Delete removes it. + +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; +use printcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle}; +use printcraft_render::DocInfo; + +use crate::canvas::{DocView, PageXform}; +use crate::theme::{self, Tokens}; +use crate::{PrintCraftApp, widgets}; + +const LINK_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LinkView { + /// A link rectangle being drawn: (page, start). + pub drag: Option<(usize, Pos2)>, + pub selected: Option<(usize, usize)>, + /// Ask the app to open Link Properties for a new rect (page, user rect) or an existing + /// link (page, index). + pub open_new: Option<(usize, [f64; 4])>, + pub open_existing: Option<(usize, usize)>, + /// The last click on a link: (time, page, index), to see a second click as a double-click. + last_click: Option<(f64, usize, usize)>, +} + +/// Link Properties' working copy. +#[derive(Clone, Debug, PartialEq)] +pub struct LinkDraft { + pub page: usize, + pub rect: [f64; 4], + /// `None` for a new link. + pub index: Option, + pub web: bool, + pub url: String, + /// 1-based target page. + pub target: usize, + pub style: LinkStyle, +} + +fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { + xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]) +} + +fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { + let (vx, vy) = xf.screen_to_view(p); + let u = info.pages[page].view_to_user(vx, vy); + [u[0] as f64, u[1] as f64] +} + +/// Link tool input on one page. Returns `true` when the gesture belongs to the tool. +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + page: usize, + info: &DocInfo, + links: &[LinkItem], + view: &mut DocView, +) -> bool { + let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let lv = &mut view.links; + if let Some((dp, start)) = lv.drag + && dp == page + { + if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + lv.drag = None; + let end = pointer.unwrap_or(start); + let r = Rect::from_two_pos(start, end).intersect(xf.rect); + if r.width() >= 4.0 && r.height() >= 4.0 { + let (a, b) = (to_user(xf, info, page, r.min), to_user(xf, info, page, r.max)); + lv.open_new = Some((page, [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])])); + } + } + return true; + } + let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; + let hit = links.iter().filter(|l| l.page == page).find(|l| screen_rect(xf, info, page, l.rect).expand(2.0).contains(p)); + ui.ctx().set_cursor_icon(if hit.is_some() { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Crosshair }); + if resp.clicked() || resp.double_clicked() { + let now = ui.input(|i| i.time); + // A second click on the same link soon after the first is a double-click. + let double = resp.double_clicked() || hit.is_some_and(|l| lv.last_click.is_some_and(|(t, p, k)| p == page && k == l.index && now - t < 0.5)); + lv.last_click = hit.map(|l| (now, page, l.index)); + lv.selected = hit.map(|l| (page, l.index)); + if double && let Some(l) = hit { + lv.open_existing = Some((page, l.index)); + lv.last_click = None; + } + return true; + } + if resp.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + lv.drag = Some((page, origin)); + return true; + } + resp.is_pointer_button_down_on() +} + +/// Outlines of the page's links (selected one bold) and the rectangle being drawn. +pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, links: &[LinkItem], view: &DocView) { + for l in links.iter().filter(|l| l.page == page) { + let r = screen_rect(xf, info, page, l.rect); + let selected = view.links.selected == Some((page, l.index)); + painter.rect_stroke( + r, + CornerRadius::ZERO, + Stroke::new(if selected { 2.0 } else { 1.0 }, LINK_BLUE.gamma_multiply(if selected { 1.0 } else { 0.6 })), + egui::StrokeKind::Outside, + ); + } + if let (Some((dp, start)), Some(p)) = (view.links.drag, ui.input(|i| i.pointer.hover_pos())) + && dp == page + { + let r = Rect::from_two_pos(start, p); + painter.rect_filled(r, CornerRadius::ZERO, LINK_BLUE.gamma_multiply(0.08)); + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, LINK_BLUE), egui::StrokeKind::Inside); + } +} + +/// Delete removes the selected link; Escape clears the selection. +pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) { + if view.links.selected.is_none() || ctx.egui_wants_keyboard_input() { + return; + } + let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape))); + if del && let Some((page, index)) = view.links.selected.take() { + view.pending_edit = Some(Edit::DeleteLink { page, index }); + } else if esc { + view.links.selected = None; + } +} + +impl PrintCraftApp { + /// Open Link Properties for a new link area or an existing link. + pub(crate) fn open_link_props(&mut self, page: usize, rect: Option<[f64; 4]>, index: Option) { + let Some((_, id)) = self.active_ids() else { return }; + let existing = index.and_then(|i| self.session.get(id).and_then(|d| d.links.iter().find(|l| l.page == page && l.index == i).cloned())); + let draft = match existing { + Some(l) => LinkDraft { + page, + rect: l.rect, + index, + web: matches!(l.action, LinkAction::Uri(_)), + url: match &l.action { + LinkAction::Uri(u) => u.clone(), + _ => "https://".into(), + }, + target: match l.action { + LinkAction::Page(p) => p + 1, + _ => 1, + }, + style: l.style, + }, + None => LinkDraft { + page, + rect: rect.unwrap_or_default(), + index: None, + web: true, + url: "https://".into(), + target: 1, + style: LinkStyle::default(), + }, + }; + self.link_draft = Some(draft); + self.dialog = Some(crate::Dialog::LinkProps); + } + + /// Create links from URLs in the text of every page. + pub fn links_from_urls(&mut self) { + let Some((_, id)) = self.active_ids() else { return }; + let found = self.session.find_urls(id); + if found.is_empty() { + self.notify("No unlinked web addresses were found in the text"); + return; + } + let n = found.len(); + if self.apply_edit(Edit::AddLinks { links: found, style: LinkStyle::default() }) { + self.notify(format!("Created {n} link{}", if n == 1 { "" } else { "s" })); + } + } +} + +/// Link Properties. Returns (apply, cancel). +pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Tokens) -> (bool, bool) { + ui.set_width(460.0); + ui.label(egui::RichText::new(if d.index.is_some() { "Link Properties" } else { "Create Link" }).font(theme::semibold(18.0))); + ui.add_space(8.0); + widgets::section_title(ui, "Link Appearance"); + egui::Grid::new("link-appearance").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label("Link Type:"); + egui::ComboBox::from_id_salt("link-type").selected_text(if d.style.visible { "Visible Rectangle" } else { "Invisible Rectangle" }).show_ui( + ui, + |ui| { + ui.selectable_value(&mut d.style.visible, true, "Visible Rectangle"); + ui.selectable_value(&mut d.style.visible, false, "Invisible Rectangle"); + }, + ); + ui.end_row(); + ui.label("Highlight Style:"); + egui::ComboBox::from_id_salt("link-hl").selected_text(d.style.highlight.label()).show_ui(ui, |ui| { + for h in LinkHighlight::ALL { + ui.selectable_value(&mut d.style.highlight, h, h.label()); + } + }); + ui.end_row(); + if d.style.visible { + ui.label("Line Thickness:"); + egui::ComboBox::from_id_salt("link-w") + .selected_text(if d.style.width <= 1.0 { + "Thin" + } else if d.style.width <= 2.0 { + "Medium" + } else { + "Thick" + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.style.width, 1.0, "Thin"); + ui.selectable_value(&mut d.style.width, 2.0, "Medium"); + ui.selectable_value(&mut d.style.width, 3.0, "Thick"); + }); + ui.end_row(); + ui.label("Line Style:"); + let mut style = if d.style.dashed { + 1 + } else if d.style.underline { + 2 + } else { + 0 + }; + egui::ComboBox::from_id_salt("link-s").selected_text(["Solid", "Dashed", "Underline"][style]).show_ui(ui, |ui| { + for (k, l) in ["Solid", "Dashed", "Underline"].iter().enumerate() { + ui.selectable_value(&mut style, k, *l); + } + }); + d.style.dashed = style == 1; + d.style.underline = style == 2; + ui.end_row(); + ui.label("Color:"); + if let Some(c) = crate::comments::swatch_grid(ui, Some(d.style.color)) { + d.style.color = c; + } + ui.end_row(); + } + }); + ui.add_space(8.0); + widgets::section_title(ui, "Link Action"); + ui.radio_value(&mut d.web, true, "Open a web page"); + ui.add_enabled_ui(d.web, |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + ui.add(egui::TextEdit::singleline(&mut d.url).desired_width(360.0).hint_text("https://example.org")); + }); + }); + ui.radio_value(&mut d.web, false, "Go to a page view"); + ui.add_enabled_ui(!d.web, |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + ui.label("Page"); + ui.add(egui::DragValue::new(&mut d.target).range(1..=pages.max(1))); + ui.label(format!("of {pages}")); + }); + }); + let ok = !d.web || (d.url.trim().len() > 3 && d.url.trim() != "https://"); + if !ok { + ui.label(egui::RichText::new("Type the web address.").small().color(t.text_faint)); + } + ui.add_space(12.0); + let (mut a, mut c) = (false, false); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "OK", true)).inner.clicked() { + a = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + c = true; + } + }); + (a, c) +} + +/// The edit for a confirmed draft. +pub(crate) fn edit_for(d: &LinkDraft) -> Edit { + let action = if d.web { LinkAction::Uri(d.url.trim().to_string()) } else { LinkAction::Page(d.target.saturating_sub(1)) }; + match d.index { + None => Edit::AddLink { page: d.page, rect: d.rect, action, style: d.style }, + Some(index) => Edit::SetLink { page: d.page, index, rect: None, action: Some(action), style: Some(d.style) }, + } +} diff --git a/crates/ui-egui/tests/pdf_links.rs b/crates/ui-egui/tests/pdf_links.rs new file mode 100644 index 00000000..27f0f290 --- /dev/null +++ b/crates/ui-egui/tests/pdf_links.rs @@ -0,0 +1,84 @@ +//! Edit a PDF ▸ Link: drawing a link, Link Properties, editing and deleting links. + +use egui::Pos2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_engine::LinkAction; +use printcraft_ui_egui::{Dialog, PrintCraftApp}; + +fn harness() -> Harness<'static, PrintCraftApp> { + // 60 fps steps, so two clicks fall inside egui's double-click window. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(6); + h +} + +fn at(h: &Harness<'static, PrintCraftApp>, x: f32, y: f32) -> Pos2 { + let r = h.state().views[0].page_screen_rect(0).unwrap(); + let k = r.width() / 300.0; + r.min + egui::vec2(x * k, y * k) +} + +fn drag(h: &mut Harness<'static, PrintCraftApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + h.run_steps(3); +} + +fn links(h: &Harness<'static, PrintCraftApp>) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().links.clone() +} + +#[test] +fn drawing_editing_and_deleting_links() { + let mut h = harness(); + assert!(h.state_mut().execute("edit.link")); + let (a, b) = (at(&h, 40.0, 300.0), at(&h, 160.0, 320.0)); + drag(&mut h, a, b); + assert_eq!(h.state().dialog, Some(Dialog::LinkProps)); + h.get_by_label("Create Link"); + h.state_mut().link_draft.as_mut().unwrap().url = "https://example.org".into(); + h.get_by_label("OK").click(); + h.run_steps(3); + let l = links(&h); + assert_eq!(l.len(), 1); + assert_eq!(l[0].action, LinkAction::Uri("https://example.org".into())); + // Double-click opens Link Properties; switch to a page action. + let c = at(&h, 100.0, 310.0); + h.hover_at(c); + h.run_steps(1); + for _ in 0..2 { + h.drag_at(c); + h.step(); + h.drop_at(c); + h.step(); + } + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::LinkProps)); + h.get_by_label("Link Properties"); + { + let d = h.state_mut().link_draft.as_mut().unwrap(); + d.web = false; + d.target = 1; + } + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(links(&h)[0].action, LinkAction::Page(0)); + // Delete the selected link. + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert!(links(&h).is_empty()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index dc057fa6..3e94da52 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2115,11 +2115,11 @@ title = "Create web and page links" area = "C" tier = "P1" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.link"] +tools = ["link_add"] +evidence = ["crates/ui-egui/tests/pdf_links.rs::drawing_editing_and_deleting_links", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed"] +notes = "drag a rectangle; go to a page or open a web page" [[feature]] id = "edit.link-properties" @@ -2127,11 +2127,11 @@ title = "Link appearance and properties" area = "C" tier = "P1" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.link"] +tools = ["link_edit"] +evidence = ["crates/ui-egui/tests/pdf_links.rs::drawing_editing_and_deleting_links", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed"] +notes = "visible/invisible rectangle, highlight, thickness, solid/dashed/underline, colour; action" [[feature]] id = "edit.links-from-urls" @@ -2139,11 +2139,11 @@ title = "Create links from URLs in the text" area = "C" tier = "P1" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.links_from_urls"] +tools = ["links_from_urls"] +evidence = ["crates/engine/src/tests.rs::links_from_urls_and_link_edits", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::urls_are_found_in_text"] +notes = "http(s):// and www. addresses; already-linked text skipped" [[feature]] id = "edit.remove-all-links" @@ -2163,11 +2163,11 @@ title = "Go-to-page-view action (with named destinations)" area = "C" tier = "P1" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.link"] +tools = ["link_add"] +evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/ui-egui/tests/pdf_links.rs::drawing_editing_and_deleting_links"] +notes = "go to a page (Fit view); named destinations and zoom pending" [[feature]] id = "edit.action-uri" @@ -2175,10 +2175,10 @@ title = "Open-a-web-link action" area = "C" tier = "P1" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] +status = "shipped" +commands = ["edit.link"] +tools = ["link_add"] +evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/automation/tests/automation.rs::links_through_tools"] notes = "" [[feature]]