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:
parent
dfc940b8f1
commit
ed8a002465
|
|
@ -15,6 +15,7 @@
|
|||
use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
|
||||
|
||||
pub mod appearance;
|
||||
pub mod links;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"])),
|
||||
|
|
|
|||
|
|
@ -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(_))));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.")),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Reference in New Issue