From c58ef50a0a368d717325e24b65da2b0be274203c Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 15 Jul 2025 17:12:19 +0200 Subject: [PATCH] Support linking from and into `html.frame` --- crates/typst-html/src/document.rs | 49 ++++++++----- crates/typst-html/src/dom.rs | 7 +- crates/typst-html/src/encode.rs | 25 +++++-- crates/typst-html/src/link.rs | 78 ++++++++++++++++++--- crates/typst-svg/src/image.rs | 2 +- crates/typst-svg/src/lib.rs | 92 +++++++++++++++++++++---- crates/typst-svg/src/paint.rs | 2 +- crates/typst-svg/src/shape.rs | 2 +- crates/typst-svg/src/text.rs | 2 +- tests/ref/html/link-html-frame-ref.html | 11 +++ tests/ref/html/link-html-frame.html | 15 ++++ tests/suite/model/link.typ | 43 ++++++++++++ 12 files changed, 278 insertions(+), 50 deletions(-) create mode 100644 tests/ref/html/link-html-frame-ref.html create mode 100644 tests/ref/html/link-html-frame.html diff --git a/crates/typst-html/src/document.rs b/crates/typst-html/src/document.rs index 32c035dcd..c581df05f 100644 --- a/crates/typst-html/src/document.rs +++ b/crates/typst-html/src/document.rs @@ -1,10 +1,13 @@ +use std::collections::HashSet; use std::num::NonZeroUsize; use comemo::{Tracked, TrackedMut}; use typst_library::diag::{bail, SourceResult}; use typst_library::engine::{Engine, Route, Sink, Traced}; use typst_library::foundations::{Content, StyleChain}; -use typst_library::introspection::{Introspector, IntrospectorBuilder, Locator}; +use typst_library::introspection::{ + Introspector, IntrospectorBuilder, Location, Locator, +}; use typst_library::layout::{Point, Position, Transform}; use typst_library::model::DocumentInfo; use typst_library::routines::{Arenas, RealizationKind, Routines}; @@ -84,43 +87,55 @@ fn html_document_impl( children.iter().copied(), )?; - let mut introspector = introspect_html(&output); + let mut link_targets = HashSet::new(); + let mut introspector = introspect_html(&output, &mut link_targets); let mut root = root_element(output, &info)?; - crate::link::identify_link_targets(&mut root, &mut introspector); + crate::link::identify_link_targets(&mut root, &mut introspector, link_targets); Ok(HtmlDocument { info, root, introspector }) } /// Introspects HTML nodes. #[typst_macros::time(name = "introspect html")] -fn introspect_html(output: &[HtmlNode]) -> Introspector { +fn introspect_html( + output: &[HtmlNode], + link_targets: &mut HashSet, +) -> Introspector { fn discover( builder: &mut IntrospectorBuilder, sink: &mut Vec<(Content, Position)>, + link_targets: &mut HashSet, nodes: &[HtmlNode], ) { for node in nodes { match node { - HtmlNode::Tag(tag) => builder.discover_in_tag( - sink, - tag, - Position { page: NonZeroUsize::ONE, point: Point::zero() }, - ), + HtmlNode::Tag(tag) => { + builder.discover_in_tag( + sink, + tag, + Position { page: NonZeroUsize::ONE, point: Point::zero() }, + ); + } HtmlNode::Text(_, _) => {} - HtmlNode::Element(elem) => discover(builder, sink, &elem.children), - HtmlNode::Frame(frame) => builder.discover_in_frame( - sink, - &frame.inner, - NonZeroUsize::ONE, - Transform::identity(), - ), + HtmlNode::Element(elem) => { + discover(builder, sink, link_targets, &elem.children) + } + HtmlNode::Frame(frame) => { + builder.discover_in_frame( + sink, + &frame.inner, + NonZeroUsize::ONE, + Transform::identity(), + ); + crate::link::introspect_frame_links(&frame.inner, link_targets); + } } } } let mut elems = Vec::new(); let mut builder = IntrospectorBuilder::new(); - discover(&mut builder, &mut elems, output); + discover(&mut builder, &mut elems, link_targets, output); builder.finalize(elems) } diff --git a/crates/typst-html/src/dom.rs b/crates/typst-html/src/dom.rs index 09a2d3759..e7f5fcbcd 100644 --- a/crates/typst-html/src/dom.rs +++ b/crates/typst-html/src/dom.rs @@ -4,7 +4,7 @@ use ecow::{EcoString, EcoVec}; use typst_library::diag::{bail, HintedStrResult, StrResult}; use typst_library::foundations::{cast, Dict, Repr, Str, StyleChain}; use typst_library::introspection::{Introspector, Tag}; -use typst_library::layout::{Abs, Frame}; +use typst_library::layout::{Abs, Frame, Point}; use typst_library::model::DocumentInfo; use typst_library::text::TextElem; use typst_syntax::Span; @@ -289,8 +289,10 @@ 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. + /// An ID to assign to the SVG itself. pub id: Option, + /// IDs to assign to destination jump points within the SVG. + pub link_points: Vec<(Point, EcoString)>, } impl HtmlFrame { @@ -300,6 +302,7 @@ impl HtmlFrame { inner, text_size: styles.resolve(TextElem::size), id: None, + link_points: vec![], } } } diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index 16363dc86..02c3f16de 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -2,6 +2,7 @@ use std::fmt::Write; use typst_library::diag::{bail, At, SourceResult, StrResult}; use typst_library::foundations::Repr; +use typst_library::introspection::Introspector; use typst_syntax::Span; use crate::{ @@ -10,7 +11,7 @@ use crate::{ /// Encodes an HTML document into a string. pub fn html(document: &HtmlDocument) -> SourceResult { - let mut w = Writer { pretty: true, ..Writer::default() }; + let mut w = Writer::new(&document.introspector, true); w.buf.push_str(""); write_indent(&mut w); write_element(&mut w, &document.root)?; @@ -20,16 +21,25 @@ pub fn html(document: &HtmlDocument) -> SourceResult { Ok(w.buf) } -#[derive(Default)] -struct Writer { +/// Encodes HTML. +struct Writer<'a> { /// The output buffer. buf: String, /// The current indentation level level: usize, + /// The document's introspector. + introspector: &'a Introspector, /// Whether pretty printing is enabled. pretty: bool, } +impl<'a> Writer<'a> { + /// Creates a new writer. + fn new(introspector: &'a Introspector, pretty: bool) -> Self { + Self { buf: String::new(), level: 0, introspector, pretty } + } +} + /// Writes a newline and indent, if pretty printing is enabled. fn write_indent(w: &mut Writer) { if w.pretty { @@ -306,7 +316,12 @@ 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, frame.id.as_deref()); + let svg = typst_svg::svg_html_frame( + &frame.inner, + frame.text_size, + frame.id.as_deref(), + &frame.link_points, + w.introspector, + ); w.buf.push_str(&svg); } diff --git a/crates/typst-html/src/link.rs b/crates/typst-html/src/link.rs index 87adc5764..0fcbe906a 100644 --- a/crates/typst-html/src/link.rs +++ b/crates/typst-html/src/link.rs @@ -4,27 +4,51 @@ use comemo::Track; use ecow::{eco_format, EcoString}; use typst_library::foundations::{Label, NativeElement}; use typst_library::introspection::{Introspector, Location, Tag}; +use typst_library::layout::{Frame, FrameItem, Point}; use typst_library::model::{Destination, LinkElem}; use typst_utils::PicoStr; use crate::{attr, tag, HtmlElement, HtmlNode}; +/// Searches for links within a frame. +/// +/// If all links are created via `LinkElem` in the future, this can be removed +/// in favor of the query in `identify_link_targets`. For the time being, some +/// links are created without existence of a `LinkElem`, so this is +/// unfortunately necessary. +pub fn introspect_frame_links(frame: &Frame, targets: &mut HashSet) { + for (_, item) in frame.items() { + match item { + FrameItem::Link(Destination::Location(loc), _) => { + targets.insert(*loc); + } + FrameItem::Group(group) => introspect_frame_links(&group.frame, targets), + _ => {} + } + } +} + /// 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) { +pub fn identify_link_targets( + root: &mut HtmlElement, + introspector: &mut Introspector, + mut targets: HashSet, +) { // 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::>(); + targets.extend( + 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, + }), + ); if targets.is_empty() { // Nothing to do. @@ -103,6 +127,13 @@ fn traverse( work.drain(|label| { frame.id.get_or_insert_with(|| identificator.identify(label)).clone() }); + traverse_frame( + work, + targets, + identificator, + &frame.inner, + &mut frame.link_points, + ); } } @@ -110,6 +141,33 @@ fn traverse( } } +/// Traverses a frame embedded in HTML. +fn traverse_frame( + work: &mut Work, + targets: &HashSet, + identificator: &mut Identificator<'_>, + frame: &Frame, + link_points: &mut Vec<(Point, EcoString)>, +) { + for (_, item) in frame.items() { + match item { + FrameItem::Tag(Tag::Start(elem)) => { + let loc = elem.location().unwrap(); + if targets.contains(&loc) { + let pos = identificator.introspector.position(loc).point; + let id = identificator.identify(elem.label()); + work.ids.insert(loc, id.clone()); + link_points.push((pos, id)); + } + } + FrameItem::Group(group) => { + traverse_frame(work, targets, identificator, &group.frame, link_points); + } + _ => {} + } + } +} + /// 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 diff --git a/crates/typst-svg/src/image.rs b/crates/typst-svg/src/image.rs index e6dd579f3..fd4aecd4f 100644 --- a/crates/typst-svg/src/image.rs +++ b/crates/typst-svg/src/image.rs @@ -9,7 +9,7 @@ use typst_library::visualize::{ use crate::SVGRenderer; -impl SVGRenderer { +impl SVGRenderer<'_> { /// Render an image element. pub(super) fn render_image(&mut self, image: &Image, size: &Axes) { let url = convert_image_to_base64_url(image); diff --git a/crates/typst-svg/src/lib.rs b/crates/typst-svg/src/lib.rs index 86ade0140..5b6db69da 100644 --- a/crates/typst-svg/src/lib.rs +++ b/crates/typst-svg/src/lib.rs @@ -6,6 +6,8 @@ mod shape; mod text; pub use image::{convert_image_scaling, convert_image_to_base64_url}; +use typst_library::introspection::Introspector; +use typst_library::model::Destination; use std::collections::HashMap; use std::fmt::{self, Display, Formatter, Write}; @@ -47,11 +49,20 @@ pub fn svg_frame(frame: &Frame) -> String { /// Export a frame into an SVG suitable for embedding into HTML. #[typst_macros::time(name = "svg html frame")] -pub fn svg_html_frame(frame: &Frame, text_size: Abs, id: Option<&str>) -> String { - let mut renderer = SVGRenderer::with_options(xmlwriter::Options { - indent: xmlwriter::Indent::None, - ..Default::default() - }); +pub fn svg_html_frame( + frame: &Frame, + text_size: Abs, + id: Option<&str>, + link_points: &[(Point, EcoString)], + introspector: &Introspector, +) -> String { + let mut renderer = SVGRenderer::with_options( + xmlwriter::Options { + indent: xmlwriter::Indent::None, + ..Default::default() + }, + Some(introspector), + ); renderer.write_header_with_custom_attrs(frame.size(), |xml| { if let Some(id) = id { xml.write_attribute("id", id); @@ -69,6 +80,11 @@ pub fn svg_html_frame(frame: &Frame, text_size: Abs, id: Option<&str>) -> String let state = State::new(frame.size(), Transform::identity()); renderer.render_frame(state, Transform::identity(), frame); + + for (pos, id) in link_points { + renderer.render_link_point(*pos, id); + } + renderer.finalize() } @@ -105,9 +121,11 @@ pub fn svg_merged(document: &PagedDocument, padding: Abs) -> String { } /// Renders one or multiple frames to an SVG file. -struct SVGRenderer { +struct SVGRenderer<'a> { /// The internal XML writer. xml: XmlWriter, + /// The document's introspector, if we're writing an HTML frame. + introspector: Option<&'a Introspector>, /// Prepared glyphs. glyphs: Deduplicator, /// Clip paths are used to clip a group. A clip path is a path that defines @@ -182,16 +200,20 @@ impl State { } } -impl SVGRenderer { +impl<'a> SVGRenderer<'a> { /// Create a new SVG renderer with empty glyph and clip path. fn new() -> Self { - Self::with_options(Default::default()) + Self::with_options(Default::default(), None) } /// Create a new SVG renderer with the given configuration. - fn with_options(options: xmlwriter::Options) -> Self { + fn with_options( + options: xmlwriter::Options, + introspector: Option<&'a Introspector>, + ) -> Self { SVGRenderer { xml: XmlWriter::new(options), + introspector, glyphs: Deduplicator::new('g'), clip_paths: Deduplicator::new('c'), gradient_refs: Deduplicator::new('g'), @@ -251,8 +273,7 @@ impl SVGRenderer { for (pos, item) in frame.items() { // File size optimization. - // TODO: SVGs could contain links, couldn't they? - if matches!(item, FrameItem::Link(_, _) | FrameItem::Tag(_)) { + if matches!(item, FrameItem::Tag(_)) { continue; } @@ -273,7 +294,7 @@ impl SVGRenderer { self.render_shape(state.pre_translate(*pos), shape) } FrameItem::Image(image, size, _) => self.render_image(image, size), - FrameItem::Link(_, _) => unreachable!(), + FrameItem::Link(dest, size) => self.render_link(dest, *size), FrameItem::Tag(_) => unreachable!(), }; @@ -311,6 +332,53 @@ impl SVGRenderer { self.xml.end_element(); } + /// Render a link element. + fn render_link(&mut self, dest: &Destination, size: Size) { + self.xml.start_element("a"); + + match dest { + Destination::Location(loc) => { + // TODO: Location links on the same page could also be supported + // outside of HTML. + if let Some(introspector) = self.introspector { + if let Some(id) = introspector.html_id(*loc) { + self.xml.write_attribute_fmt("href", format_args!("#{id}")); + self.xml.write_attribute_fmt("xlink:href", format_args!("#{id}")); + } + } + } + Destination::Position(_) => { + // TODO: Links on the same page could be supported. + } + Destination::Url(url) => { + self.xml.write_attribute("href", url.as_str()); + self.xml.write_attribute("xlink:href", url.as_str()); + } + } + + self.xml.start_element("rect"); + self.xml + .write_attribute_fmt("width", format_args!("{}", size.x.to_pt())); + self.xml + .write_attribute_fmt("height", format_args!("{}", size.y.to_pt())); + self.xml.write_attribute("fill", "transparent"); + self.xml.write_attribute("stroke", "none"); + self.xml.end_element(); + + self.xml.end_element(); + } + + /// Renders a linkable point that can be used to link into an HTML frame. + fn render_link_point(&mut self, pos: Point, id: &str) { + self.xml.start_element("g"); + self.xml.write_attribute("id", id); + self.xml.write_attribute_fmt( + "transform", + format_args!("translate({} {})", pos.x.to_pt(), pos.y.to_pt()), + ); + self.xml.end_element(); + } + /// Finalize the SVG file. This must be called after all rendering is done. fn finalize(mut self) -> String { self.write_glyph_defs(); diff --git a/crates/typst-svg/src/paint.rs b/crates/typst-svg/src/paint.rs index 75ab41cdf..1a9acaecf 100644 --- a/crates/typst-svg/src/paint.rs +++ b/crates/typst-svg/src/paint.rs @@ -15,7 +15,7 @@ use crate::{Id, SVGRenderer, State, SvgMatrix, SvgPathBuilder}; /// Smaller values could be interesting for optimization. const CONIC_SEGMENT: usize = 360; -impl SVGRenderer { +impl SVGRenderer<'_> { /// Render a frame to a string. pub(super) fn render_tiling_frame( &mut self, diff --git a/crates/typst-svg/src/shape.rs b/crates/typst-svg/src/shape.rs index 981f86a39..bcd099c49 100644 --- a/crates/typst-svg/src/shape.rs +++ b/crates/typst-svg/src/shape.rs @@ -8,7 +8,7 @@ use typst_library::visualize::{ use crate::paint::ColorEncode; use crate::{SVGRenderer, State, SvgPathBuilder}; -impl SVGRenderer { +impl SVGRenderer<'_> { /// Render a shape element. pub(super) fn render_shape(&mut self, state: State, shape: &Shape) { self.xml.start_element("path"); diff --git a/crates/typst-svg/src/text.rs b/crates/typst-svg/src/text.rs index 7099a9d72..fdc3497e9 100644 --- a/crates/typst-svg/src/text.rs +++ b/crates/typst-svg/src/text.rs @@ -13,7 +13,7 @@ use typst_utils::hash128; use crate::{SVGRenderer, State, SvgMatrix, SvgPathBuilder}; -impl SVGRenderer { +impl SVGRenderer<'_> { /// Render a text item. The text is rendered as a group of glyphs. We will /// try to render the text as SVG first, then bitmap, then outline. If none /// of them works, we will skip the text. diff --git a/tests/ref/html/link-html-frame-ref.html b/tests/ref/html/link-html-frame-ref.html new file mode 100644 index 000000000..32f2cf4d4 --- /dev/null +++ b/tests/ref/html/link-html-frame-ref.html @@ -0,0 +1,11 @@ + + + + + + + + +

1 Introduction

+ + diff --git a/tests/ref/html/link-html-frame.html b/tests/ref/html/link-html-frame.html new file mode 100644 index 000000000..60cca836d --- /dev/null +++ b/tests/ref/html/link-html-frame.html @@ -0,0 +1,15 @@ + + + + + + + +

Frame 1

+ +

Text

+

Go to teal square

+

Frame 2

+ + + diff --git a/tests/suite/model/link.typ b/tests/suite/model/link.typ index 43d72a888..1394638f5 100644 --- a/tests/suite/model/link.typ +++ b/tests/suite/model/link.typ @@ -134,6 +134,49 @@ See #metadata(none) #link()[B] // creates second empty span #link()[C] // links to #a because the generated span is contained in it +--- link-html-frame html --- += Frame 1 +#html.frame(block( + stroke: 1pt, + width: 200pt, + height: 500pt, +)[ + #place(center, dy: 100pt, stack( + dir: ltr, + spacing: 10pt, + link(, square(size: 10pt, fill: teal)), + link(, square(size: 10pt, fill: black)), + link(, square(size: 10pt, fill: yellow)), + )) + #place(center, dy: 200pt)[ + #square(size: 10pt, fill: teal) + ] +]) + += Text +#link()[Go to teal square] + += Frame 2 +#html.frame(block( + stroke: 1pt, + width: 200pt, + height: 500pt, +)[ + #place(center, dy: 100pt)[ + #square(size: 10pt, fill: yellow) + ] +]) + +--- link-html-frame-ref html --- +// Test that reference links work in `html.frame`. Currently, references (and a +// few other elements) do not internally use `LinkElem`s, so they trigger a +// slightly different code path; see `typst-html/src/link.rs`. The text show +// rule is only there to keep the output small. +#set heading(numbering: "1") +#show "Section" + sym.space.nobreak + "1": rect() +#html.frame[@intro] += Introduction + --- link-to-label-missing --- // Error: 2-20 label `` does not exist in the document #link()[Nope.]