From de794acc5bb889450ada0faf9522867de04e6614 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Tue, 26 May 2026 10:53:51 +1200 Subject: [PATCH 1/8] Improve TeX spacing and bounds --- reference/data/spacing.jl | 3 +- src/engine/layout.jl | 18 ++++++++++-- src/engine/texelements.jl | 53 +++++++++++++++++++++++++++-------- test/layout.jl | 58 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 16 deletions(-) diff --git a/reference/data/spacing.jl b/reference/data/spacing.jl index 0ddd161..14fb1f4 100644 --- a/reference/data/spacing.jl +++ b/reference/data/spacing.jl @@ -6,6 +6,7 @@ const SPACING = Dict( raw"(t)", raw"\eta(t)", raw"\alpha(t)", + raw"W(\alpha,\alpha^*)", raw"g(f(x))", raw"\mathrm{y}(x)", raw"\mathrm{g}t", @@ -91,4 +92,4 @@ const SPACING = Dict( raw"\left(\alpha_{(i+j)_k}\right)^2", raw"\frac{\partial^2 f}{\partial x_i\partial x_j}", ], -) \ No newline at end of file +) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 3562022..e2149f2 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -25,6 +25,7 @@ const _SCRIPT_SHRINK_HEIGHT_FACTOR = 1.5 const _SQRT_TALL_CONTENT_CLEARANCE_FACTOR = 0.25 const _SQRT_RULE_CONTENT_DESCENT = 0.9 const _SQRT_RULE_PADDING = 0.12 +const _SQRT_TRAILING_PADDING = 0.06 const _SLANTED_ADJACENT_GAP = 0.03 const _DISPLAY_OPERATOR_DELIMITER_HEIGHT = 1.35 const _BRACE_RULE_AXIS_PADDING = 0.2 @@ -234,7 +235,7 @@ end function _sqrt_clearance(content, font_family) xh = xheight(font_family) - clearance = _has_rule_element(content) ? xh / 3 : xh / 2 + clearance = xh / 2 return max(thickness(font_family), clearance) end @@ -494,14 +495,19 @@ function tex_layout(expr, state) rule_thickness hline = HLine(hline_width, rule_thickness) hline_x = rightinkbound(radical) - rule_thickness / 2 + hline_right = hline_x + rightinkbound(hline) + content_right = rightinkbound(content) + target_hadvance = hline_right + _SQRT_TRAILING_PADDING * xh + trailing_space_x = min(content_right, target_hadvance) + trailing_space = target_hadvance - trailing_space_x return Group( - [radical, hline, content, Space(1.2)], + [radical, hline, content, Space(trailing_space)], Point2f[ (0, y0), (hline_x, line_y), (rightinkbound(radical), 0), - (rightinkbound(content), 0), + (trailing_space_x, 0), ], ) elseif head == :text @@ -641,12 +647,18 @@ function italic_transition_offset(prev, elem) # Positive left bearings on italic glyphs make e.g. "(t)" look # asymmetric. Remove that extra font-side gap at roman-to-italic edges. + prev isa TeXChar && _preserves_italic_left_bearing(prev) && return 0.0 return -bearing end return 0.0 end +_preserves_italic_left_bearing(char::TeXChar) = + isdigit(char.represented_char) || _is_math_punctuation(char.represented_char) + +_is_math_punctuation(char) = char in (',', ';', '.', '!') + function slanted_adjacent_offset(prev, elem) top = min(topinkbound(prev), topinkbound(elem)) bottom = max(bottominkbound(prev), bottominkbound(elem)) diff --git a/src/engine/texelements.jl b/src/engine/texelements.jl index a983448..6fa09f7 100644 --- a/src/engine/texelements.jl +++ b/src/engine/texelements.jl @@ -332,24 +332,39 @@ is_slanted(g::Group) = g.slanted xpositions(g::Group) = [p[1] for p in g.positions] ypositions(g::Group) = [p[2] for p in g.positions] +has_ink(::TeXElement) = true +has_ink(::Space) = false +has_ink(g::Group) = any(has_ink, g.elements) + +function _group_bound(g::Group, bound, coordinate, combine) + result = 0.0 + found_ink = false + + for (elem, position, scale) in zip(g.elements, g.positions, g.scales) + has_ink(elem) || continue + + child_bound = Float64(position[coordinate] + scale * bound(elem))::Float64 + result = found_ink ? combine(result, child_bound) : child_bound + found_ink = true + end + + return found_ink ? result : 0.0 +end + function leftinkbound(g::Group) - lefts = leftinkbound.(g.elements) .* g.scales .+ xpositions(g) - return minimum(lefts) + return _group_bound(g, leftinkbound, 1, min) end function rightinkbound(g::Group) - rights = rightinkbound.(g.elements) .* g.scales .+ xpositions(g) - return maximum(rights) + return _group_bound(g, rightinkbound, 1, max) end function bottominkbound(g::Group) - bottoms = bottominkbound.(g.elements) .* g.scales .+ ypositions(g) - return minimum(bottoms) + return _group_bound(g, bottominkbound, 2, min) end function topinkbound(g::Group) - tops = topinkbound.(g.elements) .* g.scales .+ ypositions(g) - return maximum(tops) + return _group_bound(g, topinkbound, 2, max) end function hadvance(g::Group) @@ -358,13 +373,27 @@ function hadvance(g::Group) end function ascender(g::Group) - asc = ypositions(g) .+ ascender.(g.elements) .* g.scales - return maximum(asc) + asc = topinkbound(g) + for (elem, position, scale) in zip(g.elements, g.positions, g.scales) + has_ink(elem) || continue + iszero(position[2]) || continue + child_ascender = Float64(scale * ascender(elem))::Float64 + asc = max(asc, child_ascender) + end + + return asc end function descender(g::Group) - des = ypositions(g) .+ descender.(g.elements) .* g.scales - return minimum(des) + des = bottominkbound(g) + for (elem, position, scale) in zip(g.elements, g.positions, g.scales) + has_ink(elem) || continue + iszero(position[2]) || continue + child_descender = Float64(scale * descender(elem))::Float64 + des = min(des, child_descender) + end + + return des end xheight(g::Group) = maximum(xheight.(g.elements) .* g.scales) diff --git a/test/layout.jl b/test/layout.jl index a64a4d1..0c638e3 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -20,6 +20,21 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, elements)) / 2 @testset "Layout" begin + @testset "Group ink bounds" begin + @test rightinkbound(Space(1.2)) == 1.2 + + trailing_space_layout = tex_layout(texparse(raw"x\;"), FontFamily()) + @test rightinkbound(trailing_space_layout) < hadvance(trailing_space_layout) + @test rightinkbound(trailing_space_layout) ≈ rightinkbound(trailing_space_layout.elements[1]) + + internal_space_layout = tex_layout(texparse(raw"x\;y"), FontFamily()) + @test rightinkbound(internal_space_layout) > + internal_space_layout.positions[2][1] + rightinkbound(internal_space_layout.elements[2]) + + digit = tex_layout(manual_texexpr((:digit, '2')), FontFamily()) + @test MathTeXEngine.ascender(digit) > topinkbound(digit) + end + @testset "Decorated" begin expr = manual_texexpr((:decorated, 'x', 'b', 't')) layout = tex_layout(expr, FontFamily()) @@ -190,6 +205,28 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele @test ink_left(with[2]) - ink_right(with[1]) > ink_left(without[2]) - ink_right(without[1]) @test ink_left(with[2]) - ink_right(with[1]) > 0.01 + + MathTeXEngine.italic_correction_enabled[] = false + without = generate_tex_elements(L"2ab") + MathTeXEngine.italic_correction_enabled[] = true + with = generate_tex_elements(L"2ab") + + # Digit-letter juxtaposition represents implicit multiplication, + # not a delimiter boundary, so keep the natural digit-to-italic gap + # while still applying adjacent slanted-glyph correction to ab. + @test ink_left(with[2]) - ink_right(with[1]) ≈ + ink_left(without[2]) - ink_right(without[1]) + @test ink_left(with[3]) - ink_right(with[2]) > 0.01 + + MathTeXEngine.italic_correction_enabled[] = false + without = generate_tex_elements(L"W(\alpha,\alpha^*)") + MathTeXEngine.italic_correction_enabled[] = true + with = generate_tex_elements(L"W(\alpha,\alpha^*)") + + # Punctuation in multi-argument labels should keep the natural + # breathing room before the next italic/Greek argument. + @test ink_left(with[5]) - ink_right(with[4]) ≈ + ink_left(without[5]) - ink_right(without[4]) atol = 1.0e-6 finally MathTeXEngine.italic_correction_enabled[] = old end @@ -260,6 +297,27 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele @test ink_right(frac_elems[2]) - maximum(ink_right(e) for e in frac_elems[3:end]) < 0.1 + sqrt_layout = tex_layout(texparse(raw"\sqrt{\frac{1}{2}}"), FontFamily()).elements[1] + @test rightinkbound(sqrt_layout) < hadvance(sqrt_layout) + @test sqrt_layout.positions[2][1] + rightinkbound(sqrt_layout.elements[2]) ≈ + rightinkbound(sqrt_layout) + + followed_sqrt_layout = tex_layout(texparse(raw"\sqrt{\frac{1}{2}}\sin(x)"), FontFamily()) + @test followed_sqrt_layout.positions[2][1] ≈ hadvance(sqrt_layout) + + simple_sqrt_layout = tex_layout(texparse(raw"\sqrt{2}"), FontFamily()).elements[1] + @test MathTeXEngine.ascender(simple_sqrt_layout) ≈ topinkbound(simple_sqrt_layout) + + ylabel_layout = tex_layout( + texparse(L"x + y - \sin(x) × \tan(y) + \sqrt{2}"), + FontFamily(), + ).elements[1] + @test MathTeXEngine.ascender(ylabel_layout) ≈ topinkbound(ylabel_layout) + + wide_frac_elems = generate_tex_elements(L"\sqrt{\frac{1+6}{4+a+g}}") + @test ink_bottom(wide_frac_elems[2]) - maximum(ink_top(e) for e in wide_frac_elems[3:end]) > + 0.4 * xheight(MathTeXEngine.FontFamily()) + simple_elems = generate_tex_elements(L"\sqrt{b^2 - 4ac}") @test ink_bottom(simple_elems[1]) > -0.4 end From 9dace5f63ce6b51104839d642a8a364af2e11568 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Wed, 27 May 2026 06:21:38 +1200 Subject: [PATCH 2/8] Remove base sqrt radical candidate --- src/engine/layout.jl | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index e2149f2..79883bd 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -100,7 +100,7 @@ end function _sqrt_radical(state, target_height) font_family = state.font_family - radicals = TeXElement[TeXChar('√', state, :symbol)] + radicals = TeXElement[] for radical_name in ("radical.v1", "radical.v2", "radical.v3", "radical.v4") candidate = TeXChar(radical_name, state, :symbol; represented = '√') @@ -112,9 +112,6 @@ function _sqrt_radical(state, target_height) sort!(radicals; by = inkheight) for candidate in radicals - if candidate.glyph_id == 0 - continue - end inkheight(candidate) >= target_height && return candidate end From 17b1d4421fc6e1b2f0af5233ad307f6f50a07f59 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Wed, 27 May 2026 07:44:21 +1200 Subject: [PATCH 3/8] Resolve sqrt radicals by font capability --- src/engine/layout.jl | 66 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 79883bd..6ff3c82 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -98,24 +98,58 @@ function _has_rule_element(elem) return false end -function _sqrt_radical(state, target_height) - font_family = state.font_family +const _sqrt_radical_name_sets = ( + ("radical.v1", "radical.v2", "radical.v3", "radical.v4", "radical.v5", "radical.v6"), + ("sqrt.v1", "sqrt.v2", "sqrt.v3", "sqrt.v4"), +) + +function _sqrt_radical_variants(state) radicals = TeXElement[] + for radical_names in _sqrt_radical_name_sets + for radical_name in radical_names + candidate = TeXChar(radical_name, state, :symbol; represented = '√') + candidate.glyph_id != 0 && push!(radicals, candidate) + end + end - for radical_name in ("radical.v1", "radical.v2", "radical.v3", "radical.v4") - candidate = TeXChar(radical_name, state, :symbol; represented = '√') - candidate.glyph_id != 0 && push!(radicals, candidate) + sort!(radicals; by = inkheight) + return radicals +end - fallback = default_math_texchar(radical_name, font_family, '√') - isnothing(fallback) || push!(radicals, fallback) +function _default_sqrt_radical_variants(font_family) + radicals = TeXElement[] + for radical_name in first(_sqrt_radical_name_sets) + radical = default_math_texchar(radical_name, font_family, '√') + isnothing(radical) || push!(radicals, radical) end sort!(radicals; by = inkheight) + return radicals +end + +function _select_sqrt_radical(radicals, target_height) for candidate in radicals inkheight(candidate) >= target_height && return candidate end - return last(radicals) + return isempty(radicals) ? nothing : last(radicals) +end + +function _sqrt_radical(state, target_height, content) + fallback_radicals = _default_sqrt_radical_variants(state.font_family) + if !_has_rule_element(content) + radical = _select_sqrt_radical(fallback_radicals, target_height) + isnothing(radical) || return radical + end + + native_radicals = _sqrt_radical_variants(state) + radical = _select_sqrt_radical(native_radicals, target_height) + isnothing(radical) || return radical + + radical = _select_sqrt_radical(fallback_radicals, target_height) + isnothing(radical) || return radical + + throw(ArgumentError("No square-root radical glyph found")) end const _math_delimiter_chars = Set(['(', ')', '[', ']', '{', '}', '⟨', '⟩', '|', '‖']) @@ -390,9 +424,19 @@ function tex_layout(expr, state) ytop = y0 + xh / 2 - bottominkbound(numerator) ybottom = y0 - xh / 2 - topinkbound(denominator) + elements = [line, numerator, denominator] + positions = Point2f[(xline, y0), (x1, ytop), (x2, ybottom)] + fraction_left = minimum( + position[1] + leftinkbound(element) for + (element, position) in zip(elements, positions) + ) + if fraction_left < 0 + positions = positions .+ Ref(Point2f(-fraction_left, 0)) + end + return Group( - [line, numerator, denominator], - Point2f[(xline, y0), (x1, ytop), (x2, ybottom)]; + elements, + positions; slanted = is_slanted(numerator) || is_slanted(denominator), ) elseif head == :function @@ -476,7 +520,7 @@ function tex_layout(expr, state) clearance = _sqrt_clearance(content, font_family) radical_clearance = _sqrt_radical_extra_height(content, font_family) target_height = inkheight(content) + radical_clearance - radical = _sqrt_radical(state, target_height) + radical = _sqrt_radical(state, target_height, content) line_top = topinkbound(content) + clearance if _has_rule_element(content) From bfa074a75bdb6c76d9969992be01d837929043ac Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Wed, 27 May 2026 10:08:15 +1200 Subject: [PATCH 4/8] Refine sqrt radical selection --- src/engine/layout.jl | 69 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 6ff3c82..32b5107 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -23,7 +23,10 @@ const _SCRIPT_FRACTION_RULE_SHIFT = 0.18 const _TALL_SCRIPT_HEIGHT_FACTOR = 1.5 const _SCRIPT_SHRINK_HEIGHT_FACTOR = 1.5 const _SQRT_TALL_CONTENT_CLEARANCE_FACTOR = 0.25 -const _SQRT_RULE_CONTENT_DESCENT = 0.9 +const _SQRT_RULE_CONTENT_DESCENT = 0.25 +const _SQRT_TALL_CONTENT_DESCENT = 0.25 +const _SQRT_TALL_CONTENT_HEIGHT = 1.75 +const _SQRT_MAX_RADICAL_STRETCH = 1.25 const _SQRT_RULE_PADDING = 0.12 const _SQRT_TRAILING_PADDING = 0.06 const _SLANTED_ADJACENT_GAP = 0.03 @@ -118,6 +121,9 @@ end function _default_sqrt_radical_variants(font_family) radicals = TeXElement[] + radical = default_math_texchar('√', font_family, '√') + isnothing(radical) || push!(radicals, radical) + for radical_name in first(_sqrt_radical_name_sets) radical = default_math_texchar(radical_name, font_family, '√') isnothing(radical) || push!(radicals, radical) @@ -128,30 +134,52 @@ function _default_sqrt_radical_variants(font_family) end function _select_sqrt_radical(radicals, target_height) + previous = nothing for candidate in radicals - inkheight(candidate) >= target_height && return candidate + if inkheight(candidate) >= target_height + if !isnothing(previous) + stretch = target_height / inkheight(previous) + stretch <= _SQRT_MAX_RADICAL_STRETCH && return previous, stretch + end + + return candidate, 1.0 + end + + previous = candidate end - return isempty(radicals) ? nothing : last(radicals) + return isempty(radicals) ? nothing : (last(radicals), 1.0) end -function _sqrt_radical(state, target_height, content) +function _sqrt_radical_candidates(state, content) fallback_radicals = _default_sqrt_radical_variants(state.font_family) if !_has_rule_element(content) - radical = _select_sqrt_radical(fallback_radicals, target_height) - isnothing(radical) || return radical + if _is_tall_sqrt_content(content, state.font_family) + return filter(radical -> inkheight(radical) > 1, fallback_radicals) + end + + return fallback_radicals end native_radicals = _sqrt_radical_variants(state) - radical = _select_sqrt_radical(native_radicals, target_height) + isempty(native_radicals) || return native_radicals + + return fallback_radicals +end + +function _sqrt_radical(state, target_height, content) + radical = _select_sqrt_radical(_sqrt_radical_candidates(state, content), target_height) isnothing(radical) || return radical - radical = _select_sqrt_radical(fallback_radicals, target_height) + radical = _select_sqrt_radical(_default_sqrt_radical_variants(state.font_family), target_height) isnothing(radical) || return radical throw(ArgumentError("No square-root radical glyph found")) end +_is_tall_sqrt_content(content, font_family) = + topinkbound(content) > _SQRT_TALL_CONTENT_HEIGHT * xheight(font_family) + const _math_delimiter_chars = Set(['(', ')', '[', ']', '{', '}', '⟨', '⟩', '|', '‖']) const _display_operator_chars = Set(['∫', '∑', '∏']) const _delimiter_axis_operator_chars = @@ -518,16 +546,23 @@ function tex_layout(expr, state) rule_thickness = thickness(font_family) xh = xheight(font_family) clearance = _sqrt_clearance(content, font_family) - radical_clearance = _sqrt_radical_extra_height(content, font_family) - target_height = inkheight(content) + radical_clearance - radical = _sqrt_radical(state, target_height, content) - line_top = topinkbound(content) + clearance + if _has_rule_element(content) + desired_bottom = bottominkbound(content) - _SQRT_RULE_CONTENT_DESCENT * xh + target_height = max(inkheight(content), line_top - desired_bottom) + elseif _is_tall_sqrt_content(content, font_family) + desired_bottom = bottominkbound(content) - _SQRT_TALL_CONTENT_DESCENT * xh + target_height = max(inkheight(content), line_top - desired_bottom) + else + target_height = inkheight(content) + end + radical, radical_scale = _sqrt_radical(state, target_height, content) + if _has_rule_element(content) radical_bottom = bottominkbound(content) - _SQRT_RULE_CONTENT_DESCENT * xh - line_top = max(line_top, radical_bottom + inkheight(radical)) + line_top = max(line_top, radical_bottom + radical_scale * inkheight(radical)) end - y0 = line_top - topinkbound(radical) + y0 = line_top - radical_scale * topinkbound(radical) line_y = line_top - rule_thickness / 2 hline_width = @@ -535,7 +570,8 @@ function tex_layout(expr, state) _SQRT_RULE_PADDING * xh + rule_thickness hline = HLine(hline_width, rule_thickness) - hline_x = rightinkbound(radical) - rule_thickness / 2 + radical_right = radical_scale * rightinkbound(radical) + hline_x = radical_right - rule_thickness / 2 hline_right = hline_x + rightinkbound(hline) content_right = rightinkbound(content) target_hadvance = hline_right + _SQRT_TRAILING_PADDING * xh @@ -547,9 +583,10 @@ function tex_layout(expr, state) Point2f[ (0, y0), (hline_x, line_y), - (rightinkbound(radical), 0), + (radical_right, 0), (trailing_space_x, 0), ], + [radical_scale, 1, 1, 1], ) elseif head == :text modifier, content = args From 08c21681f6da8caa8f436d3a2e65287ff9c6a753 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Wed, 27 May 2026 16:18:42 +1200 Subject: [PATCH 5/8] Use native sqrt glyphs for font fallbacks --- src/engine/layout.jl | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 32b5107..2249465 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -22,7 +22,6 @@ const _SCRIPT_FRACTION_RULE_WIDTH = 0.45 const _SCRIPT_FRACTION_RULE_SHIFT = 0.18 const _TALL_SCRIPT_HEIGHT_FACTOR = 1.5 const _SCRIPT_SHRINK_HEIGHT_FACTOR = 1.5 -const _SQRT_TALL_CONTENT_CLEARANCE_FACTOR = 0.25 const _SQRT_RULE_CONTENT_DESCENT = 0.25 const _SQRT_TALL_CONTENT_DESCENT = 0.25 const _SQRT_TALL_CONTENT_HEIGHT = 1.75 @@ -108,10 +107,17 @@ const _sqrt_radical_name_sets = ( function _sqrt_radical_variants(state) radicals = TeXElement[] + radical = TeXChar('√', state, :symbol) + if radical.glyph_id != 0 && inkheight(radical) > 0 + push!(radicals, radical) + end + for radical_names in _sqrt_radical_name_sets for radical_name in radical_names candidate = TeXChar(radical_name, state, :symbol; represented = '√') - candidate.glyph_id != 0 && push!(radicals, candidate) + if candidate.glyph_id != 0 && inkheight(candidate) > 0 + push!(radicals, candidate) + end end end @@ -119,7 +125,7 @@ function _sqrt_radical_variants(state) return radicals end -function _default_sqrt_radical_variants(font_family) +function _fallback_sqrt_radical_variants(font_family) radicals = TeXElement[] radical = default_math_texchar('√', font_family, '√') isnothing(radical) || push!(radicals, radical) @@ -148,30 +154,37 @@ function _select_sqrt_radical(radicals, target_height) previous = candidate end - return isempty(radicals) ? nothing : (last(radicals), 1.0) + if isempty(radicals) + return nothing + end + + radical = last(radicals) + return radical, max(1.0, target_height / inkheight(radical)) end function _sqrt_radical_candidates(state, content) - fallback_radicals = _default_sqrt_radical_variants(state.font_family) + radicals = _sqrt_radical_variants(state) + if isempty(radicals) + return _fallback_sqrt_radical_variants(state.font_family) + end + if !_has_rule_element(content) if _is_tall_sqrt_content(content, state.font_family) - return filter(radical -> inkheight(radical) > 1, fallback_radicals) + taller_radicals = filter(radical -> inkheight(radical) > 1, radicals) + isempty(taller_radicals) || return taller_radicals end - return fallback_radicals + return radicals end - native_radicals = _sqrt_radical_variants(state) - isempty(native_radicals) || return native_radicals - - return fallback_radicals + return radicals end function _sqrt_radical(state, target_height, content) radical = _select_sqrt_radical(_sqrt_radical_candidates(state, content), target_height) isnothing(radical) || return radical - radical = _select_sqrt_radical(_default_sqrt_radical_variants(state.font_family), target_height) + radical = _select_sqrt_radical(_fallback_sqrt_radical_variants(state.font_family), target_height) isnothing(radical) || return radical throw(ArgumentError("No square-root radical glyph found")) @@ -298,11 +311,6 @@ function _sqrt_clearance(content, font_family) return max(thickness(font_family), clearance) end -function _sqrt_radical_extra_height(content, font_family) - _has_rule_element(content) || return 0.0 - return _SQRT_TALL_CONTENT_CLEARANCE_FACTOR * xheight(font_family) -end - """ tex_layout(mathexpr::TeXExpr, font_family) From 377b73fef89ed445634f9c543b46d19c01291de3 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Fri, 29 May 2026 11:54:42 +1200 Subject: [PATCH 6/8] Refine sqrt radical selection --- src/engine/layout.jl | 86 +++++++++++++++++++++++++++++++++++++++----- test/layout.jl | 9 +++++ 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 2249465..e210be8 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -26,6 +26,10 @@ const _SQRT_RULE_CONTENT_DESCENT = 0.25 const _SQRT_TALL_CONTENT_DESCENT = 0.25 const _SQRT_TALL_CONTENT_HEIGHT = 1.75 const _SQRT_MAX_RADICAL_STRETCH = 1.25 +const _SQRT_MAX_BASE_RADICAL_HEIGHT = 1.4 +const _SQRT_SHORT_CONTENT_HEIGHT = 1.15 +const _SQRT_SHORT_CONTENT_MAX_DESCENT = 0.45 +const _SQRT_SIMPLE_CONTENT_HEIGHT_FACTOR = 1.05 const _SQRT_RULE_PADDING = 0.12 const _SQRT_TRAILING_PADDING = 0.06 const _SLANTED_ADJACENT_GAP = 0.03 @@ -107,11 +111,9 @@ const _sqrt_radical_name_sets = ( function _sqrt_radical_variants(state) radicals = TeXElement[] - radical = TeXChar('√', state, :symbol) - if radical.glyph_id != 0 && inkheight(radical) > 0 - push!(radicals, radical) - end + # Constructed square roots need size-specific radical glyphs. Ordinary + # Unicode √ glyphs vary too much across fonts to be used as extenders. for radical_names in _sqrt_radical_name_sets for radical_name in radical_names candidate = TeXChar(radical_name, state, :symbol; represented = '√') @@ -125,20 +127,49 @@ function _sqrt_radical_variants(state) return radicals end +function _base_sqrt_radical(state) + radical = TeXChar('√', state, :symbol) + if radical.glyph_id != 0 && inkheight(radical) > 0 + return radical + end + + return nothing +end + function _fallback_sqrt_radical_variants(font_family) radicals = TeXElement[] - radical = default_math_texchar('√', font_family, '√') - isnothing(radical) || push!(radicals, radical) for radical_name in first(_sqrt_radical_name_sets) radical = default_math_texchar(radical_name, font_family, '√') isnothing(radical) || push!(radicals, radical) end + # Last-resort fallback for custom builds where the default radical variants + # are unavailable. The bundled fonts provide the named variants above. + if isempty(radicals) + radical = default_math_texchar('√', font_family, '√') + isnothing(radical) || push!(radicals, radical) + end + sort!(radicals; by = inkheight) return radicals end +function _has_compact_base_radical(radicals) + isempty(radicals) && return false + return inkheight(first(radicals)) <= _SQRT_MAX_BASE_RADICAL_HEIGHT +end + +function _with_base_sqrt_radical(state, radicals) + base_radical = _base_sqrt_radical(state) + isnothing(base_radical) && return radicals + + candidates = TeXElement[base_radical] + append!(candidates, radicals) + sort!(candidates; by = inkheight) + return candidates +end + function _select_sqrt_radical(radicals, target_height) previous = nothing for candidate in radicals @@ -164,8 +195,8 @@ end function _sqrt_radical_candidates(state, content) radicals = _sqrt_radical_variants(state) - if isempty(radicals) - return _fallback_sqrt_radical_variants(state.font_family) + if !_has_compact_base_radical(radicals) + radicals = _fallback_sqrt_radical_variants(state.font_family) end if !_has_rule_element(content) @@ -174,7 +205,7 @@ function _sqrt_radical_candidates(state, content) isempty(taller_radicals) || return taller_radicals end - return radicals + return _with_base_sqrt_radical(state, radicals) end return radicals @@ -311,6 +342,34 @@ function _sqrt_clearance(content, font_family) return max(thickness(font_family), clearance) end +function _simple_sqrt_line_top(content, radical, radical_scale, clearance) + line_top = topinkbound(content) + clearance + radical_height = radical_scale * inkheight(radical) + content_height = inkheight(content) + pad = max((radical_height - _SQRT_SIMPLE_CONTENT_HEIGHT_FACTOR * content_height) / 2, 0.0) + centered_line_top = bottominkbound(content) + radical_height - pad + return max(line_top, centered_line_top) +end + +function _short_sqrt_radical_scale(content, radical, radical_scale, clearance, font_family) + content_height = inkheight(content) + if content_height <= 0 || content_height > _SQRT_SHORT_CONTENT_HEIGHT * xheight(font_family) + return radical_scale + end + + radical_height = radical_scale * inkheight(radical) + line_top = _simple_sqrt_line_top(content, radical, radical_scale, clearance) + root_descent = bottominkbound(content) - (line_top - radical_height) + max_descent = _SQRT_SHORT_CONTENT_MAX_DESCENT * xheight(font_family) + root_descent <= max_descent && return radical_scale + + max_height = min( + 2max_descent + _SQRT_SIMPLE_CONTENT_HEIGHT_FACTOR * content_height, + max_descent + content_height + clearance, + ) + return min(radical_scale, max_height / inkheight(radical)) +end + """ tex_layout(mathexpr::TeXExpr, font_family) @@ -569,6 +628,15 @@ function tex_layout(expr, state) if _has_rule_element(content) radical_bottom = bottominkbound(content) - _SQRT_RULE_CONTENT_DESCENT * xh line_top = max(line_top, radical_bottom + radical_scale * inkheight(radical)) + elseif !_is_tall_sqrt_content(content, font_family) + radical_scale = _short_sqrt_radical_scale( + content, + radical, + radical_scale, + clearance, + font_family, + ) + line_top = _simple_sqrt_line_top(content, radical, radical_scale, clearance) end y0 = line_top - radical_scale * topinkbound(radical) line_y = line_top - rule_thickness / 2 diff --git a/test/layout.jl b/test/layout.jl index 0c638e3..b9aeab2 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -273,7 +273,13 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele @test elems[1][1] isa TeXChar @test elems[1][1].represented_char == '√' @test elems[1][1].glyph_id != 0 + @test elems[1][3] * inkheight(elems[1][1]) <= 1.25 @test ink_top(elems[2]) ≈ ink_top(elems[1]) atol = 1.0e-6 + @test minimum(ink_bottom, elems[3:end]) - ink_bottom(elems[1]) < 0.35 + + sqrt_x_elems = generate_tex_elements(L"\sqrt{x}", MathTeXEngine.FontFamily(font_name)) + @test minimum(ink_bottom, sqrt_x_elems[3:end]) - ink_bottom(sqrt_x_elems[1]) <= + 0.45 * xheight(MathTeXEngine.FontFamily(font_name)) + 1.0e-6 empty_elems = generate_tex_elements(L"\sqrt{}", MathTeXEngine.FontFamily(font_name)) @test empty_elems[1][1].represented_char == '√' @@ -288,6 +294,8 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele end frac_elems = generate_tex_elements(L"\sqrt{\frac{1}{2}}") + simple_root = generate_tex_elements(L"\sqrt{x}")[1] + @test frac_elems[1][1].glyph_id != simple_root[1].glyph_id @test ink_bottom(frac_elems[2]) - maximum(ink_top(e) for e in frac_elems[3:end]) > xheight(MathTeXEngine.FontFamily()) / 3 @test ink_bottom(frac_elems[2]) - maximum(ink_top(e) for e in frac_elems[3:end]) < @@ -319,6 +327,7 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele 0.4 * xheight(MathTeXEngine.FontFamily()) simple_elems = generate_tex_elements(L"\sqrt{b^2 - 4ac}") + @test simple_elems[1][1].glyph_id != simple_root[1].glyph_id @test ink_bottom(simple_elems[1]) > -0.4 end From d8ec35469a5b0636eca834d6fd199d3177147c5b Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Fri, 29 May 2026 15:43:18 +1200 Subject: [PATCH 7/8] Refine italic spacing and relation commands --- src/engine/layout.jl | 29 +++++++++++++++++++++++++---- src/engine/texelements.jl | 2 ++ src/parser/commands_data.jl | 3 ++- test/layout.jl | 33 ++++++++++++++++++++++++++++----- 4 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index e210be8..3f1115d 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -33,6 +33,8 @@ const _SQRT_SIMPLE_CONTENT_HEIGHT_FACTOR = 1.05 const _SQRT_RULE_PADDING = 0.12 const _SQRT_TRAILING_PADDING = 0.06 const _SLANTED_ADJACENT_GAP = 0.03 +const _LATIN_ITALIC_PAIR_GAP = 0.08 +const _DIGIT_ITALIC_LEFT_BEARING_CORRECTION = 0.5 const _DISPLAY_OPERATOR_DELIMITER_HEIGHT = 1.35 const _BRACE_RULE_AXIS_PADDING = 0.2 @@ -801,16 +803,24 @@ function italic_transition_offset(prev, elem) # Positive left bearings on italic glyphs make e.g. "(t)" look # asymmetric. Remove that extra font-side gap at roman-to-italic edges. - prev isa TeXChar && _preserves_italic_left_bearing(prev) && return 0.0 + if prev isa TeXChar + if isdigit(prev.represented_char) + if _is_lowercase_latin(elem) + gap = hadvance(prev) + bearing - rightinkbound(prev) + target_gap = _latin_italic_target_gap(prev, elem) + gap > target_gap && return target_gap - gap + end + return -_DIGIT_ITALIC_LEFT_BEARING_CORRECTION * bearing + elseif _is_math_punctuation(prev.represented_char) + return 0.0 + end + end return -bearing end return 0.0 end -_preserves_italic_left_bearing(char::TeXChar) = - isdigit(char.represented_char) || _is_math_punctuation(char.represented_char) - _is_math_punctuation(char) = char in (',', ';', '.', '!') function slanted_adjacent_offset(prev, elem) @@ -820,10 +830,21 @@ function slanted_adjacent_offset(prev, elem) gap = hadvance(prev) + leftinkbound(elem) - rightinkbound(prev) min_gap = _SLANTED_ADJACENT_GAP * min(inkheight(prev), inkheight(elem)) + if _is_lowercase_latin(prev) && _is_lowercase_latin(elem) + target_gap = max(min_gap, _latin_italic_target_gap(prev, elem)) + gap > target_gap && return target_gap - gap + end + offset = min_gap - gap return max(0.0, min(offset, 2min_gap)) end +_is_lowercase_latin(elem) = + elem isa TeXChar && 'a' <= elem.represented_char <= 'z' + +_latin_italic_target_gap(prev, elem) = + _LATIN_ITALIC_PAIR_GAP * min(inkheight(prev), inkheight(elem)) + function layout_text(string, font_family) isempty(string) && return Space(0) diff --git a/src/engine/texelements.jl b/src/engine/texelements.jl index 6fa09f7..ce24df9 100644 --- a/src/engine/texelements.jl +++ b/src/engine/texelements.jl @@ -216,6 +216,8 @@ hadvance(char::TeXChar) = hadvance(get_extent(char.font, char.glyph_id)) xheight(char::TeXChar) = xheight(char.font_family) function ascender(char::TeXChar) + char.represented_char == '√' && return topinkbound(char) + math_font = get_font(char.font_family, :math) return max(ascender(math_font), topinkbound(char)) end diff --git a/src/parser/commands_data.jl b/src/parser/commands_data.jl index 16e88d3..240feaf 100644 --- a/src/parser/commands_data.jl +++ b/src/parser/commands_data.jl @@ -22,6 +22,7 @@ relation_symbols = split(raw"= < > :") relation_commands = split(raw""" \leq \geq \equiv \models \prec \succ \sim \perp + \lesssim \gtrsim \preceq \succeq \simeq \mid \ll \gg \asymp \parallel \subset \supset \approx \bowtie @@ -147,4 +148,4 @@ delimiter_commands = Dict( font_names = split(raw"rm cal it tt sf bf default bb frak scr regular") # TODO Add to the parser what come below, if needed -wide_accent_commands = split(raw"\widehat \widetilde \widebar") \ No newline at end of file +wide_accent_commands = split(raw"\widehat \widetilde \widebar") diff --git a/test/layout.jl b/test/layout.jl index b9aeab2..5381d65 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -206,17 +206,31 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele ink_left(without[2]) - ink_right(without[1]) @test ink_left(with[2]) - ink_right(with[1]) > 0.01 + MathTeXEngine.italic_correction_enabled[] = false + without = generate_tex_elements(L"ab") + MathTeXEngine.italic_correction_enabled[] = true + with = generate_tex_elements(L"ab") + + # Adjacent Latin italic variables should be compact like TeX math, + # while the Greek collision case above still keeps a safe gap. + @test ink_left(with[2]) - ink_right(with[1]) < + ink_left(without[2]) - ink_right(without[1]) + @test 0.02 < ink_left(with[2]) - ink_right(with[1]) < 0.06 + MathTeXEngine.italic_correction_enabled[] = false without = generate_tex_elements(L"2ab") MathTeXEngine.italic_correction_enabled[] = true with = generate_tex_elements(L"2ab") # Digit-letter juxtaposition represents implicit multiplication, - # not a delimiter boundary, so keep the natural digit-to-italic gap - # while still applying adjacent slanted-glyph correction to ab. - @test ink_left(with[2]) - ink_right(with[1]) ≈ - ink_left(without[2]) - ink_right(without[1]) - @test ink_left(with[3]) - ink_right(with[2]) > 0.01 + # not a delimiter boundary. Tuck the italic left bearing only + # partway so 2a resembles the natural ab spacing without cramping. + digit_gap_without = ink_left(without[2]) - ink_right(without[1]) + digit_gap_with = ink_left(with[2]) - ink_right(with[1]) + letter_gap_with = ink_left(with[3]) - ink_right(with[2]) + @test 0 < digit_gap_with < digit_gap_without + @test digit_gap_with ≈ letter_gap_with atol = 0.02 + @test digit_gap_with < 0.06 MathTeXEngine.italic_correction_enabled[] = false without = generate_tex_elements(L"W(\alpha,\alpha^*)") @@ -249,6 +263,13 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele @test xpos(generate_tex_elements(L"\log(x)"), 4) ≈ xpos(generate_tex_elements(L"\mathrm{log}(x)"), 4) @test !(inline_layout(L"\inf_x(\tan(x))").elements[2] isa Space) + + gtrsim = generate_tex_elements(L"U\gtrsim\mu") + unicode_gtrsim = generate_tex_elements(L"U≳\mu") + @test ink_left(gtrsim[2]) - ink_right(gtrsim[1]) > 0.15 + @test ink_left(gtrsim[3]) - ink_right(gtrsim[2]) > 0.15 + @test ink_left(unicode_gtrsim[2]) - ink_right(unicode_gtrsim[1]) ≈ + ink_left(gtrsim[2]) - ink_right(gtrsim[1]) end @testset "Fraction rule padding" begin @@ -315,6 +336,8 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele simple_sqrt_layout = tex_layout(texparse(raw"\sqrt{2}"), FontFamily()).elements[1] @test MathTeXEngine.ascender(simple_sqrt_layout) ≈ topinkbound(simple_sqrt_layout) + simple_sqrt_glyph = generate_tex_elements(L"\sqrt{2}")[1][1] + @test MathTeXEngine.ascender(simple_sqrt_glyph) ≈ topinkbound(simple_sqrt_glyph) ylabel_layout = tex_layout( texparse(L"x + y - \sin(x) × \tan(y) + \sqrt{2}"), From b1674a336f8f45aaab4bfdf05723e79b51a6039d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Richard?= Date: Fri, 29 May 2026 21:35:48 +0200 Subject: [PATCH 8/8] Fix reference tests --- reference/compare.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reference/compare.jl b/reference/compare.jl index cebdecb..6496eda 100644 --- a/reference/compare.jl +++ b/reference/compare.jl @@ -66,8 +66,8 @@ image_difference(img1, img2) = colordiff.(img1, img2) ./ 100 refimg = rotr90(load(joinpath(reference_images, image_path))) img = rotr90(load(joinpath(comparison_images, image_path))) - if (failed = n_bad_pixels(img, refimg) >= 10) - @info "Saving the reference comparison for '$image_path' (image difference $(n_bad_pixels(img, refimg)))" + if (failed = (size(img) != size(refimg) || n_bad_pixels(img, refimg) >= 10)) + @info "Saving the reference comparison for '$image_path'" fig = Figure(size = (3*size(img, 1), size(img, 2))) Label(fig[1, 1], "Reference $(size(refimg))", tellwidth=false) axref = Axis(fig[2, 1], aspect = DataAspect())