Skip to content

Make text in SVG export selectable#7313

Open
natri0 wants to merge 5 commits intotypst:mainfrom
natri0:svg-selectable-text
Open

Make text in SVG export selectable#7313
natri0 wants to merge 5 commits intotypst:mainfrom
natri0:svg-selectable-text

Conversation

@natri0
Copy link

@natri0 natri0 commented Nov 5, 2025

Problem

currently, text in SVG exports is represented by a full of references to the glyphs with nothing saying this is text:

<g class="typst-text" transform="matrix(1 0 0 -1 0 9.933)">
    <use xlink:href="#g82C93E1BB9DE3CD1CBB62FD9297B78B9" x="0" y="0" fill="#000000" fill-rule="nonzero"/>
    <use xlink:href="#gAA24DF2E238687391F7A0B7BD6B6C154" x="8.9474" y="0" fill="#000000" fill-rule="nonzero"/>
    <use xlink:href="#g479E700DD8015FB487E9CA83B01AB1A3" x="16.739800000000002" y="0" fill="#000000" fill-rule="nonzero"/>
    <use xlink:href="#g815072E9BB0371B0FFD298D530BF3800" x="24.763200000000005" y="0" fill="#000000" fill-rule="nonzero"/>
    <use xlink:href="#gB0655E88089003ABDA0D0085C9A8C948" x="36.143800000000006" y="0" fill="#000000" fill-rule="nonzero"/>
</g>

Solution

this makes it so that the <g> also contains a transparent <text> element that makes it selectable / readable by screen readers.

<g class="typst-text" transform="matrix(1 0 0 -1 0 9.933)">
    <!-- <use> from previous codeblock -->
    <text fill="transparent" style="user-select: text;" x="0 8.9474 16.7398 24.7632 36.1438" y="0">page1</text>
</g>

result:
image
image

Quirks

the user-select: all CSS property is used to make it impossible to select only part of a single-glyph ligature, which is not currently supported by Safari.

textLength and lengthAdjust are used to make selection boxes line up perfectly with the glyphs, which is currently not supported by Firefox and derivatives.

currently, the "text" in exported SVG files is just glyphs with nothing linking them to the original text.
with this Typst now adds a transparent <text> element that makes the text selectable in browsers and hopefully readable by screen readers.
@laurmaedje laurmaedje added text Related to the text category, which is all about text handling, shaping, etc. svg Anything about the file format for people who like to have fun. interface PRs that add to or change Typst's user-facing interface as opposed to internals or docs changes. labels Nov 7, 2025
now for every Glyph in a TextItem a <tspan> gets generated. thanks to the `user-select: all` CSS property it can only be selected in full, so ligatures work fine!

todo: consider collapsing non-ligature tspans into larger tspans with per-character user-select (ie the default value)
@natri0
Copy link
Author

natri0 commented Nov 8, 2025

changed it so that every glyph is enclosed in its own <tspan> with user-select: all. makes Arabic look much nicer selected, albeit still not perfect:
image

one concern that i have with this, tho, is that it could make viewing the svg / selecting text slow if there's a lot of text.

uses `textLength` and `lengthAdjust`. sadly not supported in Firefox, but Safari/Chromium looks fine
@natri0
Copy link
Author

natri0 commented Nov 9, 2025

new safari:
image
image

firefox sadly doesn't support textLength and lengthAdjust in tspan (only in text).

iOS safari:
IMG_3431

@ParaN3xus
Copy link

Related: #5390, #5789

@laurmaedje laurmaedje added the waiting-on-decision A decision must be made to proceed. label Nov 18, 2025
@marie-bnl
Copy link

marie-bnl commented Nov 27, 2025

I feel like there could be an issue if the text rendered by the viewer differs from the one Typst rendered. For instance, you use font DejaVu but it is not available to the viewer, so it will render the transparent text with Arial.

This could make the text smaller/larger and change the position of linebreaks, which makes it so you have no idea what you're selecting.

Apparently it has been dealt with.

It does not look good on all clients, but I guess there is not much Typst can do about it.

image

Also the fact that clicking a letter selects it feels pretty awkward.

@saecki
Copy link
Member

saecki commented Jan 28, 2026

It does not look good on all clients, but I guess there is not much Typst can do about it.

Also the fact that clicking a letter selects it feels pretty awkward.

I've also tried chrome, and there I'm only able to select a single character.
image

This generally seems useful, but I'm not sure if we should support this while the support for it (even by major browsers) is that flaky.

It also raises the question what our goals for SVG export actually is. Do we want it to become a full semantic export target, or should it just be the vector graphic equivalent of typst-render.

@laurmaedje
Copy link
Member

It also raises the question what our goals for SVG export actually is. Do we want it to become a full semantic export target, or should it just be the vector graphic equivalent of typst-render.

From my perspective, it's clearly the latter.

@marie-bnl
Copy link

marie-bnl commented Feb 10, 2026

This generally seems useful, but I'm not sure if we should support this while the support for it (even by major browsers) is that flaky.

I think it is still a good thing to support it if it is useful somewhere but since it is not correctly supported everywhere it should be opt-in with warnings in the docs.


