summaryrefslogtreecommitdiff
path: root/crates/typst-library/src/model/link.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-library/src/model/link.rs')
-rw-r--r--crates/typst-library/src/model/link.rs212
1 files changed, 212 insertions, 0 deletions
diff --git a/crates/typst-library/src/model/link.rs b/crates/typst-library/src/model/link.rs
new file mode 100644
index 00000000..31c65a1d
--- /dev/null
+++ b/crates/typst-library/src/model/link.rs
@@ -0,0 +1,212 @@
+use std::ops::Deref;
+
+use ecow::{eco_format, EcoString};
+use smallvec::SmallVec;
+
+use crate::diag::{bail, At, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Content, Label, Packed, Repr, Show, Smart, StyleChain,
+};
+use crate::introspection::Location;
+use crate::layout::Position;
+use crate::text::{Hyphenate, TextElem};
+
+/// Links to a URL or a location in the document.
+///
+/// By default, links are not styled any different from normal text. However,
+/// you can easily apply a style of your choice with a show rule.
+///
+/// # Example
+/// ```example
+/// #show link: underline
+///
+/// https://example.com \
+///
+/// #link("https://example.com") \
+/// #link("https://example.com")[
+/// See example.com
+/// ]
+/// ```
+///
+/// # Syntax
+/// This function also has dedicated syntax: Text that starts with `http://` or
+/// `https://` is automatically turned into a link.
+#[elem(Show)]
+pub struct LinkElem {
+ /// The destination the link points to.
+ ///
+ /// - To link to web pages, `dest` should be a valid URL string. If the URL
+ /// is in the `mailto:` or `tel:` scheme and the `body` parameter is
+ /// omitted, the email address or phone number will be the link's body,
+ /// without the scheme.
+ ///
+ /// - To link to another part of the document, `dest` can take one of three
+ /// forms:
+ /// - A [label] attached to an element. If you also want automatic text
+ /// for the link based on the element, consider using a
+ /// [reference]($ref) instead.
+ ///
+ /// - A [`location`] (typically retrieved from [`here`], [`locate`] or
+ /// [`query`]).
+ ///
+ /// - A dictionary with a `page` key of type [integer]($int) and `x` and
+ /// `y` coordinates of type [length]. Pages are counted from one, and
+ /// the coordinates are relative to the page's top left corner.
+ ///
+ /// ```example
+ /// = Introduction <intro>
+ /// #link("mailto:hello@typst.app") \
+ /// #link(<intro>)[Go to intro] \
+ /// #link((page: 1, x: 0pt, y: 0pt))[
+ /// Go to top
+ /// ]
+ /// ```
+ #[required]
+ #[parse(
+ let dest = args.expect::<LinkTarget>("destination")?;
+ dest.clone()
+ )]
+ pub dest: LinkTarget,
+
+ /// The content that should become a link.
+ ///
+ /// If `dest` is an URL string, the parameter can be omitted. In this case,
+ /// the URL will be shown as the link.
+ #[required]
+ #[parse(match &dest {
+ LinkTarget::Dest(Destination::Url(url)) => match args.eat()? {
+ Some(body) => body,
+ None => body_from_url(url),
+ },
+ _ => args.expect("body")?,
+ })]
+ pub body: Content,
+
+ /// This style is set on the content contained in the `link` element.
+ #[internal]
+ #[ghost]
+ pub dests: SmallVec<[Destination; 1]>,
+}
+
+impl LinkElem {
+ /// Create a link element from a URL with its bare text.
+ pub fn from_url(url: Url) -> Self {
+ let body = body_from_url(&url);
+ Self::new(LinkTarget::Dest(Destination::Url(url)), body)
+ }
+}
+
+impl Show for Packed<LinkElem> {
+ #[typst_macros::time(name = "link", span = self.span())]
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ let body = self.body().clone();
+ let linked = match self.dest() {
+ LinkTarget::Dest(dest) => body.linked(dest.clone()),
+ LinkTarget::Label(label) => {
+ let elem = engine.introspector.query_label(*label).at(self.span())?;
+ let dest = Destination::Location(elem.location().unwrap());
+ body.clone().linked(dest)
+ }
+ };
+
+ Ok(linked.styled(TextElem::set_hyphenate(Hyphenate(Smart::Custom(false)))))
+ }
+}
+
+fn body_from_url(url: &Url) -> Content {
+ let mut text = url.as_str();
+ for prefix in ["mailto:", "tel:"] {
+ text = text.trim_start_matches(prefix);
+ }
+ let shorter = text.len() < url.len();
+ TextElem::packed(if shorter { text.into() } else { (**url).clone() })
+}
+
+/// A target where a link can go.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum LinkTarget {
+ Dest(Destination),
+ Label(Label),
+}
+
+cast! {
+ LinkTarget,
+ self => match self {
+ Self::Dest(v) => v.into_value(),
+ Self::Label(v) => v.into_value(),
+ },
+ v: Destination => Self::Dest(v),
+ v: Label => Self::Label(v),
+}
+
+impl From<Destination> for LinkTarget {
+ fn from(dest: Destination) -> Self {
+ Self::Dest(dest)
+ }
+}
+
+/// A link destination.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum Destination {
+ /// A link to a URL.
+ Url(Url),
+ /// A link to a point on a page.
+ Position(Position),
+ /// An unresolved link to a location in the document.
+ Location(Location),
+}
+
+impl Destination {}
+
+impl Repr for Destination {
+ fn repr(&self) -> EcoString {
+ eco_format!("{self:?}")
+ }
+}
+
+cast! {
+ Destination,
+ self => match self {
+ Self::Url(v) => v.into_value(),
+ Self::Position(v) => v.into_value(),
+ Self::Location(v) => v.into_value(),
+ },
+ v: Url => Self::Url(v),
+ v: Position => Self::Position(v),
+ v: Location => Self::Location(v),
+}
+
+/// A uniform resource locator with a maximum length.
+#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Url(EcoString);
+
+impl Url {
+ /// Create a URL from a string, checking the maximum length.
+ pub fn new(url: impl Into<EcoString>) -> StrResult<Self> {
+ let url = url.into();
+ if url.len() > 8000 {
+ bail!("URL is too long")
+ }
+ Ok(Self(url))
+ }
+
+ /// Extract the underlying [`EcoString`].
+ pub fn into_inner(self) -> EcoString {
+ self.0
+ }
+}
+
+impl Deref for Url {
+ type Target = EcoString;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+cast! {
+ Url,
+ self => self.0.into_value(),
+ v: EcoString => Self::new(v)?,
+}