Skip to content

Don't round vertical metrics #297

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

Merged
merged 2 commits into from
Mar 26, 2025
Merged

Conversation

valadaptive
Copy link
Contributor

This is admittedly a bit of a blind fix. I'll have to do this anyway as part of implementing vertical alignment, which requires more precise measurement of vertical metrics, but this change alone fixes some issues I had when porting egui to use Parley. I'd still like to make the case that this is a desirable change on its own merits.

The rounding code seems to date back 3 years, and doesn't seem to have been examined since. It causes vertical alignment errors when manually setting the line height, which egui does to align different spans (especially with different fonts):
image

These problems are greatly reduced if the line height isn't rounded:
image

The line height is also double rounded. First, the ascent, descent, and line height are rounded. Then, leading is calculated from the rounded metrics, added to the ascent and descent, and rounded again to get the min/max coords of the line. These coordinates are what are used to increment y, and are thus the actual line height used when rendering. This means the rounding error also accumulates.

This calculated line height can be up to 1.5 pixels off per line. For instance, let's say there's an ascent of 12.3, a descent of 1.9, and a line height of 16.5. With the existing rounding, the calculated line height comes out to 18, which is 1.5 pixels higher than the true value.

If the API consumer wants to implement DPI scaling by scaling the rendered output and not the input font size (to avoid e.g. differences in line wrapping due to floating point error), they'll also have to end up rounding the vertical positions of the lines themselves in screen space. This adds yet another layer of rounding.

I think the correct place for rounding is in whichever renderer the API consumer uses--they can round the glyph positions once at whatever scale they want to, and avoid accumulating any rounding error.

@DJMcNab DJMcNab requested a review from dfrg March 17, 2025 10:26
@DJMcNab
Copy link
Member

DJMcNab commented Mar 17, 2025

This seems reasonable to me. I see that the snapshots end up having quite significant changes as a result of this, which is a little bit concerning.

Porting egui to Parley sounds exciting! Is there an upstream issue about this?

@valadaptive
Copy link
Contributor Author

This does affect line spacing, so every snapshot test with more than one line (and even some with one line if a single line's height is rounded differently) will be affected. One thing that is now occurring to me: wherever we round to pixels in screen-space, could that code assume the height is already rounded and do a truncating conversion? I'll try and look into that.

I have a draft PR tracking my egui work at emilk/egui#5784. A tracking issue here may be helpful.

@DJMcNab
Copy link
Member

DJMcNab commented Mar 18, 2025

@valadaptive would be willing to share that work in our office hours (#office hours on Zulip). It looks really exciting.

They're at 8:00AM San Francisco time on Thursdays.

@valadaptive
Copy link
Contributor Author

Sure! (as my sleep schedule permits)

@valadaptive
Copy link
Contributor Author

wherever we round to pixels in screen-space, could that code assume the height is already rounded and do a truncating conversion? I'll try and look into that.

The answer seems to be no; we actually take the ceiling of the width/height in the snapshot renderer in order to calculate the image dimensions and layout box dimensions. And there don't seem to be any other places where float-to-integer conversions or even any rounding happens.

@valadaptive valadaptive force-pushed the no-vertical-rounding branch from 8e90b8b to e1030f0 Compare March 23, 2025 23:30
@dfrg
Copy link
Collaborator

dfrg commented Mar 24, 2025

My concern with this change is that you really want baselines to have an integral y coordinate to avoid blurry rendering. This is essential if you’re applying hinting because the adjustments are meaningless otherwise.

And I chose to round the height rather than the computed baseline to guarantee consistent spacing between baselines (particularly in the common single font/size case). Otherwise, the accumulated fractional bits can lead to a 1 pixel jitter between lines and there’s nothing the user can do to correct for this.

I think my preference here would be to round (or ceil) only the final line height calculation and leave the others unrounded. If we want to offer fully fractional layouts, then maybe we can make this configurable.

@valadaptive
Copy link
Contributor Author

The problem is with fractional scaling, or really any case where you want to zoom into a piece of text. You have to either give the fractional scale to Parley and have the error accumulate over many lines, resulting in the height of a box of text fluctuating wildly as it changes scale, or do the scaling yourself when rendering and round the lines' vertical positions a second time.

Because I chose the second option and have to round lines' vertical positions myself before scaling, I don't gain anything from Parley doing it for me. This double rounding especially messes up vertical centering at fractional resolutions.

I think a better option would be to add support for absolute line height to the style settings--that way, if one wants to avoid the "jitter", they can provide a round number for the line height themselves.

Web browsers seem to choose the "jitter" option--with my font size settings, Wikipedia's line height jitters between 22px and 23px on Firefox, and between 25px and 26px on Chrome:

Firefox Chrome
image image

It's a little noticeable if you're paying attention, but not too objectionable IMO.

@dfrg
Copy link
Collaborator

dfrg commented Mar 25, 2025

The fact that browsers accept inconsistent line height seems like a compelling enough argument to move forward. Let’s go ahead and merge this. We’ll need to make sure all of our users (at least Blitz and Masonry) round baselines when rendering.

@xorgy xorgy added this pull request to the merge queue Mar 26, 2025
@xorgy xorgy added this to the 0.4 Release milestone Mar 26, 2025
Merged via the queue into linebender:main with commit c8ad88a Mar 26, 2025
21 checks passed
github-merge-queue bot pushed a commit that referenced this pull request May 7, 2025
## Background

Vertical metrics are no longer rounded since
#297 and so the rounding needs
to happen in the renderer. The upside is that there's potential for more
control, the downside is that it's another footgun. I initially created
documentation and updated examples in #343 but after going through that
I came to the conclusion that asking users to do that will be a major
inconvenience, and likely will lead to incorrect results.

## Solution

This PR here takes a different approach. We bring back quantization but
make it optional. That way advanced users who know what they're doing
can still get access to the raw fractions. However, for most users we
can recommend just using our quantization which will automatically take
care of the blurry text issue. The examples will also use the much
simpler quantization option.

## Implementation details

Now as pointed out in #297 our old quantization strategy was flawed
where rounding errors accumulated and weren't dealt with. I didn't want
to just bring that back so instead I played around with Chrome a bunch
to see how it does line height handling. This PR now contains a solution
that mimics Chrome.

We now only round `baseline`, `min_coord`, and `max_coord` in the
exported metrics. There's more rounding internally, but only temporarily
to calculate the aforementioned. So e.g. exported `ascent` will still be
the raw fractional. For the line's `y` position, when the accumulated
overflow reaches over half a pixel we will either overflow to the
previous line or start with a gap, depending on the direction of the
accumulation.

## Examples

As seen in the example below (*16.0px font size, 1.3em line height
(20.8px)*), we now match Chrome's behavior of overflowing the **1st,
4th, 9th, and 14th** line over the previous line. Best spotted by
looking at the edges of the red/green selection boxes. Also note that
despite the perceived line height fluctuating between lines due
positional changes, the rendered line height does not change. Easy to
see via the selection boxes, which are always the same height. (*They're
colored spans in Chrome, but colored spans are the same height as
selection boxes until line height is greater than ascent + descent,
which is not the case in this example - making it a valid comparison.*)

![16 0px-1
3em-2fps](https://github.com/user-attachments/assets/d80e3a2a-29b5-4166-933a-0172dbad3029)

Here's another example (*16.0px font size, 1.33125em line height
(21.3px)*) showing a scenario where gaps are formed due to the line
height rounding going in the other direction. Also the line height is
greater than ascent + descent, so the gaps are actually visible with
selection boxes.

| Chrome | Swash |
| --- | --- |
| ![16 0px-21
3px-chrome](https://github.com/user-attachments/assets/ea0fb537-1806-4fa2-b993-4f566b7260d6)
| ![16 0px-21
3px-swash](https://github.com/user-attachments/assets/32aadfde-670d-4a4b-a81b-15d3fd5a7c07)
|

Here's a bunch more examples:

| Notes | Chrome | Swash |
| --- | --- | --- |
| Mixing of fonts and sizes, 1.99em line height |
![mixed-fonts-chrome](https://github.com/user-attachments/assets/9d319bb7-7797-4e08-9541-c2976a1e9c30)
|
![mixed-fonts-swash](https://github.com/user-attachments/assets/a28c446a-9827-4a84-ba8f-b45b6f5e4173)
|
| 16px 0.83em (13.28px) | ![16 0px-0
83em-chrome](https://github.com/user-attachments/assets/e7314452-db2a-4980-adb7-8140820fa301)
| ![16 0px-0
83em-swash](https://github.com/user-attachments/assets/0d6a34ac-c1d9-469a-9f16-82032088328e)
|
| 19px 0.83em (13.28px) | ![19 0px-0
83em-chrome](https://github.com/user-attachments/assets/f4950b1c-7b65-4276-8acb-0d1e9db386c4)
| ![19 0px-0
83em-swash](https://github.com/user-attachments/assets/61315181-47b7-4122-b5a3-6c824f38c29e)
|

## Mismatches

However, it's not always a perfect match. Fractional font sizes are
rendered at a different size, which probably causes the layout changes
seen in the 18.5px example. Also, when going to very small line heights,
starting from around 0.65em Chrome has some additional logic to keep
perceived line height higher. I'm not exactly sure what it is, but it
accumulates by the time you get to tiny line heights like 0.25em. It's
not related to the rounding of the line height, because in the 0.25em
case the line height is exactly 4px. Anyway, not super important right
now, but might be worth figuring out later.

| Notes | Chrome | Swash |
| --- | --- | --- |
| 18.5px 0.83em (13.28px) | ![18 5px-0
83em-chrome](https://github.com/user-attachments/assets/11980f67-18c7-419b-8006-809712506b61)
| ![18 5px-0
83em-swash](https://github.com/user-attachments/assets/9d3b44f0-bfd6-4ff5-86b1-08c304a468c0)
|
| 16px 0.65em (10.4px) | ![16 0px-0
65em-chrome](https://github.com/user-attachments/assets/c5aa3452-0f1e-4eb7-9326-167dade930e3)
| ![16 0px-0
65em-swash](https://github.com/user-attachments/assets/9acff7c9-f933-42b9-8521-3f43381e871c)
|
| 16px 0.25em (4px) | ![16 0px-0
25em-chrome](https://github.com/user-attachments/assets/3016809f-ce1f-4b6c-b089-e4c20364797e)
| ![16 0px-0
25em-swash](https://github.com/user-attachments/assets/cf3a92d9-727c-4bc0-8601-7977fa94a33f)
|

---

Alternative to and closes #343.
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

Successfully merging this pull request may close these issues.

4 participants