Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Font rendering (line-height) in 2024.3.X #860

Open
MercinaM opened this issue Apr 25, 2024 · 32 comments
Open

Font rendering (line-height) in 2024.3.X #860

MercinaM opened this issue Apr 25, 2024 · 32 comments

Comments

@MercinaM
Copy link

Describe the bug
Line height calculations seem to be behaving very differently in the 2024.3.X versions than they did in 2023.12.6 and before, in a way that seems like it might be a bug.

When using a line height of 1, multiple lines will now overlap, which is something I would only expect with a line height of less than 1 (but I am not an expert).

In 2023.12.6 multiple lines of text did not overlap with the same line height.

To Reproduce

public void Compose(IDocumentContainer container)
{
    container.Page(page =>
    {
        page.Margin(10f, Unit.Millimetre);

        page.DefaultTextStyle(TextStyle
            .Default
            .FontFamily(Fonts.SegoeUI)
            .LineHeight(1f)
            .FontSize(FontSizeConverter.CrystalVerdanaToQuestSegoeUI(9))
            .NormalWeight());

        page.Content().Column(column =>
        {
            column.Item().Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non dictum est.").BackgroundColor("#FF0000");
            column.Item().Height(5f, Unit.Millimetre);
            column.Item().Text("(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)(g)(j)(q)");
            column.Item().Text("OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO");
        });
    });
}

Expected behavior
I don't know what the expected behaviour is. I realize the 2024.3.X branch has an entirely new text renderer, and the way line heights are calculated might just be the way things work now. If so, we can obviously work around the issue by re-adjusting the line heights in our documents. But we are holding off on updating for now until this is clarified.

Screenshots

Before:
image

After:
image

Environment
Library version: 2024.3.1
OS: Windows 11

Additional context
Add any other context about the problem here.

@ebarnard
Copy link

I've noticed this as well when looking to upgrade. It seems like font height measuring has changed from top to bottom, to ascent to descent (as shown in the image below).

LwZJF
Source: https://stackoverflow.com/questions/27631736/meaning-of-top-ascent-baseline-descent-bottom-and-leading-in-androids-font

Another example of this is a header with filled background component that is less tall in 2024.3.X compared to previous versions (no changes have been made aside from QuestPDF version).
image

@MarcinZiabek
Copy link
Member

Thank you for noticing those changes 😄

Indeed, the text engine has been rebuilt from the ground up in the 2024.3.X release. Now, it is based on the SkParagraph module from the Skia project (used in Flutter) and should be more compliant with various Unicode standards (including better handling of RTL languages, text bidirectionality, splitting text to a new line, and applying text styles).

While I am confident that Skia SkParagraph is a superior solution, I am also aware that I could have made certain mistakes during the integration. Your feedback on this would be greatly appreciated.

@MercinaM While experimenting with CSS in the web browser, I found that the Verdana font behaves this way (putting certain glyph parts below the bottom line). It looks ugly, but does it seem to be the intended way?

@ebarnard Do you think it is correct to extend the first line to use top; and the last line to use bottom? I am just wondering what solution is proper from a typography perspective. I am for consistency with other technologies (Flutter, HTML, CSS, Word).

I am happy to collaborate on this topic.

@MercinaM
Copy link
Author

@MarcinZiabek I may be a little out of my depth here 😄.

That said, I did test how Browsers (tested in Firefox, but Chrome seems to behave the same way) handle the very same example (Segoe UI, line-height: 1), and I found that the way 2024.3.1 handles is mostly consistent (it does seem to be just slightly more squished together), and the way 2023.12.6 handled it was actually very inconsistent, so the new renderer may very well be the better choice in that regard:

2023.12.6
image

2024.3.1
image

Firefox (HTML)
image

Like I said, I don't actually know what the correct way of handling this is, I was mostly just looking for confirmation that this is intended behavior before I start modifying our documents.

@ebarnard
Copy link

ebarnard commented Apr 29, 2024

@MarcinZiabek I think @MercinaM is correct that 2023.12.6 did not lay out text in a "standard" way, but I'm not sure 2024.3.1 is correct either.

There seems to be a mismatch between how QuestPDF line-height is and how SkParagraph uses it. The example in the post above isn't too bad, but the SegoeUI example in the first post definitely seems incorrect.

It looks like font files include a default "leading" value which describes the spacing between adjacent lines. Typically this is the specified as the distance between the descent of one line and the ascent of the following line.

QuestPDF unconditionally sets SkParagraph's font height override/multiplier to be the line height, and uses 1 if the user does not specify a line height.

https://github.com/QuestPDF/QuestPDF.Native/blob/c9f384f08bf8941b7aa88ba33832420e668ebd1a/native/src/text/textStyle.cpp#L70C1-L71C40

This overrides the default leading specified in the font file (below and in other places in SkParagraph)

https://github.com/google/skia/blob/aeab79038011b6e3869834ebd2577da37859527f/modules/skparagraph/src/Run.cpp#L65C1-L67C6

The fix would seem to be to not override the font's default leading if the user does not specify a line height, and allow passing a line height of null or adding a DefaultLineHeight() method to reset any previously set line height.

@ebarnard
Copy link

I forgot to add why I think we should use the font's default leading/line height by default - in CSS the default value of line-height is normal which AFAIK uses the font's default built-in leading/line height.

@ebarnard
Copy link

The other thing is that we should ensure fUseHalfLeading is always true here and elsewhere in SkParagraph

https://github.com/google/skia/blob/aeab79038011b6e3869834ebd2577da37859527f/modules/skparagraph/src/Run.cpp#L65C1-L67C6

This also matches the behaviour of CSS where text remains centered in its parent element as you increase line-height (see jsfiddle below). This might already be the case.

https://jsfiddle.net/stao857n/

@MarcinZiabek
Copy link
Member

I forgot to add why I think we should use the font's default leading/line height by default - in CSS the default value of line-height is normal which AFAIK uses the font's default built-in leading/line height.

This is an important observation. Indeed, in CSS, the line-height property set to 1 or 100% produces slightly different results than the default normal value.

In Chrome, line-height set to normal:
image

And to 100%:
image

If I am not mistaken, in QuestPDF, the default line-height value (calculated with rules up to 2023.12.X revision) roughly corresponds to normal.

In new releases (starting from 2024.3.X), the library can follow default font metrics when the line-height is set to null, and also use null as a default.

@MarcinZiabek
Copy link
Member

The 2024.3.3 release fixes the font line-height rendering problem. Would you please give it a try?

Also, let me express my gratitude for helping me understand and resolve this problem. You are great! ❤️

@ebarnard
Copy link

ebarnard commented May 3, 2024

Default line height seems to be working sensibly in 2024.3.3.

Spacing around text is still not as expected. We should be using half leadings. Skia's default behaviour is to add the extra leading between lines unevenly so text is no longer centered in its parent container. This is the issue mentioned in #863.

We should be using half leadings as it's the behaviour used by HTML/CSS and is a lot more intuitive. If you draw some text with a line hight set to 2, and then draw a box around it, the text should be centered in the box.

This is done by setting setHalfLeading(true) on SkParahraph's TextStyle.

@blogcraft
Copy link

2024.3.4 fixed a lot of text differences for me!

@MarcinZiabek
Copy link
Member

In 2024.3.4, I followed @ebarnard suggestion. Applying half-leading indeed produces much better results.

@ebarnard Thank you for investigating this issue and helping me localize the appropriate enhancement. I highly appreciate your help!

@ebarnard
Copy link

ebarnard commented May 8, 2024

Thanks @MarcinZiabek, 2024.3.4 looks so much better.

I had a look at the change to QuestPDF.Native and I think we should always be using half leading, not just when line height is specified manually. When line height is automatic, there is still a line height which is set by the font, and in that case I think we still want text with automatic/default line height to be centered within its parent container.

@MercinaM
Copy link
Author

I've tried upgrading to 2024.3.x again, and one thing is still causing me problems in regards to line-heights.

The new font renderer seems to be clamping line-heights at certain intervals.

To show what I mean, here is an example that uses 8 distinct line height values:

container.Page(page =>
{
    page.Margin(10f, Unit.Millimetre);

    page.DefaultTextStyle(
        TextStyle.Default
            .FontFamily("Courier new")
            .FontSize(7.07f)
            .Bold()
    );

    page.Content().Column(column => {
        column.Item().Row(row => {
            foreach (var lineHeight in new[] { 1.2f, 1.21f, 1.28f, 1.34f, 1.35f, 1.45f, 1.48f, 1.49f })
            {
                var text = $"lh {lineHeight.ToString(CultureInfo.CreateSpecificCulture("en-GB"))}f";

                row.RelativeItem()
                    .Text($"{text}\n{text}\n{text}")
                    .LineHeight(lineHeight)
                    .BackgroundColor("#FF0000");
            }
        });

        column.Item()
            .BorderBottom(0.5f);
    });
});

What I'd expect to happen is that each line-height value would produce a slightly taller result. What actually happens, however, is that all values between 1.21f and 1.34f produce an identical result, as do all values between 1.35f and 1.48f.

image