I feel like this is more of an issue with the SVG specification itself that would affect a lot of tools and users working with putting text in SVG graphics. I don't know how feasible this is nor how much time it would take for it to be accepted and adopted but I think it should be part of the specification to be able to embed text that would look the same everywhere and still be an object that contains the actual text.

I can see how practically you would more often just need your text to look the same everywhere and don't need semantic information since SVG's purpose is not describing documents, but it aims to be semantic in how it describes shapes and text so ideally we could encode it in a way that keeps that semantic information. Also it's pretty nice that it keeps text accessible to people who can't read it on screen.

So maybe we could make a proposal so we can come up with a cleaner solution in a distant future?


Currently, we can apparently embed fonts into the SVG code using @font-face with the font as base64 (see graphicdesign.stackexchange.com). I don't know if it is supported everywhere and it would probably make heavy files but it looks like a good solution.

When it comes to heaviness of the files, if the file is large enough and you embed the font and optimize it, it goes back to approximately its initial size according to vecta.io. Now on smaller files it would still make it significantly heavier. But we could edit the font file so it contains only the glyphs that are used (and maybe perform other optimizations - I don't know what's possible with font files) before embedding it.

As for it not being supported everywhere, it does look like this could be an issue (see caniuse.com) (and this is only browsers, also I don't know if it is testing only with remote webfonts that could be blocked for security reasons, or also base64-encoded ones). In this case I would at least make it possible to generate such outputs, if not default, but leave a "compatibility" option which just operates as the current SVG export.

Note: For what's it's worth, the SVG wiki states about embedding fonts "WOFF fonts encoded in data: URIs should suffice." (https://www.w3.org/Graphics/SVG/WG/wiki/SVG_Fonts)

Testing compatibility

If the left text appears normal and the right text handwritten, the embedded font works. So far I've seen it work in Firefox, Chromium and VSCode and not work in the GTK File Chooser Dialog preview. Also weirdly on my end, here on GitHub, the font works from the preview in the message but clicking the image opens it in a new tab and the font doesn't display there (though it does display when opening the local file).

text-with-font text-with-font-embedded

changed it so that every glyph is enclosed in its own with user-select: all. makes Arabic look much nicer selected, albeit still not perfect:

the user-select: all CSS property is used to make it impossible to select only part of a single-glyph ligature, which is not currently supported by Safari.

Isn't it just the browser's responsibility? If when just writing text by hand in an SVG file results in this behaviour it's nothing specific to our use case and trying to fix it makes the UX pretty bad.

textLength and lengthAdjust are used to make selection boxes line up perfectly with the glyphs, which is currently not supported by Firefox and derivatives.

Why not use texts instead of tspans then? Apparently it is not a great solution either:

Implementing this for <tspan> would work wonders for multiline text. Right now I have to use <text> for everything to get the proper fallback behavior, which has two problems:

  • Text selection gets weird (Firefox interprets <tspan> boundaries like inline elements, <text> boundaries like line breaks)
  • Assistive technology thinks each <text> should be treated like a paragraph

https://bugzilla.mozilla.org/show_bug.cgi?id=890692

@laurmaedje
Copy link
Member

I've looked into this a bit more together with @reknih and we've come to the following results and conclusions:

  • Support for text selection would in principle be nice and in scope, but not at the cost of worse rendering or compatibility. The main purpose of SVG export should remain exact reproduction.

  • SVG <font>s would have been a potentially nice solution, but they are deprecated, removed from SVG 2.0, and have never been implemented in Firefox. So unfortunately not at an option.

  • @font-face with a base64 encoded subset font is the thing that SVG <font>s were deprecated in favor of and are also a nice solution in principle. However, they are not widely supported outside of browsers, e.g. not in resvg. Emitting text only with this would hurt the "exact reproduction" goal too much.

  • The approach taken here is thus already quite close to the best we can do! (Let me also mention that I appreciate the amount of research put into compatibility here.) That said, in practice:

    • In Firefox it does not feel great due to the lack of support for textLength + lengthAdjust
    • In Chrome it does not feel great because the large number of individual <tspan> items (which are needed to set length and adjustment) lead to somewhat poor selection behavior
    • In Safari, it works actually surprisingly well! Though it also sometimes invents an extra space (probably some position-based heuristic).

With this in mind, we've come up with one more approach that I would like to bring to the discussion: What if we mix this PR with an optimized @font-face rule, but drop the poorly supported textLength + lengthAdjust?

  • We would still render the outlines as paths (as we do today and in this PR), leading to perfect reproduction.
  • We'd also add invisible <text> as done in this PR, potentially with <tspan> if manual positioning adjustments are needed, but without textLength + lengthAdjust.
  • Instead, we'd add a @font-face rule, with a highly subset / custom created font. We don't even need the outlines, so we could even generate a minimal OpenType font that basically only serves the purpose of providing metrics. Generating a minimal font with correct metrics should not be overly complex, but not sure whether it would be preferrable to do that or to just subset the font.

With this, the font-face rule should lead to correct bounding boxes in Firefox. In addition, we could probably drop a lot of the <tspan> elements (only writing them if actually needed because the shaper output differs from the glyph's advance; same as in PDF export), leading to potentially better selection behavior in Chrome. We would worsen the behavior in viewers that support textLength and lengthAdjust but not @font-face, but viewers that don't support @font-face probably often won't support selection anyway.

Thoughts?

@natri0
Copy link
Author

natri0 commented Mar 6, 2026

@laurmaedje making a font with just the metrics sounds good, i've actually originally intended to do that but decided there ought to be a simpler way (and, well, i wasn't sure if this is even wanted at all, so didn't wanna burn through a lot of effort for nothing. :p) and it's definitely a more robust solution!

looked into it a bit, and it seems that for a very basic implementation (i.e. no ligatures, probably no RTL) we'd need to only preserve head, hhea, hmtx tables? the first two being a required header. i do have a fear that applications might reject fonts that don't have glyphs (glyf table) in them, so might need to emit empty entries there.

as for the <tspan>s, there is a text-justify: inter-character css property that might (untested) do the same as typst's set par(justify: true), but it's not supported in Safari, and the issue in their bug tracker has been inactive for years, which doesn't make it more likely to get fix in the near future if you ask me.

@marie-bnl
Copy link

marie-bnl commented Mar 6, 2026

Your solution sounds really nice laurmaedje.

We'd also add invisible <text> as done in this PR, potentially with <tspan> if manual positioning adjustments are needed, but without textLength + lengthAdjust.

Could you give some examples of those manual positioning adjustments you're talking about?

With your solution, either the viewer supports @font-face and the text will be placed correctly already, or it doesn't and in this case the text will be misplaced but we can't predict it.

Preemptively, all we can do is either use a @font-face and hope the viewer supports it, or manually position everything which is already what this PR is doing and brings weird behaviour. I can't see which problem would be solved by introducing some manual adjustments along with your solution.

We would worsen the behavior in viewers that support textLength and lengthAdjust but not @font-face

I don't really understand that part. But textLength and lengthAdjust are just ignored in viewers that don't support them afaik, so maybe we could still use them in addition to providing the font with the metrics, so the text looks best in viewers that support these properties.

@natri0
Copy link
Author

natri0 commented Mar 6, 2026

Could you give some examples of those manual positioning adjustments you're talking about?

@marie-bnl not sure but i believe things like #h(1em) would need to break the text into tspan's. also probably paragraphs with justify: true would need (almost?) every character wrapped in a tspan thanks to the spacing getting modified.

@laurmaedje
Copy link
Member

looked into it a bit, and it seems that for a very basic implementation (i.e. no ligatures, probably no RTL) we'd need to only preserve head, hhea, hmtx tables? the first two being a required header. i do have a fear that applications might reject fonts that don't have glyphs (glyf table) in them, so might need to emit empty entries there.

glyf is not technically required, but it might be a good idea to generate one still. An empty glyf table is quite trivial to build as an empty glyph is just zero bytes. You'd still need a matching loca table that basically contains a bunch of zeros (representing the indices of all glyphs in the empty glyf table).

There are a couple other tables that are required by the OpenType spec though: https://learn.microsoft.com/en-us/typography/opentype/spec/otff#required-tables

Could you give some examples of those manual positioning adjustments you're talking about?

Basically, Typst will emit the text as a bunch of TextItems each containing a bunch of Glyph values. A TextItem is placed at a fixed position in the layout while the Glyph contains x_advance, x_offset, y_advance, and y_offset values. The cummulative advance values will affect the positioning of all following glyphs in the item, while the offsets only affect the specific glyph.

One TextItem would correspond to one <text>. Now, the question is when do we emit <tspan>? Ideally, we want to emit as few as possible. The thing is: If a Glyph's x_advance matches the advance width stored in the (synthesized) font and its x_offset is zero (ignoring Y for the moment), then the next glyph will automatically be positioned correctly because the browser will use that same metric.

However, it's not guaranteed that the x_advance is always the base one stored in the font. The x_advance could in a specific instance e.g. be smaller because there was a kerning pair. So what I proposed is writing the <tspan> only if strictly necessary because text shaping gave a different advance/offset than the naive font advance width. This is what we already do in PDF export.

Notably, in the browser's layout the advance width would always1 be the one from the font because our synthesized font would be so primitive: No kerning, etc.

not sure but i believe things like #h(1em) would need to break the text into tspan's. also probably paragraphs with justify: true would need (almost?) every character wrapped in a tspan thanks to the spacing getting modified.

h(1em) would result in separate TextItems, so that would be separate <text> elements. justify: true on the other hand could result in different advances, though not out of the box since character-level justification is opt-in via set par(justification-limits: ..).

Footnotes

  1. Probably there is some edge case where it does not match, but I couldn't think of one right now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

interface PRs that add to or change Typst's user-facing interface as opposed to internals or docs changes. svg Anything about the file format for people who like to have fun. text Related to the text category, which is all about text handling, shaping, etc. waiting-on-decision A decision must be made to proceed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants