RazrFalcon / resvg

An SVG rendering library.
Mozilla Public License 2.0
2.74k stars 220 forks source link

Wrong font used for `<text>` with multiple `<tspan>`s and fallback fonts #598

Closed paxbun closed 1 year ago

paxbun commented 1 year ago

usvg-text-layout 0.29 uses wrong fonts for <text> with multiple <tspan>s, where each <tspan> uses different fonts and some <tspan> contains texts that are not supported by the designated font.

For example, for the following SVG file,

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="300px" height="300px" version="1.1" xmlns="http://www.w3.org/2000/svg"
    xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:mei="http://www.music-encoding.org/ns/mei" overflow="visible">
    <g font-size="42px">
        <!-- Case 1: resvg renders the following wrongly -->
        <text x="30px" y="50px">
            <tspan font-family="Helvetica">가</tspan><tspan font-family="Times">Text</tspan>
        </text>

        <!-- Case 2: resvg renders Case 1 as the following -->
        <text x="30px" y="100px">
            <tspan font-family="Helvetica">가Text</tspan>
        </text>

        <!-- Case 3: resvg should render Case 1 as the following -->
        <text x="30px" y="150px">
            <tspan font-family="Helvetica">가</tspan>
        </text>
        <text x="72px" y="150px">
            <tspan font-family="Times">Text</tspan>
        </text>

        <!-- Case 4: resvg renders the following correctly -->
        <text x="30px" y="200px">
            <tspan font-family="Helvetica">Text</tspan><tspan font-family="Times">Text</tspan>
        </text>

        <!-- Case 5: Another example that resvg renders wrongly -->
        <text x="30px" y="250px">
            <tspan font-family="Times">Text</tspan><tspan font-family="Helvetica">가</tspan>
        </text>
    </g>
</svg>
use usvg::Options;
use usvg_text_layout::{fontdb, TreeTextToPath};

fn main() {
    let mut fontdb = fontdb::Database::new();
    fontdb.load_system_fonts();

    let svg_data = std::fs::read("input.svg").unwrap();
    let mut tree = usvg::Tree::from_data(&svg_data, &Options::default()).unwrap();
    tree.convert_text(&fontdb);

    let pixmap_size = tree.size.to_screen_size();
    let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height()).unwrap();
    resvg::render(
        &tree,
        usvg::FitTo::Original,
        tiny_skia::Transform::default(),
        pixmap.as_mut(),
    )
    .unwrap();

    pixmap.save_png("output.png").unwrap();
}

resvg renders the file as the following: issue

while Chrome renders the file as the following. image

As you can see in cases 1 and 5, resvg used a sans-serif font for the "Text" parts, though font-family is set to "Times" for those <tspan>s. This happens only when another <tspan> in the same <text> contains glyphs unsupported by its font.

It seems like usvg_text_layout::shape_text[_with_font] converts the entire <text> with a single font even if it contains multiple <tspan>s with different fonts and copies glyphs to a correct position later with span_contains, could you please share any reason for this logic?

Thanks for reading!

RazrFalcon commented 1 year ago

This is a known issue. #486 should help eventually.

Also, there are no such thing as a correct font fallback. Its behavior is unspecified. A library can do whatever it wants. So resvg isn't wrong here - just different from Chrome. Could it be improved - yes. Is it easy to do - no.

A proper SVG document must not rely on font fallback. It's up to the author to make sure that provided fonts have the required glyphs.

It seems like usvg_text_layout::shape_text[_with_font] converts the entire <text> with a single font even if it contains multiple <tspan>s with different fonts and copies glyphs to a correct position later with span_contains, could you please share any reason for this logic?

You will be surprised, but this is the correct behavior. In SVG text layout, shaping is done across text chunks, not text spans. In your case, resvg has to shape the whole text element (which contains a single text chunk in this case) twice. Once with Helvetica and once with Times. And then use glyphs according to spans. So resvg does work as expected. It's just the font fallback algorithm fallbacks to a different font compared to Chrome. Mainly because how dumb the current implementation is.

RazrFalcon commented 1 year ago

Duplicate of #486

paxbun commented 1 year ago

I'm new to the SVG and the CSS standard, so please let me know if I'm wrong.

In 15.2.2 Font family: the 'font-family' property of the CSS 2 standard, which SVG 1.1 is based on:

This property specifies a prioritized list of font family names ... that are tried in sequence to see if they contain a glyph for a certain character. ...

In 15.5 Font matching algorithm of CSS 2:

2. At a given element and for each character in that element, the UA assembles ...

though descriptors seem to be different from properties.

In addition, in 5. Font Matching Algorithm of CSS Fonts Module Level 4:

... For each character in the run a font family is chosen and a particular font face is selected containing a glyph for that character.

All standard publications I've read say that the font fallback logic should be done character-wise with a specific priority. From this point of view, shouldn't resvg render texts as Chrome does in my case?

RazrFalcon commented 1 year ago

This is a very abstract overview of the font fallback algorithm. There are much more nuances. For example, librsvg and QtSvg would fail to render this file as well. But in a different way. Writing reproducible SVGs is extremely hard. Even Safari would render this file wrong. It would use correct fonts, but would add needless whitespaces in cases 4 and 5.

I'm not sure what is the exact issue with this file. I would have to investigate it. Maybe this is not font fallback at all.