diff --git a/src/eval/value.rs b/src/eval/value.rs index 011a7613f..b47d1e910 100644 --- a/src/eval/value.rs +++ b/src/eval/value.rs @@ -634,9 +634,13 @@ where Ok(sides) } - v => T::cast(v) - .map(Sides::splat) - .map_err(|msg| with_alternative(msg, "dictionary")), + v => T::cast(v).map(Sides::splat).map_err(|msg| { + with_alternative( + msg, + "dictionary with any of `left`, `top`, `right`, `bottom`,\ + `x`, `y`, or `rest` as keys", + ) + }), } } } diff --git a/src/export/pdf.rs b/src/export/pdf.rs index 7fda504eb..aeb5c47eb 100644 --- a/src/export/pdf.rs +++ b/src/export/pdf.rs @@ -16,10 +16,10 @@ use ttf_parser::{name_id, GlyphId, Tag}; use super::subset::subset; use crate::font::{find_name, FaceId, FontStore}; -use crate::frame::{Element, Frame, Group, Text}; +use crate::frame::{Destination, Element, Frame, Group, Text}; use crate::geom::{ - self, Color, Dir, Em, Geometry, Length, Numeric, Paint, Point, Shape, Size, Stroke, - Transform, + self, Color, Dir, Em, Geometry, Length, Numeric, Paint, Point, Ratio, Shape, Size, + Stroke, Transform, }; use crate::image::{Image, ImageId, ImageStore, RasterImage}; use crate::library::text::Lang; @@ -304,13 +304,18 @@ impl<'a> PdfExporter<'a> { // The page objects (non-root nodes in the page tree). let mut page_refs = vec![]; - let mut languages = HashMap::new(); - for page in self.pages { + let mut page_heights = vec![]; + for page in &self.pages { let page_id = self.alloc.bump(); - let content_id = self.alloc.bump(); page_refs.push(page_id); + page_heights.push(page.size.y.to_f32()); + } - let mut page_writer = self.writer.page(page_id); + let mut languages = HashMap::new(); + for (page, page_id) in self.pages.into_iter().zip(page_refs.iter()) { + let content_id = self.alloc.bump(); + + let mut page_writer = self.writer.page(*page_id); page_writer.parent(page_tree_ref); let w = page.size.x.to_f32(); @@ -319,14 +324,25 @@ impl<'a> PdfExporter<'a> { page_writer.contents(content_id); let mut annotations = page_writer.annotations(); - for (url, rect) in page.links { - annotations - .push() - .subtype(AnnotationType::Link) - .rect(rect) - .action() - .action_type(ActionType::Uri) - .uri(Str(url.as_bytes())); + for (dest, rect) in page.links { + let mut link = annotations.push(); + link.subtype(AnnotationType::Link).rect(rect); + match dest { + Destination::Url(uri) => { + link.action() + .action_type(ActionType::Uri) + .uri(Str(uri.as_str().as_bytes())); + } + Destination::Internal(page, point) => { + let page = page - 1; + let height = page_heights[page]; + link.action() + .action_type(ActionType::GoTo) + .destination_direct() + .page(page_refs[page]) + .xyz(point.x.to_f32(), height - point.y.to_f32(), None); + } + } } annotations.finish(); @@ -403,7 +419,7 @@ struct PageExporter<'a> { languages: HashMap, bottom: f32, content: Content, - links: Vec<(String, Rect)>, + links: Vec<(Destination, Rect)>, state: State, saves: Vec, } @@ -412,7 +428,7 @@ struct PageExporter<'a> { struct Page { size: Size, content: Content, - links: Vec<(String, Rect)>, + links: Vec<(Destination, Rect)>, languages: HashMap, } @@ -445,7 +461,14 @@ impl<'a> PageExporter<'a> { fn export(mut self, frame: &Frame) -> Page { // Make the coordinate system start at the top-left. self.bottom = frame.size.y.to_f32(); - self.content.transform([1.0, 0.0, 0.0, -1.0, 0.0, self.bottom]); + self.transform(Transform { + sx: Ratio::one(), + ky: Ratio::zero(), + kx: Ratio::zero(), + sy: Ratio::new(-1.0), + tx: Length::zero(), + ty: frame.size.y, + }); self.content.set_fill_color_space(ColorSpaceOperand::Named(SRGB)); self.content.set_stroke_color_space(ColorSpaceOperand::Named(SRGB)); self.write_frame(frame); @@ -466,7 +489,7 @@ impl<'a> PageExporter<'a> { Element::Text(ref text) => self.write_text(x, y, text), Element::Shape(ref shape) => self.write_shape(x, y, shape), Element::Image(id, size) => self.write_image(x, y, id, size), - Element::Link(ref url, size) => self.write_link(pos, url, size), + Element::Link(ref dest, size) => self.write_link(pos, dest, size), } } } @@ -627,7 +650,7 @@ impl<'a> PageExporter<'a> { self.content.restore_state(); } - fn write_link(&mut self, pos: Point, url: &str, size: Size) { + fn write_link(&mut self, pos: Point, dest: &Destination, size: Size) { let mut min_x = Length::inf(); let mut min_y = Length::inf(); let mut max_x = -Length::inf(); @@ -649,10 +672,11 @@ impl<'a> PageExporter<'a> { let x1 = min_x.to_f32(); let x2 = max_x.to_f32(); - let y1 = self.bottom - max_y.to_f32(); - let y2 = self.bottom - min_y.to_f32(); + let y1 = max_y.to_f32(); + let y2 = min_y.to_f32(); let rect = Rect::new(x1, y1, x2, y2); - self.links.push((url.to_string(), rect)); + + self.links.push((dest.clone(), rect)); } fn save_state(&mut self) { diff --git a/src/frame.rs b/src/frame.rs index 04551e6e2..6475f92a2 100644 --- a/src/frame.rs +++ b/src/frame.rs @@ -127,8 +127,8 @@ impl Frame { } /// Link the whole frame to a resource. - pub fn link(&mut self, url: EcoString) { - self.push(Point::zero(), Element::Link(url, self.size)); + pub fn link(&mut self, dest: Destination) { + self.push(Point::zero(), Element::Link(dest, self.size)); } } @@ -217,7 +217,7 @@ pub enum Element { /// An image and its size. Image(ImageId, Size), /// A link to an external resource and its trigger region. - Link(EcoString, Size), + Link(Destination, Size), } impl Debug for Element { @@ -227,7 +227,7 @@ impl Debug for Element { Self::Text(text) => write!(f, "{text:?}"), Self::Shape(shape) => write!(f, "{shape:?}"), Self::Image(image, _) => write!(f, "{image:?}"), - Self::Link(url, _) => write!(f, "Link({url:?})"), + Self::Link(target, _) => write!(f, "Link({target:?})"), } } } @@ -308,3 +308,23 @@ pub struct Glyph { /// The first character of the glyph's cluster. pub c: char, } + +/// A link destination. +#[derive(Clone, Eq, PartialEq, Hash)] +pub enum Destination { + /// A link to a point on a page. + Internal(usize, Point), + /// A link to a URL. + Url(EcoString), +} + +impl Debug for Destination { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Internal(page, point) => { + write!(f, "Internal(Page {}, {:?})", page, point) + } + Self::Url(url) => write!(f, "Url({})", url), + } + } +} diff --git a/src/library/text/link.rs b/src/library/text/link.rs index 728b594f5..2ce7a4695 100644 --- a/src/library/text/link.rs +++ b/src/library/text/link.rs @@ -5,8 +5,8 @@ use crate::util::EcoString; /// Link text and other elements to an URL. #[derive(Debug, Hash)] pub struct LinkNode { - /// The url the link points to. - pub url: EcoString, + /// The destination the link points to. + pub dest: Destination, /// How the link is represented. pub body: Option, } @@ -17,20 +17,36 @@ impl LinkNode { /// if `auto`. pub const FILL: Smart = Smart::Auto; /// Whether to underline link. - pub const UNDERLINE: bool = true; + pub const UNDERLINE: Smart = Smart::Auto; fn construct(_: &mut Machine, args: &mut Args) -> TypResult { - Ok(Content::show(Self { - url: args.expect::("url")?, - body: args.eat()?, + Ok(Content::show({ + let dest = args.expect::("destination")?; + let body = match dest { + Destination::Url(_) => args.eat()?, + Destination::Internal(_, _) => Some(args.expect("body")?), + }; + Self { dest, body } })) } } +castable! { + Destination, + Expected: "string or dictionary with `page`, `x`, and `y` keys", + Value::Str(string) => Self::Url(string), + Value::Dict(dict) => { + let page: i64 = dict.get(&EcoString::from_str("page"))?.clone().cast()?; + let x: RawLength = dict.get(&EcoString::from_str("x"))?.clone().cast()?; + let y: RawLength = dict.get(&EcoString::from_str("y"))?.clone().cast()?; + Self::Internal(page as usize, Point::new(x.length, y.length)) + }, +} + impl Show for LinkNode { fn unguard(&self, sel: Selector) -> ShowNode { Self { - url: self.url.clone(), + dest: self.dest.clone(), body: self.body.as_ref().map(|body| body.unguard(sel)), } .pack() @@ -38,7 +54,14 @@ impl Show for LinkNode { fn encode(&self, _: StyleChain) -> Dict { dict! { - "url" => Value::Str(self.url.clone()), + "url" => match &self.dest { + Destination::Url(url) => Value::Str(url.clone()), + Destination::Internal(page, point) => Value::Dict(dict!{ + "page" => Value::Int(*page as i64), + "x" => Value::Length(point.x.into()), + "y" => Value::Length(point.y.into()), + }), + }, "body" => match &self.body { Some(body) => Value::Content(body.clone()), None => Value::None, @@ -47,14 +70,16 @@ impl Show for LinkNode { } fn realize(&self, _: &mut Context, _: StyleChain) -> TypResult { - Ok(self.body.clone().unwrap_or_else(|| { - let url = &self.url; - let mut text = url.as_str(); - for prefix in ["mailto:", "tel:"] { - text = text.trim_start_matches(prefix); + Ok(self.body.clone().unwrap_or_else(|| match &self.dest { + Destination::Url(url) => { + let mut text = url.as_str(); + for prefix in ["mailto:", "tel:"] { + text = text.trim_start_matches(prefix); + } + let shorter = text.len() < url.len(); + Content::Text(if shorter { text.into() } else { url.clone() }) } - let shorter = text.len() < url.len(); - Content::Text(if shorter { text.into() } else { url.clone() }) + Destination::Internal(_, _) => panic!("missing body"), })) } @@ -65,13 +90,19 @@ impl Show for LinkNode { mut realized: Content, ) -> TypResult { let mut map = StyleMap::new(); - map.set(TextNode::LINK, Some(self.url.clone())); + map.set(TextNode::LINK, Some(self.dest.clone())); if let Smart::Custom(fill) = styles.get(Self::FILL) { map.set(TextNode::FILL, fill); } - if styles.get(Self::UNDERLINE) { + if match styles.get(Self::UNDERLINE) { + Smart::Auto => match &self.dest { + Destination::Url(_) => true, + Destination::Internal(_, _) => false, + }, + Smart::Custom(underline) => underline, + } { realized = realized.underlined(); } diff --git a/src/library/text/mod.rs b/src/library/text/mod.rs index bbe397ca9..a10894863 100644 --- a/src/library/text/mod.rs +++ b/src/library/text/mod.rs @@ -122,7 +122,7 @@ impl TextNode { pub const SMALLCAPS: bool = false; /// An URL the text should link to. #[property(skip, referenced)] - pub const LINK: Option = None; + pub const LINK: Option = None; /// Decorative lines. #[property(skip, fold)] pub const DECO: Decoration = vec![]; diff --git a/tests/ref/text/link.png b/tests/ref/text/link.png index 75ea19e80..5a9b4ca3b 100644 Binary files a/tests/ref/text/link.png and b/tests/ref/text/link.png differ diff --git a/tests/typ/text/link.typ b/tests/typ/text/link.typ index 64697bff5..01c0b88d1 100644 --- a/tests/typ/text/link.typ +++ b/tests/typ/text/link.typ @@ -33,3 +33,7 @@ My cool #move(dx: 0.7cm, dy: 0.7cm, rotate(10deg, scale(200%, mylink))) My cool rhino #move(dx: 10pt, image("/res/rhino.png", width: 1cm)) ]) + +--- +// Link to page one. +#link((page: 1, x: 10pt, y: 20pt))[Back to the start]