Support linking from and into html.frame

This commit is contained in:
Laurenz 2025-07-15 17:12:19 +02:00
parent 47ac4165e1
commit c58ef50a0a
12 changed files with 278 additions and 50 deletions

View File

@ -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<Location>,
) -> Introspector {
fn discover(
builder: &mut IntrospectorBuilder,
sink: &mut Vec<(Content, Position)>,
link_targets: &mut HashSet<Location>,
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)
}

View File

@ -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<EcoString>,
/// 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![],
}
}
}

View File

@ -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<String> {
let mut w = Writer { pretty: true, ..Writer::default() };
let mut w = Writer::new(&document.introspector, true);
w.buf.push_str("<!DOCTYPE html>");
write_indent(&mut w);
write_element(&mut w, &document.root)?;
@ -20,16 +21,25 @@ pub fn html(document: &HtmlDocument) -> SourceResult<String> {
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);
}

View File

@ -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<Location>) {
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 `<span>`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<Location>,
) {
// 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::<LinkElem>().unwrap())
.filter_map(|elem| match elem.dest.resolve(introspector.track()) {
Ok(Destination::Location(loc)) => Some(loc),
_ => None,
})
.collect::<HashSet<_>>();
targets.extend(
introspector
.query(&LinkElem::ELEM.select())
.iter()
.map(|elem| elem.to_packed::<LinkElem>().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<Location>,
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

View File

@ -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<Abs>) {
let url = convert_image_to_base64_url(image);

View File

@ -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<RenderedGlyph>,
/// 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();

View File

@ -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,

View File

@ -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");

View File

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

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<svg class="typst-frame" style="overflow: visible; width: 4.5em; height: 3em;" viewBox="0 0 45 30" width="45pt" height="30pt" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:h5="http://www.w3.org/1999/xhtml"><g><g transform="translate(-0 -0)"><path class="typst-shape" fill="none" stroke="#000000" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="4" d="M 0 0 L 0 30 L 45 30 L 45 0 Z "/></g><g transform="translate(0 0)"><a href="#intro" xlink:href="#intro"><rect width="45" height="30" fill="transparent" stroke="none"/></a></g></g></svg>
<h2 id="intro">1 Introduction</h2>
</body>
</html>

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<h2>Frame 1</h2>
<svg class="typst-frame" style="overflow: visible; width: 20em; height: 50em;" viewBox="0 0 200 500" width="200pt" height="500pt" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:h5="http://www.w3.org/1999/xhtml"><g><g transform="translate(0 0)"><g class="typst-group"><g><g transform="translate(-0 -0)"><path class="typst-shape" fill="none" stroke="#000000" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="4" d="M 0 0 L 0 500 L 200 500 L 200 0 Z "/></g><g transform="translate(75 100)"><g class="typst-group"><g><g transform="translate(-0 -0)"><path class="typst-shape" fill="#39cccc" fill-rule="nonzero" d="M 0 0 L 0 10 L 10 10 L 10 0 Z "/></g><g transform="translate(0 0)"><a href="#f1" xlink:href="#f1"><rect width="10" height="10" fill="transparent" stroke="none"/></a></g><g transform="translate(20 0)"><path class="typst-shape" fill="#000000" fill-rule="nonzero" d="M 0 0 L 0 10 L 10 10 L 10 0 Z "/></g><g transform="translate(20 0)"><a href="#text" xlink:href="#text"><rect width="10" height="10" fill="transparent" stroke="none"/></a></g><g transform="translate(40 0)"><path class="typst-shape" fill="#ffdc00" fill-rule="nonzero" d="M 0 0 L 0 10 L 10 10 L 10 0 Z "/></g><g transform="translate(40 0)"><a href="#f2" xlink:href="#f2"><rect width="10" height="10" fill="transparent" stroke="none"/></a></g></g></g></g><g transform="translate(95 200)"><path class="typst-shape" fill="#39cccc" fill-rule="nonzero" d="M 0 0 L 0 10 L 10 10 L 10 0 Z "/></g></g></g></g></g><g id="f1" transform="translate(95 200)"/></svg>
<h2 id="text">Text</h2>
<p><a href="#f1">Go to teal square</a></p>
<h2>Frame 2</h2>
<svg class="typst-frame" style="overflow: visible; width: 20em; height: 50em;" viewBox="0 0 200 500" width="200pt" height="500pt" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:h5="http://www.w3.org/1999/xhtml"><g><g transform="translate(0 0)"><g class="typst-group"><g><g transform="translate(-0 -0)"><path class="typst-shape" fill="none" stroke="#000000" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="4" d="M 0 0 L 0 500 L 200 500 L 200 0 Z "/></g><g transform="translate(95 100)"><path class="typst-shape" fill="#ffdc00" fill-rule="nonzero" d="M 0 0 L 0 10 L 10 10 L 10 0 Z "/></g></g></g></g></g><g id="f2" transform="translate(95 100)"/></svg>
</body>
</html>

View File

@ -134,6 +134,49 @@ See #metadata(none) <t8>
#link(<b>)[B] // creates second empty span
#link(<c>)[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(<f1>, square(size: 10pt, fill: teal)),
link(<text>, square(size: 10pt, fill: black)),
link(<f2>, square(size: 10pt, fill: yellow)),
))
#place(center, dy: 200pt)[
#square(size: 10pt, fill: teal) <f1>
]
])
= Text <text>
#link(<f1>)[Go to teal square]
= Frame 2
#html.frame(block(
stroke: 1pt,
width: 200pt,
height: 500pt,
)[
#place(center, dy: 100pt)[
#square(size: 10pt, fill: yellow) <f2>
]
])
--- 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 <intro>
--- link-to-label-missing ---
// Error: 2-20 label `<hey>` does not exist in the document
#link(<hey>)[Nope.]