M7: Links: Link tool (drag to create; outlines, select, double-click for Link Properties, Delete), Link Properties (visible/invisible, highlight, thickness, style, colour; go to a page or open a web page), Create links from URLs in the text, Remove all links; link_list/link_add/link_edit/link_delete/links_from_urls/links_remove tools (75 tools)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 01:49:33 -04:00
parent dfc940b8f1
commit ed8a002465
19 changed files with 1186 additions and 27 deletions

View File

@ -15,6 +15,7 @@
use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
pub mod appearance;
pub mod links;
#[cfg(test)]
mod tests;

344
crates/annot/src/links.rs Normal file
View File

@ -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<ObjRef>,
pub rect: [f64; 4],
pub action: LinkAction,
pub style: LinkStyle,
}
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
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<usize> {
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<LinkItem> {
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<usize, AnnotError> {
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<usize, AnnotError> {
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<Object> = 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<usize>, String)> {
let text: String = chars.iter().collect();
let lower = text.to_lowercase();
let lc: Vec<char> = 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<usize, AnnotError> {
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)
}

View File

@ -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<char> = "See https://example.org/a?b=1, or www.rust-lang.org. Not a.b or http:/x.".chars().collect();
let found: Vec<String> = 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"]);
}

View File

@ -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)?,

View File

@ -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<String>) -> ToolError {
ToolError::InvalidArgs(m.into())
}
impl Automation {
fn view_rect(&self, a: &Args, page: usize) -> Result<Option<[f64; 4]>> {
let Some(v) = a.get("rect") else { return Ok(None) };
let r: Vec<f64> = 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<Option<LinkAction>> {
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<LinkStyle> {
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<Value> {
let doc = self.doc(a)?;
let items: Vec<Value> = 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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<String> = 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<Value> {
let before = self.doc(a)?.links.len();
let mut out = self.apply(a, Edit::RemoveLinks { pages: None })?;
out["removed"] = json!(before);
Ok(out)
}
}

View File

@ -410,6 +410,39 @@ pub fn tools() -> Vec<ToolDef> {
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"])),

View File

@ -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(_))));
}

View File

@ -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")),

View File

@ -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"),

View File

@ -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<MarkKind>,
/// Text and images added with Edit a PDF ▸ Add content (still editable).
pub added: Vec<printcraft_edit::Added>,
/// Link annotations, for Edit a PDF ▸ Link.
pub links: Vec<printcraft_annot::links::LinkItem>,
editor: Option<Editor>,
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<LinkAction>,
style: Option<LinkStyle>,
},
DeleteLink {
page: usize,
index: usize,
},
/// Remove all links (on `pages`, or everywhere).
RemoveLinks {
pages: Option<Vec<usize>>,
},
/// 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<Vec<(std::ops::Range<usize>, 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() };

View File

@ -961,3 +961,45 @@ fn split_by_size_and_bookmarks_and_page_filters() {
assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Landscape), Vec::<usize>::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<u8>> =
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");
}

View File

@ -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));

View File

@ -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");

View File

@ -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.")),

View File

@ -28,6 +28,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
let mut split_ready: Option<crate::SplitPlan> = None;
let mut extract_now = false;
let mut rotate_now = false;
let mut link_now: Option<Edit> = None;
let mut recover: Option<bool> = None;
let mut number_now: Option<Edit> = 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();
}

View File

@ -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<LinkDraft>,
/// 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()

View File

@ -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<usize>,
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<usize>) {
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) },
}
}

View File

@ -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<printcraft_engine::LinkItem> {
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());
}

View File

@ -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]]