The intervals at which the line-height is clamped seem to be sensitive to the font size (and possibly the font used, although I haven't tested that), so changing the font size in the example from 7.07f to i.e. 8.0f will produce a different result.

The way it current behaves makes it very hard to make minor corrections, and also has me worried that unless I always choose the value on the correct end of the clamped range (which would probably be the lowest value, assuming the library currently rounds down), a future library update may yet again change the look of our existing documents.

@MarcinZiabek
Copy link
Member

@MercinaM This behavior seems to be consistent with HTML and CSS.

image

It also happens for other fonts (Arial, Arial Bold, Times New Roman, Lato):

image image image image

Maybe it is related to some text-rendering optimization? Text hinting?

@MarcinZiabek
Copy link
Member

This effect is related to TextHeightBehavior:

paragraphStyle.setTextHeightBehavior(skia::textlayout::kAll); // works as CSS
paragraphStyle.setTextHeightBehavior(skia::textlayout::kDisableAll); // works as MercinaM expect
image

@MercinaM
Copy link
Author

@MarcinZiabek I think I've copied over your example exactly, but I'm getting a very different result:

<DOCTYPE html>
<html>
    <body>
        <div style="font-family: 'Courier new'; font-size: 20px; display: flex; flex-direction: row; align-content: end; font-smooth: never; text-rendering: geometricPrecision;">
            <span style="line-height: 1.14;">Line1<br/>Line2<br/>Line3<br/>1.14</span>
            <span style="line-height: 1.15;">Line1<br/>Line2<br/>Line3<br/>1.15</span>
            <span style="line-height: 1.18;">Line1<br/>Line2<br/>Line3<br/>1.18</span>
            <span style="line-height: 1.21;">Line1<br/>Line2<br/>Line3<br/>1.21</span>
            <span style="line-height: 1.24;">Line1<br/>Line2<br/>Line3<br/>1.24</span>
            <span style="line-height: 1.25;">Line1<br/>Line2<br/>Line3<br/>1.25</span>
        </div>
    </body>
</html>

Rendered in firefox (Windows 11):

image

In chrome (Windows 11):

image

@MarcinZiabek
Copy link
Member

Initially, have tested it on Safari. I confirm that it looks correct (as on your screenshots) on MacOS Chrome. Apparently there are differences between web browsers.

I am unsure what the equivalent of TextHeightBehavior is in CSS / HTML / Typography.

enum TextHeightBehavior {
    kAll = 0x0,
    kDisableFirstAscent = 0x1,
    kDisableLastDescent = 0x2,
    kDisableAll = 0x1 | 0x2,
};

Skia uses TextHeightBehavior::kAll; by default, which produces undesired results.

Link to Flutter documentation: https://api.flutter.dev/flutter/dart-ui/TextHeightBehavior-class.html

@ebarnard
Copy link

What's odd is it looks like if the kDisableFirstAscent or kDisableLastDescent bits are set, then LineMetricStyle for the relevent line is changed back from CSS (which is set due to us using setHalfLeading) to Typographic in the layout code below:
https://github.com/google/skia/blob/8cc93368482b200e122c61d54fe1b28d4e1d0e89/modules/skparagraph/src/TextWrapper.cpp#L507C1-L513C6

but this is not true if a paragraph contains only a single line:
https://github.com/google/skia/blob/8cc93368482b200e122c61d54fe1b28d4e1d0e89/modules/skparagraph/src/ParagraphImpl.cpp#L594

The behaviour here (from @MercinaM's original comment) looks like what I would expect from a bothced implementation of kDisableAll as the text is not always centered in its containing highlighted area:

image

Whereas it is in @MarcinZiabek's later example (which uses kDisableAll), so I'm very confused.

@ebarnard
Copy link

ebarnard commented May 31, 2024

As far as I am aware, when using TextHeightBehavior::kAll the height of each line should always be fontSize * lineHeightMultiplier, irrespective of setHalfLeading as that just changes where the text is positioned in the containing fontSize * lineHeightMultiplier` tall area.

When the kDisableFirstAscent bit is set, and setHalfLeading is set, the height of the first line will be a more complex expression that looks like a + b * lineHeightMultiplier.

In both cases there should not be this strange stepping/truncation behaviour.

But I think ideally we should be using TextHeightBehavior::kAll as the intended behaviour of that matches CSS and makes the resulting line height predictable.

@ebarnard
Copy link

@MarcinZiabek does TextHeightBehavior::kAll still give the same stepping behaviour with the other two style changes you made in 5d66a521?

@MarcinZiabek
Copy link
Member

@MarcinZiabek does TextHeightBehavior::kAll still give the same stepping behaviour with the other two style changes you made in 5d66a521?

Only the TextHeightBehavior option is related to the issue. I decided to adjust the other two, just in case.

The thing that confuses me is that:

  • TextHeightBehavior::kAll, which is Skia's default, produces incorrect results (stepping),
  • TextHeightBehavior::kDisableAll produces correct results.
image

@MarcinZiabek
Copy link
Member

OK... I must have been looking at the image too many times. TextHeightBehavior::kDisableAll improves the result but does not fix the issue. I am sorry for causing confusion 😢

@ebarnard
Copy link

I've just looked through all of the SkParagraph tests, and there are none that check resulting paragraph heights when using line heights close to 1.

There's also this comment which I haven't looked into but sounds like it could be the cause:

// The height of the current line is `round(ascent + descent)`.
double fHeight = 0.0;

@MarcinZiabek
Copy link
Member

I've just looked through all of the SkParagraph tests, and there are none that check resulting paragraph heights when using line heights close to 1.

There's also this comment which I haven't looked into but sounds like it could be the cause:

// The height of the current line is `round(ascent + descent)`.
double fHeight = 0.0;

After a quick look, this looks to be related to rounding: https://github.com/google/skia/blob/ce975ddfd9fb95eabcc53ae456cefd436f96e9ef/modules/skparagraph/src/TextLine.cpp#L1127

https://github.com/google/skia/blob/ce975ddfd9fb95eabcc53ae456cefd436f96e9ef/modules/skparagraph/src/TextLine.cpp#L53

https://github.com/google/skia/blob/ce975ddfd9fb95eabcc53ae456cefd436f96e9ef/include/private/base/SkFloatingPoint.h#L38

It seems to performing rounding operation with 2-point level precision. I am not sure if it is the root cause.

@ebarnard
Copy link

I think that rounding is disabled by calling ParagraphStyle's setApplyRoundingHack(false)

@ebarnard
Copy link

ebarnard commented May 31, 2024

It might be worth seeing if enabling a strut with all parameters matching the text results in the correct line spacing. I'm not sure how well this fits into Quest's API as it has to be enabled at the paragraph-level rather than text-span-level, but it would be an interesting experiment.

https://api.flutter.dev/flutter/painting/StrutStyle-class.html

@MarcinZiabek
Copy link
Member

There is 1px precision for line height. For example, for a font of 20pt, the available precision is 0.05 for line height. For smaller fonts, it gets worse, of course.

It's important to note that for each line, the precision issue accumulates, potentially leading to significant deviations in the overall layout.

I think it is caused by not respecting the hinting configuration here: font.setHinting(SkFontHinting::kSlight);

@ebarnard
Copy link

ebarnard commented Jun 1, 2024

I've gone through the SkParagraph source again and think I finally understand how it works and what is going on. It's layout process is as follows (all driven by ParagraphImpl::layout).

  1. Shape all text blocks in a paragraph into a single line of glyphs of unlimited length
    • ParagraphImpl::shapeTextIntoEndlessLine which creates and calls into OneLineShaper
  2. Add extra horizontal spacing between glyphs if required
    • ParagraphImpl::applySpacingAndBuildClusterTable
  3. Break up single line into clusters of glyphs that must stay together on a single line
    • ParagraphImpl::buildClusterTable
  4. Break up clusters into lines of a given maximum width
    • ParagraphImpl::breakShapedTextIntoLines
    • Fast path if all text fits on a single line
    • Otherwise calls TextWrapper::breakTextIntoLines

The rounding happens during part 4. Line height is retrieved here from InternalLineMetrics::height() and used later to increment the y position of a new line:
https://github.com/google/skia/blob/6ffe89f9b4cc8c1dae9c4a916f16f9c463e3fa6d/modules/skparagraph/src/TextWrapper.cpp#L378C2-L378C59

Unfortunately InternalLineMetrics::height() unconditionally rounds the line height to the nearest integer:
https://github.com/google/skia/blob/6ffe89f9b4cc8c1dae9c4a916f16f9c463e3fa6d/modules/skparagraph/src/Run.h#L476C1-L478C6

I suppose this makes sense for screen rendering where you probably do want the bottom of glyphs to align with pixel boundaries, but not for print where it's unlikely you'll ever open a PDF and view it at exactly 72 DPI.

Given Skia is built from source for QuestPDF.Native, I wonder if we could just apply a small patch to remove that call to round?

@MarcinZiabek
Copy link
Member

@ebarnard, You have done an extraordinary job! I am highly impressed! 😄

Given Skia is built from source for QuestPDF.Native, I wonder if we could just apply a small patch to remove that call to round?

While I am cautious about modifying the official Skia code due to the potential maintenance issues each change can introduce, this particular adjustment is minor. I hope it will not affect the quality in the future.

I would also like to finish this never-ending, yet highly important, typography discussion for at least a couple of weeks. I am so tired 🤣

@MarcinZiabek
Copy link
Member

I have integrated a git patch in the build process that attempts to fix the rounding problem in the text-height calculation. I can confirm that it works as expected.

QuestPDF/QuestPDF.Native@3b7c068

I plan to release this enhancement next week as part of the 2024.6.0 update.

Once again, thank you so much for your help, feedback and patience. It's a joy to collaborate with you 😄

@ebarnard
Copy link

Thank you so much for being so responsive to all my messages. It's great to get this all sorted.

@MarcinZiabek
Copy link
Member

I am happy to share that the LineHeight issue should be hopefully fixed in the 2024.6.0 release 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants