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.]