From 9a6268050fb769e18c4889fa5f59d4150e8878d6 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 15 Jul 2025 16:48:31 +0200 Subject: [PATCH 01/12] HTML frame improvements (#6605) --- crates/typst-html/src/convert.rs | 5 +--- crates/typst-html/src/dom.rs | 10 ++++++- crates/typst-html/src/encode.rs | 11 ++------ crates/typst-svg/src/lib.rs | 48 +++++++++++++++++++++++++++++--- tests/ref/html/html-frame.html | 11 ++++++++ tests/suite/html/frame.typ | 5 ++-- 6 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 tests/ref/html/html-frame.html diff --git a/crates/typst-html/src/convert.rs b/crates/typst-html/src/convert.rs index 171b4cb7e..817b0f025 100644 --- a/crates/typst-html/src/convert.rs +++ b/crates/typst-html/src/convert.rs @@ -109,10 +109,7 @@ fn handle( styles.chain(&style), Region::new(Size::splat(Abs::inf()), Axes::splat(false)), )?; - output.push(HtmlNode::Frame(HtmlFrame { - inner: frame, - text_size: styles.resolve(TextElem::size), - })); + output.push(HtmlNode::Frame(HtmlFrame::new(frame, styles))); } else { engine.sink.warn(warning!( child.span(), diff --git a/crates/typst-html/src/dom.rs b/crates/typst-html/src/dom.rs index cf74e1bfc..d7287d42d 100644 --- a/crates/typst-html/src/dom.rs +++ b/crates/typst-html/src/dom.rs @@ -2,10 +2,11 @@ use std::fmt::{self, Debug, Display, Formatter}; use ecow::{EcoString, EcoVec}; use typst_library::diag::{bail, HintedStrResult, StrResult}; -use typst_library::foundations::{cast, Dict, Repr, Str}; +use typst_library::foundations::{cast, Dict, Repr, Str, StyleChain}; use typst_library::introspection::{Introspector, Tag}; use typst_library::layout::{Abs, Frame}; use typst_library::model::DocumentInfo; +use typst_library::text::TextElem; use typst_syntax::Span; use typst_utils::{PicoStr, ResolvedPicoStr}; @@ -279,3 +280,10 @@ pub struct HtmlFrame { /// consistently. pub text_size: Abs, } + +impl HtmlFrame { + /// Wraps a laid-out frame. + pub fn new(inner: Frame, styles: StyleChain) -> Self { + Self { inner, text_size: styles.resolve(TextElem::size) } + } +} diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index be8137399..4447186b8 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -121,6 +121,7 @@ fn write_children(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { let pretty_inside = allows_pretty_inside(element.tag) && element.children.iter().any(|node| match node { HtmlNode::Element(child) => wants_pretty_around(child.tag), + HtmlNode::Frame(_) => true, _ => false, }); @@ -305,14 +306,6 @@ 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) { - // FIXME: This string replacement is obviously a hack. - let svg = typst_svg::svg_frame(&frame.inner).replace( - " String { renderer.finalize() } +/// 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) -> String { + let mut renderer = SVGRenderer::with_options(xmlwriter::Options { + indent: xmlwriter::Indent::None, + ..Default::default() + }); + renderer.write_header_with_custom_attrs(frame.size(), |xml| { + xml.write_attribute("class", "typst-frame"); + xml.write_attribute_fmt( + "style", + format_args!( + "overflow: visible; width: {}em; height: {}em;", + frame.width() / text_size, + frame.height() / text_size, + ), + ); + }); + + let state = State::new(frame.size(), Transform::identity()); + renderer.render_frame(state, Transform::identity(), frame); + renderer.finalize() +} + /// Export a document with potentially multiple pages into a single SVG file. /// /// The padding will be added around and between the individual frames. @@ -158,8 +182,13 @@ impl State { impl SVGRenderer { /// Create a new SVG renderer with empty glyph and clip path. fn new() -> Self { + Self::with_options(Default::default()) + } + + /// Create a new SVG renderer with the given configuration. + fn with_options(options: xmlwriter::Options) -> Self { SVGRenderer { - xml: XmlWriter::new(xmlwriter::Options::default()), + xml: XmlWriter::new(options), glyphs: Deduplicator::new('g'), clip_paths: Deduplicator::new('c'), gradient_refs: Deduplicator::new('g'), @@ -170,11 +199,22 @@ impl SVGRenderer { } } - /// Write the SVG header, including the `viewBox` and `width` and `height` - /// attributes. + /// Write the default SVG header, including a `typst-doc` class, the + /// `viewBox` and `width` and `height` attributes. fn write_header(&mut self, size: Size) { + self.write_header_with_custom_attrs(size, |xml| { + xml.write_attribute("class", "typst-doc"); + }); + } + + /// Write the SVG header with additional attributes and standard attributes. + fn write_header_with_custom_attrs( + &mut self, + size: Size, + write_custom_attrs: impl FnOnce(&mut XmlWriter), + ) { self.xml.start_element("svg"); - self.xml.write_attribute("class", "typst-doc"); + write_custom_attrs(&mut self.xml); self.xml.write_attribute_fmt( "viewBox", format_args!("0 0 {} {}", size.x.to_pt(), size.y.to_pt()), diff --git a/tests/ref/html/html-frame.html b/tests/ref/html/html-frame.html new file mode 100644 index 000000000..f19ef4b88 --- /dev/null +++ b/tests/ref/html/html-frame.html @@ -0,0 +1,11 @@ + + + + + + + +

A rectangle:

+ + + diff --git a/tests/suite/html/frame.typ b/tests/suite/html/frame.typ index 711933d76..e389dc5d6 100644 --- a/tests/suite/html/frame.typ +++ b/tests/suite/html/frame.typ @@ -1,5 +1,6 @@ -// No proper HTML tests here yet because we don't want to test SVG export just -// yet. We'll definitely add tests at some point. +--- html-frame html --- +A rectangle: +#html.frame(rect()) --- html-frame-in-layout --- // Ensure that HTML frames are transparent in layout. This is less important for From cdbf60e883413d6ffbd1575d18439b02046e0069 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Wed, 16 Jul 2025 09:05:52 +0100 Subject: [PATCH 02/12] Change `enum.item.number` to `Smart` instead of `Option` (#6609) --- crates/typst-eval/src/markup.rs | 2 +- crates/typst-html/src/rules.rs | 2 +- crates/typst-library/src/model/enum.rs | 2 +- tests/ref/enum-item-number-optional.png | Bin 0 -> 474 bytes tests/suite/model/enum.typ | 6 +++++- 5 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 tests/ref/enum-item-number-optional.png diff --git a/crates/typst-eval/src/markup.rs b/crates/typst-eval/src/markup.rs index cc9606269..baa548459 100644 --- a/crates/typst-eval/src/markup.rs +++ b/crates/typst-eval/src/markup.rs @@ -251,7 +251,7 @@ impl Eval for ast::EnumItem<'_> { let body = self.body().eval(vm)?; let mut elem = EnumItem::new(body); if let Some(number) = self.number() { - elem.number.set(Some(number)); + elem.number.set(Smart::Custom(number)); } Ok(elem.pack()) } diff --git a/crates/typst-html/src/rules.rs b/crates/typst-html/src/rules.rs index 04a58ca47..0fe5bd087 100644 --- a/crates/typst-html/src/rules.rs +++ b/crates/typst-html/src/rules.rs @@ -103,7 +103,7 @@ const ENUM_RULE: ShowFn = |elem, _, styles| { let body = Content::sequence(elem.children.iter().map(|item| { let mut li = HtmlElem::new(tag::li); - if let Some(nr) = item.number.get(styles) { + if let Smart::Custom(nr) = item.number.get(styles) { li = li.with_attr(attr::value, eco_format!("{nr}")); } // Text in wide enums shall always turn into paragraphs. diff --git a/crates/typst-library/src/model/enum.rs b/crates/typst-library/src/model/enum.rs index 388fb9eda..93a2c1dc6 100644 --- a/crates/typst-library/src/model/enum.rs +++ b/crates/typst-library/src/model/enum.rs @@ -220,7 +220,7 @@ impl EnumElem { pub struct EnumItem { /// The item's number. #[positional] - pub number: Option, + pub number: Smart, /// The item's body. #[required] diff --git a/tests/ref/enum-item-number-optional.png b/tests/ref/enum-item-number-optional.png new file mode 100644 index 0000000000000000000000000000000000000000..2f30566caa355d9ed711ba504ba3136695892727 GIT binary patch literal 474 zcmV<00VV#4P)(4@1y#WNHcCxf_u#xCU`t z^3baTL?uka@hm_aOr*IK!0|9THBh)!liu0@lkjHWDVFSsTo_OTLau&f28LOw2cui$ zfDria!V#T^cKGG+EtwzU#eSrmulj~=_;K1_o@{{*$4I@wTe@Md3x$!Nme<>W(7NOq zs=r=t-doDEbugePEG`mPxGYR5Qaep5Kn9H9NRO#%0PQP>sCx1lV?KR~ll#6#-^ z-SBlAs|x~aqF1LjG_dr{8zJl8gLO=-_YMl3-I6bkaAuKQE&>yn!2cBf2AgM3x~{-? QrT_o{07*qoM6N<$f?)dEJ^%m! literal 0 HcmV?d00001 diff --git a/tests/suite/model/enum.typ b/tests/suite/model/enum.typ index 7ee4dc20c..f7e097bda 100644 --- a/tests/suite/model/enum.typ +++ b/tests/suite/model/enum.typ @@ -56,6 +56,10 @@ a + 0. enum.item(5)[Fifth] ) +--- enum-item-number-optional --- +#enum.item[First] +#enum.item[Second] + --- enum-numbering-pattern --- // Test numbering pattern. #set enum(numbering: "(1.a.*)") @@ -217,7 +221,7 @@ a + 0. --- issue-2530-enum-item-panic --- // Enum item (pre-emptive) -#enum.item(none)[Hello] +#enum.item(auto)[Hello] #enum.item(17)[Hello] --- issue-5503-enum-in-align --- From ea5272bb2b6461757f25c6b59d3ea9eb02e92dbc Mon Sep 17 00:00:00 2001 From: "Y.D.X." <73375426+YDX-2147483647@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:10:21 +0800 Subject: [PATCH 03/12] Support setting fonts repeatedly with different `covers` (#6604) --- crates/typst-layout/src/inline/shaping.rs | 9 ++++++- tests/ref/text-font-covers-repeat.png | Bin 0 -> 467 bytes tests/ref/text-font-covers-riffle.png | Bin 0 -> 1603 bytes tests/suite/text/font.typ | 28 ++++++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/ref/text-font-covers-repeat.png create mode 100644 tests/ref/text-font-covers-riffle.png diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs index d1e748da8..a8f2bcaba 100644 --- a/crates/typst-layout/src/inline/shaping.rs +++ b/crates/typst-layout/src/inline/shaping.rs @@ -719,6 +719,10 @@ fn glyphs_width(glyphs: &[ShapedGlyph]) -> Abs { struct ShapingContext<'a, 'v> { engine: &'a Engine<'v>, glyphs: Vec, + /// Font families that have been used with unlimited coverage. + /// + /// These font families are considered exhausted and will not be used again, + /// even if they are declared again (e.g., during fallback after normal selection). used: Vec, styles: StyleChain<'a>, size: Abs, @@ -777,7 +781,10 @@ fn shape_segment<'a>( return; }; - ctx.used.push(font.clone()); + // This font has been exhausted and will not be used again. + if covers.is_none() { + ctx.used.push(font.clone()); + } // Fill the buffer with our text. let mut buffer = UnicodeBuffer::new(); diff --git a/tests/ref/text-font-covers-repeat.png b/tests/ref/text-font-covers-repeat.png new file mode 100644 index 0000000000000000000000000000000000000000..aceb8d007934e2747f6d2282a9f73d90d56098db GIT binary patch literal 467 zcmV;^0WAKBP)-q_@$7+zzKD;I8dF-qxu{(;}!6Tv4Hp}-2Pz+VsETqab`^^b=Wn^I#N;%sSz zs;!c@-8kWVOazz37bZN%umSsC2B8uSyy-Bt406bH4cfP}!-TWujj{8@bBMmI%)x8f^F|S{Xmpl(Z+!cQq>LJS={EN^` z-lk}v%Um>kBB6Eg3ZKw9U4k3~A5F$?`nUIqeUho_wG4>@4|*?0>N( zgn&R3Xfgs#pb0b?fi8E@N;)GaQ%NPgU?y_nDJ46%l~d_hQYq>P5$JpiO0wSNcZZ@2 zW+KPp6O~McZD%~;XReUWDHgiH7begz*qGH-cl%-2xB-7}jZDsyiRhKi$H=_W^*H-q zRbOo%c=f$Q%MKi0cCdE&{+fzC2UqUcy=L=P)w*@x|Iw58@w6K@| zs;WZJnoFk`<%z3jr+&KZ9_mSkgJl8wnJd64bAlFM2rA4^qR}Y+%CdaEeb5(?+3$gh zmXpdX=)1jZ-rBah`khtVcCN18vF6R{$_<-9xhQBLo&$Z(a_=`(cf>MN`koN4{*tDaGv-8(D0S_7jzW^v`FDT)HhY~63LpK0wn(bS=L`myDptMY8o zpfwGrqJpWEL33U1oG_w1&0Z0*@b={!oHDoGNi#A}w{-XEtz1c<*F2&5;n>GtTqx=Y zA%UA;^B8aqH?M&&1X|*VOBHk^p3K$dZmscjOV7ow;akJnyiEKCD0ZtqjQkIOY2$jG zYt}$y$`xeZHg3Ay{Wsqt#M5x^J^A#YrLDP)+B-BAym{cd%+!FJlt^jq{tNdWvD!6z zL!UHvFv_AR7X4bhcgAdi0fPrmmE3bhmO|57rWIw^M+5Y?J;NuVY1as(ZXC;gEa>xGLN>zD0;h&LRYFGo=V zL9wVKL{D%s0!^UF2sD8v&}0OfW`;B~q?sYj3~6RaGeepg(#()%hBPyznIX*#X=X?> zLz)@V%#db=G&7_pI2nN^&}0OfKoe*(0!^UF2=q%1I+0AZcJ`fbY-+lDAM0#3n^Mwd zmrrN0BN~dzf;Z?2hO{QT6i?*8(FJF4P|deGlc1h77IlN*o@aPi`k+DfdP~RkmUgSt z^Xr}aU@a$728Y+^^p2YZXF$Z(5z-)+)XS7^6Z3~zo{2}c(Rn*Dn^&r1_DR}3ur)#IK0BYX>* zzEITY@Tm3XSUd@hhFz*9IpEJ4tm zGWZUtwYks{odfOVpiK<4NkFlC|9qg{{yrQIm`4At+;b zSo-Z9$O17dZQO*KXNKHp;brLbzi=7Ca1#zJRiV0>hTuMA=g5h~;_#<>bH~6491C`W z5Gpr!rd9f&SsQ0_dU6$m8~Qou1~eL?%-TC>G}=3OY_7f2?E45EG}mkB23v Date: Wed, 16 Jul 2025 10:17:42 +0200 Subject: [PATCH 04/12] Support intra-doc links in HTML (#6602) --- crates/typst-html/src/document.rs | 51 ++- crates/typst-html/src/dom.rs | 25 +- crates/typst-html/src/encode.rs | 24 +- crates/typst-html/src/lib.rs | 14 + crates/typst-html/src/link.rs | 290 ++++++++++++++++++ crates/typst-html/src/rules.rs | 43 ++- crates/typst-layout/src/rules.rs | 14 +- .../src/introspection/introspector.rs | 25 +- crates/typst-library/src/model/link.rs | 71 ++++- crates/typst-library/src/model/reference.rs | 14 +- crates/typst-svg/src/image.rs | 2 +- crates/typst-svg/src/lib.rs | 95 +++++- crates/typst-svg/src/paint.rs | 2 +- crates/typst-svg/src/shape.rs | 2 +- crates/typst-svg/src/text.rs | 2 +- crates/typst-utils/src/pico.rs | 17 + tests/ref/html/link-html-frame-ref.html | 11 + tests/ref/html/link-html-frame.html | 15 + tests/ref/html/link-html-here.html | 10 + tests/ref/html/link-html-id-attach.html | 29 ++ tests/ref/html/link-html-id-existing.html | 11 + .../html/link-html-label-disambiguation.html | 31 ++ tests/ref/html/link-html-nested-empty.html | 12 + tests/ref/html/ref-basic.html | 13 + tests/suite/model/link.typ | 111 +++++++ tests/suite/model/ref.typ | 2 +- 26 files changed, 861 insertions(+), 75 deletions(-) create mode 100644 crates/typst-html/src/link.rs create mode 100644 tests/ref/html/link-html-frame-ref.html create mode 100644 tests/ref/html/link-html-frame.html create mode 100644 tests/ref/html/link-html-here.html create mode 100644 tests/ref/html/link-html-id-attach.html create mode 100644 tests/ref/html/link-html-id-existing.html create mode 100644 tests/ref/html/link-html-label-disambiguation.html create mode 100644 tests/ref/html/link-html-nested-empty.html create mode 100644 tests/ref/html/ref-basic.html diff --git a/crates/typst-html/src/document.rs b/crates/typst-html/src/document.rs index 9f0124e57..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}; @@ -83,42 +86,56 @@ fn html_document_impl( &mut locator, children.iter().copied(), )?; - let introspector = introspect_html(&output); - let root = root_element(output, &info)?; + + 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, 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 d7287d42d..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; @@ -172,10 +172,20 @@ impl HtmlAttrs { Self::default() } - /// Add an attribute. + /// Adds an attribute. pub fn push(&mut self, attr: HtmlAttr, value: impl Into) { self.0.push((attr, value.into())); } + + /// Adds an attribute to the start of the list. + pub fn push_front(&mut self, attr: HtmlAttr, value: impl Into) { + self.0.insert(0, (attr, value.into())); + } + + /// Finds an attribute value. + pub fn get(&self, attr: HtmlAttr) -> Option<&EcoString> { + self.0.iter().find(|&&(k, _)| k == attr).map(|(_, v)| v) + } } cast! { @@ -279,11 +289,20 @@ 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 itself. + pub id: Option, + /// IDs to assign to destination jump points within the SVG. + pub link_points: Vec<(Point, EcoString)>, } 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, + link_points: vec![], + } } } diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index 4447186b8..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,6 +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); + 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/lib.rs b/crates/typst-html/src/lib.rs index d7b29dbbc..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; @@ -79,6 +80,19 @@ impl HtmlElem { self } + /// Adds the attribute to the element if value is not `None`. + pub fn with_optional_attr( + self, + attr: HtmlAttr, + value: Option>, + ) -> Self { + if let Some(value) = value { + self.with_attr(attr, value) + } else { + self + } + } + /// Adds CSS styles to an element. fn with_styles(self, properties: css::Properties) -> Self { if let Some(value) = properties.into_inline_styles() { diff --git a/crates/typst-html/src/link.rs b/crates/typst-html/src/link.rs new file mode 100644 index 000000000..0fcbe906a --- /dev/null +++ b/crates/typst-html/src/link.rs @@ -0,0 +1,290 @@ +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::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, + mut targets: HashSet, +) { + // Query for all links with an intra-doc (i.e. `Location`) destination to + // know what needs IDs. + 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. + 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() + }); + traverse_frame( + work, + targets, + identificator, + &frame.inner, + &mut frame.link_points, + ); + } + } + + i += 1; + } +} + +/// 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 + /// now. + queue: VecDeque<(Location, Option