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