From 665d6e93f79580f6252e83c770d9c890687f301e Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Thu, 7 May 2026 21:12:09 +1200 Subject: [PATCH 01/13] Fix math spacing around italic boundaries --- reference/spacing_visuals.jl | 286 +++++++++++++++++++++++++++++++++++ src/MathTeXEngine.jl | 5 + src/engine/fonts.jl | 2 +- src/engine/layout.jl | 163 +++++++++++++++----- src/engine/layout_context.jl | 11 +- src/engine/texelements.jl | 39 +++-- src/parser/parser.jl | 41 +++-- test/layout.jl | 73 ++++++++- test/parser.jl | 36 +++++ test/runtests.jl | 12 +- 10 files changed, 605 insertions(+), 63 deletions(-) create mode 100644 reference/spacing_visuals.jl diff --git a/reference/spacing_visuals.jl b/reference/spacing_visuals.jl new file mode 100644 index 0000000..b821dec --- /dev/null +++ b/reference/spacing_visuals.jl @@ -0,0 +1,286 @@ +using CairoMakie +using FileIO +using LaTeXStrings + +using MathTeXEngine + +const SPACING_VISUAL_FONT_NAMES = + ["NewComputerModern", "TeXGyreHeros", "TeXGyrePagella", "LucioleMath"] + +const SPACING_VISUAL_CASES = Pair{String,Vector{String}}[ + "Issue #142: italic/roman boundaries"=>[ + raw"f(t)", + raw"g(x)", + raw"(f)x", + raw"(t)", + raw"\eta(t)", + raw"\alpha(t)", + raw"g(f(x))", + raw"\mathrm{y}(x)", + raw"\mathrm{g}t", + ], + "Issue #95: lower-case Greek and subscript spacing"=>[ + raw"\eta(t)", + raw"\alpha_k", + raw"\omega_k", + raw"\nu(k)", + raw"N_\nu L_\nu A_\nu J_\nu", + raw"x_{\alpha(k)}", + raw"v_{(a + b)_k}^i", + raw"\partial_i u_j", + raw"\phi_\varphi \rho_\sigma", + ], + "Subscript and superscript combinations"=>[ + raw"x_i", + raw"x^i", + raw"x_i^j", + raw"x_{i_j}", + raw"x^{i^j}", + raw"x_{(a+b)_k}^i", + raw"T_{\alpha\beta}^{ij}", + raw"\Gamma^\mu_{\nu\rho}", + raw"\psi^\dagger_i\psi_i", + ], + "PR #151: primes and deep scripts"=>[ + raw"x' f'", + raw"x'' f''", + raw"x′ f′", + raw"x\prime f\prime", + raw"x^\prime f^\prime", + raw"x'_y f_g'", + raw"A^{B^{C^{D^E}}}_{F_{G_{H_I}}}", + raw"f^{A'}", + ], + "Roman/upright and capital boundaries"=>[ + raw"\mathrm{d}x", + raw"\mathrm{e}^{-x}", + raw"\mathrm{Re}\,z", + raw"\mathrm{Im}\,z", + raw"\mathrm{Tr}\,A_i^j", + raw"\mathrm{Cov}(X,Y)", + raw"A_\nu B_\nu C_\nu D_\nu", + raw"M\mathrm{M}M", + ], + "Issue #129: math operator spacing"=>[ + raw"\log x", + raw"\log(x)", + raw"\sin x", + raw"\sin(x)", + raw"\exp t", + raw"\exp(t)", + raw"\max_{t \in \{1,...,5\}}", + ], + "Operators, delimiters, and fractions"=>[ + raw"-1,\ 2-1,\ (-1)", + raw"\alpha^*", + raw"\psi^* \psi", + raw"\frac{1}{2}\pm\sqrt{3}", + raw"\frac{1}{2}{}\pm\sqrt{3}", + raw"\left(\frac{1}{2}\right)f(t)", + raw"\sqrt{x_i^2+y_i^2}", + raw"\sum_{k=0}^n a_k x^k", + raw"\int_0^{2\pi}\sin(x)\,dx", + ], + "Script layout issues #93, #105, #110, #126"=>[ + raw"\left(\frac{dy}{dx}\right)_0", + raw"\left(\frac{A^{xy}}{B}\right)^{1/4}", + raw"(\frac{A^{xy}}{B})^{1/4}", + raw"\left\langle\left|\int\right|\right\rangle", + raw"\left\langle\left|\left\langle\left|\int\right|\right\rangle\right|\right\rangle", + raw"x^{\frac{1}{1+2}}", + raw"x_{\frac{1}{1+2}}", + ], + "Nested expressions"=>[ + raw"\frac{\alpha_i+\beta_i}{\gamma_i+\delta_i}", + raw"\sqrt{\frac{1+\alpha_k}{1+\beta_k}}", + raw"F_{\mu\nu}F^{\mu\nu}", + raw"\overline{z}_i", + raw"\left(\alpha_{(i+j)_k}\right)^2", + raw"\frac{\partial^2 f}{\partial x_i\partial x_j}", + ], +] + +repo_root() = dirname(@__DIR__) +reference_project_dir() = @__DIR__ + +function spacing_visual_output_path() + return get( + ENV, + "MTE_SPACING_VISUAL_PATH", + joinpath(@__DIR__, "spacing_visual_inspection.png"), + ) +end + +spacing_baseline_ref() = get(ENV, "MTE_SPACING_BASELINE_REF", "HEAD") + +font_latex(font_name, expr) = latexstring("\\fontfamily{$font_name}$expr") + +function spacing_label_sheet( + cases = SPACING_VISUAL_CASES; + font_names = SPACING_VISUAL_FONT_NAMES, +) + nrows = sum(length(last(group)) + 1 for group in cases) + 1 + fig = Figure(size = (2200, max(900, 54nrows)), fontsize = 18) + + Label(fig[1, 1], "case"; tellwidth = false, halign = :left, font = :bold) + for (col, font_name) in enumerate(font_names) + Label(fig[1, col+1], font_name; tellwidth = false, halign = :left, font = :bold) + end + + row = 2 + for (group, exprs) in cases + Label( + fig[row, 1:(length(font_names)+1)], + group; + tellwidth = false, + halign = :left, + font = :bold, + fontsize = 17, + ) + row += 1 + + for expr in exprs + Label(fig[row, 1], expr; tellwidth = false, halign = :left, fontsize = 13) + for (col, font_name) in enumerate(font_names) + Label( + fig[row, col+1], + font_latex(font_name, expr); + tellwidth = false, + halign = :left, + fontsize = 24, + ) + end + row += 1 + end + end + + colsize!(fig.layout, 1, Relative(0.22)) + for col = 2:(length(font_names)+1) + colsize!(fig.layout, col, Relative(0.78 / length(font_names))) + end + rowgap!(fig.layout, 7) + return fig +end + +function save_spacing_label_sheet(path) + fig = spacing_label_sheet() + save(path, fig, px_per_unit = 2) + return path +end + +function render_spacing_sheet_in_subprocess(package_path, output_path) + julia_executable = joinpath(Sys.BINDIR, Base.julia_exename()) + project_dir = mktempdir() + cp( + joinpath(reference_project_dir(), "Project.toml"), + joinpath(project_dir, "Project.toml"), + ) + cp( + joinpath(reference_project_dir(), "Manifest.toml"), + joinpath(project_dir, "Manifest.toml"), + ) + script = """ + import Pkg + Pkg.develop(path=$(repr(package_path))) + Pkg.instantiate() + include($(repr(@__FILE__))) + save_spacing_label_sheet($(repr(output_path))) + """ + run(`$julia_executable --project=$project_dir -e $script`) + return output_path +end + +function with_baseline_checkout(f) + if haskey(ENV, "MTE_SPACING_BASELINE_PATH") + return f( + ENV["MTE_SPACING_BASELINE_PATH"], + get(ENV, "MTE_SPACING_BASELINE_LABEL", "baseline"), + ) + end + + return mktempdir() do dir + checkout_path = joinpath(dir, "MathTeXEngine-baseline") + ref = spacing_baseline_ref() + run(`git -C $(repo_root()) worktree add --detach $checkout_path $ref`) + try + return f(checkout_path, ref) + finally + run(`git -C $(repo_root()) worktree remove --force $checkout_path`) + end + end +end + +function add_image_panel!(figpos, image_path, title) + img = rotr90(load(image_path)) + ax = Axis(figpos; title, aspect = DataAspect()) + hidedecorations!(ax) + hidespines!(ax) + image!(ax, img) + return ax +end + +function pixel_darkness(c) + return clamp(1 - (Float32(c.r) + Float32(c.g) + Float32(c.b)) / 3, 0, 1) +end + +function spacing_overlay_image(after_path, before_path) + after_img = load(after_path) + before_img = load(before_path) + height = min(size(after_img, 1), size(before_img, 1)) + width = min(size(after_img, 2), size(before_img, 2)) + overlay = Matrix{RGBAf}(undef, height, width) + + for y = 1:height, x = 1:width + after_dark = pixel_darkness(after_img[y, x]) + before_dark = pixel_darkness(before_img[y, x]) + + # Blue ink is the current checkout, red ink is the baseline. Matching + # ink becomes dark, while spacing changes leave visible colored fringes. + overlay[y, x] = + RGBAf(1 - after_dark, (1 - after_dark) * (1 - before_dark), 1 - before_dark, 1) + end + + return overlay +end + +function spacing_visual_figure() + return with_baseline_checkout() do baseline_path, baseline_label + mktempdir() do dir + before_path = joinpath(dir, "spacing_before.png") + after_path = joinpath(dir, "spacing_after.png") + overlay_path = joinpath(dir, "spacing_overlay.png") + + render_spacing_sheet_in_subprocess(baseline_path, before_path) + render_spacing_sheet_in_subprocess(repo_root(), after_path) + save(overlay_path, spacing_overlay_image(after_path, before_path)) + + fig = Figure(size = (3000, 1800), fontsize = 24) + Label( + fig[1, 1:3], + "Spacing regression visual inspection"; + tellwidth = false, + halign = :left, + font = :bold, + ) + add_image_panel!(fig[2, 1], before_path, "before: $(baseline_label)") + add_image_panel!(fig[2, 2], after_path, "after: current checkout") + add_image_panel!(fig[2, 3], overlay_path, "overlay: after blue, before red") + colsize!(fig.layout, 1, Relative(0.34)) + colsize!(fig.layout, 2, Relative(0.34)) + colsize!(fig.layout, 3, Relative(0.32)) + return fig + end + end +end + +function generate_spacing_visuals(path = spacing_visual_output_path()) + fig = spacing_visual_figure() + mkpath(dirname(path)) + save(path, fig, px_per_unit = 2) + return path +end + +if abspath(PROGRAM_FILE) == @__FILE__ + path = generate_spacing_visuals() + @info "Wrote spacing visual inspection sheet" path +end diff --git a/src/MathTeXEngine.jl b/src/MathTeXEngine.jl index ffb03f3..bd18325 100644 --- a/src/MathTeXEngine.jl +++ b/src/MathTeXEngine.jl @@ -28,6 +28,11 @@ export glyph_index # Reexport from LaTeXStrings export @L_str +# Global layout/parser heuristics. These are Refs so callers and tests can +# temporarily toggle them without redefining package methods. +const italic_correction_enabled = Ref(true) +const unspace_binary_operators_heuristic_enabled = Ref(true) + include("parser/tokenizer.jl") include("parser/texexpr.jl") include("parser/commands_data.jl") diff --git a/src/engine/fonts.jl b/src/engine/fonts.jl index 677bfc2..6e674ed 100644 --- a/src/engine/fonts.jl +++ b/src/engine/fonts.jl @@ -257,7 +257,7 @@ get_fontpath(fontstyle::Symbol) = get_fontpath(FontFamily(), fontstyle) function is_slanted(font_family, char_type) font_id = font_family.font_mapping[char_type] - return font_id == :italic + return font_id == :italic || font_id == :bolditalic end slant_angle(font_family) = font_family.slant_angle * π / 180 diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 43bcac6..163e75f 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -26,6 +26,7 @@ function tex_layout(expr, state) head = expr.head args = [expr.args...] shrink = 0.6 + italic_correction = state.tex_mode == :inline_math && italic_correction_enabled[] try if isleaf(expr) # :char, :delimiter, :digit, :punctuation, :symbol @@ -50,11 +51,9 @@ function tex_layout(expr, state) return Group( [core, accent], - Point2f[ - (0, 0), - (x + hmid(core) - hmid(accent), y) - ], - [1, 1] + Point2f[(0, 0), (x + hmid(core) - hmid(accent), y)], + [1, 1]; + slanted = is_slanted(core), ) elseif head == :decorated core, sub, super = tex_layout.(args, state) @@ -74,13 +73,16 @@ function tex_layout(expr, state) Point2f[ (0, 0), ( - # The logic is to have the ink of the subscript starts - # where the ink of the unshrink glyph would - hadvance(core) + (1 - shrink) * leftinkbound(sub), - -0.2 + # Respect italic overhangs so lower scripts do not + # tuck under the visible ink of the core glyph. + max(hadvance(core), rightinkbound(core)) + + (1 - shrink) * leftinkbound(sub), + -0.2, ), - ( super_x, super_y)], - [1, shrink, super_shrink] + (super_x, super_y), + ], + [1, shrink, super_shrink]; + slanted = is_slanted(core) || is_slanted(super), ) elseif head == :delimited elements = tex_layout.(args, state) @@ -90,20 +92,22 @@ function tex_layout(expr, state) left_scale = max(1, height / inkheight(left)) right_scale = max(1, height / inkheight(right)) scales = [left_scale, 1, right_scale] - + dxs = hadvance.(elements) .* scales xs = [0, cumsum(dxs[1:end-1])...] # TODO Height calculation for the parenthesis looks wrong # TODO Check what the algorithm should be there # Center the delimiters in the middle of the bot and top baselines ? - return Group(elements, + return Group( + elements, Point2f[ (xs[1], -bottominkbound(left) + bottominkbound(content)), (xs[2], 0), - (xs[3], -bottominkbound(right) + bottominkbound(content)) + (xs[3], -bottominkbound(right) + bottominkbound(content)), ], - scales + scales; + slanted = is_slanted(right), ) elseif head == :font modifier, content = args @@ -133,12 +137,13 @@ function tex_layout(expr, state) return Group( [line, numerator, denominator], - Point2f[(0, y0), (x1, ytop), (x2, ybottom)] + Point2f[(0, y0), (x1, ytop), (x2, ybottom)]; + slanted = is_slanted(numerator) || is_slanted(denominator), ) elseif head == :function name = args[1] elements = TeXChar.(collect(name), state, Ref(:function)) - return horizontal_layout(elements) + return horizontal_layout(elements; italic_correction) elseif head == :glyph font_id, glyph_id = argument_as_string.(args) font_id = Symbol(font_id) @@ -147,11 +152,18 @@ function tex_layout(expr, state) return TeXChar(glyph_id, font, state.font_family, false, '?') elseif head in (:group, :inline_math, :line) mode = (head == :inline_math) ? :inline_math : state.tex_mode - elements = tex_layout.(args, change_mode(state, mode)) + child_state = change_mode(state, mode) + elements = tex_layout.(args, child_state) if isempty(elements) return Space(0.0) end - return horizontal_layout(elements) + + if mode == :inline_math + elements = _add_function_spacing(args, elements) + end + + italic_correction = mode == :inline_math && italic_correction_enabled[] + return horizontal_layout(elements; italic_correction) elseif head == :integral pad = 0.1 int, sub, super = tex_layout.(args, state) @@ -162,14 +174,12 @@ function tex_layout(expr, state) (0, 0), ( 0.15 - inkwidth(sub)*shrink/2, - bottominkbound(int) - topinkbound(sub)*shrink - pad + bottominkbound(int) - topinkbound(sub)*shrink - pad, ), - ( - 0.85 - inkwidth(super)*shrink/2, - topinkbound(int) + pad - ) + (0.85 - inkwidth(super)*shrink/2, topinkbound(int) + pad), ], - [1, shrink, shrink] + [1, shrink, shrink]; + slanted = is_slanted(int), ) elseif head == :lines length(args) == 1 && return tex_layout(only(args), state) @@ -192,19 +202,17 @@ function tex_layout(expr, state) return Group( [hline, content], - Point2f[ - (0.25, y + lw/2 + 0.2), - (0, 0) - ] + Point2f[(0.25, y + lw/2 + 0.2), (0, 0)]; + slanted = is_slanted(content), ) elseif head == :primes primes = [TeXExpr(:char, ''') for _ in 1:only(args)] - return horizontal_layout(tex_layout.(primes, state)) + return horizontal_layout(tex_layout.(primes, state); italic_correction) elseif head == :space return Space(args[1]) elseif head == :spaced sym = tex_layout(args[1], state) - return horizontal_layout([Space(0.2), sym, Space(0.2)]) + return horizontal_layout([Space(0.2), sym, Space(0.2)]; italic_correction) elseif head == :sqrt content = tex_layout(args[1], state) h = inkheight(content) @@ -233,8 +241,8 @@ function tex_layout(expr, state) (0, y0), (rightinkbound(sqrt) - lw/2, y + lw/2), (rightinkbound(sqrt), 0), - (rightinkbound(content), 0) - ] + (rightinkbound(content), 0), + ], ) elseif head == :text modifier, content = args @@ -260,9 +268,10 @@ function tex_layout(expr, state) Point2f[ (x0, y0), (x0 + dxsub, y0 + under_offset), - (x0 + dxsuper, y0 + over_offset) + (x0 + dxsuper, y0 + over_offset), ], - [1, shrink, shrink] + [1, shrink, shrink]; + slanted = is_slanted(core), ) elseif head == :unicode font_id, glyph_id = argument_as_string.(args) @@ -287,11 +296,91 @@ tex_layout(::Nothing, state) = Space(0) Layout the elements horizontally, like normal text. """ -function horizontal_layout(elements) +function horizontal_layout(elements; italic_correction = false) + if italic_correction + elements = _italic_correction(elements) + end + dxs = hadvance.(elements) xs = [0, cumsum(dxs[1:end-1])...] - return Group(elements, Point2f.(xs, 0)) + return Group(elements, Point2f.(xs, 0); slanted = is_slanted(last(elements))) +end + +function _add_function_spacing(args, elements) + spaced = TeXElement[] + + for (i, elem) in enumerate(elements) + push!(spaced, elem) + if args[i].head == :function && _function_takes_space(args, i) + push!(spaced, Space(1/6)) + end + end + + return spaced +end + +function _function_takes_space(args, i) + for j in (i+1):length(args) + arg = args[j] + if arg.head == :char && only(arg.args) == ' ' + continue + end + return !_is_opening_delimiter(arg) + end + + return false +end + +function _is_opening_delimiter(expr) + return expr.head == :delimiter && only(expr.args) in ('(', '[', '⟨', '{') +end + +function _italic_correction(elements) + corrected = TeXElement[] + + for (i, elem) in enumerate(elements) + if i > 1 + offset = italic_transition_offset(elements[i-1], elem) + if offset != 0 + push!(corrected, Space(offset)) + end + end + push!(corrected, elem) + end + + return corrected +end + +function italic_transition_offset(prev, elem) + (prev isa Space || elem isa Space) && return 0.0 + is_slanted(prev) == is_slanted(elem) && return 0.0 + + if is_slanted(prev) && !is_slanted(elem) + height_prev = topinkbound(prev) + height_prev <= 0 && return 0.0 + + overhang = rightinkbound(prev) - hadvance(prev) + overhang <= 0 && return 0.0 + + height_elem = topinkbound(elem) + return height_prev <= height_elem ? overhang : overhang * height_elem / height_prev + elseif !is_slanted(prev) && is_slanted(elem) + bearing = leftinkbound(elem) + + if bearing < 0 + depth_prev = inkheight(prev) - topinkbound(prev) + depth_elem = inkheight(elem) - topinkbound(elem) + depth_prev <= 0 && return 0.0 + return depth_prev >= depth_elem ? -bearing : -bearing * depth_prev / depth_elem + end + + # Positive left bearings on italic glyphs make e.g. "(t)" look + # asymmetric. Remove that extra font-side gap at roman-to-italic edges. + return -bearing + end + + return 0.0 end function layout_text(string, font_family) diff --git a/src/engine/layout_context.jl b/src/engine/layout_context.jl index 5c9b8db..3fcdb7c 100644 --- a/src/engine/layout_context.jl +++ b/src/engine/layout_context.jl @@ -23,7 +23,7 @@ function add_font_modifier(state::LayoutState, modifier) return LayoutState(state.font_family, modifiers, state.tex_mode) end -function get_font(state::LayoutState, char_type) +function get_font_identifier(state::LayoutState, char_type) if state.tex_mode == :text char_type = :text end @@ -40,5 +40,10 @@ function get_font(state::LayoutState, char_type) end end - return get_font(font_family, font_id) -end \ No newline at end of file + return font_id +end + +function get_font(state::LayoutState, char_type) + font_id = get_font_identifier(state, char_type) + return get_font(state.font_family, font_id) +end diff --git a/src/engine/texelements.jl b/src/engine/texelements.jl index 2d32932..ec60c78 100644 --- a/src/engine/texelements.jl +++ b/src/engine/texelements.jl @@ -154,28 +154,43 @@ function TeXChar(char::Char, state::LayoutState, char_type) if haskey(font_family.special_chars, char) fontpath, id = font_family.special_chars[char] font = load_font(fontpath) - return TeXChar(id, font, font_family, false, char) + return TeXChar(id, font, font_family, is_slanted_math_symbol(char, char_type), char) end - font = get_font(state, char_type) + font_id = get_font_identifier(state, char_type) + font = get_font(font_family, font_id) return TeXChar( glyph_index(font, char), font, font_family, - is_slanted(state.font_family, char_type), - char) + font_id == :italic || font_id == :bolditalic, + char, + ) end +is_slanted_math_symbol(char, char_type) = char_type == :symbol && is_lowercase_greek(char) +is_lowercase_greek(char) = + 'α' <= char <= 'ω' || + char == 'ϕ' || + char == 'ϵ' || + char == 'ϑ' || + char == 'ϰ' || + char == 'ϱ' || + char == 'ϖ' + function TeXChar(name::AbstractString, state::LayoutState, char_type ; represented='?') font_family = state.font_family - font = get_font(state, char_type) + font_id = get_font_identifier(state, char_type) + font = get_font(font_family, font_id) + return TeXChar( glyph_index(font, name), font, font_family, - is_slanted(state.font_family, char_type), - represented) + font_id == :italic || font_id == :bolditalic, + represented, + ) end for inkfunc in (:leftinkbound, :rightinkbound, :bottominkbound, :topinkbound) @@ -289,9 +304,15 @@ struct Group{T} <: TeXElement elements::Vector{<:TeXElement} positions::Vector{Point2f} scales::Vector{T} + slanted::Bool end -Group(elements, positions) = Group(elements, positions, ones(length(elements))) +Group(elements, positions, scales; slanted = false) = + Group(elements, positions, scales, slanted) +Group(elements, positions; slanted = false) = + Group(elements, positions, ones(length(elements)); slanted) + +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] @@ -334,4 +355,4 @@ end xheight(g::Group) = maximum(xheight.(g.elements) .* g.scales) leftmost_glyph(g::Group) = leftmost_glyph(first(g.elements)) -rightmost_glyph(g::Group) = rightmost_glyph(last(glyph)) \ No newline at end of file +rightmost_glyph(g::Group) = rightmost_glyph(last(g.elements)) diff --git a/src/parser/parser.jl b/src/parser/parser.jl index 0759323..991412a 100644 --- a/src/parser/parser.jl +++ b/src/parser/parser.jl @@ -59,7 +59,7 @@ end show_stack(stack) = show_stack(stdout, stack) -function push_down!(stack) +function push_down!(stack, math_mode = false) top = pop!(stack) if head(top) == :group # Replace empty groups by 0 spaces @@ -70,6 +70,13 @@ function push_down!(stack) top = only(top.args) end end + + if unspace_binary_operators_heuristic_enabled[] && math_mode && head(top) == :spaced + if !_has_plausible_binary_left_argument(first(stack)) + top = only(top.args) + end + end + push!(first(stack), top) if head(first(stack)) in [:subscript, :superscript] @@ -79,10 +86,10 @@ function push_down!(stack) push!(first(stack).args, decorated) end - conclude_command!!(stack) + conclude_command!!(stack, math_mode) end -function conclude_command!!(stack) +function conclude_command!!(stack, math_mode = false) com = first(stack) head(com) != :command && return false nargs = length(com.args) - 1 @@ -90,7 +97,7 @@ function conclude_command!!(stack) if required_args(first(com.args)) == nargs pop!(stack) push!(stack, command_expr(com.args[1], com.args[2:end])) - push_down!(stack) + push_down!(stack, math_mode) end end @@ -151,7 +158,7 @@ function texparse(tex ; root = TeXExpr(:lines), showdebug = false) if token == dollar if head(first(stack)) == :inline_math inside_math = false - push_down!(stack) + push_down!(stack, true) else inside_math = true push!(stack, TeXExpr(:inline_math)) @@ -161,7 +168,7 @@ function texparse(tex ; root = TeXExpr(:lines), showdebug = false) throw(TeXParseError("unexpected new line", stack, length(tex), tex)) end - push_down!(stack) + push_down!(stack, inside_math) push!(stack, TeXExpr(:line)) elseif token == lcurly push!(stack, TeXExpr(:group)) @@ -169,7 +176,7 @@ function texparse(tex ; root = TeXExpr(:lines), showdebug = false) if head(first(stack)) != :group throw(TeXParseError("missing closing '}'", stack, pos, tex)) end - push_down!(stack) + push_down!(stack, inside_math) elseif token == left push!(stack, TeXExpr(:delimited, delimiter(raw"\left", tex[pos:pos+len-1]))) elseif token == right @@ -189,7 +196,7 @@ function texparse(tex ; root = TeXExpr(:lines), showdebug = false) elseif token == command com_str = tex[pos:pos+len-1] push!(stack, TeXExpr(:command, [com_str])) - conclude_command!!(stack) + conclude_command!!(stack, inside_math) elseif token == underscore || token == caret || token == primes dec = (token == underscore) ? :subscript : :superscript @@ -229,7 +236,7 @@ function texparse(tex ; root = TeXExpr(:lines), showdebug = false) expr = canonical_expr(c) end push!(stack, expr) - push_down!(stack) + push_down!(stack, inside_math) end catch err throw(TeXParseError("unexpected error", stack, pos, tex)) @@ -237,7 +244,7 @@ function texparse(tex ; root = TeXExpr(:lines), showdebug = false) end if head(first(stack)) == :line - push_down!(stack) + push_down!(stack, inside_math) end if length(stack) > 1 @@ -251,3 +258,17 @@ function texparse(tex ; root = TeXExpr(:lines), showdebug = false) return lines end end + +function _has_plausible_binary_left_argument(expr) + if head(expr) in + (:punctuation, :space, :function, :integral, :underover, :superscript, :subscript) + return false + elseif head(expr) == :delimiter + return !(length(expr.args) == 1 && expr.args[1] in ('(', '[', '⟨', '{')) + elseif head(expr) in (:line, :inline_math, :group, :delimited) + isempty(expr.args) && return false + return _has_plausible_binary_left_argument(last(expr.args)) + end + + return true +end diff --git a/test/layout.jl b/test/layout.jl index 881a8a0..fe389a9 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -85,6 +85,74 @@ end char, pos, size = first(elems) @test pos[1] == 2 end + + @testset "Italic boundary correction" begin + old = MathTeXEngine.italic_correction_enabled[] + try + MathTeXEngine.italic_correction_enabled[] = false + without = generate_tex_elements(L"(f)x η(t)") + + MathTeXEngine.italic_correction_enabled[] = true + with = generate_tex_elements(L"(f)x η(t)") + + xpos(elems, i) = elems[i][2][1] + + # Issue #142: roman delimiters next to italic glyphs should not + # inherit an asymmetric font-side gap. + @test xpos(with, 2) > xpos(without, 2) + @test xpos(with, 3) > xpos(without, 3) + + # Issue #95: the same boundary correction also applies to lower + # case Greek followed by roman delimiters in subscripts/labels. + @test MathTeXEngine.is_slanted(with[5][1]) + @test xpos(with, 7) - xpos(with, 6) < xpos(without, 7) - xpos(without, 6) + @test xpos(with, 8) - xpos(with, 7) > xpos(without, 8) - xpos(without, 7) + finally + MathTeXEngine.italic_correction_enabled[] = old + end + end + + @testset "Function spacing" begin + xpos(elems, i) = elems[i][2][1] + + # Issue #129: LaTeX inserts a thin space after math operators when + # the argument is not parenthesized. + @test xpos(generate_tex_elements(L"\log x"), 4) > + xpos(generate_tex_elements(L"\mathrm{log}x"), 4) + 0.1 + @test xpos(generate_tex_elements(L"\sin\alpha"), 4) > + xpos(generate_tex_elements(L"\mathrm{sin}\alpha"), 4) + 0.1 + + # No operator space is inserted before an opening delimiter. + @test xpos(generate_tex_elements(L"\log(x)"), 4) ≈ + xpos(generate_tex_elements(L"\mathrm{log}(x)"), 4) + end + + @testset "Subscript spacing respects italic overhangs" begin + ink_start(element) = element[2][1] + element[3] * leftinkbound(element[1]) + ink_end(element) = element[2][1] + element[3] * rightinkbound(element[1]) + + font_names = sort(collect(keys(MathTeXEngine.default_font_families))) + cases = (L"N_\nu", L"J_\nu", L"x_{\alpha(k)}") + + for font_name in font_names, tex in cases + elems = generate_tex_elements(tex, MathTeXEngine.FontFamily(font_name)) + @test ink_start(elems[2]) + 0.002 >= ink_end(elems[1]) + end + + elems = generate_tex_elements(L"N_\nu L_\nu A_\nu J_\nu") + @test ink_start(elems[2]) >= ink_end(elems[1]) + @test ink_start(elems[8]) >= ink_end(elems[7]) + + # Issue #95 includes a nested subscript case where the inner `(k)` + # should stay inside the lower script instead of being squeezed left. + for font_name in font_names + elems = generate_tex_elements( + L"v_{(a + b)_k}^i", + MathTeXEngine.FontFamily(font_name), + ) + @test ink_start(elems[7]) + 0.002 >= ink_end(elems[6]) + end + end end @testset "Generate elements" begin @@ -95,7 +163,8 @@ end @test length(elems) == 7 # Check the following does not error - tex = L"\lim_{α →\infty} A^j v_{(a + b)_k}^i \sqrt{2} x!= \sqrt{\frac{1+2}{4+a+x}}\int_{0}^{2π} \sin(x) dx" + tex = + L"\lim_{α →\infty} A^j v_{(a + b)_k}^i \sqrt{2} x!= \sqrt{\frac{1+2}{4+a+x}}\int_{0}^{2π} \sin(x) dx" generate_tex_elements(tex) tex = L"Momentum $p_x$ (a.u.)" @@ -106,4 +175,4 @@ end elems = generate_tex_elements(L"Time $t_0$") @test length(elems) == 7 -end \ No newline at end of file +end diff --git a/test/parser.jl b/test/parser.jl index d3a2d59..52f3fd5 100644 --- a/test/parser.jl +++ b/test/parser.jl @@ -250,6 +250,42 @@ end (:char, 'd'))) end + @testset "Unary operator spacing heuristic" begin + old = MathTeXEngine.unspace_binary_operators_heuristic_enabled[] + try + MathTeXEngine.unspace_binary_operators_heuristic_enabled[] = true + + test_parse(raw"$-1$", (:inline_math, (:symbol, '−'), (:digit, '1'))) + test_parse(raw"$2-1$", + (:inline_math, + (:digit, '2'), + (:spaced, (:symbol, '−')), + (:digit, '1'))) + test_parse(raw"$\alpha^*$", + (:inline_math, + (:decorated, (:symbol, 'α'), nothing, (:symbol, '*')))) + test_parse(raw"$\frac{1}{2}\pm\sqrt{3}$", + (:inline_math, + (:frac, (:digit, '1'), (:digit, '2')), + (:spaced, (:symbol, '±')), + (:sqrt, (:digit, '3')))) + test_parse(raw"$\frac{1}{2}{}\pm\sqrt{3}$", + (:inline_math, + (:frac, (:digit, '1'), (:digit, '2')), + (:space, 0.0), + (:symbol, '±'), + (:sqrt, (:digit, '3')))) + + MathTeXEngine.unspace_binary_operators_heuristic_enabled[] = false + test_parse(raw"$-1$", (:inline_math, (:spaced, (:symbol, '−')), (:digit, '1'))) + test_parse(raw"$\alpha^*$", + (:inline_math, + (:decorated, (:symbol, 'α'), nothing, (:spaced, (:symbol, '*'))))) + finally + MathTeXEngine.unspace_binary_operators_heuristic_enabled[] = old + end + end + @testset "Subscript and superscript" begin @test texparse(raw"a^2_3") == texparse(raw"a_3^2") @test texparse(raw"^7_b") == texparse(raw"{}^7_b") diff --git a/test/runtests.jl b/test/runtests.jl index 17e3032..41817f8 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -9,8 +9,18 @@ import MathTeXEngine: tex_layout, generate_tex_elements import MathTeXEngine: Space, TeXElement import MathTeXEngine: load_font import MathTeXEngine: inkheight, inkwidth +import MathTeXEngine: leftinkbound, rightinkbound include("texexpr.jl") include("parser.jl") include("fonts.jl") -include("layout.jl") \ No newline at end of file +include("layout.jl") + +if get(ENV, "MTE_GENERATE_SPACING_VISUALS", "false") in ("1", "true", "yes") + @testset "Spacing visual inspection sheet" begin + include(joinpath(@__DIR__, "..", "reference", "spacing_visuals.jl")) + path = generate_spacing_visuals() + @info "Wrote spacing visual inspection sheet" path + @test isfile(path) + end +end From dd59b4c1b00f5e44f200d83e921bb4dc43f46896 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Fri, 8 May 2026 09:27:08 +1200 Subject: [PATCH 02/13] Tighten spacing helper internals --- src/MathTeXEngine.jl | 6 ++---- src/engine/fonts.jl | 6 ++---- src/engine/layout.jl | 15 ++++----------- src/engine/texelements.jl | 4 ++-- src/parser/parser.jl | 2 +- test/layout.jl | 8 ++++---- test/parser.jl | 8 ++++---- 7 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/MathTeXEngine.jl b/src/MathTeXEngine.jl index bd18325..f4b73a5 100644 --- a/src/MathTeXEngine.jl +++ b/src/MathTeXEngine.jl @@ -28,10 +28,8 @@ export glyph_index # Reexport from LaTeXStrings export @L_str -# Global layout/parser heuristics. These are Refs so callers and tests can -# temporarily toggle them without redefining package methods. -const italic_correction_enabled = Ref(true) -const unspace_binary_operators_heuristic_enabled = Ref(true) +const _italic_correction_enabled = Ref(true) +const _unspace_binary_operators_heuristic_enabled = Ref(true) include("parser/tokenizer.jl") include("parser/texexpr.jl") diff --git a/src/engine/fonts.jl b/src/engine/fonts.jl index 6e674ed..0d94dd7 100644 --- a/src/engine/fonts.jl +++ b/src/engine/fonts.jl @@ -255,10 +255,8 @@ object. get_fontpath(font_family::FontFamily, fontstyle::Symbol) = full_fontpath(font_family.fonts[fontstyle]) get_fontpath(fontstyle::Symbol) = get_fontpath(FontFamily(), fontstyle) -function is_slanted(font_family, char_type) - font_id = font_family.font_mapping[char_type] - return font_id == :italic || font_id == :bolditalic -end +is_slanted_font(font_id) = font_id in (:italic, :bolditalic) +is_slanted(font_family, char_type) = is_slanted_font(font_family.font_mapping[char_type]) slant_angle(font_family) = font_family.slant_angle * π / 180 diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 163e75f..3e5af9f 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -26,7 +26,7 @@ function tex_layout(expr, state) head = expr.head args = [expr.args...] shrink = 0.6 - italic_correction = state.tex_mode == :inline_math && italic_correction_enabled[] + italic_correction = state.tex_mode == :inline_math && _italic_correction_enabled[] try if isleaf(expr) # :char, :delimiter, :digit, :punctuation, :symbol @@ -162,7 +162,7 @@ function tex_layout(expr, state) elements = _add_function_spacing(args, elements) end - italic_correction = mode == :inline_math && italic_correction_enabled[] + italic_correction = mode == :inline_math && _italic_correction_enabled[] return horizontal_layout(elements; italic_correction) elseif head == :integral pad = 0.1 @@ -321,15 +321,8 @@ function _add_function_spacing(args, elements) end function _function_takes_space(args, i) - for j in (i+1):length(args) - arg = args[j] - if arg.head == :char && only(arg.args) == ' ' - continue - end - return !_is_opening_delimiter(arg) - end - - return false + next = findnext(arg -> !(arg.head == :char && only(arg.args) == ' '), args, i + 1) + return !isnothing(next) && !_is_opening_delimiter(args[next]) end function _is_opening_delimiter(expr) diff --git a/src/engine/texelements.jl b/src/engine/texelements.jl index ec60c78..77ededd 100644 --- a/src/engine/texelements.jl +++ b/src/engine/texelements.jl @@ -164,7 +164,7 @@ function TeXChar(char::Char, state::LayoutState, char_type) glyph_index(font, char), font, font_family, - font_id == :italic || font_id == :bolditalic, + is_slanted_font(font_id), char, ) end @@ -188,7 +188,7 @@ function TeXChar(name::AbstractString, state::LayoutState, char_type ; represent glyph_index(font, name), font, font_family, - font_id == :italic || font_id == :bolditalic, + is_slanted_font(font_id), represented, ) end diff --git a/src/parser/parser.jl b/src/parser/parser.jl index 991412a..00d9c14 100644 --- a/src/parser/parser.jl +++ b/src/parser/parser.jl @@ -71,7 +71,7 @@ function push_down!(stack, math_mode = false) end end - if unspace_binary_operators_heuristic_enabled[] && math_mode && head(top) == :spaced + if _unspace_binary_operators_heuristic_enabled[] && math_mode && head(top) == :spaced if !_has_plausible_binary_left_argument(first(stack)) top = only(top.args) end diff --git a/test/layout.jl b/test/layout.jl index fe389a9..ed3a0dd 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -87,12 +87,12 @@ end end @testset "Italic boundary correction" begin - old = MathTeXEngine.italic_correction_enabled[] + old = MathTeXEngine._italic_correction_enabled[] try - MathTeXEngine.italic_correction_enabled[] = false + MathTeXEngine._italic_correction_enabled[] = false without = generate_tex_elements(L"(f)x η(t)") - MathTeXEngine.italic_correction_enabled[] = true + MathTeXEngine._italic_correction_enabled[] = true with = generate_tex_elements(L"(f)x η(t)") xpos(elems, i) = elems[i][2][1] @@ -108,7 +108,7 @@ end @test xpos(with, 7) - xpos(with, 6) < xpos(without, 7) - xpos(without, 6) @test xpos(with, 8) - xpos(with, 7) > xpos(without, 8) - xpos(without, 7) finally - MathTeXEngine.italic_correction_enabled[] = old + MathTeXEngine._italic_correction_enabled[] = old end end diff --git a/test/parser.jl b/test/parser.jl index 52f3fd5..71186b1 100644 --- a/test/parser.jl +++ b/test/parser.jl @@ -251,9 +251,9 @@ end end @testset "Unary operator spacing heuristic" begin - old = MathTeXEngine.unspace_binary_operators_heuristic_enabled[] + old = MathTeXEngine._unspace_binary_operators_heuristic_enabled[] try - MathTeXEngine.unspace_binary_operators_heuristic_enabled[] = true + MathTeXEngine._unspace_binary_operators_heuristic_enabled[] = true test_parse(raw"$-1$", (:inline_math, (:symbol, '−'), (:digit, '1'))) test_parse(raw"$2-1$", @@ -276,13 +276,13 @@ end (:symbol, '±'), (:sqrt, (:digit, '3')))) - MathTeXEngine.unspace_binary_operators_heuristic_enabled[] = false + MathTeXEngine._unspace_binary_operators_heuristic_enabled[] = false test_parse(raw"$-1$", (:inline_math, (:spaced, (:symbol, '−')), (:digit, '1'))) test_parse(raw"$\alpha^*$", (:inline_math, (:decorated, (:symbol, 'α'), nothing, (:spaced, (:symbol, '*'))))) finally - MathTeXEngine.unspace_binary_operators_heuristic_enabled[] = old + MathTeXEngine._unspace_binary_operators_heuristic_enabled[] = old end end From d03417e0945196f12101b674d72732df85a03a39 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Sat, 9 May 2026 09:21:12 +1200 Subject: [PATCH 03/13] Clean up math layout fixes --- src/engine/layout.jl | 191 ++++++++++++++++++------- src/engine/layout_context.jl | 18 ++- src/engine/new_computer_modern_data.jl | 8 +- src/engine/texelements.jl | 30 ++-- src/parser/texexpr.jl | 4 +- test/layout.jl | 73 +++++++++- test/runtests.jl | 2 +- 7 files changed, 254 insertions(+), 72 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 3e5af9f..7be0957 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -11,6 +11,81 @@ function argument_as_string(arg) return String(Char.(arg.args)) end +const _TEXT_OPERATOR_SPACE = 0.2 +const _SCRIPT_OPERATOR_SPACE = 0.08 +const _MIN_SCRIPT_GAP = 0.04 +const _TALL_SCRIPT_CORE_HEIGHT = 1.2 +const _TALL_SCRIPT_VERTICAL_CLEARANCE = 0.65 +const _TALL_SCRIPT_CORE_OVERLAP = 0.3 +const _SCRIPT_FRACTION_RULE_WIDTH = 0.45 +const _SCRIPT_FRACTION_RULE_SHIFT = 0.18 + +function _script_y_positions(core, sub, super, font_family, sub_shrink, super_shrink) + xh = xheight(font_family) + script_gap = max(thickness(font_family), _MIN_SCRIPT_GAP) + + sub_y = -0.15 + if inkheight(sub) * sub_shrink > xh + sub_y = min(sub_y, -topinkbound(sub) * sub_shrink - script_gap) + end + + super_y = 0.85xh + if inkheight(super) * super_shrink > xh + super_y = max(super_y, -bottominkbound(super) * super_shrink + xh + script_gap) + end + + if inkheight(core) > _TALL_SCRIPT_CORE_HEIGHT + sub_top = bottominkbound(core) + _TALL_SCRIPT_VERTICAL_CLEARANCE * xh + super_bottom = topinkbound(core) - _TALL_SCRIPT_VERTICAL_CLEARANCE * xh + sub_y = min(sub_y, sub_top - topinkbound(sub) * sub_shrink) + super_y = max(super_y, super_bottom - bottominkbound(super) * super_shrink) + end + + return sub_y, super_y +end + +function _script_shrink(elem, font_family, shrink) + return inkheight(elem) * shrink > xheight(font_family) ? 0.5 : shrink +end + +function _sqrt_radical(state, target_height) + font_family = state.font_family + radicals = TeXElement[TeXChar('√', state, :symbol)] + + 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) + + fallback = default_math_texchar(radical_name, font_family, '√') + isnothing(fallback) || push!(radicals, fallback) + end + + sort!(radicals; by = inkheight) + for candidate in radicals + if candidate.glyph_id == 0 + continue + end + inkheight(candidate) >= target_height && return candidate + end + + return last(radicals) +end + +const _math_delimiter_chars = Set(['(', ')', '[', ']', '{', '}', '⟨', '⟩']) + +function _delimiter_element(char, state) + font_family = state.font_family + + if char in _math_delimiter_chars || char in ('|', '‖') + texchar = default_math_texchar(char, font_family, char) + if !isnothing(texchar) + return texchar + end + end + + return TeXChar(char, state, :delimiter) +end + """ tex_layout(mathexpr::TeXExpr, font_family) @@ -33,6 +108,8 @@ function tex_layout(expr, state) char = args[1] if char == ' ' && state.tex_mode == :inline_math return Space(0.0) + elseif head == :delimiter + return _delimiter_element(char, state) end return TeXChar(char, state, head) elseif head == :combining_accent @@ -56,32 +133,46 @@ function tex_layout(expr, state) slanted = is_slanted(core), ) elseif head == :decorated - core, sub, super = tex_layout.(args, state) - + core = tex_layout(args[1], state) + script_state = increase_script_level(state) + sub = tex_layout(args[2], script_state) + super = tex_layout(args[3], script_state) + + sub_shrink = _script_shrink(sub, font_family, shrink) + super_shrink = _script_shrink(super, font_family, shrink) + tall_core = inkheight(core) > _TALL_SCRIPT_CORE_HEIGHT + sub_y, super_y = _script_y_positions( + core, + sub, + super, + font_family, + sub_shrink, + super_shrink, + ) if !isnothing(args[3]) && args[3].head == :primes super_x = min(hadvance(core), rightinkbound(core)) - 0.1 super_y = 0.1 super_shrink = 1 else super_x = max(hadvance(core), rightinkbound(core)) - super_y = xheight(font_family) - super_shrink = shrink + if tall_core + super_x -= _TALL_SCRIPT_CORE_OVERLAP * xheight(font_family) + end + end + sub_x = max(hadvance(core), rightinkbound(core)) + + (1 - sub_shrink) * leftinkbound(sub) + if tall_core + sub_x -= _TALL_SCRIPT_CORE_OVERLAP * xheight(font_family) end return Group( [core, sub, super], Point2f[ (0, 0), - ( - # Respect italic overhangs so lower scripts do not - # tuck under the visible ink of the core glyph. - max(hadvance(core), rightinkbound(core)) + - (1 - shrink) * leftinkbound(sub), - -0.2, - ), + (sub_x, sub_y), (super_x, super_y), ], - [1, shrink, super_shrink]; + [1, sub_shrink, super_shrink]; slanted = is_slanted(core) || is_slanted(super), ) elseif head == :delimited @@ -95,16 +186,14 @@ function tex_layout(expr, state) dxs = hadvance.(elements) .* scales xs = [0, cumsum(dxs[1:end-1])...] + content_midline = vmid(content) - # TODO Height calculation for the parenthesis looks wrong - # TODO Check what the algorithm should be there - # Center the delimiters in the middle of the bot and top baselines ? return Group( elements, Point2f[ - (xs[1], -bottominkbound(left) + bottominkbound(content)), + (xs[1], content_midline - vmid(left) * left_scale), (xs[2], 0), - (xs[3], -bottominkbound(right) + bottominkbound(content)), + (xs[3], content_midline - vmid(right) * right_scale), ], scales; slanted = is_slanted(right), @@ -118,26 +207,35 @@ function tex_layout(expr, state) numerator = tex_layout(args[1], state) denominator = tex_layout(args[2], state) - # extend fraction line by half an xheight xh = xheight(font_family) - w = max(inkwidth(numerator), inkwidth(denominator)) + xh/2 + argument_width = max(inkwidth(numerator), inkwidth(denominator)) + rule_width = state.script_level > 0 ? + _SCRIPT_FRACTION_RULE_WIDTH * argument_width : + argument_width # fixed width fraction line - lw = thickness(font_family) - - line = HLine(w, lw) - y0 = xh/2 - lw/2 - - # horizontal center align for numerator and denominator - x1 = (w-inkwidth(numerator))/2 - x2 = (w-inkwidth(denominator))/2 + rule_thickness = thickness(font_family) + + line = HLine(rule_width, rule_thickness) + y0 = xh/2 - rule_thickness/2 + + # Align the rule and arguments around the same center. This matters + # for shortened script-style rules, where anchoring at x = 0 makes + # the rule look too long on one side. + center = argument_width / 2 + xline = center - hmid(line) + if state.script_level > 0 + xline -= _SCRIPT_FRACTION_RULE_SHIFT * argument_width + end + x1 = center - hmid(numerator) + x2 = center - hmid(denominator) - ytop = y0 + xh/2 - bottominkbound(numerator) + ytop = y0 + xh/2 - bottominkbound(numerator) ybottom = y0 - xh/2 - topinkbound(denominator) return Group( [line, numerator, denominator], - Point2f[(0, y0), (x1, ytop), (x2, ybottom)]; + Point2f[(xline, y0), (x1, ytop), (x2, ybottom)]; slanted = is_slanted(numerator) || is_slanted(denominator), ) elseif head == :function @@ -212,35 +310,28 @@ function tex_layout(expr, state) return Space(args[1]) elseif head == :spaced sym = tex_layout(args[1], state) - return horizontal_layout([Space(0.2), sym, Space(0.2)]; italic_correction) + space = state.script_level > 0 ? _SCRIPT_OPERATOR_SPACE : _TEXT_OPERATOR_SPACE + return horizontal_layout([Space(space), sym, Space(space)]; italic_correction) elseif head == :sqrt content = tex_layout(args[1], state) - h = inkheight(content) - sqrt = nothing - - for name in ["radical.v1", "radical.v2", "radical.v3", "radical.v4"] - sqrt = TeXChar(name, state, :symbol ; represented = '√') - pad = inkheight(sqrt) - if inkheight(sqrt) >= 1.05h - pad = (inkheight(sqrt) - 1.05h) / 2 - break - end - end + rule_thickness = thickness(font_family) + clearance = max(rule_thickness, xheight(font_family) / 3) + target_height = inkheight(content) + clearance + radical = _sqrt_radical(state, target_height) - h = inkheight(sqrt) - - lw = thickness(font_family) - y0 = bottominkbound(content) - bottominkbound(sqrt) - pad - y = y0 + topinkbound(sqrt) - lw + line_top = topinkbound(content) + clearance + y0 = line_top - topinkbound(radical) + line_y = line_top - rule_thickness / 2 - hline = HLine(inkwidth(content) + pad, lw) + hline_width = max(inkwidth(content), xheight(font_family) / 2) + clearance + hline = HLine(hline_width, rule_thickness) return Group( - [sqrt, hline, content, Space(1.2)], + [radical, hline, content, Space(1.2)], Point2f[ (0, y0), - (rightinkbound(sqrt) - lw/2, y + lw/2), - (rightinkbound(sqrt), 0), + (rightinkbound(radical) - rule_thickness/2, line_y), + (rightinkbound(radical), 0), (rightinkbound(content), 0), ], ) diff --git a/src/engine/layout_context.jl b/src/engine/layout_context.jl index 3fcdb7c..e502e26 100644 --- a/src/engine/layout_context.jl +++ b/src/engine/layout_context.jl @@ -2,25 +2,35 @@ struct LayoutState font_family::FontFamily font_modifiers::Vector{Symbol} tex_mode::Symbol + script_level::Int end -LayoutState(font_family::FontFamily, modifiers::Vector) = LayoutState(font_family, modifiers, :text) +LayoutState(font_family::FontFamily, modifiers::Vector) = LayoutState(font_family, modifiers, :text, 0) LayoutState(font_family::FontFamily) = LayoutState(font_family, Symbol[]) LayoutState() = LayoutState(FontFamily()) function Base.show(io::IO, state::LayoutState) - print(io, "LayoutState($(state.font_modifiers), $(state.tex_mode))") + print(io, "LayoutState($(state.font_modifiers), $(state.tex_mode), $(state.script_level))") end Base.broadcastable(state::LayoutState) = Ref(state) function change_mode(state::LayoutState, mode) - LayoutState(state.font_family, state.font_modifiers, mode) + LayoutState(state.font_family, state.font_modifiers, mode, state.script_level) end function add_font_modifier(state::LayoutState, modifier) modifiers = vcat(state.font_modifiers, modifier) - return LayoutState(state.font_family, modifiers, state.tex_mode) + LayoutState(state.font_family, modifiers, state.tex_mode, state.script_level) +end + +function increase_script_level(state::LayoutState) + return LayoutState( + state.font_family, + state.font_modifiers, + state.tex_mode, + state.script_level + 1, + ) end function get_font_identifier(state::LayoutState, char_type) diff --git a/src/engine/new_computer_modern_data.jl b/src/engine/new_computer_modern_data.jl index 14736b4..7ee3dea 100644 --- a/src/engine/new_computer_modern_data.jl +++ b/src/engine/new_computer_modern_data.jl @@ -1,5 +1,5 @@ _latex_to_new_computer_modern = Dict( - raw"\int" => 5930, + raw"\int" => 878, raw"\sum" => 5941, raw"\partial" => 3377, @@ -22,13 +22,13 @@ _latex_to_new_computer_modern = Dict( _symbol_to_new_computer_modern = Dict{Char, Tuple{String, Int}}() cmmath_fontpath = joinpath("NewComputerModern", "NewCMMath-Regular.otf") -for (symbol, glyph_id) in _latex_to_new_computer_modern +for (symbol, glyph_id) in _latex_to_new_computer_modern if haskey(latex_symbols, symbol) symbol = latex_symbols[symbol][1] else symbol = symbol[1] end - + _symbol_to_new_computer_modern[symbol] = (cmmath_fontpath, glyph_id) end @@ -45,4 +45,4 @@ end _symbol_to_new_computer_modern['ħ'] = ( joinpath("NewComputerModern", "NewCM10-Italic.otf"), 231 -) \ No newline at end of file +) diff --git a/src/engine/texelements.jl b/src/engine/texelements.jl index 77ededd..768e639 100644 --- a/src/engine/texelements.jl +++ b/src/engine/texelements.jl @@ -148,6 +148,17 @@ struct TeXChar <: TeXElement represented_char::Char end +function default_math_glyph(char_or_name) + font = load_font(_default_fonts[:math]) + return glyph_index(font, char_or_name), font +end + +function default_math_texchar(char_or_name, font_family, represented::Char) + glyph_id, font = default_math_glyph(char_or_name) + glyph_id == 0 && return nothing + return TeXChar(glyph_id, font, font_family, false, represented) +end + function TeXChar(char::Char, state::LayoutState, char_type) font_family = state.font_family @@ -160,13 +171,15 @@ function TeXChar(char::Char, state::LayoutState, char_type) font_id = get_font_identifier(state, char_type) font = get_font(font_family, font_id) - return TeXChar( - glyph_index(font, char), - font, - font_family, - is_slanted_font(font_id), - char, - ) + glyph_id = glyph_index(font, char) + if glyph_id == 0 && char_type in (:delimiter, :symbol) + fallback = default_math_texchar(char, font_family, char) + if !isnothing(fallback) + return fallback + end + end + + return TeXChar(glyph_id, font, font_family, is_slanted_font(font_id), char) end is_slanted_math_symbol(char, char_type) = char_type == :symbol && is_lowercase_greek(char) @@ -183,9 +196,10 @@ function TeXChar(name::AbstractString, state::LayoutState, char_type ; represent font_family = state.font_family font_id = get_font_identifier(state, char_type) font = get_font(font_family, font_id) + glyph_id = glyph_index(font, name) return TeXChar( - glyph_index(font, name), + glyph_id, font, font_family, is_slanted_font(font_id), diff --git a/src/parser/texexpr.jl b/src/parser/texexpr.jl index acc29ea..c720543 100644 --- a/src/parser/texexpr.jl +++ b/src/parser/texexpr.jl @@ -47,7 +47,7 @@ Base.pop!(texexpr::TeXExpr) = pop!(texexpr.args) Base.copy(texexpr::TeXExpr) = TeXExpr(texexpr.head, deepcopy(texexpr.args)) function Base.Char(texexpr::TeXExpr) - if texexpr.head in [:char, :symbol, :digit] + if isleaf(texexpr) return texexpr.args[1] end @@ -77,7 +77,7 @@ function manual_texexpr(tuple::Tuple) head = tuple[1] args = [] - if head in [:char, :digit, :symbol] + if head in [:char, :delimiter, :digit, :punctuation, :symbol] return TeXExpr(head, tuple[2]) end diff --git a/test/layout.jl b/test/layout.jl index ed3a0dd..70c9e30 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -11,6 +11,10 @@ function test_same_layout(layout1, layout2) end end +ink_bottom(element) = element[2][2] + element[3] * bottominkbound(element[1]) +ink_top(element) = element[2][2] + element[3] * topinkbound(element[1]) +ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 + @testset "Layout" begin @testset "Decorated" begin expr = manual_texexpr((:decorated, 'x', 'b', 't')) @@ -31,6 +35,14 @@ end @test length(generate_tex_elements(L"a_b^c")) == 3 @test length(generate_tex_elements(L"_b^c")) == 2 + + elems = generate_tex_elements(L"\left(\frac{dy}{dx}\right)_0") + @test ink_bottom(elems[8]) < ink_bottom(elems[7]) - 0.03 + @test ink_top(elems[8]) > ink_bottom(elems[7]) + + elems = generate_tex_elements(L"\left(\frac{A^{xy}}{B}\right)^{1/4}") + @test ink_top(elems[8]) > ink_top(elems[7]) + 0.03 + @test ink_bottom(elems[8]) < ink_top(elems[7]) end @testset "Delimited" begin @@ -40,6 +52,21 @@ end hs = inkheight.(layout.elements) .* layout.scales @test hs[1] >= hs[2] @test hs[3] >= hs[2] + + elems = generate_tex_elements( + L"\left\langle\left|\left\langle\left|\int\right|\right\rangle\right|\right\rangle", + ) + @test maximum(abs(ink_vmid(elem) - ink_vmid(elems[5])) for elem in elems) < 0.1 + + for font_name in keys(MathTeXEngine.default_font_families) + bar = tex_layout(manual_texexpr((:delimiter, '|')), MathTeXEngine.FontFamily(font_name)) + default_bar_id, _ = MathTeXEngine.default_math_glyph('|') + @test bar.glyph_id == default_bar_id + + paren = tex_layout(manual_texexpr((:delimiter, '(')), MathTeXEngine.FontFamily(font_name)) + default_paren_id, _ = MathTeXEngine.default_math_glyph('(') + @test paren.glyph_id == default_paren_id + end end @testset "Font" begin @@ -118,13 +145,53 @@ end # Issue #129: LaTeX inserts a thin space after math operators when # the argument is not parenthesized. @test xpos(generate_tex_elements(L"\log x"), 4) > - xpos(generate_tex_elements(L"\mathrm{log}x"), 4) + 0.1 + xpos(generate_tex_elements(L"\mathrm{log}x"), 4) + 0.1 @test xpos(generate_tex_elements(L"\sin\alpha"), 4) > - xpos(generate_tex_elements(L"\mathrm{sin}\alpha"), 4) + 0.1 + xpos(generate_tex_elements(L"\mathrm{sin}\alpha"), 4) + 0.1 # No operator space is inserted before an opening delimiter. @test xpos(generate_tex_elements(L"\log(x)"), 4) ≈ - xpos(generate_tex_elements(L"\mathrm{log}(x)"), 4) + xpos(generate_tex_elements(L"\mathrm{log}(x)"), 4) + end + + @testset "Fraction rule padding" begin + elems = generate_tex_elements(L"x^{\frac{1}{1+2}}") + rule_end = elems[2][2][1] + elems[2][3] * rightinkbound(elems[2][1]) + denom_end = maximum(e[2][1] + e[3] * rightinkbound(e[1]) for e in elems[4:6]) + @test rule_end - denom_end < 0.1 + end + + @testset "Square root glyph fallback" begin + for font_name in keys(MathTeXEngine.default_font_families) + elems = generate_tex_elements(L"\sqrt{3}", MathTeXEngine.FontFamily(font_name)) + @test elems[1][1] isa TeXChar + @test elems[1][1].represented_char == '√' + @test elems[1][1].glyph_id != 0 + @test ink_top(elems[2]) ≈ ink_top(elems[1]) atol = 1.0e-6 + + empty_elems = generate_tex_elements(L"\sqrt{}", MathTeXEngine.FontFamily(font_name)) + @test empty_elems[1][1].represented_char == '√' + @test empty_elems[1][1].glyph_id != 0 + @test rightinkbound(empty_elems[2][1]) > 0 + + tall_elems = generate_tex_elements( + L"\sqrt{x_i^2+y_i^2}", + MathTeXEngine.FontFamily(font_name), + ) + @test ink_top(tall_elems[2]) >= maximum(ink_top(e) for e in tall_elems[3:end]) + end + end + + @testset "Missing math symbols use default math fallback" begin + for font_name in keys(MathTeXEngine.default_font_families) + elems = generate_tex_elements(L"\int_0^1 f(x) dx", MathTeXEngine.FontFamily(font_name)) + @test elems[1][1] isa TeXChar + @test elems[1][1].represented_char == '∫' + @test elems[1][1].glyph_id != 0 + end + + elems = generate_tex_elements(L"\int", MathTeXEngine.FontFamily("NewComputerModern")) + @test inkheight(elems[1][1]) < 1.5 end @testset "Subscript spacing respects italic overhangs" begin diff --git a/test/runtests.jl b/test/runtests.jl index 41817f8..49e5532 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -9,7 +9,7 @@ import MathTeXEngine: tex_layout, generate_tex_elements import MathTeXEngine: Space, TeXElement import MathTeXEngine: load_font import MathTeXEngine: inkheight, inkwidth -import MathTeXEngine: leftinkbound, rightinkbound +import MathTeXEngine: bottominkbound, leftinkbound, rightinkbound, topinkbound include("texexpr.jl") include("parser.jl") From 941394e754b11d2aed656443713a6e92fd9d62f4 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Tue, 12 May 2026 06:31:44 +1200 Subject: [PATCH 04/13] Address alignment reviewer feedback --- src/MathTeXEngine.jl | 6 ++++-- src/engine/layout.jl | 16 +++++++++------- src/engine/layout_context.jl | 3 +++ src/engine/texelements.jl | 1 + src/parser/parser.jl | 2 +- test/layout.jl | 8 ++++---- test/parser.jl | 8 ++++---- 7 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/MathTeXEngine.jl b/src/MathTeXEngine.jl index f4b73a5..584d11f 100644 --- a/src/MathTeXEngine.jl +++ b/src/MathTeXEngine.jl @@ -28,8 +28,10 @@ export glyph_index # Reexport from LaTeXStrings export @L_str -const _italic_correction_enabled = Ref(true) -const _unspace_binary_operators_heuristic_enabled = Ref(true) +# Advanced layout/parser knobs. These are intentionally not exported, but may +# be toggled by qualified access when debugging regressions. +const italic_correction_enabled = Ref(true) +const unspace_binary_operators_heuristic_enabled = Ref(true) include("parser/tokenizer.jl") include("parser/texexpr.jl") diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 7be0957..3f80808 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -19,18 +19,20 @@ const _TALL_SCRIPT_VERTICAL_CLEARANCE = 0.65 const _TALL_SCRIPT_CORE_OVERLAP = 0.3 const _SCRIPT_FRACTION_RULE_WIDTH = 0.45 const _SCRIPT_FRACTION_RULE_SHIFT = 0.18 +const _TALL_SCRIPT_HEIGHT_FACTOR = 1.1 function _script_y_positions(core, sub, super, font_family, sub_shrink, super_shrink) xh = xheight(font_family) script_gap = max(thickness(font_family), _MIN_SCRIPT_GAP) + tall_script_height = _TALL_SCRIPT_HEIGHT_FACTOR * xh - sub_y = -0.15 - if inkheight(sub) * sub_shrink > xh + sub_y = -0.2 + if inkheight(sub) * sub_shrink > tall_script_height sub_y = min(sub_y, -topinkbound(sub) * sub_shrink - script_gap) end - super_y = 0.85xh - if inkheight(super) * super_shrink > xh + super_y = xh + if inkheight(super) * super_shrink > tall_script_height super_y = max(super_y, -bottominkbound(super) * super_shrink + xh + script_gap) end @@ -101,7 +103,7 @@ function tex_layout(expr, state) head = expr.head args = [expr.args...] shrink = 0.6 - italic_correction = state.tex_mode == :inline_math && _italic_correction_enabled[] + italic_correction = state.tex_mode == :inline_math && italic_correction_enabled[] try if isleaf(expr) # :char, :delimiter, :digit, :punctuation, :symbol @@ -260,7 +262,7 @@ function tex_layout(expr, state) elements = _add_function_spacing(args, elements) end - italic_correction = mode == :inline_math && _italic_correction_enabled[] + italic_correction = mode == :inline_math && italic_correction_enabled[] return horizontal_layout(elements; italic_correction) elseif head == :integral pad = 0.1 @@ -294,7 +296,7 @@ function tex_layout(expr, state) content = tex_layout(args[1], state) lw = thickness(font_family) - y = topinkbound(content) - lw + y = topinkbound(content) - lw hline = HLine(inkwidth(content) - 0.15, lw) diff --git a/src/engine/layout_context.jl b/src/engine/layout_context.jl index e502e26..66ba383 100644 --- a/src/engine/layout_context.jl +++ b/src/engine/layout_context.jl @@ -2,6 +2,9 @@ struct LayoutState font_family::FontFamily font_modifiers::Vector{Symbol} tex_mode::Symbol + # Nesting depth of subscript/superscript layout. This lets compact script + # contexts use tighter operator spacing and fraction rules than text-style + # math, even when the element itself has already been geometrically scaled. script_level::Int end diff --git a/src/engine/texelements.jl b/src/engine/texelements.jl index 768e639..3fbb705 100644 --- a/src/engine/texelements.jl +++ b/src/engine/texelements.jl @@ -313,6 +313,7 @@ Fields - elements::Vector{<:TeXElement} Vector of the elements contained in the group. - positions::Vector{Point2f} Vector of the relative positions of the contained elements. - scales::Vector Vector of the relative scales of the contained elements. + - slanted::Bool Whether the group behaves like a slanted element for accent placement. """ struct Group{T} <: TeXElement elements::Vector{<:TeXElement} diff --git a/src/parser/parser.jl b/src/parser/parser.jl index 00d9c14..991412a 100644 --- a/src/parser/parser.jl +++ b/src/parser/parser.jl @@ -71,7 +71,7 @@ function push_down!(stack, math_mode = false) end end - if _unspace_binary_operators_heuristic_enabled[] && math_mode && head(top) == :spaced + if unspace_binary_operators_heuristic_enabled[] && math_mode && head(top) == :spaced if !_has_plausible_binary_left_argument(first(stack)) top = only(top.args) end diff --git a/test/layout.jl b/test/layout.jl index 70c9e30..77bb85e 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -114,12 +114,12 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 end @testset "Italic boundary correction" begin - old = MathTeXEngine._italic_correction_enabled[] + old = MathTeXEngine.italic_correction_enabled[] try - MathTeXEngine._italic_correction_enabled[] = false + MathTeXEngine.italic_correction_enabled[] = false without = generate_tex_elements(L"(f)x η(t)") - MathTeXEngine._italic_correction_enabled[] = true + MathTeXEngine.italic_correction_enabled[] = true with = generate_tex_elements(L"(f)x η(t)") xpos(elems, i) = elems[i][2][1] @@ -135,7 +135,7 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 @test xpos(with, 7) - xpos(with, 6) < xpos(without, 7) - xpos(without, 6) @test xpos(with, 8) - xpos(with, 7) > xpos(without, 8) - xpos(without, 7) finally - MathTeXEngine._italic_correction_enabled[] = old + MathTeXEngine.italic_correction_enabled[] = old end end diff --git a/test/parser.jl b/test/parser.jl index 71186b1..52f3fd5 100644 --- a/test/parser.jl +++ b/test/parser.jl @@ -251,9 +251,9 @@ end end @testset "Unary operator spacing heuristic" begin - old = MathTeXEngine._unspace_binary_operators_heuristic_enabled[] + old = MathTeXEngine.unspace_binary_operators_heuristic_enabled[] try - MathTeXEngine._unspace_binary_operators_heuristic_enabled[] = true + MathTeXEngine.unspace_binary_operators_heuristic_enabled[] = true test_parse(raw"$-1$", (:inline_math, (:symbol, '−'), (:digit, '1'))) test_parse(raw"$2-1$", @@ -276,13 +276,13 @@ end (:symbol, '±'), (:sqrt, (:digit, '3')))) - MathTeXEngine._unspace_binary_operators_heuristic_enabled[] = false + MathTeXEngine.unspace_binary_operators_heuristic_enabled[] = false test_parse(raw"$-1$", (:inline_math, (:spaced, (:symbol, '−')), (:digit, '1'))) test_parse(raw"$\alpha^*$", (:inline_math, (:decorated, (:symbol, 'α'), nothing, (:spaced, (:symbol, '*'))))) finally - MathTeXEngine._unspace_binary_operators_heuristic_enabled[] = old + MathTeXEngine.unspace_binary_operators_heuristic_enabled[] = old end end From 41a05a682e5dc14702d02435eca23f8a9abf1049 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Tue, 12 May 2026 13:23:58 +1200 Subject: [PATCH 05/13] Address layout reviewer feedback --- reference/spacing_visuals.jl | 59 ++++---- src/engine/layout.jl | 183 +++++++++++++++++++------ src/engine/layout_context.jl | 6 +- src/engine/new_computer_modern_data.jl | 4 +- src/engine/texelements.jl | 22 +-- test/layout.jl | 45 +++++- test/runtests.jl | 2 +- 7 files changed, 236 insertions(+), 85 deletions(-) diff --git a/reference/spacing_visuals.jl b/reference/spacing_visuals.jl index b821dec..46a8b49 100644 --- a/reference/spacing_visuals.jl +++ b/reference/spacing_visuals.jl @@ -7,8 +7,8 @@ using MathTeXEngine const SPACING_VISUAL_FONT_NAMES = ["NewComputerModern", "TeXGyreHeros", "TeXGyrePagella", "LucioleMath"] -const SPACING_VISUAL_CASES = Pair{String,Vector{String}}[ - "Issue #142: italic/roman boundaries"=>[ +const SPACING_VISUAL_CASES = Pair{String, Vector{String}}[ + "Issue #142: italic/roman boundaries" => [ raw"f(t)", raw"g(x)", raw"(f)x", @@ -19,7 +19,7 @@ const SPACING_VISUAL_CASES = Pair{String,Vector{String}}[ raw"\mathrm{y}(x)", raw"\mathrm{g}t", ], - "Issue #95: lower-case Greek and subscript spacing"=>[ + "Issue #95: lower-case Greek and subscript spacing" => [ raw"\eta(t)", raw"\alpha_k", raw"\omega_k", @@ -30,10 +30,11 @@ const SPACING_VISUAL_CASES = Pair{String,Vector{String}}[ raw"\partial_i u_j", raw"\phi_\varphi \rho_\sigma", ], - "Subscript and superscript combinations"=>[ + "Subscript and superscript combinations" => [ raw"x_i", raw"x^i", raw"x_i^j", + raw"V^i_j", raw"x_{i_j}", raw"x^{i^j}", raw"x_{(a+b)_k}^i", @@ -41,7 +42,7 @@ const SPACING_VISUAL_CASES = Pair{String,Vector{String}}[ raw"\Gamma^\mu_{\nu\rho}", raw"\psi^\dagger_i\psi_i", ], - "PR #151: primes and deep scripts"=>[ + "PR #151: primes and deep scripts" => [ raw"x' f'", raw"x'' f''", raw"x′ f′", @@ -51,7 +52,7 @@ const SPACING_VISUAL_CASES = Pair{String,Vector{String}}[ raw"A^{B^{C^{D^E}}}_{F_{G_{H_I}}}", raw"f^{A'}", ], - "Roman/upright and capital boundaries"=>[ + "Roman/upright and capital boundaries" => [ raw"\mathrm{d}x", raw"\mathrm{e}^{-x}", raw"\mathrm{Re}\,z", @@ -61,7 +62,7 @@ const SPACING_VISUAL_CASES = Pair{String,Vector{String}}[ raw"A_\nu B_\nu C_\nu D_\nu", raw"M\mathrm{M}M", ], - "Issue #129: math operator spacing"=>[ + "Issue #129: math operator spacing" => [ raw"\log x", raw"\log(x)", raw"\sin x", @@ -70,7 +71,7 @@ const SPACING_VISUAL_CASES = Pair{String,Vector{String}}[ raw"\exp(t)", raw"\max_{t \in \{1,...,5\}}", ], - "Operators, delimiters, and fractions"=>[ + "Operators, delimiters, and fractions" => [ raw"-1,\ 2-1,\ (-1)", raw"\alpha^*", raw"\psi^* \psi", @@ -81,7 +82,7 @@ const SPACING_VISUAL_CASES = Pair{String,Vector{String}}[ raw"\sum_{k=0}^n a_k x^k", raw"\int_0^{2\pi}\sin(x)\,dx", ], - "Script layout issues #93, #105, #110, #126"=>[ + "Script layout issues #93, #105, #110, #126" => [ raw"\left(\frac{dy}{dx}\right)_0", raw"\left(\frac{A^{xy}}{B}\right)^{1/4}", raw"(\frac{A^{xy}}{B})^{1/4}", @@ -90,7 +91,7 @@ const SPACING_VISUAL_CASES = Pair{String,Vector{String}}[ raw"x^{\frac{1}{1+2}}", raw"x_{\frac{1}{1+2}}", ], - "Nested expressions"=>[ + "Nested expressions" => [ raw"\frac{\alpha_i+\beta_i}{\gamma_i+\delta_i}", raw"\sqrt{\frac{1+\alpha_k}{1+\beta_k}}", raw"F_{\mu\nu}F^{\mu\nu}", @@ -116,21 +117,24 @@ spacing_baseline_ref() = get(ENV, "MTE_SPACING_BASELINE_REF", "HEAD") font_latex(font_name, expr) = latexstring("\\fontfamily{$font_name}$expr") function spacing_label_sheet( - cases = SPACING_VISUAL_CASES; - font_names = SPACING_VISUAL_FONT_NAMES, -) + cases = SPACING_VISUAL_CASES; + font_names = SPACING_VISUAL_FONT_NAMES, + ) + row_height = 68 + row_gap = 7 nrows = sum(length(last(group)) + 1 for group in cases) + 1 - fig = Figure(size = (2200, max(900, 54nrows)), fontsize = 18) + fig_height = row_height * nrows + row_gap * (nrows - 1) + fig = Figure(size = (2200, max(900, fig_height)), fontsize = 18) Label(fig[1, 1], "case"; tellwidth = false, halign = :left, font = :bold) for (col, font_name) in enumerate(font_names) - Label(fig[1, col+1], font_name; tellwidth = false, halign = :left, font = :bold) + Label(fig[1, col + 1], font_name; tellwidth = false, halign = :left, font = :bold) end row = 2 for (group, exprs) in cases Label( - fig[row, 1:(length(font_names)+1)], + fig[row, 1:(length(font_names) + 1)], group; tellwidth = false, halign = :left, @@ -143,7 +147,7 @@ function spacing_label_sheet( Label(fig[row, 1], expr; tellwidth = false, halign = :left, fontsize = 13) for (col, font_name) in enumerate(font_names) Label( - fig[row, col+1], + fig[row, col + 1], font_latex(font_name, expr); tellwidth = false, halign = :left, @@ -155,10 +159,13 @@ function spacing_label_sheet( end colsize!(fig.layout, 1, Relative(0.22)) - for col = 2:(length(font_names)+1) + for col in 2:(length(font_names) + 1) colsize!(fig.layout, col, Relative(0.78 / length(font_names))) end - rowgap!(fig.layout, 7) + for row in 1:nrows + rowsize!(fig.layout, row, Fixed(row_height)) + end + rowgap!(fig.layout, row_gap) return fig end @@ -180,12 +187,12 @@ function render_spacing_sheet_in_subprocess(package_path, output_path) joinpath(project_dir, "Manifest.toml"), ) script = """ - import Pkg - Pkg.develop(path=$(repr(package_path))) - Pkg.instantiate() - include($(repr(@__FILE__))) - save_spacing_label_sheet($(repr(output_path))) - """ + import Pkg + Pkg.develop(path=$(repr(package_path))) + Pkg.instantiate() + include($(repr(@__FILE__))) + save_spacing_label_sheet($(repr(output_path))) + """ run(`$julia_executable --project=$project_dir -e $script`) return output_path end @@ -230,7 +237,7 @@ function spacing_overlay_image(after_path, before_path) width = min(size(after_img, 2), size(before_img, 2)) overlay = Matrix{RGBAf}(undef, height, width) - for y = 1:height, x = 1:width + for y in 1:height, x in 1:width after_dark = pixel_darkness(after_img[y, x]) before_dark = pixel_darkness(before_img[y, x]) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 3f80808..e4cc45c 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -4,7 +4,7 @@ middle of the xheight. """ function y_for_centered(font_family, elem) h = inkheight(elem) - return h/2 + xheight(font_family)/2 + return h / 2 + xheight(font_family) / 2 end function argument_as_string(arg) @@ -17,9 +17,13 @@ const _MIN_SCRIPT_GAP = 0.04 const _TALL_SCRIPT_CORE_HEIGHT = 1.2 const _TALL_SCRIPT_VERTICAL_CLEARANCE = 0.65 const _TALL_SCRIPT_CORE_OVERLAP = 0.3 +const _FRACTION_RULE_PADDING = 0.5 const _SCRIPT_FRACTION_RULE_WIDTH = 0.45 const _SCRIPT_FRACTION_RULE_SHIFT = 0.18 -const _TALL_SCRIPT_HEIGHT_FACTOR = 1.1 +const _TALL_SCRIPT_HEIGHT_FACTOR = 1.5 +const _SCRIPT_SHRINK_HEIGHT_FACTOR = 1.5 +const _SQRT_TALL_CONTENT_CLEARANCE_FACTOR = 0.25 +const _DISPLAY_OPERATOR_DELIMITER_HEIGHT = 1.35 function _script_y_positions(core, sub, super, font_family, sub_shrink, super_shrink) xh = xheight(font_family) @@ -27,12 +31,12 @@ function _script_y_positions(core, sub, super, font_family, sub_shrink, super_sh tall_script_height = _TALL_SCRIPT_HEIGHT_FACTOR * xh sub_y = -0.2 - if inkheight(sub) * sub_shrink > tall_script_height + if _has_rule_element(sub) || inkheight(sub) * sub_shrink > tall_script_height sub_y = min(sub_y, -topinkbound(sub) * sub_shrink - script_gap) end super_y = xh - if inkheight(super) * super_shrink > tall_script_height + if _has_rule_element(super) || inkheight(super) * super_shrink > tall_script_height super_y = max(super_y, -bottominkbound(super) * super_shrink + xh + script_gap) end @@ -47,7 +51,55 @@ function _script_y_positions(core, sub, super, font_family, sub_shrink, super_sh end function _script_shrink(elem, font_family, shrink) - return inkheight(elem) * shrink > xheight(font_family) ? 0.5 : shrink + is_tall_script = + _has_rule_element(elem) || + inkheight(elem) * shrink > _SCRIPT_SHRINK_HEIGHT_FACTOR * xheight(font_family) + return is_tall_script ? 0.5 : shrink +end + +function _subscript_x_position(core, sub, font_family, tall_core) + script_edge = _has_lowercase_greek(sub) ? max(hadvance(core), rightinkbound(core)) : hadvance(core) + if tall_core + script_edge -= _TALL_SCRIPT_CORE_OVERLAP * xheight(font_family) + end + + return script_edge +end + +function _superscript_x_position(core, font_family, tall_core) + script_edge = max(hadvance(core), rightinkbound(core)) + if tall_core + script_edge -= _TALL_SCRIPT_CORE_OVERLAP * xheight(font_family) + end + + return script_edge +end + +function _fraction_rule_width(argument_width, font_family, script_level) + if script_level > 0 + return _SCRIPT_FRACTION_RULE_WIDTH * argument_width + end + + return argument_width + _FRACTION_RULE_PADDING * xheight(font_family) +end + +function _has_rule_element(elem) + elem isa Union{HLine, VLine} && return true + if elem isa Group + return any(_has_rule_element, elem.elements) + end + + return false +end + +function _has_lowercase_greek(elem) + if elem isa TeXChar + return is_lowercase_greek(elem.represented_char) + elseif elem isa Group + return any(_has_lowercase_greek, elem.elements) + end + + return false end function _sqrt_radical(state, target_height) @@ -73,12 +125,13 @@ function _sqrt_radical(state, target_height) return last(radicals) end -const _math_delimiter_chars = Set(['(', ')', '[', ']', '{', '}', '⟨', '⟩']) +const _math_delimiter_chars = Set(['(', ')', '[', ']', '{', '}', '⟨', '⟩', '|', '‖']) +const _display_operator_chars = Set(['∫', '∑', '∏']) function _delimiter_element(char, state) font_family = state.font_family - if char in _math_delimiter_chars || char in ('|', '‖') + if char in _math_delimiter_chars texchar = default_math_texchar(char, font_family, char) if !isnothing(texchar) return texchar @@ -88,6 +141,49 @@ function _delimiter_element(char, state) return TeXChar(char, state, :delimiter) end +function _delimiter_target_height(content) + n_visible, elem = _count_visible_nondelimiters(content) + if n_visible == 1 + if elem isa TeXChar && elem.represented_char in _display_operator_chars + return min(inkheight(content), _DISPLAY_OPERATOR_DELIMITER_HEIGHT) + end + end + + return inkheight(content) +end + +function _count_visible_nondelimiters(elem) + elem isa Space && return 0, nothing + + if elem isa Group + n_visible = 0 + only_visible = nothing + for child in elem.elements + n_child, child_visible = _count_visible_nondelimiters(child) + n_visible += n_child + if n_child == 1 + only_visible = child_visible + end + n_visible > 1 && return n_visible, nothing + end + return n_visible, only_visible + end + + if elem isa TeXChar && elem.represented_char in _math_delimiter_chars + return 0, nothing + end + + return 1, elem +end + +function _delimiter_midline(content, font_family) + if _has_rule_element(content) || inkheight(content) > _TALL_SCRIPT_CORE_HEIGHT + return vmid(content) + end + + return xheight(font_family) / 2 +end + """ tex_layout(mathexpr::TeXExpr, font_family) @@ -156,16 +252,18 @@ function tex_layout(expr, state) super_y = 0.1 super_shrink = 1 else - super_x = max(hadvance(core), rightinkbound(core)) - if tall_core - super_x -= _TALL_SCRIPT_CORE_OVERLAP * xheight(font_family) - end - end - sub_x = max(hadvance(core), rightinkbound(core)) + - (1 - sub_shrink) * leftinkbound(sub) - if tall_core - sub_x -= _TALL_SCRIPT_CORE_OVERLAP * xheight(font_family) + super_x = _superscript_x_position( + core, + font_family, + tall_core, + ) end + sub_x = _subscript_x_position( + core, + sub, + font_family, + tall_core, + ) return Group( [core, sub, super], @@ -181,14 +279,14 @@ function tex_layout(expr, state) elements = tex_layout.(args, state) left, content, right = elements - height = inkheight(content) + height = _delimiter_target_height(content) left_scale = max(1, height / inkheight(left)) right_scale = max(1, height / inkheight(right)) scales = [left_scale, 1, right_scale] dxs = hadvance.(elements) .* scales - xs = [0, cumsum(dxs[1:end-1])...] - content_midline = vmid(content) + xs = [0, cumsum(dxs[1:(end - 1)])...] + content_midline = _delimiter_midline(content, font_family) return Group( elements, @@ -210,21 +308,21 @@ function tex_layout(expr, state) denominator = tex_layout(args[2], state) xh = xheight(font_family) - argument_width = max(inkwidth(numerator), inkwidth(denominator)) - rule_width = state.script_level > 0 ? - _SCRIPT_FRACTION_RULE_WIDTH * argument_width : - argument_width + argument_left = min(leftinkbound(numerator), leftinkbound(denominator)) + argument_right = max(rightinkbound(numerator), rightinkbound(denominator)) + argument_width = argument_right - argument_left + rule_width = _fraction_rule_width(argument_width, font_family, state.script_level) # fixed width fraction line rule_thickness = thickness(font_family) line = HLine(rule_width, rule_thickness) - y0 = xh/2 - rule_thickness/2 + y0 = xh / 2 - rule_thickness / 2 # Align the rule and arguments around the same center. This matters # for shortened script-style rules, where anchoring at x = 0 makes # the rule look too long on one side. - center = argument_width / 2 + center = (argument_left + argument_right) / 2 xline = center - hmid(line) if state.script_level > 0 xline -= _SCRIPT_FRACTION_RULE_SHIFT * argument_width @@ -232,8 +330,8 @@ function tex_layout(expr, state) x1 = center - hmid(numerator) x2 = center - hmid(denominator) - ytop = y0 + xh/2 - bottominkbound(numerator) - ybottom = y0 - xh/2 - topinkbound(denominator) + ytop = y0 + xh / 2 - bottominkbound(numerator) + ybottom = y0 - xh / 2 - topinkbound(denominator) return Group( [line, numerator, denominator], @@ -273,10 +371,10 @@ function tex_layout(expr, state) Point2f[ (0, 0), ( - 0.15 - inkwidth(sub)*shrink/2, - bottominkbound(int) - topinkbound(sub)*shrink - pad, + 0.15 - inkwidth(sub) * shrink / 2, + bottominkbound(int) - topinkbound(sub) * shrink - pad, ), - (0.85 - inkwidth(super)*shrink/2, topinkbound(int) + pad), + (0.85 - inkwidth(super) * shrink / 2, topinkbound(int) + pad), ], [1, shrink, shrink]; slanted = is_slanted(int), @@ -287,7 +385,7 @@ function tex_layout(expr, state) lines = tex_layout.(args, state) points = map(enumerate(lines)) do (k, line) x = -inkwidth(line) / 2 - y = (1 - k)*lineheight + y = (1 - k) * lineheight return Point2f(x, y) end @@ -302,7 +400,7 @@ function tex_layout(expr, state) return Group( [hline, content], - Point2f[(0.25, y + lw/2 + 0.2), (0, 0)]; + Point2f[(0.25, y + lw / 2 + 0.2), (0, 0)]; slanted = is_slanted(content), ) elseif head == :primes @@ -317,8 +415,12 @@ function tex_layout(expr, state) elseif head == :sqrt content = tex_layout(args[1], state) rule_thickness = thickness(font_family) - clearance = max(rule_thickness, xheight(font_family) / 3) - target_height = inkheight(content) + clearance + xh = xheight(font_family) + clearance = max(rule_thickness, xh / 2) + radical_clearance = _has_rule_element(content) ? + _SQRT_TALL_CONTENT_CLEARANCE_FACTOR * clearance : + 0.0 + target_height = inkheight(content) + radical_clearance radical = _sqrt_radical(state, target_height) line_top = topinkbound(content) + clearance @@ -327,12 +429,13 @@ function tex_layout(expr, state) hline_width = max(inkwidth(content), xheight(font_family) / 2) + clearance hline = HLine(hline_width, rule_thickness) + hline_x = rightinkbound(radical) - rule_thickness / 2 return Group( [radical, hline, content, Space(1.2)], Point2f[ (0, y0), - (rightinkbound(radical) - rule_thickness/2, line_y), + (hline_x, line_y), (rightinkbound(radical), 0), (rightinkbound(content), 0), ], @@ -395,7 +498,7 @@ function horizontal_layout(elements; italic_correction = false) end dxs = hadvance.(elements) - xs = [0, cumsum(dxs[1:end-1])...] + xs = [0, cumsum(dxs[1:(end - 1)])...] return Group(elements, Point2f.(xs, 0); slanted = is_slanted(last(elements))) end @@ -406,7 +509,7 @@ function _add_function_spacing(args, elements) for (i, elem) in enumerate(elements) push!(spaced, elem) if args[i].head == :function && _function_takes_space(args, i) - push!(spaced, Space(1/6)) + push!(spaced, Space(1 / 6)) end end @@ -427,7 +530,7 @@ function _italic_correction(elements) for (i, elem) in enumerate(elements) if i > 1 - offset = italic_transition_offset(elements[i-1], elem) + offset = italic_transition_offset(elements[i - 1], elem) if offset != 0 push!(corrected, Space(offset)) end @@ -482,7 +585,7 @@ end Flatten the layouted TeXElement and produce a single list of base element with their associated absolute position and scale. """ -function unravel(group::Group, parent_pos=Point2f(0), parent_scale=1.0f0) +function unravel(group::Group, parent_pos = Point2f(0), parent_scale = 1.0f0) scales = group.scales .* parent_scale positions = [parent_pos .+ pos for pos in parent_scale .* group.positions] elements = [] @@ -510,7 +613,7 @@ The elments are of one of the following types - `HLine` a horizontal line. - `VLine` a vertical line. """ -function generate_tex_elements(str, font_family=FontFamily()) +function generate_tex_elements(str, font_family = FontFamily()) expr = texparse(str) for node in PreOrderDFS(expr) diff --git a/src/engine/layout_context.jl b/src/engine/layout_context.jl index 66ba383..0d6d44d 100644 --- a/src/engine/layout_context.jl +++ b/src/engine/layout_context.jl @@ -13,18 +13,18 @@ LayoutState(font_family::FontFamily) = LayoutState(font_family, Symbol[]) LayoutState() = LayoutState(FontFamily()) function Base.show(io::IO, state::LayoutState) - print(io, "LayoutState($(state.font_modifiers), $(state.tex_mode), $(state.script_level))") + return print(io, "LayoutState($(state.font_modifiers), $(state.tex_mode), $(state.script_level))") end Base.broadcastable(state::LayoutState) = Ref(state) function change_mode(state::LayoutState, mode) - LayoutState(state.font_family, state.font_modifiers, mode, state.script_level) + return LayoutState(state.font_family, state.font_modifiers, mode, state.script_level) end function add_font_modifier(state::LayoutState, modifier) modifiers = vcat(state.font_modifiers, modifier) - LayoutState(state.font_family, modifiers, state.tex_mode, state.script_level) + return LayoutState(state.font_family, modifiers, state.tex_mode, state.script_level) end function increase_script_level(state::LayoutState) diff --git a/src/engine/new_computer_modern_data.jl b/src/engine/new_computer_modern_data.jl index 7ee3dea..cb85577 100644 --- a/src/engine/new_computer_modern_data.jl +++ b/src/engine/new_computer_modern_data.jl @@ -1,5 +1,5 @@ _latex_to_new_computer_modern = Dict( - raw"\int" => 878, + raw"\int" => 5930, raw"\sum" => 5941, raw"\partial" => 3377, @@ -44,5 +44,5 @@ end # Special case : get hbar from the italic font _symbol_to_new_computer_modern['ħ'] = ( joinpath("NewComputerModern", "NewCM10-Italic.otf"), - 231 + 231, ) diff --git a/src/engine/texelements.jl b/src/engine/texelements.jl index 3fbb705..a983448 100644 --- a/src/engine/texelements.jl +++ b/src/engine/texelements.jl @@ -84,14 +84,14 @@ xheight(x::TeXElement) = 0 Return the horizontal middle of the element ink. """ -hmid(x::TeXElement) = 0.5*(leftinkbound(x) + rightinkbound(x)) +hmid(x::TeXElement) = 0.5 * (leftinkbound(x) + rightinkbound(x)) """ vmid(elem::TeXElement) Return the vertical middle of the element ink. """ -vmid(x::TeXElement) = 0.5*(bottominkbound(x) + topinkbound(x)) +vmid(x::TeXElement) = 0.5 * (bottominkbound(x) + topinkbound(x)) """ inkwidth(elem::TeXElement) @@ -192,7 +192,7 @@ is_lowercase_greek(char) = char == 'ϱ' || char == 'ϖ' -function TeXChar(name::AbstractString, state::LayoutState, char_type ; represented='?') +function TeXChar(name::AbstractString, state::LayoutState, char_type; represented = '?') font_family = state.font_family font_id = get_font_identifier(state, char_type) font = get_font(font_family, font_id) @@ -274,10 +274,10 @@ end VLine(height, thickness) = VLine(promote(height, thickness)...) -leftinkbound(line::VLine) = -line.thickness/2 -rightinkbound(line::VLine) = line.thickness/2 -bottominkbound(line::VLine{T}) where T = min(line.height, zero(T)) -topinkbound(line::VLine{T}) where T = max(line.height, zero(T)) +leftinkbound(line::VLine) = -line.thickness / 2 +rightinkbound(line::VLine) = line.thickness / 2 +bottominkbound(line::VLine{T}) where {T} = min(line.height, zero(T)) +topinkbound(line::VLine{T}) where {T} = max(line.height, zero(T)) """ Hline @@ -296,10 +296,10 @@ end HLine(height, thickness) = HLine(promote(height, thickness)...) -leftinkbound(line::HLine{T}) where T = min(line.width, zero(T)) -rightinkbound(line::HLine{T}) where T = max(line.width, zero(T)) -bottominkbound(line::HLine) = -line.thickness/2 -topinkbound(line::HLine) = line.thickness/2 +leftinkbound(line::HLine{T}) where {T} = min(line.width, zero(T)) +rightinkbound(line::HLine{T}) where {T} = max(line.width, zero(T)) +bottominkbound(line::HLine) = -line.thickness / 2 +topinkbound(line::HLine) = line.thickness / 2 """ Group diff --git a/test/layout.jl b/test/layout.jl index 77bb85e..293f072 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -9,6 +9,7 @@ function test_same_layout(layout1, layout2) test_same_layout(elem1, elem2) end end + return end ink_bottom(element) = element[2][2] + element[3] * bottominkbound(element[1]) @@ -43,6 +44,16 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 elems = generate_tex_elements(L"\left(\frac{A^{xy}}{B}\right)^{1/4}") @test ink_top(elems[8]) > ink_top(elems[7]) + 0.03 @test ink_bottom(elems[8]) < ink_top(elems[7]) + + @test generate_tex_elements(L"W^{(i+j)}")[2][3] ≈ 0.6 + @test generate_tex_elements(L"x_{y \rightarrow 0}")[2][3] ≈ 0.6 + + ordinary_elems = generate_tex_elements(L"V^1_2") + sub_start = ordinary_elems[2][2][1] + + ordinary_elems[2][3] * leftinkbound(ordinary_elems[2][1]) + super_start = ordinary_elems[3][2][1] + + ordinary_elems[3][3] * leftinkbound(ordinary_elems[3][1]) + @test sub_start < super_start end @testset "Delimited" begin @@ -53,6 +64,10 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 @test hs[1] >= hs[2] @test hs[3] >= hs[2] + simple_elems = generate_tex_elements(L"\left(1 + 2\right)") + @test ink_vmid(simple_elems[1]) ≈ xheight(FontFamily()) / 2 atol = 0.01 + @test ink_vmid(simple_elems[end]) ≈ xheight(FontFamily()) / 2 atol = 0.01 + elems = generate_tex_elements( L"\left\langle\left|\left\langle\left|\int\right|\right\rangle\right|\right\rangle", ) @@ -155,6 +170,15 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 end @testset "Fraction rule padding" begin + elems = generate_tex_elements(L"\frac{1}{2}") + rule_start = elems[1][2][1] + elems[1][3] * leftinkbound(elems[1][1]) + rule_end = elems[1][2][1] + elems[1][3] * rightinkbound(elems[1][1]) + numerator_start = elems[2][2][1] + elems[2][3] * leftinkbound(elems[2][1]) + denominator_end = elems[3][2][1] + elems[3][3] * rightinkbound(elems[3][1]) + @test numerator_start - rule_start > 0.1 + @test rule_end - denominator_end > 0.1 + @test abs((numerator_start - rule_start) - (rule_end - denominator_end)) < 0.05 + elems = generate_tex_elements(L"x^{\frac{1}{1+2}}") rule_end = elems[2][2][1] + elems[2][3] * rightinkbound(elems[2][1]) denom_end = maximum(e[2][1] + e[3] * rightinkbound(e[1]) for e in elems[4:6]) @@ -178,8 +202,15 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 L"\sqrt{x_i^2+y_i^2}", MathTeXEngine.FontFamily(font_name), ) - @test ink_top(tall_elems[2]) >= maximum(ink_top(e) for e in tall_elems[3:end]) + @test ink_bottom(tall_elems[2]) >= maximum(ink_top(e) for e in tall_elems[3:end]) end + + frac_elems = generate_tex_elements(L"\sqrt{\frac{1}{2}}") + @test ink_bottom(frac_elems[2]) - maximum(ink_top(e) for e in frac_elems[3:end]) > + xheight(MathTeXEngine.FontFamily()) / 3 + + simple_elems = generate_tex_elements(L"\sqrt{b^2 - 4ac}") + @test ink_bottom(simple_elems[1]) > -0.4 end @testset "Missing math symbols use default math fallback" begin @@ -191,7 +222,17 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 end elems = generate_tex_elements(L"\int", MathTeXEngine.FontFamily("NewComputerModern")) - @test inkheight(elems[1][1]) < 1.5 + @test inkheight(elems[1][1]) > 2.0 + + nested_elems = generate_tex_elements( + L"\left\langle\left|\int\right|\right\rangle", + MathTeXEngine.FontFamily("NewComputerModern"), + ) + @test all(e -> e[1].glyph_id != 0, nested_elems) + @test nested_elems[3][3] == 1 + @test nested_elems[1][3] < 1.5 + @test abs(ink_vmid(nested_elems[1]) - ink_vmid(nested_elems[3])) < 0.05 + @test abs(ink_vmid(nested_elems[2]) - ink_vmid(nested_elems[3])) < 0.05 end @testset "Subscript spacing respects italic overhangs" begin diff --git a/test/runtests.jl b/test/runtests.jl index 49e5532..4cb0d1b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,7 +8,7 @@ import MathTeXEngine: TeXParseError import MathTeXEngine: tex_layout, generate_tex_elements import MathTeXEngine: Space, TeXElement import MathTeXEngine: load_font -import MathTeXEngine: inkheight, inkwidth +import MathTeXEngine: inkheight, inkwidth, xheight import MathTeXEngine: bottominkbound, leftinkbound, rightinkbound, topinkbound include("texexpr.jl") From 2847d175c3cfe3301ee04df3e5633c8cfae2b794 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Tue, 12 May 2026 14:00:15 +1200 Subject: [PATCH 06/13] Tune square root fraction layout --- src/engine/layout.jl | 7 ++++++- test/layout.jl | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index e4cc45c..4bb0161 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -23,6 +23,7 @@ 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.3 const _DISPLAY_OPERATOR_DELIMITER_HEIGHT = 1.35 function _script_y_positions(core, sub, super, font_family, sub_shrink, super_shrink) @@ -424,10 +425,14 @@ function tex_layout(expr, state) radical = _sqrt_radical(state, target_height) line_top = topinkbound(content) + clearance + if _has_rule_element(content) + radical_bottom = bottominkbound(content) - _SQRT_RULE_CONTENT_DESCENT * xh + line_top = max(line_top, radical_bottom + inkheight(radical)) + end y0 = line_top - topinkbound(radical) line_y = line_top - rule_thickness / 2 - hline_width = max(inkwidth(content), xheight(font_family) / 2) + clearance + hline_width = max(rightinkbound(content), xheight(font_family) / 2) + rule_thickness hline = HLine(hline_width, rule_thickness) hline_x = rightinkbound(radical) - rule_thickness / 2 diff --git a/test/layout.jl b/test/layout.jl index 293f072..d1edd29 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -13,6 +13,7 @@ function test_same_layout(layout1, layout2) end ink_bottom(element) = element[2][2] + element[3] * bottominkbound(element[1]) +ink_right(element) = element[2][1] + element[3] * rightinkbound(element[1]) ink_top(element) = element[2][2] + element[3] * topinkbound(element[1]) ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 @@ -208,6 +209,10 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 frac_elems = generate_tex_elements(L"\sqrt{\frac{1}{2}}") @test ink_bottom(frac_elems[2]) - maximum(ink_top(e) for e in frac_elems[3:end]) > xheight(MathTeXEngine.FontFamily()) / 3 + @test minimum(ink_bottom(e) for e in frac_elems[3:end]) - ink_bottom(frac_elems[1]) < + xheight(MathTeXEngine.FontFamily()) / 3 + @test ink_right(frac_elems[2]) - maximum(ink_right(e) for e in frac_elems[3:end]) < + 0.1 simple_elems = generate_tex_elements(L"\sqrt{b^2 - 4ac}") @test ink_bottom(simple_elems[1]) > -0.4 From 34c09bb09415a2c0b9ccae4c3e22c1baf9785c4b Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Tue, 12 May 2026 14:23:18 +1200 Subject: [PATCH 07/13] Refine radical and fraction rule spacing --- src/engine/layout.jl | 10 +++++++--- test/layout.jl | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 4bb0161..58b3de1 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -17,13 +17,14 @@ const _MIN_SCRIPT_GAP = 0.04 const _TALL_SCRIPT_CORE_HEIGHT = 1.2 const _TALL_SCRIPT_VERTICAL_CLEARANCE = 0.65 const _TALL_SCRIPT_CORE_OVERLAP = 0.3 -const _FRACTION_RULE_PADDING = 0.5 +const _FRACTION_RULE_PADDING = 0.65 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.3 +const _SQRT_RULE_CONTENT_DESCENT = 0.55 +const _SQRT_RULE_PADDING = 0.12 const _DISPLAY_OPERATOR_DELIMITER_HEIGHT = 1.35 function _script_y_positions(core, sub, super, font_family, sub_shrink, super_shrink) @@ -432,7 +433,10 @@ function tex_layout(expr, state) y0 = line_top - topinkbound(radical) line_y = line_top - rule_thickness / 2 - hline_width = max(rightinkbound(content), xheight(font_family) / 2) + rule_thickness + hline_width = + max(rightinkbound(content), xheight(font_family) / 2) + + _SQRT_RULE_PADDING * xh + + rule_thickness hline = HLine(hline_width, rule_thickness) hline_x = rightinkbound(radical) - rule_thickness / 2 diff --git a/test/layout.jl b/test/layout.jl index d1edd29..4d28c95 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -210,7 +210,7 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 @test ink_bottom(frac_elems[2]) - maximum(ink_top(e) for e in frac_elems[3:end]) > xheight(MathTeXEngine.FontFamily()) / 3 @test minimum(ink_bottom(e) for e in frac_elems[3:end]) - ink_bottom(frac_elems[1]) < - xheight(MathTeXEngine.FontFamily()) / 3 + 0.6 * xheight(MathTeXEngine.FontFamily()) @test ink_right(frac_elems[2]) - maximum(ink_right(e) for e in frac_elems[3:end]) < 0.1 From 3913de558c5162972b838eac164ff961b74b5215 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Tue, 12 May 2026 16:09:44 +1200 Subject: [PATCH 08/13] Use core slant for subscript anchors --- src/engine/layout.jl | 15 ++------------- test/layout.jl | 17 +++++++++++++---- test/runtests.jl | 2 +- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 58b3de1..bbf79f7 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -59,8 +59,8 @@ function _script_shrink(elem, font_family, shrink) return is_tall_script ? 0.5 : shrink end -function _subscript_x_position(core, sub, font_family, tall_core) - script_edge = _has_lowercase_greek(sub) ? max(hadvance(core), rightinkbound(core)) : hadvance(core) +function _subscript_x_position(core, font_family, tall_core) + script_edge = is_slanted(core) ? hadvance(core) : max(hadvance(core), rightinkbound(core)) if tall_core script_edge -= _TALL_SCRIPT_CORE_OVERLAP * xheight(font_family) end @@ -94,16 +94,6 @@ function _has_rule_element(elem) return false end -function _has_lowercase_greek(elem) - if elem isa TeXChar - return is_lowercase_greek(elem.represented_char) - elseif elem isa Group - return any(_has_lowercase_greek, elem.elements) - end - - return false -end - function _sqrt_radical(state, target_height) font_family = state.font_family radicals = TeXElement[TeXChar('√', state, :symbol)] @@ -262,7 +252,6 @@ function tex_layout(expr, state) end sub_x = _subscript_x_position( core, - sub, font_family, tall_core, ) diff --git a/test/layout.jl b/test/layout.jl index 4d28c95..99713dc 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -245,16 +245,25 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 ink_end(element) = element[2][1] + element[3] * rightinkbound(element[1]) font_names = sort(collect(keys(MathTeXEngine.default_font_families))) - cases = (L"N_\nu", L"J_\nu", L"x_{\alpha(k)}") + for font_name in font_names + elems = generate_tex_elements(L"x_{\alpha(k)}", MathTeXEngine.FontFamily(font_name)) + @test ink_end(elems[1]) - ink_start(elems[2]) < 0.04 + end - for font_name in font_names, tex in cases + for font_name in font_names, tex in (L"N_\nu", L"J_\nu", L"V_\nu") elems = generate_tex_elements(tex, MathTeXEngine.FontFamily(font_name)) + @test elems[2][2][1] ≈ hadvance(elems[1][1]) + @test ink_start(elems[2]) < ink_end(elems[1]) + end + + for tex in (L"\mathrm{N}_\nu", L"\mathrm{J}_\nu") + elems = generate_tex_elements(tex) @test ink_start(elems[2]) + 0.002 >= ink_end(elems[1]) end elems = generate_tex_elements(L"N_\nu L_\nu A_\nu J_\nu") - @test ink_start(elems[2]) >= ink_end(elems[1]) - @test ink_start(elems[8]) >= ink_end(elems[7]) + @test ink_start(elems[2]) < ink_end(elems[1]) + @test ink_start(elems[8]) < ink_end(elems[7]) # Issue #95 includes a nested subscript case where the inner `(k)` # should stay inside the lower script instead of being squeezed left. diff --git a/test/runtests.jl b/test/runtests.jl index 4cb0d1b..c619d2a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,7 +8,7 @@ import MathTeXEngine: TeXParseError import MathTeXEngine: tex_layout, generate_tex_elements import MathTeXEngine: Space, TeXElement import MathTeXEngine: load_font -import MathTeXEngine: inkheight, inkwidth, xheight +import MathTeXEngine: hadvance, inkheight, inkwidth, xheight import MathTeXEngine: bottominkbound, leftinkbound, rightinkbound, topinkbound include("texexpr.jl") From fac3388180eb6ae05bfdca9885199e80c39f342d Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Wed, 13 May 2026 11:49:48 +1200 Subject: [PATCH 09/13] Refine delimiter and slanted spacing --- reference/references.jl | 1 + reference/spacing_visuals.jl | 1 + src/engine/layout.jl | 114 ++++++++++++++++++++++++++++++----- test/layout.jl | 48 ++++++++++++++- 4 files changed, 145 insertions(+), 19 deletions(-) diff --git a/reference/references.jl b/reference/references.jl index 1ccd984..a5e9993 100644 --- a/reference/references.jl +++ b/reference/references.jl @@ -84,6 +84,7 @@ inputs["subsuper"] = [ ] inputs["symbols"] = [ + L"k\xi", L"\alpha \beta \gamma \delta \epsilon \omega \theta \phi \varphi \psi", L"\Gamma \Delta \Omega \Theta \Phi \Psi", L"\nabla \rightarrow \neq \leq \hbar", diff --git a/reference/spacing_visuals.jl b/reference/spacing_visuals.jl index 46a8b49..f707b41 100644 --- a/reference/spacing_visuals.jl +++ b/reference/spacing_visuals.jl @@ -23,6 +23,7 @@ const SPACING_VISUAL_CASES = Pair{String, Vector{String}}[ raw"\eta(t)", raw"\alpha_k", raw"\omega_k", + raw"k\xi", raw"\nu(k)", raw"N_\nu L_\nu A_\nu J_\nu", raw"x_{\alpha(k)}", diff --git a/src/engine/layout.jl b/src/engine/layout.jl index bbf79f7..09062c5 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -23,9 +23,11 @@ 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.55 +const _SQRT_RULE_CONTENT_DESCENT = 0.9 const _SQRT_RULE_PADDING = 0.12 +const _SLANTED_ADJACENT_GAP = 0.03 const _DISPLAY_OPERATOR_DELIMITER_HEIGHT = 1.35 +const _BRACE_RULE_AXIS_PADDING = 0.2 function _script_y_positions(core, sub, super, font_family, sub_shrink, super_shrink) xh = xheight(font_family) @@ -41,6 +43,7 @@ function _script_y_positions(core, sub, super, font_family, sub_shrink, super_sh if _has_rule_element(super) || inkheight(super) * super_shrink > tall_script_height super_y = max(super_y, -bottominkbound(super) * super_shrink + xh + script_gap) end + super_y -= max(bottominkbound(super), 0) * super_shrink if inkheight(core) > _TALL_SCRIPT_CORE_HEIGHT sub_top = bottominkbound(core) + _TALL_SCRIPT_VERTICAL_CLEARANCE * xh @@ -119,6 +122,8 @@ end const _math_delimiter_chars = Set(['(', ')', '[', ']', '{', '}', '⟨', '⟩', '|', '‖']) const _display_operator_chars = Set(['∫', '∑', '∏']) +const _delimiter_axis_operator_chars = + Set(['+', '-', '−', '±', '∓', '×', '⋅', '=', '<', '>', '≤', '≥', '≠']) function _delimiter_element(char, state) font_family = state.font_family @@ -133,7 +138,11 @@ function _delimiter_element(char, state) return TeXChar(char, state, :delimiter) end -function _delimiter_target_height(content) +function _is_brace_delimiter(delimiter) + return delimiter isa TeXChar && delimiter.represented_char in ('{', '}') +end + +function _delimiter_target_height(content, content_axis, delimiter, font_family) n_visible, elem = _count_visible_nondelimiters(content) if n_visible == 1 if elem isa TeXChar && elem.represented_char in _display_operator_chars @@ -141,7 +150,22 @@ function _delimiter_target_height(content) end end - return inkheight(content) + height = inkheight(content) + if _has_rule_element(content) + axis_height = 2max( + content_axis - bottominkbound(content), + topinkbound(content) - content_axis, + ) + if _is_brace_delimiter(delimiter) + axis_height = min( + axis_height, + height + _BRACE_RULE_AXIS_PADDING * xheight(font_family), + ) + end + return max(height, axis_height) + end + + return height end function _count_visible_nondelimiters(elem) @@ -168,12 +192,55 @@ function _count_visible_nondelimiters(elem) return 1, elem end -function _delimiter_midline(content, font_family) - if _has_rule_element(content) || inkheight(content) > _TALL_SCRIPT_CORE_HEIGHT - return vmid(content) +function _delimiter_visual_bounds(elem) + elem isa Space && return nothing + elem isa Union{HLine, VLine} && return nothing + + if elem isa TeXChar + elem.represented_char in _math_delimiter_chars && return nothing + elem.represented_char in _delimiter_axis_operator_chars && return nothing + return bottominkbound(elem), topinkbound(elem) end - return xheight(font_family) / 2 + if elem isa Group + bottom = Inf + top = -Inf + for (child, position, scale) in zip(elem.elements, elem.positions, elem.scales) + child_bounds = _delimiter_visual_bounds(child) + isnothing(child_bounds) && continue + + child_bottom, child_top = child_bounds + bottom = min(bottom, position[2] + scale * child_bottom) + top = max(top, position[2] + scale * child_top) + end + + isinf(bottom) && return nothing + return bottom, top + end + + return bottominkbound(elem), topinkbound(elem) +end + +function _delimiter_axis(content, font_family) + axis = max(vmid(content), xheight(font_family) / 2) + _has_rule_element(content) && return axis + + bounds = _delimiter_visual_bounds(content) + isnothing(bounds) && return axis + + visual_axis = (bounds[1] + bounds[2]) / 2 + return max(axis, visual_axis) +end + +function _sqrt_clearance(content, font_family) + xh = xheight(font_family) + clearance = _has_rule_element(content) ? xh / 3 : xh / 2 + 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 """ @@ -270,14 +337,15 @@ function tex_layout(expr, state) elements = tex_layout.(args, state) left, content, right = elements - height = _delimiter_target_height(content) - left_scale = max(1, height / inkheight(left)) - right_scale = max(1, height / inkheight(right)) + content_midline = _delimiter_axis(content, font_family) + left_height = _delimiter_target_height(content, content_midline, left, font_family) + right_height = _delimiter_target_height(content, content_midline, right, font_family) + left_scale = max(1, left_height / inkheight(left)) + right_scale = max(1, right_height / inkheight(right)) scales = [left_scale, 1, right_scale] dxs = hadvance.(elements) .* scales xs = [0, cumsum(dxs[1:(end - 1)])...] - content_midline = _delimiter_midline(content, font_family) return Group( elements, @@ -407,10 +475,8 @@ function tex_layout(expr, state) content = tex_layout(args[1], state) rule_thickness = thickness(font_family) xh = xheight(font_family) - clearance = max(rule_thickness, xh / 2) - radical_clearance = _has_rule_element(content) ? - _SQRT_TALL_CONTENT_CLEARANCE_FACTOR * clearance : - 0.0 + 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) @@ -541,7 +607,12 @@ end function italic_transition_offset(prev, elem) (prev isa Space || elem isa Space) && return 0.0 - is_slanted(prev) == is_slanted(elem) && return 0.0 + + if is_slanted(prev) && is_slanted(elem) + return slanted_adjacent_offset(prev, elem) + elseif !is_slanted(prev) && !is_slanted(elem) + return 0.0 + end if is_slanted(prev) && !is_slanted(elem) height_prev = topinkbound(prev) @@ -570,6 +641,17 @@ function italic_transition_offset(prev, elem) return 0.0 end +function slanted_adjacent_offset(prev, elem) + top = min(topinkbound(prev), topinkbound(elem)) + bottom = max(bottominkbound(prev), bottominkbound(elem)) + top <= bottom && return 0.0 + + gap = hadvance(prev) + leftinkbound(elem) - rightinkbound(prev) + min_gap = _SLANTED_ADJACENT_GAP * min(inkheight(prev), inkheight(elem)) + offset = min_gap - gap + return max(0.0, min(offset, 2min_gap)) +end + function layout_text(string, font_family) isempty(string) && return Space(0) diff --git a/test/layout.jl b/test/layout.jl index 99713dc..d37f235 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -13,9 +13,11 @@ function test_same_layout(layout1, layout2) end ink_bottom(element) = element[2][2] + element[3] * bottominkbound(element[1]) +ink_left(element) = element[2][1] + element[3] * leftinkbound(element[1]) ink_right(element) = element[2][1] + element[3] * rightinkbound(element[1]) ink_top(element) = element[2][2] + element[3] * topinkbound(element[1]) 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 "Decorated" begin @@ -55,6 +57,10 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 super_start = ordinary_elems[3][2][1] + ordinary_elems[3][3] * leftinkbound(ordinary_elems[3][1]) @test sub_start < super_start + + star_elems = generate_tex_elements(L"x^*") + @test ink_top(star_elems[2]) > ink_top(star_elems[1]) + @test ink_bottom(star_elems[2]) < xheight(FontFamily()) + 0.05 end @testset "Delimited" begin @@ -66,8 +72,31 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 @test hs[3] >= hs[2] simple_elems = generate_tex_elements(L"\left(1 + 2\right)") - @test ink_vmid(simple_elems[1]) ≈ xheight(FontFamily()) / 2 atol = 0.01 - @test ink_vmid(simple_elems[end]) ≈ xheight(FontFamily()) / 2 atol = 0.01 + simple_axis = ink_group_vmid(simple_elems[[2, 4]]) + @test ink_vmid(simple_elems[1]) ≈ simple_axis atol = 0.01 + @test ink_vmid(simple_elems[end]) ≈ simple_axis atol = 0.01 + + capital_elems = generate_tex_elements(L"\left{A + B\right}") + capital_axis = ink_group_vmid(capital_elems[[2, 4]]) + @test ink_vmid(capital_elems[1]) ≈ capital_axis atol = 0.01 + @test ink_vmid(capital_elems[end]) ≈ capital_axis atol = 0.01 + + fraction_elems = generate_tex_elements(L"\left(\frac{1}{2}\right)") + fraction_axis = max(ink_group_vmid(fraction_elems[2:(end - 1)]), xheight(FontFamily()) / 2) + @test ink_vmid(fraction_elems[1]) ≈ fraction_axis atol = 0.01 + @test ink_vmid(fraction_elems[end]) ≈ fraction_axis atol = 0.01 + + descender_fraction_elems = generate_tex_elements(L"\left[\frac{a}{b}\right]") + @test ink_bottom(descender_fraction_elems[1]) <= ink_bottom(descender_fraction_elems[end - 1]) + @test ink_top(descender_fraction_elems[1]) >= ink_top(descender_fraction_elems[2]) + + greek_brace_fraction_elems = generate_tex_elements(L"\left{\frac{\alpha}{\beta}\right}") + @test greek_brace_fraction_elems[1][3] < descender_fraction_elems[1][3] + + nested_elems = generate_tex_elements(L"\left{1 + \left[2 + \left(3 + 4\right)\right]\right}") + nested_axis = ink_group_vmid(nested_elems[[2, 5, 8, 10]]) + nested_delimiters = nested_elems[[1, 4, 7, 11, 12, 13]] + @test maximum(abs(ink_vmid(elem) - nested_axis) for elem in nested_delimiters) < 0.015 elems = generate_tex_elements( L"\left\langle\left|\left\langle\left|\int\right|\right\rangle\right|\right\rangle", @@ -150,6 +179,17 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 @test MathTeXEngine.is_slanted(with[5][1]) @test xpos(with, 7) - xpos(with, 6) < xpos(without, 7) - xpos(without, 6) @test xpos(with, 8) - xpos(with, 7) > xpos(without, 8) - xpos(without, 7) + + MathTeXEngine.italic_correction_enabled[] = false + without = generate_tex_elements(L"k\xi") + MathTeXEngine.italic_correction_enabled[] = true + with = generate_tex_elements(L"k\xi") + + # Adjacent slanted glyphs can still collide even without a + # roman/italic transition. Keep a small ink gap for cases like kξ. + @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 finally MathTeXEngine.italic_correction_enabled[] = old end @@ -209,8 +249,10 @@ ink_vmid(element) = (ink_bottom(element) + ink_top(element)) / 2 frac_elems = generate_tex_elements(L"\sqrt{\frac{1}{2}}") @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]) < + 0.55 * xheight(MathTeXEngine.FontFamily()) @test minimum(ink_bottom(e) for e in frac_elems[3:end]) - ink_bottom(frac_elems[1]) < - 0.6 * xheight(MathTeXEngine.FontFamily()) + xheight(MathTeXEngine.FontFamily()) @test ink_right(frac_elems[2]) - maximum(ink_right(e) for e in frac_elems[3:end]) < 0.1 From 0d6d2ca2958335055805bf2fc1a45844b25eb855 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Wed, 13 May 2026 12:21:13 +1200 Subject: [PATCH 10/13] Tighten sqrt radical selection --- src/engine/layout.jl | 40 ++++++++++++++++++---------------------- test/layout.jl | 7 ++++--- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 09062c5..c6fb1f1 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -22,9 +22,8 @@ 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.9 const _SQRT_RULE_PADDING = 0.12 +const _SQRT_RADICAL_VERTICAL_TOLERANCE = 0.35 const _SLANTED_ADJACENT_GAP = 0.03 const _DISPLAY_OPERATOR_DELIMITER_HEIGHT = 1.35 const _BRACE_RULE_AXIS_PADDING = 0.2 @@ -97,7 +96,7 @@ function _has_rule_element(elem) return false end -function _sqrt_radical(state, target_height) +function _sqrt_radicals(state) font_family = state.font_family radicals = TeXElement[TeXChar('√', state, :symbol)] @@ -109,15 +108,24 @@ function _sqrt_radical(state, target_height) isnothing(fallback) || push!(radicals, fallback) end - sort!(radicals; by = inkheight) - for candidate in radicals - if candidate.glyph_id == 0 - continue + return sort!(filter(candidate -> candidate.glyph_id != 0, radicals); by = inkheight) +end + +function _sqrt_radical(state, line_top, content_bottom) + radicals = _sqrt_radicals(state) + tolerance = _SQRT_RADICAL_VERTICAL_TOLERANCE * xheight(state.font_family) + + fallback = last(radicals) + for radical in radicals + radical_bottom = line_top - inkheight(radical) + if radical_bottom <= content_bottom + tolerance + fallback = radical + radical_bottom >= content_bottom - tolerance && return radical + break end - inkheight(candidate) >= target_height && return candidate end - return last(radicals) + return fallback end const _math_delimiter_chars = Set(['(', ')', '[', ']', '{', '}', '⟨', '⟩', '|', '‖']) @@ -238,11 +246,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) @@ -476,15 +479,8 @@ 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) - line_top = topinkbound(content) + clearance - if _has_rule_element(content) - radical_bottom = bottominkbound(content) - _SQRT_RULE_CONTENT_DESCENT * xh - line_top = max(line_top, radical_bottom + inkheight(radical)) - end + radical = _sqrt_radical(state, line_top, bottominkbound(content)) y0 = line_top - topinkbound(radical) line_y = line_top - rule_thickness / 2 diff --git a/test/layout.jl b/test/layout.jl index d37f235..3bdc427 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -248,11 +248,12 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele frac_elems = generate_tex_elements(L"\sqrt{\frac{1}{2}}") @test ink_bottom(frac_elems[2]) - maximum(ink_top(e) for e in frac_elems[3:end]) > - xheight(MathTeXEngine.FontFamily()) / 3 + 0.2 * xheight(MathTeXEngine.FontFamily()) @test ink_bottom(frac_elems[2]) - maximum(ink_top(e) for e in frac_elems[3:end]) < 0.55 * xheight(MathTeXEngine.FontFamily()) - @test minimum(ink_bottom(e) for e in frac_elems[3:end]) - ink_bottom(frac_elems[1]) < - xheight(MathTeXEngine.FontFamily()) + radicand_bottom = minimum(ink_bottom(e) for e in frac_elems[3:end]) + @test abs(radicand_bottom - ink_bottom(frac_elems[1])) < + 0.35 * xheight(MathTeXEngine.FontFamily()) @test ink_right(frac_elems[2]) - maximum(ink_right(e) for e in frac_elems[3:end]) < 0.1 From b138f1ed56dcdd4a87e8ef5cc763c6468365ff03 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Wed, 13 May 2026 12:40:44 +1200 Subject: [PATCH 11/13] Restore larger sqrt radical for fractions --- src/engine/layout.jl | 40 ++++++++++++++++++++++------------------ test/layout.jl | 7 +++---- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index c6fb1f1..09062c5 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -22,8 +22,9 @@ 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.9 const _SQRT_RULE_PADDING = 0.12 -const _SQRT_RADICAL_VERTICAL_TOLERANCE = 0.35 const _SLANTED_ADJACENT_GAP = 0.03 const _DISPLAY_OPERATOR_DELIMITER_HEIGHT = 1.35 const _BRACE_RULE_AXIS_PADDING = 0.2 @@ -96,7 +97,7 @@ function _has_rule_element(elem) return false end -function _sqrt_radicals(state) +function _sqrt_radical(state, target_height) font_family = state.font_family radicals = TeXElement[TeXChar('√', state, :symbol)] @@ -108,24 +109,15 @@ function _sqrt_radicals(state) isnothing(fallback) || push!(radicals, fallback) end - return sort!(filter(candidate -> candidate.glyph_id != 0, radicals); by = inkheight) -end - -function _sqrt_radical(state, line_top, content_bottom) - radicals = _sqrt_radicals(state) - tolerance = _SQRT_RADICAL_VERTICAL_TOLERANCE * xheight(state.font_family) - - fallback = last(radicals) - for radical in radicals - radical_bottom = line_top - inkheight(radical) - if radical_bottom <= content_bottom + tolerance - fallback = radical - radical_bottom >= content_bottom - tolerance && return radical - break + sort!(radicals; by = inkheight) + for candidate in radicals + if candidate.glyph_id == 0 + continue end + inkheight(candidate) >= target_height && return candidate end - return fallback + return last(radicals) end const _math_delimiter_chars = Set(['(', ')', '[', ']', '{', '}', '⟨', '⟩', '|', '‖']) @@ -246,6 +238,11 @@ 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) @@ -479,8 +476,15 @@ 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) + line_top = topinkbound(content) + clearance - radical = _sqrt_radical(state, line_top, bottominkbound(content)) + if _has_rule_element(content) + radical_bottom = bottominkbound(content) - _SQRT_RULE_CONTENT_DESCENT * xh + line_top = max(line_top, radical_bottom + inkheight(radical)) + end y0 = line_top - topinkbound(radical) line_y = line_top - rule_thickness / 2 diff --git a/test/layout.jl b/test/layout.jl index 3bdc427..d37f235 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -248,12 +248,11 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele frac_elems = generate_tex_elements(L"\sqrt{\frac{1}{2}}") @test ink_bottom(frac_elems[2]) - maximum(ink_top(e) for e in frac_elems[3:end]) > - 0.2 * xheight(MathTeXEngine.FontFamily()) + xheight(MathTeXEngine.FontFamily()) / 3 @test ink_bottom(frac_elems[2]) - maximum(ink_top(e) for e in frac_elems[3:end]) < 0.55 * xheight(MathTeXEngine.FontFamily()) - radicand_bottom = minimum(ink_bottom(e) for e in frac_elems[3:end]) - @test abs(radicand_bottom - ink_bottom(frac_elems[1])) < - 0.35 * xheight(MathTeXEngine.FontFamily()) + @test minimum(ink_bottom(e) for e in frac_elems[3:end]) - ink_bottom(frac_elems[1]) < + xheight(MathTeXEngine.FontFamily()) @test ink_right(frac_elems[2]) - maximum(ink_right(e) for e in frac_elems[3:end]) < 0.1 From f00ee0a8dbdbe667cbf718447aca73da7b78f34a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Richard?= Date: Thu, 14 May 2026 20:17:12 +0200 Subject: [PATCH 12/13] Integrate the spacing reference sheet to the reference tests --- reference/data/basics.jl | 97 +++++++++++++++++++++++++ reference/data/spacing.jl | 94 ++++++++++++++++++++++++ reference/references.jl | 146 ++++++++++---------------------------- 3 files changed, 229 insertions(+), 108 deletions(-) create mode 100644 reference/data/basics.jl create mode 100644 reference/data/spacing.jl diff --git a/reference/data/basics.jl b/reference/data/basics.jl new file mode 100644 index 0000000..28083e2 --- /dev/null +++ b/reference/data/basics.jl @@ -0,0 +1,97 @@ +BASICS = Dict() + +BASICS["accents"] = [ + L"\dot{Q} \dot{q}", + L"\vec{A} \vec{a}", + L"\bar{L} \bar{l}", + L"\hat{\Phi} \bar{\varphi}" +] + +BASICS["delimiters"] = [ + L"(1 + 2) (\frac{1}{2})", + L"\left(1 + 2\right) \left(\frac{1}{2}\right)", + L"\left[a + b\right] \left[\frac{a}{b}\right]", + L"\left{A + B \right} + \left{\frac{A}{B}\right}", + L"\left{\alpha + \beta \right} + \left{\frac{\alpha}{\beta}\right}", + L"\left{1 + \left[2 + \left(3 + 4\right)\right]\right}" +] + +BASICS["fonts"] = [ + L"\mathrm{bonjour}", + L"\mathbb{R} \mathbb{Q} \mathbb{C}", + L"\mathcal{N} \mathcal{K}" +] + +BASICS["fractions"] = [ + L"\frac{a + b + c}{c + b + a}", + L"\frac{a}{A + B + C}", + L"\frac{j - f}{f - j}" +] + +BASICS["functions"] = [ + L"\sin{\omega} + \cos{\theta}", + L"\exp{\log{2}} = 2", + L"\inf_{x} \tan(x) \leq \sup_{x} \tan(x)" +] + +BASICS["infix"] = [ + L"T + V", + L"7 - 2", + L"v \cdot w", + L"E = m c^2" +] + +BASICS["integrals"] = [ + L"\int_a^b", + L"\int \int \int" +] + +BASICS["linebreaks"] = [ + L"we clearly see $x = 22$\\and $y > x^2$" +] + +BASICS["punctuation"] = [ + L"x!", + L"23.17", + L"10,000" +] + +BASICS["spaces"] = [ + L"a \! b", + L"a \; b", + L"a \quad b", + L"a \qquad b" +] + +BASICS["square_roots"] = [ + L"\sqrt{2}", + L"\sqrt{\frac{1}{2}}", + L"\sqrt{b^2 - 4ac}", + L"\sqrt{1 + \frac{A + B}{J + U}}" +] + +BASICS["subsuper"] = [ + L"V^1_2", + L"U_{ij}", + L"W^{(i + j)}", + L"x_L x_y x_{y \rightarrow 0}", + L"N_\nu L_\nu A_\nu J_\nu", + L"N^\nu L^\nu A^\nu J^\nu", + L"^{87} Rb" +] + +BASICS["symbols"] = [ + L"k\xi", + L"\alpha \beta \gamma \delta \epsilon \omega \theta \phi \varphi \psi", + L"\Gamma \Delta \Omega \Theta \Phi \Psi", + L"\nabla \rightarrow \neq \leq \hbar", + L"\text{phi} \rightarrow \phi \quad \text{varphi} \rightarrow \varphi", + L"\text{epsilon} \rightarrow \epsilon \quad \text{varepsilon} \rightarrow \varepsilon" +] + +BASICS["underover"] = [ + L"\sum_{n = 1}^{m^2}", + L"\sum_{N = 1}^{M^2}", + L"\prod_{n \neq m}", + L"\prod_{N \neq M}" +] \ No newline at end of file diff --git a/reference/data/spacing.jl b/reference/data/spacing.jl new file mode 100644 index 0000000..0ddd161 --- /dev/null +++ b/reference/data/spacing.jl @@ -0,0 +1,94 @@ +const SPACING = Dict( + "Issue #142 italic roman boundaries" => [ + raw"f(t)", + raw"g(x)", + raw"(f)x", + raw"(t)", + raw"\eta(t)", + raw"\alpha(t)", + raw"g(f(x))", + raw"\mathrm{y}(x)", + raw"\mathrm{g}t", + ], + "Issue #95 lower-case Greek and subscript spacing" => [ + raw"\eta(t)", + raw"\alpha_k", + raw"\omega_k", + raw"k\xi", + raw"\nu(k)", + raw"N_\nu L_\nu A_\nu J_\nu", + raw"x_{\alpha(k)}", + raw"v_{(a + b)_k}^i", + raw"\partial_i u_j", + raw"\phi_\varphi \rho_\sigma", + ], + "Subscript and superscript combinations" => [ + raw"x_i", + raw"x^i", + raw"x_i^j", + raw"V^i_j", + raw"x_{i_j}", + raw"x^{i^j}", + raw"x_{(a+b)_k}^i", + raw"T_{\alpha\beta}^{ij}", + raw"\Gamma^\mu_{\nu\rho}", + raw"\psi^\dagger_i\psi_i", + ], + "PR #151 primes and deep scripts" => [ + raw"x' f'", + raw"x'' f''", + raw"x′ f′", + raw"x\prime f\prime", + raw"x^\prime f^\prime", + raw"x'_y f_g'", + raw"A^{B^{C^{D^E}}}_{F_{G_{H_I}}}", + raw"f^{A'}", + ], + "Roman upright and capital boundaries" => [ + raw"\mathrm{d}x", + raw"\mathrm{e}^{-x}", + raw"\mathrm{Re}\,z", + raw"\mathrm{Im}\,z", + raw"\mathrm{Tr}\,A_i^j", + raw"\mathrm{Cov}(X,Y)", + raw"A_\nu B_\nu C_\nu D_\nu", + raw"M\mathrm{M}M", + ], + "Issue #129 math operator spacing" => [ + raw"\log x", + raw"\log(x)", + raw"\sin x", + raw"\sin(x)", + raw"\exp t", + raw"\exp(t)", + raw"\max_{t \in \{1,...,5\}}", + ], + "Operators, delimiters, and fractions" => [ + raw"-1,\ 2-1,\ (-1)", + raw"\alpha^*", + raw"\psi^* \psi", + raw"\frac{1}{2}\pm\sqrt{3}", + raw"\frac{1}{2}{}\pm\sqrt{3}", + raw"\left(\frac{1}{2}\right)f(t)", + raw"\sqrt{x_i^2+y_i^2}", + raw"\sum_{k=0}^n a_k x^k", + raw"\int_0^{2\pi}\sin(x)\,dx", + ], + "Script layout issues #93, #105, #110, #126" => [ + raw"\left(\frac{dy}{dx}\right)_0", + raw"\left(\frac{A^{xy}}{B}\right)^{1/4}", + raw"(\frac{A^{xy}}{B})^{1/4}", + raw"\left\langle\left|\int\right|\right\rangle", + raw"\left\langle\left|\left\langle\left|\int\right|\right\rangle\right|\right\rangle", + raw"x^{\frac{1}{1+2}}", + raw"x_{\frac{1}{1+2}}", + ], + "Nested expressions" => [ + raw"\frac{\alpha_i+\beta_i}{\gamma_i+\delta_i}", + raw"\sqrt{\frac{1+\alpha_k}{1+\beta_k}}", + raw"F_{\mu\nu}F^{\mu\nu}", + raw"\overline{z}_i", + 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/reference/references.jl b/reference/references.jl index a5e9993..0a0a018 100644 --- a/reference/references.jl +++ b/reference/references.jl @@ -1,129 +1,59 @@ using CairoMakie using MathTeXEngine +using LaTeXStrings -inputs = Dict() +include("data/basics.jl") +include("data/spacing.jl") -inputs["accents"] = [ - L"\dot{Q} \dot{q}", - L"\vec{A} \vec{a}", - L"\bar{L} \bar{l}", - L"\hat{\Phi} \bar{\varphi}" -] - -inputs["delimiters"] = [ - L"(1 + 2) (\frac{1}{2})", - L"\left(1 + 2\right) \left(\frac{1}{2}\right)", - L"\left[a + b\right] \left[\frac{a}{b}\right]", - L"\left{A + B \right} + \left{\frac{A}{B}\right}", - L"\left{\alpha + \beta \right} + \left{\frac{\alpha}{\beta}\right}", - L"\left{1 + \left[2 + \left(3 + 4\right)\right]\right}" -] +with_font(font_name, expr) = latexstring("\\fontfamily{$font_name}$expr") -inputs["fonts"] = [ - L"\mathrm{bonjour}", - L"\mathbb{R} \mathbb{Q} \mathbb{C}", - L"\mathcal{N} \mathcal{K}" -] +const REFERENCES = Dict( + "basics" => BASICS, + "spacing" => SPACING +) -inputs["fractions"] = [ - L"\frac{a + b + c}{c + b + a}", - L"\frac{a}{A + B + C}", - L"\frac{j - f}{f - j}" +const SUPPORTED_FONTS = [ + "NewComputerModern", + "TeXGyreHeros", + "TeXGyrePagella", + "LucioleMath" ] -inputs["functions"] = [ - L"\sin{\omega} + \cos{\theta}", - L"\exp{\log{2}} = 2", - L"\inf_{x} \tan(x) \leq \sup_{x} \tan(x)" -] - -inputs["infix"] = [ - L"T + V", - L"7 - 2", - L"v \cdot w", - L"E = m c^2" -] - -inputs["integrals"] = [ - L"\int_a^b", - L"\int \int \int" -] - -inputs["linebreaks"] = [ - L"we clearly see $x = 22$\\and $y > x^2$" -] - -inputs["punctuation"] = [ - L"x!", - L"23.17", - L"10,000" -] +function generate(destination_folder, references = REFERENCES, fonts = SUPPORTED_FONTS) + @info "Generating reference images in folder $destination_folder" -inputs["spaces"] = [ - L"a \! b", - L"a \; b", - L"a \quad b", - L"a \qquad b" -] - -inputs["square_roots"] = [ - L"\sqrt{2}", - L"\sqrt{\frac{1}{2}}", - L"\sqrt{b^2 - 4ac}", - L"\sqrt{1 + \frac{A + B}{J + U}}" -] - -inputs["subsuper"] = [ - L"V^1_2", - L"U_{ij}", - L"W^{(i + j)}", - L"x_L x_y x_{y \rightarrow 0}", - L"N_\nu L_\nu A_\nu J_\nu", - L"N^\nu L^\nu A^\nu J^\nu", - L"^{87} Rb" -] - -inputs["symbols"] = [ - L"k\xi", - L"\alpha \beta \gamma \delta \epsilon \omega \theta \phi \varphi \psi", - L"\Gamma \Delta \Omega \Theta \Phi \Psi", - L"\nabla \rightarrow \neq \leq \hbar", - L"\text{phi} \rightarrow \phi \quad \text{varphi} \rightarrow \varphi", - L"\text{epsilon} \rightarrow \epsilon \quad \text{varepsilon} \rightarrow \varepsilon" -] + path = mkpath(destination_folder) + failures = Dict() + for (group, data) in references + if data isa AbstractDict + generate(joinpath(destination_folder, group), data) + else + fig, fails = reference_figure(data, fonts) -inputs["underover"] = [ - L"\sum_{n = 1}^{m^2}", - L"\sum_{N = 1}^{M^2}", - L"\prod_{n \neq m}", - L"\prod_{N \neq M}" -] + if !isempty(fails) + failures[group] = fails + end -function generate(name) - @info "Generating reference image at $name" - path = mkpath(name) - failures = Dict() - for (group, exprs) in inputs - fig, fails = single_figure(exprs) - if !isempty(fails) - failures[group] = fails + save(joinpath(path, "$group.png"), fig, px_per_unit=3) end - - save(joinpath(path, "$group.png"), fig, px_per_unit=3) end return failures end -function single_figure(exprs) - fig = Figure(size=(200, length(exprs)*60)) +function reference_figure(exprs, fonts = SUPPORTED_FONTS) + fig = Figure() failures = Dict() - for (i, expr) in enumerate(exprs) - try - Label(fig[i, 1], expr) - catch e - failures[expr] = e - end + for (j, font) in enumerate(fonts) + Label(fig[0, j], font) + for (i, expr) in enumerate(exprs) + try + Label(fig[i, j], with_font(font, expr)) + catch e + failures[expr] = e + end + end end + resize_to_layout!(fig) return fig, failures end From 7f253862a79c6f6907d7ecc3f55bd5e4145d3a12 Mon Sep 17 00:00:00 2001 From: AshtonSBradley Date: Fri, 15 May 2026 11:38:54 +1200 Subject: [PATCH 13/13] Address operator spacing review --- reference/spacing_visuals.jl | 294 ----------------------------------- src/engine/layout.jl | 14 +- test/layout.jl | 4 + test/runtests.jl | 9 -- 4 files changed, 14 insertions(+), 307 deletions(-) delete mode 100644 reference/spacing_visuals.jl diff --git a/reference/spacing_visuals.jl b/reference/spacing_visuals.jl deleted file mode 100644 index f707b41..0000000 --- a/reference/spacing_visuals.jl +++ /dev/null @@ -1,294 +0,0 @@ -using CairoMakie -using FileIO -using LaTeXStrings - -using MathTeXEngine - -const SPACING_VISUAL_FONT_NAMES = - ["NewComputerModern", "TeXGyreHeros", "TeXGyrePagella", "LucioleMath"] - -const SPACING_VISUAL_CASES = Pair{String, Vector{String}}[ - "Issue #142: italic/roman boundaries" => [ - raw"f(t)", - raw"g(x)", - raw"(f)x", - raw"(t)", - raw"\eta(t)", - raw"\alpha(t)", - raw"g(f(x))", - raw"\mathrm{y}(x)", - raw"\mathrm{g}t", - ], - "Issue #95: lower-case Greek and subscript spacing" => [ - raw"\eta(t)", - raw"\alpha_k", - raw"\omega_k", - raw"k\xi", - raw"\nu(k)", - raw"N_\nu L_\nu A_\nu J_\nu", - raw"x_{\alpha(k)}", - raw"v_{(a + b)_k}^i", - raw"\partial_i u_j", - raw"\phi_\varphi \rho_\sigma", - ], - "Subscript and superscript combinations" => [ - raw"x_i", - raw"x^i", - raw"x_i^j", - raw"V^i_j", - raw"x_{i_j}", - raw"x^{i^j}", - raw"x_{(a+b)_k}^i", - raw"T_{\alpha\beta}^{ij}", - raw"\Gamma^\mu_{\nu\rho}", - raw"\psi^\dagger_i\psi_i", - ], - "PR #151: primes and deep scripts" => [ - raw"x' f'", - raw"x'' f''", - raw"x′ f′", - raw"x\prime f\prime", - raw"x^\prime f^\prime", - raw"x'_y f_g'", - raw"A^{B^{C^{D^E}}}_{F_{G_{H_I}}}", - raw"f^{A'}", - ], - "Roman/upright and capital boundaries" => [ - raw"\mathrm{d}x", - raw"\mathrm{e}^{-x}", - raw"\mathrm{Re}\,z", - raw"\mathrm{Im}\,z", - raw"\mathrm{Tr}\,A_i^j", - raw"\mathrm{Cov}(X,Y)", - raw"A_\nu B_\nu C_\nu D_\nu", - raw"M\mathrm{M}M", - ], - "Issue #129: math operator spacing" => [ - raw"\log x", - raw"\log(x)", - raw"\sin x", - raw"\sin(x)", - raw"\exp t", - raw"\exp(t)", - raw"\max_{t \in \{1,...,5\}}", - ], - "Operators, delimiters, and fractions" => [ - raw"-1,\ 2-1,\ (-1)", - raw"\alpha^*", - raw"\psi^* \psi", - raw"\frac{1}{2}\pm\sqrt{3}", - raw"\frac{1}{2}{}\pm\sqrt{3}", - raw"\left(\frac{1}{2}\right)f(t)", - raw"\sqrt{x_i^2+y_i^2}", - raw"\sum_{k=0}^n a_k x^k", - raw"\int_0^{2\pi}\sin(x)\,dx", - ], - "Script layout issues #93, #105, #110, #126" => [ - raw"\left(\frac{dy}{dx}\right)_0", - raw"\left(\frac{A^{xy}}{B}\right)^{1/4}", - raw"(\frac{A^{xy}}{B})^{1/4}", - raw"\left\langle\left|\int\right|\right\rangle", - raw"\left\langle\left|\left\langle\left|\int\right|\right\rangle\right|\right\rangle", - raw"x^{\frac{1}{1+2}}", - raw"x_{\frac{1}{1+2}}", - ], - "Nested expressions" => [ - raw"\frac{\alpha_i+\beta_i}{\gamma_i+\delta_i}", - raw"\sqrt{\frac{1+\alpha_k}{1+\beta_k}}", - raw"F_{\mu\nu}F^{\mu\nu}", - raw"\overline{z}_i", - raw"\left(\alpha_{(i+j)_k}\right)^2", - raw"\frac{\partial^2 f}{\partial x_i\partial x_j}", - ], -] - -repo_root() = dirname(@__DIR__) -reference_project_dir() = @__DIR__ - -function spacing_visual_output_path() - return get( - ENV, - "MTE_SPACING_VISUAL_PATH", - joinpath(@__DIR__, "spacing_visual_inspection.png"), - ) -end - -spacing_baseline_ref() = get(ENV, "MTE_SPACING_BASELINE_REF", "HEAD") - -font_latex(font_name, expr) = latexstring("\\fontfamily{$font_name}$expr") - -function spacing_label_sheet( - cases = SPACING_VISUAL_CASES; - font_names = SPACING_VISUAL_FONT_NAMES, - ) - row_height = 68 - row_gap = 7 - nrows = sum(length(last(group)) + 1 for group in cases) + 1 - fig_height = row_height * nrows + row_gap * (nrows - 1) - fig = Figure(size = (2200, max(900, fig_height)), fontsize = 18) - - Label(fig[1, 1], "case"; tellwidth = false, halign = :left, font = :bold) - for (col, font_name) in enumerate(font_names) - Label(fig[1, col + 1], font_name; tellwidth = false, halign = :left, font = :bold) - end - - row = 2 - for (group, exprs) in cases - Label( - fig[row, 1:(length(font_names) + 1)], - group; - tellwidth = false, - halign = :left, - font = :bold, - fontsize = 17, - ) - row += 1 - - for expr in exprs - Label(fig[row, 1], expr; tellwidth = false, halign = :left, fontsize = 13) - for (col, font_name) in enumerate(font_names) - Label( - fig[row, col + 1], - font_latex(font_name, expr); - tellwidth = false, - halign = :left, - fontsize = 24, - ) - end - row += 1 - end - end - - colsize!(fig.layout, 1, Relative(0.22)) - for col in 2:(length(font_names) + 1) - colsize!(fig.layout, col, Relative(0.78 / length(font_names))) - end - for row in 1:nrows - rowsize!(fig.layout, row, Fixed(row_height)) - end - rowgap!(fig.layout, row_gap) - return fig -end - -function save_spacing_label_sheet(path) - fig = spacing_label_sheet() - save(path, fig, px_per_unit = 2) - return path -end - -function render_spacing_sheet_in_subprocess(package_path, output_path) - julia_executable = joinpath(Sys.BINDIR, Base.julia_exename()) - project_dir = mktempdir() - cp( - joinpath(reference_project_dir(), "Project.toml"), - joinpath(project_dir, "Project.toml"), - ) - cp( - joinpath(reference_project_dir(), "Manifest.toml"), - joinpath(project_dir, "Manifest.toml"), - ) - script = """ - import Pkg - Pkg.develop(path=$(repr(package_path))) - Pkg.instantiate() - include($(repr(@__FILE__))) - save_spacing_label_sheet($(repr(output_path))) - """ - run(`$julia_executable --project=$project_dir -e $script`) - return output_path -end - -function with_baseline_checkout(f) - if haskey(ENV, "MTE_SPACING_BASELINE_PATH") - return f( - ENV["MTE_SPACING_BASELINE_PATH"], - get(ENV, "MTE_SPACING_BASELINE_LABEL", "baseline"), - ) - end - - return mktempdir() do dir - checkout_path = joinpath(dir, "MathTeXEngine-baseline") - ref = spacing_baseline_ref() - run(`git -C $(repo_root()) worktree add --detach $checkout_path $ref`) - try - return f(checkout_path, ref) - finally - run(`git -C $(repo_root()) worktree remove --force $checkout_path`) - end - end -end - -function add_image_panel!(figpos, image_path, title) - img = rotr90(load(image_path)) - ax = Axis(figpos; title, aspect = DataAspect()) - hidedecorations!(ax) - hidespines!(ax) - image!(ax, img) - return ax -end - -function pixel_darkness(c) - return clamp(1 - (Float32(c.r) + Float32(c.g) + Float32(c.b)) / 3, 0, 1) -end - -function spacing_overlay_image(after_path, before_path) - after_img = load(after_path) - before_img = load(before_path) - height = min(size(after_img, 1), size(before_img, 1)) - width = min(size(after_img, 2), size(before_img, 2)) - overlay = Matrix{RGBAf}(undef, height, width) - - for y in 1:height, x in 1:width - after_dark = pixel_darkness(after_img[y, x]) - before_dark = pixel_darkness(before_img[y, x]) - - # Blue ink is the current checkout, red ink is the baseline. Matching - # ink becomes dark, while spacing changes leave visible colored fringes. - overlay[y, x] = - RGBAf(1 - after_dark, (1 - after_dark) * (1 - before_dark), 1 - before_dark, 1) - end - - return overlay -end - -function spacing_visual_figure() - return with_baseline_checkout() do baseline_path, baseline_label - mktempdir() do dir - before_path = joinpath(dir, "spacing_before.png") - after_path = joinpath(dir, "spacing_after.png") - overlay_path = joinpath(dir, "spacing_overlay.png") - - render_spacing_sheet_in_subprocess(baseline_path, before_path) - render_spacing_sheet_in_subprocess(repo_root(), after_path) - save(overlay_path, spacing_overlay_image(after_path, before_path)) - - fig = Figure(size = (3000, 1800), fontsize = 24) - Label( - fig[1, 1:3], - "Spacing regression visual inspection"; - tellwidth = false, - halign = :left, - font = :bold, - ) - add_image_panel!(fig[2, 1], before_path, "before: $(baseline_label)") - add_image_panel!(fig[2, 2], after_path, "after: current checkout") - add_image_panel!(fig[2, 3], overlay_path, "overlay: after blue, before red") - colsize!(fig.layout, 1, Relative(0.34)) - colsize!(fig.layout, 2, Relative(0.34)) - colsize!(fig.layout, 3, Relative(0.32)) - return fig - end - end -end - -function generate_spacing_visuals(path = spacing_visual_output_path()) - fig = spacing_visual_figure() - mkpath(dirname(path)) - save(path, fig, px_per_unit = 2) - return path -end - -if abspath(PROGRAM_FILE) == @__FILE__ - path = generate_spacing_visuals() - @info "Wrote spacing visual inspection sheet" path -end diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 09062c5..3562022 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -416,7 +416,7 @@ function tex_layout(expr, state) end if mode == :inline_math - elements = _add_function_spacing(args, elements) + elements = _add_math_operator_spacing(args, elements) end italic_correction = mode == :inline_math && italic_correction_enabled[] @@ -567,12 +567,12 @@ function horizontal_layout(elements; italic_correction = false) return Group(elements, Point2f.(xs, 0); slanted = is_slanted(last(elements))) end -function _add_function_spacing(args, elements) +function _add_math_operator_spacing(args, elements) spaced = TeXElement[] for (i, elem) in enumerate(elements) push!(spaced, elem) - if args[i].head == :function && _function_takes_space(args, i) + if _is_spaced_math_operator(args[i]) && _operator_takes_space(args, i) push!(spaced, Space(1 / 6)) end end @@ -580,11 +580,17 @@ function _add_function_spacing(args, elements) return spaced end -function _function_takes_space(args, i) +function _operator_takes_space(args, i) next = findnext(arg -> !(arg.head == :char && only(arg.args) == ' '), args, i + 1) return !isnothing(next) && !_is_opening_delimiter(args[next]) end +function _is_spaced_math_operator(arg) + arg isa TeXExpr || return false + arg.head == :function && return true + return arg.head in (:decorated, :underover) && _is_spaced_math_operator(first(arg.args)) +end + function _is_opening_delimiter(expr) return expr.head == :delimiter && only(expr.args) in ('(', '[', '⟨', '{') end diff --git a/test/layout.jl b/test/layout.jl index d37f235..a64a4d1 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -197,6 +197,7 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele @testset "Function spacing" begin xpos(elems, i) = elems[i][2][1] + inline_layout(expr) = tex_layout(texparse(expr), FontFamily()).elements[1] # Issue #129: LaTeX inserts a thin space after math operators when # the argument is not parenthesized. @@ -204,10 +205,13 @@ ink_group_vmid(elements) = (minimum(ink_bottom, elements) + maximum(ink_top, ele xpos(generate_tex_elements(L"\mathrm{log}x"), 4) + 0.1 @test xpos(generate_tex_elements(L"\sin\alpha"), 4) > xpos(generate_tex_elements(L"\mathrm{sin}\alpha"), 4) + 0.1 + @test inline_layout(L"\inf_x\tan(x)").elements[2] == Space(1 / 6) + @test inline_layout(L"\sup_x\tan(x)").elements[2] == Space(1 / 6) # No operator space is inserted before an opening delimiter. @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) end @testset "Fraction rule padding" begin diff --git a/test/runtests.jl b/test/runtests.jl index c619d2a..e3a5c6f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -15,12 +15,3 @@ include("texexpr.jl") include("parser.jl") include("fonts.jl") include("layout.jl") - -if get(ENV, "MTE_GENERATE_SPACING_VISUALS", "false") in ("1", "true", "yes") - @testset "Spacing visual inspection sheet" begin - include(joinpath(@__DIR__, "..", "reference", "spacing_visuals.jl")) - path = generate_spacing_visuals() - @info "Wrote spacing visual inspection sheet" path - @test isfile(path) - end -end