Skip to content

Conversation

cfillion
Copy link
Contributor

@cfillion cfillion commented Aug 3, 2025

This pull request fixes the inconsistent font sizes (between fonts and between ImGui and other software) caused by ImGui treating the font size as the total pixel height instead of the size of the font's em square. (#4780, #8822)

Noto Sans vs Helvetica vs Segoe UI at 12px (FreeType):

Before:

before_noto_12px before_helvetica_12px before_segoe_12px

After:

after_noto_12px after_helvetica_12px after_segoe_12px

Firefox:

20250803_04h08m23s_grim

Baseline alignment when mixing multiple line heights in the same line is not handled (can't adjust already-drawn items after pushing a taller font later in the line...).


The first commit only adds the concept of line height. It's set to the font size so there are no visual changes with it alone.

I sifted through and individually checked all usages of the font size. Tab close buttons look better at the font size (while the titlebar's look best using the line height) so I tweaked CloseButton to allow any size.

I was uncertain about these non-rendering-related uses that also affect the X axis (left them unchanged):

  • ImGuiWindow::FontRefSize
  • ref_unit in BeginMenuEx
  • scroll_r.Expand in EndBoxSelect

The second commit sets the font size to be the em square size and the line ascender - descender. (FreeType's metrics.height includes line_gap which is undesirable.)

The old size behavior remains when ImFontCfg::SizePixels is greater than zero (for legacy backends, bitmap fonts like the default one...).

@cfillion cfillion changed the title Set font size to be the em square's size and add decouple from the line height Set font size to be the em square's size and decouple from the line height Aug 4, 2025
@ocornut
Copy link
Owner

ocornut commented Aug 4, 2025

Taking note that decoupling LineHeight from FontSize is also required for #4742.
It indeed would be good to merge this separately, thanks for splitting it.

@cfillion cfillion changed the base branch from docking to master August 5, 2025 04:09
@cfillion cfillion force-pushed the size=em-square branch 2 times, most recently from 1742dd1 to 4ec08d9 Compare August 5, 2025 04:27
@cfillion
Copy link
Contributor Author

cfillion commented Aug 5, 2025

Rebased against the master branch. Separate patch for the docking branch including conflict resolutions:

Expand
diff --git a/imgui.cpp b/imgui.cpp
index 277df8d3..345f9cca 100644
--- a/imgui.cpp
+++ b/imgui.cpp
@@ -19246,7 +19246,7 @@ static void ImGui::DockNodeCalcTabBarLayout(const ImGuiDockNode* node, ImRect* o
     ImGuiContext& g = *GImGui;
     ImGuiStyle& style = g.Style;
 
-    ImRect r = ImRect(node->Pos.x, node->Pos.y, node->Pos.x + node->Size.x, node->Pos.y + g.FontSize + g.Style.FramePadding.y * 2.0f);
+    ImRect r = ImRect(node->Pos.x, node->Pos.y, node->Pos.x + node->Size.x, node->Pos.y + g.LineHeight + g.Style.FramePadding.y * 2.0f);
     if (out_title_rect) { *out_title_rect = r; }
 
     r.Min.x += style.WindowBorderSize;
diff --git a/imgui_internal.h b/imgui_internal.h
remerge CONFLICT (content): Merge conflict in imgui_internal.h
index 91a4fe7e..61a8c18a 100644
--- a/imgui_internal.h
+++ b/imgui_internal.h
@@ -3848,13 +3848,8 @@ namespace ImGui
     IMGUI_API bool          CheckboxFlags(const char* label, ImU64* flags, ImU64 flags_value);
 
     // Widgets: Window Decorations
-<<<<<<< eda70b4e (Tabs: docking nodes use ImGuiTabBarFlags_FittingPolicyMixed. (explicit default, solely for discoverability). (#3421, #8800))
-    IMGUI_API bool          CloseButton(ImGuiID id, const ImVec2& pos);
-    IMGUI_API bool          CollapseButton(ImGuiID id, const ImVec2& pos, ImGuiDockNode* dock_node);
-=======
     IMGUI_API bool          CloseButton(ImGuiID id, ImVec2 pos);
-    IMGUI_API bool          CollapseButton(ImGuiID id, ImVec2 pos);
->>>>>>> 9c83827d (make font size be the em square's size when ImFontCfg::SizePixels is <= 0.0f)
+    IMGUI_API bool          CollapseButton(ImGuiID id, ImVec2 pos, ImGuiDockNode* dock_node);
     IMGUI_API void          Scrollbar(ImGuiAxis axis);
     IMGUI_API bool          ScrollbarEx(const ImRect& bb, ImGuiID id, ImGuiAxis axis, ImS64* p_scroll_v, ImS64 avail_v, ImS64 contents_v, ImDrawFlags draw_rounding_flags = 0);
     IMGUI_API ImRect        GetWindowScrollbarRect(ImGuiWindow* window, ImGuiAxis axis);
diff --git a/imgui_widgets.cpp b/imgui_widgets.cpp
remerge CONFLICT (content): Merge conflict in imgui_widgets.cpp
index 4e655c5c..04089171 100644
--- a/imgui_widgets.cpp
+++ b/imgui_widgets.cpp
@@ -923,12 +923,8 @@ bool ImGui::CloseButton(ImGuiID id, ImVec2 pos)
     return pressed;
 }
 
-<<<<<<< eda70b4e (Tabs: docking nodes use ImGuiTabBarFlags_FittingPolicyMixed. (explicit default, solely for discoverability). (#3421, #8800))
 // The Collapse button also functions as a Dock Menu button.
-bool ImGui::CollapseButton(ImGuiID id, const ImVec2& pos, ImGuiDockNode* dock_node)
-=======
-bool ImGui::CollapseButton(ImGuiID id, ImVec2 pos)
->>>>>>> 9c83827d (make font size be the em square's size when ImFontCfg::SizePixels is <= 0.0f)
+bool ImGui::CollapseButton(ImGuiID id, ImVec2 pos, ImGuiDockNode* dock_node)
 {
     ImGuiContext& g = *GImGui;
     ImGuiWindow* window = g.CurrentWindow;

Replaced ceil(metrics.height) with ceil(font->height * metrics.y_scale) to workaround the line height being sometimes 1px smaller than expected. FreeType rounds metrics.height (GRID_FIT_METRICS is always defined). (Typical case: height=round(16.22), ascender=ceil(12.53), descender=floor(-3.69) -> height=16px, asc-desc=17px)

(Also took the opportunity to replace ceil(metrics.descender) with floor for correctness. FreeType already does it so the ceil had no effect anyway.)

EDIT: Some fonts (eg. Segoe UI @ 12px) still had 1px discrepancy due to ceil(ascent - descent) != ceil(ceil(ascent) - floor(descent)). Replaced with max(metrics.height, ascent-descent).

I've also reverted the close/collapse button size change. On second thought, and especially with lower vertical frame padding settings (eg. 1), I think having them be line-height-tall looked a bit iffy.

EDIT2: Removed line gap from the line height after testing with a font that has a non-zero one (Nimbus Sans).

@ocornut
Copy link
Owner

ocornut commented Aug 26, 2025

I'll be trying to tackle this soon.

(1) Any reason why this is still a draft?

The old size behavior remains when ImFontCfg::SizePixels is greater than zero (for legacy backends, bitmap fonts like the default one...).

(2) It would be tempted to promote new behavior and introduce a ImFontFlags (opt-in or opt-out yet undecided). One issue is that ImFontFlags are not yet exposed nicely (currently only inside ImFontCfg) as the aim was to redesign AddFontXXX() API. My guess is that supporting font source sharing would have meaningful effect on whatever is the new API, so I cannot introduce it right now (otherwise I would add the ImFontFlags right away to AddFontXXX signatures).

EDIT2: Removed line gap from the line height after testing with a font that has a non-zero one (Nimbus Sans).

(3) Can you tell of a free font that exhibit same property?

(4) If you have time to rebase this, you can apply two minor changes

  • renamed g.LineHeight to g.FontLineHeight, would you agree?
  • ditch signature change for CloseButton() and CollapsingButton() in favor of copying the value before modifying.

@cfillion
Copy link
Contributor Author

cfillion commented Aug 27, 2025

(1) Any reason why this is still a draft?

Figured I'd give it time for additional testing, but I think it's good now: so far since releasing it, the only related reports I got from users have been from surprise at the increased size of the fonts which were previously rendered too small.

(3) Can you tell of a free font that exhibit same property?

Nimbus Sans is free (part of the Ghostscript font set shipped in many Linux distros): https://github.com/ArtifexSoftware/urw-base35-fonts/blob/master/fonts/NimbusSans-Regular.otf

FreeType: Ascent-Descent=14, FT_CEIL(metrics.height)=16 Stb: unscaled_line_gap*scale_for_layout = 2

Speaking of which (unrelated to the PR): that specific font renders a bit misaligned (higher than others). The following solves that but I don't know if it's the correct way to handle every font with a non-zero line gap value:

baked->Ascent += ImMax(0.0f, ((float)FT_CEIL(metrics.height) * scale) - (baked->Ascent - baked->Descent));
Before After

(4) If you have time to rebase this, you can apply two minor changes

Done!

@cfillion cfillion marked this pull request as ready for review August 27, 2025 02:35
@ocornut ocornut added this to the v1.93 milestone Aug 27, 2025
@ocornut
Copy link
Owner

ocornut commented Sep 8, 2025

Replaced ceil(metrics.height) with ceil(font->height * metrics.y_scale) to workaround the line height being sometimes 1px smaller than expected. FreeType rounds metrics.height (GRID_FIT_METRICS is always defined). (Typical case: height=round(16.22), ascender=ceil(12.53), descender=floor(-3.69) -> height=16px, asc-desc=17px)

I'm not sure I understand what this refers to. There's no use of y_scale in your code.

EDIT: Some fonts (eg. Segoe UI @ 12px) still had 1px discrepancy due to ceil(ascent - descent) != ceil(ceil(ascent) - floor(descent)). Replaced with max(metrics.height, ascent-descent)

Ditto, what code is this referring to? I don't see a max() in your code.

@cfillion
Copy link
Contributor Author

cfillion commented Sep 8, 2025

It's just ascent - descent now as per EDIT2 because the included line gap in metrics.height turned out to be undesirable.

The previous iterations of the PR were:

  1. baked->LineHeight = (float)FT_CEIL(metrics.height) * scale
    Could be smaller than ascent-descent because of FT_PIX_ROUND in FreeType
  2. baked->LineHeight = (float)FT_CEIL(FT_MulFix(bd_font_data->FtFace->height, metrics.y_scale)) * scale
    Could still be smaller in other cases because height is computed from unrounded ascent/descent (and line gap)
  3. baked->LineHeight = ImMax((float)FT_CEIL(metrics.height) * scale, baked->Ascent - baked->Descent)
    Including the font's line gap turned out to be wrong (eg. Nimbus Sans)
  4. baked->LineHeight = baked->Ascent - baked->Descent
    Current version

@ocornut
Copy link
Owner

ocornut commented Sep 8, 2025

Notes:

  • Try to compile with both #define IMGUI_ENABLE_FREETYPE and #define IMGUI_ENABLE_STB_TRUETYPE which allows dynamic toggling, which is useful to validate some calculation.
  • TableAngledHeadersRowEx() uses g.FontSize a few times is that desired?

Speaking of which (unrelated to the PR): that specific font renders a bit misaligned (higher than others). The following solves that but I don't know if it's the correct way to handle every font with a non-zero line gap value:
baked->Ascent += ImMax(0.0f, ((float)FT_CEIL(metrics.height) * scale) - (baked->Ascent - baked->Descent));

It seems like the correct recipe is to honor line_gap, which would get it closer to:

stb_truetype:

baked->Ascent = ImFloor((unscaled_ascent + unscaled_line_gap) * scale_for_layout);
baked->Descent = ImFloor(unscaled_descent * scale_for_layout);
baked->LineHeight = ImFloor((unscaled_ascent - unscaled_descent + unscaled_line_gap) * scale_for_layout);

freetype:

baked->Ascent     = (float)FT_CEIL(metrics.height + metrics.descender) * scale;
baked->Descent    = (float)(metrics.descender >> 6)  * scale;
baked->LineHeight = (float)FT_CEIL(metrics.height) * scale;

Which contradicts:

EDIT2: Removed line gap from the line height after testing with a font that has a non-zero one (Nimbus Sans).

You can test on NimbusSans-Regular.otf and ProggyVector.ttf which both have non-zero line gap but use it differently.

We could perfectly decide to expose an optional to "compact" lines but I think we first need to get to the bottom of using/understanding those values better.

@ocornut
Copy link
Owner

ocornut commented Sep 8, 2025

I found that the best way to reason about this is to enable both backends, and printout a maximum numbers of infos, e.g.

FT_Pos line_gap = metrics.height - metrics.ascender + metrics.descender;
IMGUI_DEBUG_LOG("[FreeType] '%s' at %.3f\n", src->Name, baked->Size);
IMGUI_DEBUG_LOG("[FreeType] -- in  ascender %.3f, descender %.3f, height %.3f, line_gap %.3f\n", metrics.ascender / 64.0f, metrics.descender / 64.0f, metrics.height / 64.0f, line_gap / 64.0f);
IMGUI_DEBUG_LOG("[FreeType] -- out Ascent %.2f, Descent %.2f, LineHeight %.2f\n", baked->Ascent, baked->Descent, baked->LineHeight);
IMGUI_DEBUG_LOG("[stb_true] '%s' at %.3f\n", src->Name, baked->Size);
IMGUI_DEBUG_LOG("[stb_true] -- in  ascent %.3f, descent %.3f, line_gap %.3f .. sum %.3f\n", unscaled_ascent * scale_for_layout, unscaled_descent * scale_for_layout, unscaled_line_gap * scale_for_layout, (unscaled_ascent - unscaled_descent + unscaled_line_gap) * scale_for_layout);
IMGUI_DEBUG_LOG("[stb_true] -- out Ascent %.2f, Descent %.2f, LineHeight %.2f\n", baked->Ascent, baked->Descent, baked->LineHeight);
[00001] [FreeType] 'NimbusSans-Regular.otf' at 30.000
[00001] [FreeType] -- in  ascender 22.000, descender -9.000, height 36.000, line_gap 5.000
[00001] [FreeType] -- out Ascent 27.00, Descent -9.00, LineHeight 36.00
[00067] [FreeType] 'ProggyVector-minimal.ttf' at 30.000
[00067] [FreeType] -- in  ascender 23.000, descender -8.000, height 33.000, line_gap 2.000
[00067] [FreeType] -- out Ascent 25.00, Descent -8.00, LineHeight 33.00
[00067] [FreeType] 'segoeui.ttf' at 30.000
[00067] [FreeType] -- in  ascender 33.000, descender -8.000, height 40.000, line_gap -1.000
[00067] [FreeType] -- out Ascent 32.00, Descent -8.00, LineHeight 40.00
[00273] [stb_true] 'NimbusSans-Regular.otf' at 30.000
[00273] [stb_true] -- in  ascent 21.870, descent -8.130, line_gap 6.000 .. sum 36.000
[00273] [stb_true] -- out Ascent 28.00, Descent -9.00, LineHeight 37.00
[00273] [stb_true] 'ProggyVector-minimal.ttf' at 30.000
[00273] [stb_true] -- in  ascent 22.300, descent -7.700, line_gap 3.357 .. sum 33.357
[00273] [stb_true] -- out Ascent 26.00, Descent -8.00, LineHeight 34.00
[00273] [stb_true] 'segoeui.ttf' at 30.000
[00273] [stb_true] -- in  ascent 32.373, descent -7.529, line_gap 0.000 .. sum 39.902
[00273] [stb_true] -- out Ascent 33.00, Descent -8.00, LineHeight 41.00

(don't mind exact number here, it's just an example of a way to look at the calc next to the visuals)

@ocornut
Copy link
Owner

ocornut commented Sep 8, 2025

In #8857 (comment) I edited the stb_truetype's baked->Ascent = line to use ImFloor() and I get matching Ascent/Descent/LineHeight value with both stb_truetype and FreeType for most fonts i tried (a dozen), basically aligning to FreeType's rounding, except for ArialUni.ttf. But Cousine-Regular.ttf looks too high in both cases.

@cfillion
Copy link
Contributor Author

cfillion commented Sep 8, 2025

TableAngledHeadersRowEx() uses g.FontSize a few times is that desired?

Yes, as they refer to the X axis rotated (plus line_off_step_x with LineHeight makes the text blurry at angle = 0).

baked->Ascent = (float)FT_CEIL(metrics.height + metrics.descender) * scale;
baked->LineHeight = (float)FT_CEIL(metrics.height) * scale;

Ah, nice! However it generally renders 1-2px higher than the full ascent because of that rounding FreeType does (eg. Segoe UI has face->horizontal.Line_Gap == 0 here, not -1). (Also produces 1-2px shorter lines than Firefox & Chromium w/ default line-height for comparison):

20250908_17h24m05s_grim (left = height+descender, right = ascender)

...that one pixel is very noticeable with Helvetica:

Screen Shot 2025-09-08 at 16 37 28

EDIT: Rounding differences depending on the font size:

Ascent: ascender -> height+descent, LineHeight: ascent-descent -> height
SegoeUI: [12px] Ascent: 13->12 LineHeight: 17->16
SegoeUI: [13px] Ascent: 15->13 LineHeight: 19->17 !!!
SegoeUI: [14px] Ascent: 16->15 LineHeight: 20->19
SegoeUI: [15px] Ascent: 17->16 LineHeight: 21->20
SegoeUI: [16px] Ascent: 18->16 LineHeight: 23->21 !!!
SegoeUI: [17px] Ascent: 19->18 LineHeight: 24->23
...
SegoeUI: [22px] Ascent: 24->23 LineHeight: 30->29
SegoeUI: [23px] Ascent: 25->25 LineHeight: 31->31 !
SegoeUI: [24px] Ascent: 26->25 LineHeight: 33->32
...
SegoeUI: [27px] Ascent: 30->29 LineHeight: 37->36
SegoeUI: [28px] Ascent: 31->29 LineHeight: 39->37 !!!
SegoeUI: [29px] Ascent: 32->31 LineHeight: 40->39
...
SegoeUI: [34px] Ascent: 37->36 LineHeight: 46->45
SegoeUI: [35px] Ascent: 38->38 LineHeight: 47->47 !
SegoeUI: [36px] Ascent: 39->38 LineHeight: 49->48
SegoeUI: [37px] Ascent: 40->39 LineHeight: 50->49
SegoeUI: [38px] Ascent: 41->41 LineHeight: 51->51 !
SegoeUI: [39px] Ascent: 43->42 LineHeight: 53->52
SegoeUI: [40px] Ascent: 44->42 LineHeight: 55->53 !!!
SegoeUI: [41px] Ascent: 45->44 LineHeight: 56->55

@cfillion
Copy link
Contributor Author

cfillion commented Sep 8, 2025

Perhaps it could dynamically switch depending on whether the font has a line gap?

const FT_Pos line_gap = metrics.height - metrics.ascender + metrics.descender;
baked->Descent = (float)(metrics.descender >> 6) * scale;
if (line_gap > 0)
{
    baked->Ascent     = (float)FT_CEIL(metrics.height + metrics.descender) * scale;
    baked->LineHeight = (float)FT_CEIL(metrics.height) * scale;
}
else
{
    baked->Ascent     = (float)FT_CEIL(metrics.ascender) * scale;
    baked->LineHeight = baked->Ascent - baked->Descent;
}

EDIT: Some level of alignment difference appears normal (happens in browsers too): https://jsfiddle.net/p4hq2atj/.
EDIT2: This produces almost the same line heights (except Noto Sans: +1px) as Firefox on Linux (FreeType):

20250908_20h33m07s_grim

EDIT3: To prevent FreeType's rounding from causing any false detections:

const FT_Pos line_gap = bd_font_data->FtFace->height - bd_font_data->FtFace->ascender + bd_font_data->FtFace->descender;

@ocornut
Copy link
Owner

ocornut commented Sep 9, 2025

Thanks for the details, though the sum of details gets easily confusing.
Your suggested logic (branching on line_gap>0) written as is doesn't seem to fix Helvetica or Cousine being too high? but using metrics.Ascender for Ascent does. I can't seem to find a suitable recipe yet, and it would be good to take stb_truetype into account as well.

@ocornut
Copy link
Owner

ocornut commented Sep 9, 2025

Perhaps it could dynamically switch depending on whether the font has a line gap?

What's the reasoning for that again? the (line_gap>0) path seems ok to me, where does it breaks?

const int LOGIC = 'A';
if (LOGIC == 'A')
{
    baked->Ascent = (float)FT_CEIL(metrics.height + metrics.descender) * scale;
    baked->LineHeight = (float)FT_CEIL(metrics.height) * scale;
}
else if (LOGIC == 'B')
{
    baked->Ascent = (float)FT_CEIL(metrics.height + metrics.descender) * scale;
    baked->LineHeight = baked->Ascent - baked->Descent;
}
else if (LOGIC == 'C')
{
    baked->Ascent = (float)FT_CEIL(metrics.ascender) * scale;
    baked->LineHeight = (float)FT_CEIL(metrics.height) * scale;
}
else if (LOGIC == 'D')
{
    baked->Ascent = (float)FT_CEIL(metrics.ascender) * scale;
    baked->LineHeight = baked->Ascent - baked->Descent;
}
Nimbus 30
    ascender 22.000, descender -9.000, height 36.000, line_gap 3.125/5.000
	A OK	 	out Ascent 27.00, Descent -9.00, LineHeight 36.00
	B OK    	out Ascent 27.00, Descent -9.00, LineHeight 36.00
    C KO  		out Ascent 22.00, Descent -9.00, LineHeight 36.00 (too high)
	D KO  		out Ascent 22.00, Descent -9.00, LineHeight 31.00 (too high)

Cousine 30
	ascender 25.000, descender -10.000, height 34.000, line_gap 0.000/-1.000
	A OK-ish  	out Ascent 24.00, Descent -10.00, LineHeight 34.00 (a little too high)
	B OK-ish    out Ascent 24.00, Descent -10.00, LineHeight 34.00
	C OK-ish    out Ascent 25.00, Descent -10.00, LineHeight 34.00
	D OK-ish    out Ascent 25.00, Descent -10.00, LineHeight 35.00

Helvetica 45
    ascender 35.000, descender -11.000, height 45.000, line_gap 0.000/-1.000
    A OK-ish    out Ascent 34.00, Descent -11.00, LineHeight 45.00
    B OK-ish    out Ascent 34.00, Descent -11.00, LineHeight 45.00
    C OK-ish    out Ascent 35.00, Descent -11.00, LineHeight 45.00 (bit better?)
    D OK-ish    out Ascent 35.00, Descent -11.00, LineHeight 46.00 (bit better?)

segoui 30
    ascender 33.000, descender -8.000, height 40.000, line_gap 0.000/-1.000
    A OK        out Ascent 32.00, Descent -8.00, LineHeight 40.00
    B OK        out Ascent 32.00, Descent -8.00, LineHeight 40.00
    C OK        out Ascent 33.00, Descent -8.00, LineHeight 40.00 (bit better?)
    D OK        out Ascent 33.00, Descent -8.00, LineHeight 41.00 (bit better?)

ProggyVector 30
    ascender 23.000, descender -8.000, height 33.000, line_gap 2.234/2.000
    A OK        out Ascent 25.00, Descent -8.00, LineHeight 33.00
    B OK        out Ascent 25.00, Descent -8.00, LineHeight 33.00
    C OK-ish    out Ascent 23.00, Descent -8.00, LineHeight 33.00 (too high)
    D OK-ish    out Ascent 23.00, Descent -8.00, LineHeight 31.00 (too high)

Which seems slightly improved with:

else if (LOGIC == 'E')
{
    float ascent_a = (float)FT_CEIL(metrics.height + metrics.descender) * scale;
    float ascent_c = (float)FT_CEIL(metrics.ascender)* scale;
    baked->Ascent = ImMax(ascent_a, ascent_c);
    baked->LineHeight = (float)FT_CEIL(metrics.height) * scale;
}

EDIT Pushed 045645e which may be helpful to compare fonts with less clicks.

@cfillion
Copy link
Contributor Author

cfillion commented Sep 9, 2025

(branching on line_gap>0) written as is doesn't seem to fix Helvetica or Cousine being too high? but using metrics.Ascender for Ascent does

Helvetica and Cousine don't have a line gap so they do use metrics.ascender.

Helvetica (1px too high/shorter in gap path, OK in non-gap path)
20250909_10h30m22s_grim 20250909_10h30m33s_grim

EDIT2: Noto Sans (same, looks visually too high in gap path)
20250909_12h31m20s_grim 20250909_12h31m35s_grim

Helvetica, Cousine and Nimbus Sans all look a bit high (same as non-gap path) in Firefox w/ FreeType so I assume it's just how the fonts are? At least Nimbus Sans w/ line_gap path is better than when I ignored the gap.

Segoe UI (1px too high/2px shorter in gap path)
20250909_10h31m21s_grim 20250909_10h31m33s_grim

The 'E' logic renders at the correct position but the line is still 2px too short (@ 13px):
20250909_11h29m47s_grim

EDIT: line_gap > 0 path compared to Firefox (same snippet as linked above):

20250909_12h01m58s_grim

@ocornut
Copy link
Owner

ocornut commented Sep 9, 2025

What settings or piece of code do you use to have a one to one mapping between Firebox and Dear ImGui, in terms of spacing and DPI handling?

for (int n = 0; n < io.Fonts->Fonts.Size; n++)
{
    ImFont* font = io.Fonts->Fonts[n];
    ImGui::PushFont(font, 0.0f);
    ImGui::Text("Test %s", font->GetDebugName());
    ImGui::DebugDrawItemRect();
    ImGui::PopFont();
}
image This is with `int LOGIC = (line_gap1 > 0) ? 'A' : 'D';` and I can't get it match as neatly.

@cfillion
Copy link
Contributor Author

cfillion commented Sep 9, 2025

I was using Button with FramePadding.y = 0 and ItemSpacing.y = 5. No scaling or other non-default settings. Firefox 142.0.1 on Linux using freetype2 2.13.3. Text+DebugDrawItemRect+ItemSpacing.y=5 has the red rectangles fit inside as well here. (5 = 3px margin-bottom + (1px border * 2))

I've compared with Firefox 115 on macOS (CoreText) and 142 on Windows (DirectWrite): it renders differently there so it's not consistent with itself across platforms...

I've traced where and how Firefox on Linux (FreeType) computes its metrics: it does essentially lineHeight = round(max(lineHeight, Ascent-Descent)) here. Before that it computes lineHeight directly from the os2 table (ascent-descent+gap) bypassing FreeType's rounding (it reaches line 383 at least with Noto & Nimbus). externalLeading is the line gap.

Values observed at the end of gfxFT2FontBase::InitMetrics:
Noto Sans: internalLeading = 6, externalLeading = 0, maxAscent = 17, maxHeight = 22, emHeight = 16
Nimbus Sans: internalLeading = 1, externalLeading = 2, maxAscent = 12, maxHeight = 17, emHeight = 16

Ported to ImGui:

diff --git i/misc/freetype/imgui_freetype.cpp w/misc/freetype/imgui_freetype.cpp
index 0eff6be0..78c6378a 100644
--- i/misc/freetype/imgui_freetype.cpp
+++ w/misc/freetype/imgui_freetype.cpp
@@ -48,6 +48,7 @@
 #include FT_GLYPH_H             // <freetype/ftglyph.h>
 #include FT_SIZES_H             // <freetype/ftsizes.h>
 #include FT_SYNTHESIS_H         // <freetype/ftsynth.h>
+#include FT_TRUETYPE_TABLES_H   // <freetype/ttables.h>
 
 // Handle LunaSVG and PlutoSVG
 #if defined(IMGUI_ENABLE_FREETYPE_LUNASVG) && defined(IMGUI_ENABLE_FREETYPE_PLUTOSVG)
@@ -454,11 +455,38 @@ bool ImGui_ImplFreeType_FontBakedInit(ImFontAtlas* atlas, ImFontConfig* src, ImF
     {
         // Read metrics
         FT_Size_Metrics metrics = bd_baked_data->FtSize->metrics;
+
+        baked->Ascent  = metrics.ascender  / 64.0f;
+        baked->Descent = metrics.descender / 64.0f;
+        baked->LineHeight = metrics.height / 64.0f;
+
+        const float y_scale = (metrics.y_scale / 65536.0f) / 64.0f;
+        TT_OS2* os2 = (TT_OS2*)FT_Get_Sfnt_Table(bd_font_data->FtFace, ft_sfnt_os2);
+        if (os2 && os2->sTypoAscender && y_scale > 0.0f)
+        {
+            baked->LineHeight = (os2->sTypoAscender - os2->sTypoDescender + os2->sTypoLineGap) * y_scale;
+
+            // Set maxAscent/Descent from the sTypo* fields instead of hhea if the OS/2 fsSelection USE_TYPO_METRICS bit is set
+            const uint16_t kUseTypoMetricsMask = 1 << 7;
+            if (os2->fsSelection & kUseTypoMetricsMask)
+            {
+                // Fixes Noto Sans being 1px taller
+                baked->Ascent  = ImFloor((os2->sTypoAscender * y_scale) + 0.5f);
+                baked->Descent = ImCeil((os2->sTypoDescender * y_scale) - 0.5f);
+            }
+        }
+
+        const float max_height = baked->Ascent - baked->Descent;
+        baked->LineHeight = ImFloor(ImMax(baked->LineHeight, max_height) + 0.5f);
+
+        const float internal_gap = ImFloor(max_height - baked->Size + 0.5f);
+        const float external_gap = baked->LineHeight - internal_gap - baked->Size;
+        baked->Ascent += external_gap;
+
         const float scale = 1.0f / rasterizer_density;
-        baked->Ascent     = (float)FT_CEIL(metrics.ascender) * scale;       // The pixel extents above the baseline in pixels (typically positive).
-        baked->Descent    = (float)(metrics.descender >> 6)  * scale;       // The extents below the baseline in pixels (typically negative).
-        baked->LineHeight = src->SizePixels > 0.0f ? baked->Size : baked->Ascent - baked->Descent; // metrics.height also includes the font's suggested line gap
-        //MaxAdvanceWidth = (float)FT_CEIL(metrics.max_advance) * scale;    // This field gives the maximum horizontal cursor advance for all glyphs in the font.
+        baked->Ascent *= scale;
+        baked->Descent *= scale;
+        baked->LineHeight *= scale;
     }
     return true;
 }

With that, Noto Sans at the bottom also matches (otherwise same as #8857 (comment)).

20250909_17h50m10s_grim

(Not sure if the complexity for that extra accuracy is really worth it though, or even if Firefox truly is a gold standard to strive for, versus just picking one of the simpler "good enough" approaches from above...)

EDIT: Improved version of the 'E' path from above, identical results to #8857 (comment) (= Noto Sans sometimes 1px taller than Firefox depending on the size):

else if (LOGIC == 'F')
{
    const float ascent_a = (float)FT_CEIL(metrics.height + metrics.descender);
    const float ascent_b = (float)FT_CEIL(metrics.ascender);
    baked->Ascent = ImMax(ascent_a, ascent_b) * scale;
    baked->LineHeight = baked->Ascent - baked->Descent;
}

Aka this PR currently (D, not LineHeight = metrics.height because that's often not enough&some fonts would render too low, Ascent = at least ascender to avoid other fonts appearing too high) + adding the line gap to the ascender (not just to the line height) for the fonts that have one (B).

@cfillion
Copy link
Contributor Author

cfillion commented Sep 10, 2025

At 12px (G=Firefox's logic, h=too high, l=too low, H=way too high, dot=OK):

A B C D E F G
San Francisco h h l . l . .
Segoe UI h h l . l . . metrics.height 2px shorter than Ascent-Descent at 13px
Noto Sans h h l . l . . G has line height -1px at 16px, -2px at 15px
DejaVu Sans h h l . l . . C&E = bottom of the 'y' chopped off in the Small Button
Arial . . . . . . .
Helvetica H H l h l h h C&E = 'y' chopped off
Helvetica Neue . . l . l . .
Lucida Grande h h l . l . .
Nimbus Sans h h H H h h h C&D = even higher than the others
Proggy Vector . . h . . . . D = shorter line (but still looks good)
Cousine h h h h h h h
Hack h h l . l . . C&E = 'y' chopped off
Input Mono h h l . l . .
Menlo h h l . l . . C&E = 'y' chopped off
screencap4.mp4

EDIT:

F vs G at increasing font sizes w/ Noto Sans

Notably, at 14px, Firefox'x logic (G) looks visually too low in my opinion.

12px
[NotoSans-Regular] LOGIC=F Ascent=13 Descent=-4 LineHeight=17
[NotoSans-Regular] LOGIC=G Ascent=13 Descent=-4 LineHeight=17

13px
[NotoSans-Regular] LOGIC=F Ascent=14 Descent=-4 LineHeight=18
[NotoSans-Regular] LOGIC=G Ascent=14 Descent=-4 LineHeight=18

14px
[NotoSans-Regular] LOGIC=F Ascent=15 Descent=-5 LineHeight=20
[NotoSans-Regular] LOGIC=G Ascent=15 Descent=-4 LineHeight=19

15px
[NotoSans-Regular] LOGIC=F Ascent=17 Descent=-5 LineHeight=22
[NotoSans-Regular] LOGIC=G Ascent=16 Descent=-4 LineHeight=20

16px
[NotoSans-Regular] LOGIC=F Ascent=18 Descent=-5 LineHeight=23
[NotoSans-Regular] LOGIC=G Ascent=17 Descent=-5 LineHeight=22

17px
[NotoSans-Regular] LOGIC=F Ascent=19 Descent=-5 LineHeight=24
[NotoSans-Regular] LOGIC=G Ascent=18 Descent=-5 LineHeight=23

18px
[NotoSans-Regular] LOGIC=F Ascent=20 Descent=-6 LineHeight=26
[NotoSans-Regular] LOGIC=G Ascent=20 Descent=-5 LineHeight=25

19px
[NotoSans-Regular] LOGIC=F Ascent=21 Descent=-6 LineHeight=27
[NotoSans-Regular] LOGIC=G Ascent=20 Descent=-6 LineHeight=26
EDIT2: I should note that I have my default FramePadding.y set to 2 (more is overkill when line height is used).

@ocornut
Copy link
Owner

ocornut commented Sep 25, 2025

Here are some immediate tasks I want to suggest you could look at:

[01]

(Not sure if the complexity for that extra accuracy is really worth it though, or even if Firefox truly is a gold standard to strive for, versus just picking one of the simpler "good enough" approaches from above...)

We above all need an implementation for stb_truetype (nb: if you compile both in you can dynamically change backend).
I think that matching output between Freetype and stb_truetype is more important than matching with e.g. Firefox.
There's a possibility that G (Firefox's logic) is not easy to get with stb_truetype?

But while we're there you could do a casual compare with e.g. Chrome, mostly as a sanity check.
Depending on both of those we can decide if we go for F or G.

(FYI part of the goal of the font loader refactor was to eventually allow for a DirectWrite backend, but I haven't started to write this at all. For reference I guess we'd pull things from DWRITE_FONT_METRICS https://learn.microsoft.com/en-us/windows/win32/api/dwrite/ns-dwrite-dwrite_font_metrics. In theory it'd be nice to confirm that we can obtain same metrics there... in practice it seems overstretching for now).

[2]
I worry a bit about the backward compat issues with separating g.FontSize from g.FontLineHeight but I do agree it is a necessary step onward, and we can probably find a way to maneuver through this without creating general havoc.

  • A portion of calls ImGui::GetFontSize() were done as a workaround to lacking a horizontal metrics (e.g. width of 'A') to compute some width or some height. Those won't be affected too much. But some are erroneously done for GetLineHeight().
  • As per your edits in imgui_widgets.cpp, many custom widgets/code probably copied direct use of g.FontSize, and those would definitively be affected.
  • We will investigate this later. I mean it - let's not worry about this right now else I'll be paralyzed.

Both to sort of have an easy transition/backup plan and to facilitate compact UI which is desirable for some users in imgui land, I think it would be good if we developed a feature that allows to dynamically cancel out the "extra" spacing that is PR is introducing.

My current vision is that we would have a floating point factor, 0.0f to 1.0f, which is part of the style. Where 1.0f would be the new default and behave like in the current PR, and 0.0f would ensure that g.FontSize == g.FontLineHeight, and anything between is smooth. For some fonts it may not make a difference.

I'm not sure yet if implementing this may be done from the current Ascent/Descent/LineHeight values. We currently bake font_baked->Ascent into ImFontGlyph::, so at minimum, that new feature would likely introduce a Y offset on each new line in CalcTextSize/RenderText function.
Changing that value will likely require calling ImGui::UpdateCurrentFontSize() internally to recompute whatever new offset is needed and to recompute g.FontLineHeight which is then not a straight copy of g.FontBaked->LineHeight. By which I mean, that logic should be reduced to one spot ideally + honor new offset in CalcTextSize/RenderText function.

What do you think?
Note that once we have that logic in, it becomes natural to support #4742.
We might end up with two components altering the gap:

  • one scale factor over the natural font gap (above)
  • one increment, but which is probably is itself expressed as a scale factor of e.g. FontSize, to facilitate dynamic resizing.

@ocornut
Copy link
Owner

ocornut commented Sep 26, 2025

My current vision is that we would have a floating point factor, 0.0f to 1.0f, which is part of the style.

I thought about this again and I believe it is best if part of the font, not the style.
This way we don't need to introduce states or params everywhere (eg. in RenderText).
We can still allow dynamic tweaking in font panels, but there's no point in allowing code to keep editing this value.

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

Successfully merging this pull request may close these issues.

2 participants