diff --git a/crates/typst-html/src/document.rs b/crates/typst-html/src/document.rs
index 9f0124e57..32c035dcd 100644
--- a/crates/typst-html/src/document.rs
+++ b/crates/typst-html/src/document.rs
@@ -83,8 +83,10 @@ fn html_document_impl(
&mut locator,
children.iter().copied(),
)?;
- let introspector = introspect_html(&output);
- let root = root_element(output, &info)?;
+
+ let mut introspector = introspect_html(&output);
+ let mut root = root_element(output, &info)?;
+ crate::link::identify_link_targets(&mut root, &mut introspector);
Ok(HtmlDocument { info, root, introspector })
}
diff --git a/crates/typst-html/src/dom.rs b/crates/typst-html/src/dom.rs
index 10691545d..09a2d3759 100644
--- a/crates/typst-html/src/dom.rs
+++ b/crates/typst-html/src/dom.rs
@@ -289,11 +289,17 @@ pub struct HtmlFrame {
/// frame with em units to make text in and outside of the frame sized
/// consistently.
pub text_size: Abs,
+ /// An ID to assign to the SVG.
+ pub id: Option,
}
impl HtmlFrame {
/// Wraps a laid-out frame.
pub fn new(inner: Frame, styles: StyleChain) -> Self {
- Self { inner, text_size: styles.resolve(TextElem::size) }
+ Self {
+ inner,
+ text_size: styles.resolve(TextElem::size),
+ id: None,
+ }
}
}
diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs
index 4447186b8..16363dc86 100644
--- a/crates/typst-html/src/encode.rs
+++ b/crates/typst-html/src/encode.rs
@@ -306,6 +306,7 @@ fn write_escape(w: &mut Writer, c: char) -> StrResult<()> {
/// Encode a laid out frame into the writer.
fn write_frame(w: &mut Writer, frame: &HtmlFrame) {
- let svg = typst_svg::svg_html_frame(&frame.inner, frame.text_size);
+ let svg =
+ typst_svg::svg_html_frame(&frame.inner, frame.text_size, frame.id.as_deref());
w.buf.push_str(&svg);
}
diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs
index e3d33bf5a..42b3c5d6f 100644
--- a/crates/typst-html/src/lib.rs
+++ b/crates/typst-html/src/lib.rs
@@ -8,6 +8,7 @@ mod document;
mod dom;
mod encode;
mod fragment;
+mod link;
mod rules;
mod tag;
mod typed;
diff --git a/crates/typst-html/src/link.rs b/crates/typst-html/src/link.rs
new file mode 100644
index 000000000..87adc5764
--- /dev/null
+++ b/crates/typst-html/src/link.rs
@@ -0,0 +1,232 @@
+use std::collections::{HashMap, HashSet, VecDeque};
+
+use comemo::Track;
+use ecow::{eco_format, EcoString};
+use typst_library::foundations::{Label, NativeElement};
+use typst_library::introspection::{Introspector, Location, Tag};
+use typst_library::model::{Destination, LinkElem};
+use typst_utils::PicoStr;
+
+use crate::{attr, tag, HtmlElement, HtmlNode};
+
+/// Attaches IDs to nodes produced by link targets to make them linkable.
+///
+/// May produce ``s for link targets that turned into text nodes or no
+/// nodes at all. See the [`LinkElem`] documentation for more details.
+pub fn identify_link_targets(root: &mut HtmlElement, introspector: &mut Introspector) {
+ // Query for all links with an intra-doc (i.e. `Location`) destination to
+ // know what needs IDs.
+ let targets = introspector
+ .query(&LinkElem::ELEM.select())
+ .iter()
+ .map(|elem| elem.to_packed::().unwrap())
+ .filter_map(|elem| match elem.dest.resolve(introspector.track()) {
+ Ok(Destination::Location(loc)) => Some(loc),
+ _ => None,
+ })
+ .collect::>();
+
+ if targets.is_empty() {
+ // Nothing to do.
+ return;
+ }
+
+ // Assign IDs to all link targets.
+ let mut work = Work::new();
+ traverse(
+ &mut work,
+ &targets,
+ &mut Identificator::new(introspector),
+ &mut root.children,
+ );
+
+ // Add the mapping from locations to IDs to the introspector to make it
+ // available to links in the next iteration.
+ introspector.set_html_ids(work.ids);
+}
+
+/// Traverses a list of nodes.
+fn traverse(
+ work: &mut Work,
+ targets: &HashSet,
+ identificator: &mut Identificator<'_>,
+ nodes: &mut Vec,
+) {
+ let mut i = 0;
+ while i < nodes.len() {
+ let node = &mut nodes[i];
+ match node {
+ // When visiting a start tag, we check whether the element needs an
+ // ID and if so, add it to the queue, so that its first child node
+ // receives an ID.
+ HtmlNode::Tag(Tag::Start(elem)) => {
+ let loc = elem.location().unwrap();
+ if targets.contains(&loc) {
+ work.enqueue(loc, elem.label());
+ }
+ }
+
+ // When we reach an end tag, we check whether it closes an element
+ // that is still in our queue. If so, that means the element
+ // produced no nodes and we need to insert an empty span.
+ HtmlNode::Tag(Tag::End(loc, _)) => {
+ work.remove(*loc, |label| {
+ let mut element = HtmlElement::new(tag::span);
+ let id = identificator.assign(&mut element, label);
+ nodes.insert(i + 1, HtmlNode::Element(element));
+ id
+ });
+ }
+
+ // When visiting an element and the queue is non-empty, we assign an
+ // ID. Then, we traverse its children.
+ HtmlNode::Element(element) => {
+ work.drain(|label| identificator.assign(element, label));
+ traverse(work, targets, identificator, &mut element.children);
+ }
+
+ // When visiting text and the queue is non-empty, we generate a span
+ // and assign an ID.
+ HtmlNode::Text(..) => {
+ work.drain(|label| {
+ let mut element =
+ HtmlElement::new(tag::span).with_children(vec![node.clone()]);
+ let id = identificator.assign(&mut element, label);
+ *node = HtmlNode::Element(element);
+ id
+ });
+ }
+
+ // When visiting a frame and the queue is non-empty, we assign an
+ // ID to it (will be added to the resulting SVG element).
+ HtmlNode::Frame(frame) => {
+ work.drain(|label| {
+ frame.id.get_or_insert_with(|| identificator.identify(label)).clone()
+ });
+ }
+ }
+
+ i += 1;
+ }
+}
+
+/// Keeps track of the work to be done during ID generation.
+struct Work {
+ /// The locations and labels of elements we need to assign an ID to right
+ /// now.
+ queue: VecDeque<(Location, Option