typst_library/model/
link.rs

1use std::ops::Deref;
2use std::str::FromStr;
3
4use comemo::Tracked;
5use ecow::{EcoString, eco_format};
6
7use crate::diag::{SourceResult, StrResult, bail};
8use crate::engine::Engine;
9use crate::foundations::{
10    Args, Construct, Content, Label, Packed, Repr, Selector, ShowSet, Smart, StyleChain,
11    Styles, cast, elem,
12};
13use crate::introspection::{
14    Counter, CounterKey, Introspector, Locatable, Location, Tagged,
15};
16use crate::layout::{PageElem, Position};
17use crate::model::{NumberingPattern, Refable};
18use crate::text::{LocalName, TextElem};
19
20/// Links to a URL or a location in the document.
21///
22/// By default, links do not look any different from normal text. However,
23/// you can easily apply a style of your choice with a show rule.
24///
25/// # Example
26/// ```example
27/// #show link: underline
28///
29/// https://example.com \
30///
31/// #link("https://example.com") \
32/// #link("https://example.com")[
33///   See example.com
34/// ]
35/// ```
36///
37/// # Syntax
38/// This function also has dedicated syntax: Text that starts with `http://` or
39/// `https://` is automatically turned into a link.
40///
41/// # Hyphenation
42/// If you enable hyphenation or justification, by default, it will not apply to
43/// links to prevent unwanted hyphenation in URLs. You can opt out of this
44/// default via `{show link: set text(hyphenate: true)}`.
45///
46/// # Accessibility
47/// The destination of a link should be clear from the link text itself, or at
48/// least from the text immediately surrounding it. In PDF export, Typst will
49/// automatically generate a tooltip description for links based on their
50/// destination. For links to URLs, the URL itself will be used as the tooltip.
51///
52/// # Links in HTML export
53/// In HTML export, a link to a [label] or [location] will be turned into a
54/// fragment link to a named anchor point. To support this, targets without an
55/// existing ID will automatically receive an ID in the DOM. How this works
56/// varies by which kind of HTML node(s) the link target turned into:
57///
58/// - If the link target turned into a single HTML element, that element will
59///   receive the ID. This is, for instance, typically the case when linking to
60///   a top-level heading (which turns into a single `<h2>` element).
61///
62/// - If the link target turned into a single text node, the node will be
63///   wrapped in a `<span>`, which will then receive the ID.
64///
65/// - If the link target turned into multiple nodes, the first node will receive
66///   the ID.
67///
68/// - If the link target turned into no nodes at all, an empty span will be
69///   generated to serve as a link target.
70///
71/// If you rely on a specific DOM structure, you should ensure that the link
72/// target turns into one or multiple elements, as the compiler makes no
73/// guarantees on the precise segmentation of text into text nodes.
74///
75/// If present, the automatic ID generation tries to reuse the link target's
76/// label to create a human-readable ID. A label can be reused if:
77///
78/// - All characters are alphabetic or numeric according to Unicode, or a
79///   hyphen, or an underscore.
80///
81/// - The label does not start with a digit or hyphen.
82///
83/// These rules ensure that the label is both a valid CSS identifier and a valid
84/// URL fragment for linking.
85///
86/// As IDs must be unique in the DOM, duplicate labels might need disambiguation
87/// when reusing them as IDs. The precise rules for this are as follows:
88///
89/// - If a label can be reused and is unique in the document, it will directly
90///   be used as the ID.
91///
92/// - If it's reusable, but not unique, a suffix consisting of a hyphen and an
93///   integer will be added. For instance, if the label `<mylabel>` exists
94///   twice, it would turn into `mylabel-1` and `mylabel-2`.
95///
96/// - Otherwise, a unique ID of the form `loc-` followed by an integer will be
97///   generated.
98#[elem(Locatable)]
99pub struct LinkElem {
100    /// The destination the link points to.
101    ///
102    /// - To link to web pages, `dest` should be a valid URL string. If the URL
103    ///   is in the `mailto:` or `tel:` scheme and the `body` parameter is
104    ///   omitted, the email address or phone number will be the link's body,
105    ///   without the scheme.
106    ///
107    /// - To link to another part of the document, `dest` can take one of three
108    ///   forms:
109    ///   - A [label] attached to an element. If you also want automatic text
110    ///     for the link based on the element, consider using a
111    ///     [reference]($ref) instead.
112    ///
113    ///   - A [`location`] (typically retrieved from [`here`], [`locate`] or
114    ///     [`query`]).
115    ///
116    ///   - A dictionary with a `page` key of type [integer]($int) and `x` and
117    ///     `y` coordinates of type [length]. Pages are counted from one, and
118    ///     the coordinates are relative to the page's top left corner.
119    ///
120    /// ```example
121    /// = Introduction <intro>
122    /// #link("mailto:hello@typst.app") \
123    /// #link(<intro>)[Go to intro] \
124    /// #link((page: 1, x: 0pt, y: 0pt))[
125    ///   Go to top
126    /// ]
127    /// ```
128    #[required]
129    #[parse(
130        let dest = args.expect::<LinkTarget>("destination")?;
131        dest.clone()
132    )]
133    pub dest: LinkTarget,
134
135    /// The content that should become a link.
136    ///
137    /// If `dest` is an URL string, the parameter can be omitted. In this case,
138    /// the URL will be shown as the link.
139    #[required]
140    #[parse(match &dest {
141        LinkTarget::Dest(Destination::Url(url)) => match args.eat()? {
142            Some(body) => body,
143            None => body_from_url(url),
144        },
145        _ => args.expect("body")?,
146    })]
147    pub body: Content,
148
149    /// A destination style that should be applied to elements.
150    #[internal]
151    #[ghost]
152    pub current: Option<Destination>,
153}
154
155impl LinkElem {
156    /// Create a link element from a URL with its bare text.
157    pub fn from_url(url: Url) -> Self {
158        let body = body_from_url(&url);
159        Self::new(LinkTarget::Dest(Destination::Url(url)), body)
160    }
161}
162
163impl ShowSet for Packed<LinkElem> {
164    fn show_set(&self, _: StyleChain) -> Styles {
165        let mut out = Styles::new();
166        out.set(TextElem::hyphenate, Smart::Custom(false));
167        out
168    }
169}
170
171pub(crate) fn body_from_url(url: &Url) -> Content {
172    let stripped = url.strip_contact_scheme().map(|(_, s)| s.into());
173    TextElem::packed(stripped.unwrap_or_else(|| url.clone().into_inner()))
174}
175
176/// A target where a link can go.
177#[derive(Debug, Clone, PartialEq, Hash)]
178pub enum LinkTarget {
179    Dest(Destination),
180    Label(Label),
181}
182
183impl LinkTarget {
184    /// Resolves the destination.
185    pub fn resolve(&self, introspector: Tracked<Introspector>) -> StrResult<Destination> {
186        Ok(match self {
187            LinkTarget::Dest(dest) => dest.clone(),
188            LinkTarget::Label(label) => {
189                let elem = introspector.query_label(*label)?;
190                Destination::Location(elem.location().unwrap())
191            }
192        })
193    }
194}
195
196cast! {
197    LinkTarget,
198    self => match self {
199        Self::Dest(v) => v.into_value(),
200        Self::Label(v) => v.into_value(),
201    },
202    v: Destination => Self::Dest(v),
203    v: Label => Self::Label(v),
204}
205
206impl From<Destination> for LinkTarget {
207    fn from(dest: Destination) -> Self {
208        Self::Dest(dest)
209    }
210}
211
212/// A link destination.
213#[derive(Debug, Clone, Eq, PartialEq, Hash)]
214pub enum Destination {
215    /// A link to a URL.
216    Url(Url),
217    /// A link to a point on a page.
218    Position(Position),
219    /// An unresolved link to a location in the document.
220    Location(Location),
221}
222
223impl Destination {
224    pub fn alt_text(
225        &self,
226        engine: &mut Engine,
227        styles: StyleChain,
228    ) -> SourceResult<EcoString> {
229        match self {
230            Destination::Url(url) => {
231                let contact = url.strip_contact_scheme().map(|(scheme, stripped)| {
232                    eco_format!("{} {stripped}", scheme.local_name_in(styles))
233                });
234                Ok(contact.unwrap_or_else(|| url.clone().into_inner()))
235            }
236            Destination::Position(pos) => {
237                let page_nr = eco_format!("{}", pos.page.get());
238                let page_str = PageElem::local_name_in(styles);
239                Ok(eco_format!("{page_str} {page_nr}"))
240            }
241            &Destination::Location(loc) => {
242                let fallback = |engine: &mut Engine| {
243                    // Fall back to a generating a page reference.
244                    let numbering = loc.page_numbering(engine).unwrap_or_else(|| {
245                        NumberingPattern::from_str("1").unwrap().into()
246                    });
247                    let page_nr = Counter::new(CounterKey::Page)
248                        .display_at_loc(engine, loc, styles, &numbering)?
249                        .plain_text();
250                    let page_str = PageElem::local_name_in(styles);
251                    Ok(eco_format!("{page_str} {page_nr}"))
252                };
253
254                // Try to generate more meaningful alt text if the location is a
255                // refable element.
256                let loc_selector = Selector::Location(loc);
257                if let Some(elem) = engine.introspector.query_first(&loc_selector)
258                    && let Some(refable) = elem.with::<dyn Refable>()
259                {
260                    let counter = refable.counter();
261                    let supplement = refable.supplement().plain_text();
262
263                    if let Some(numbering) = refable.numbering() {
264                        let numbers = counter.display_at_loc(
265                            engine,
266                            loc,
267                            styles,
268                            &numbering.clone().trimmed(),
269                        )?;
270                        return Ok(eco_format!("{supplement} {}", numbers.plain_text()));
271                    } else {
272                        let page_ref = fallback(engine)?;
273                        return Ok(eco_format!("{supplement}, {page_ref}"));
274                    }
275                }
276
277                fallback(engine)
278            }
279        }
280    }
281}
282
283impl Repr for Destination {
284    fn repr(&self) -> EcoString {
285        eco_format!("{self:?}")
286    }
287}
288
289cast! {
290    Destination,
291    self => match self {
292        Self::Url(v) => v.into_value(),
293        Self::Position(v) => v.into_value(),
294        Self::Location(v) => v.into_value(),
295    },
296    v: Url => Self::Url(v),
297    v: Position => Self::Position(v),
298    v: Location => Self::Location(v),
299}
300
301/// A uniform resource locator with a maximum length.
302#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
303pub struct Url(EcoString);
304
305impl Url {
306    /// Create a URL from a string, checking the maximum length.
307    pub fn new(url: impl Into<EcoString>) -> StrResult<Self> {
308        let url = url.into();
309        if url.len() > 8000 {
310            bail!("URL is too long")
311        } else if url.is_empty() {
312            bail!("URL must not be empty")
313        }
314        Ok(Self(url))
315    }
316
317    /// Extract the underlying [`EcoString`].
318    pub fn into_inner(self) -> EcoString {
319        self.0
320    }
321
322    pub fn strip_contact_scheme(&self) -> Option<(UrlContactScheme, &str)> {
323        [UrlContactScheme::Mailto, UrlContactScheme::Tel]
324            .into_iter()
325            .find_map(|scheme| {
326                let stripped = self.strip_prefix(scheme.as_str())?;
327                Some((scheme, stripped))
328            })
329    }
330}
331
332impl Deref for Url {
333    type Target = EcoString;
334
335    fn deref(&self) -> &Self::Target {
336        &self.0
337    }
338}
339
340cast! {
341    Url,
342    self => self.0.into_value(),
343    v: EcoString => Self::new(v)?,
344}
345
346/// This is a temporary hack to dispatch to
347/// - a raw link that does not go through `LinkElem` in paged
348/// - `LinkElem` in HTML (there is no equivalent to a direct link)
349///
350/// We'll want to dispatch all kinds of links to `LinkElem` in the future, but
351/// this is a visually breaking change in paged export as e.g.
352/// `show link: underline` will suddenly also affect references, bibliography
353/// back references, footnote references, etc. We'll want to do this change
354/// carefully and in a way where we provide a good way to keep styling only URL
355/// links, which is a bit too complicated to achieve right now for such a basic
356/// requirement.
357#[elem(Construct)]
358pub struct DirectLinkElem {
359    #[required]
360    #[internal]
361    pub loc: Location,
362    #[required]
363    #[internal]
364    pub body: Content,
365    #[required]
366    #[internal]
367    pub alt: Option<EcoString>,
368}
369
370impl Construct for DirectLinkElem {
371    fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
372        bail!(args.span, "cannot be constructed manually");
373    }
374}
375
376/// An element that wraps all content that is [`Content::linked`] to a
377/// destination.
378#[elem(Tagged, Construct)]
379pub struct LinkMarker {
380    /// The content.
381    #[internal]
382    #[required]
383    pub body: Content,
384    #[internal]
385    #[required]
386    pub alt: Option<EcoString>,
387}
388
389impl Construct for LinkMarker {
390    fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
391        bail!(args.span, "cannot be constructed manually");
392    }
393}
394
395#[derive(Copy, Clone)]
396pub enum UrlContactScheme {
397    /// The `mailto:` prefix.
398    Mailto,
399    /// The `tel:` prefix.
400    Tel,
401}
402
403impl UrlContactScheme {
404    pub fn as_str(self) -> &'static str {
405        match self {
406            Self::Mailto => "mailto:",
407            Self::Tel => "tel:",
408        }
409    }
410
411    pub fn local_name_in(self, styles: StyleChain) -> &'static str {
412        match self {
413            UrlContactScheme::Mailto => Email::local_name_in(styles),
414            UrlContactScheme::Tel => Telephone::local_name_in(styles),
415        }
416    }
417}
418
419#[derive(Copy, Clone)]
420pub struct Email;
421impl LocalName for Email {
422    const KEY: &'static str = "email";
423}
424
425#[derive(Copy, Clone)]
426pub struct Telephone;
427impl LocalName for Telephone {
428    const KEY: &'static str = "telephone";
429}