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}