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 1ccd984..0a0a018 100644 --- a/reference/references.jl +++ b/reference/references.jl @@ -1,128 +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"\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 diff --git a/src/MathTeXEngine.jl b/src/MathTeXEngine.jl index ffb03f3..584d11f 100644 --- a/src/MathTeXEngine.jl +++ b/src/MathTeXEngine.jl @@ -28,6 +28,11 @@ export glyph_index # Reexport from LaTeXStrings export @L_str +# 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") include("parser/commands_data.jl") diff --git a/src/engine/fonts.jl b/src/engine/fonts.jl index 677bfc2..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 -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 43bcac6..3562022 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -4,13 +4,245 @@ 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) 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 _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.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) + script_gap = max(thickness(font_family), _MIN_SCRIPT_GAP) + tall_script_height = _TALL_SCRIPT_HEIGHT_FACTOR * xh + + sub_y = -0.2 + 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 _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 + 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) + 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, 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 + + 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 _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(['(', ')', '[', ']', '{', '}', '⟨', '⟩', '|', '‖']) +const _display_operator_chars = Set(['∫', '∑', '∏']) +const _delimiter_axis_operator_chars = + Set(['+', '-', '−', '±', '∓', '×', '⋅', '=', '<', '>', '≤', '≥', '≠']) + +function _delimiter_element(char, state) + font_family = state.font_family + + if char in _math_delimiter_chars + texchar = default_math_texchar(char, font_family, char) + if !isnothing(texchar) + return texchar + end + end + + return TeXChar(char, state, :delimiter) +end + +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 + return min(inkheight(content), _DISPLAY_OPERATOR_DELIMITER_HEIGHT) + end + end + + 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) + 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_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 + + 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 + """ tex_layout(mathexpr::TeXExpr, font_family) @@ -26,12 +258,15 @@ 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 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 @@ -50,60 +285,77 @@ 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) - + 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 + super_x = _superscript_x_position( + core, + font_family, + tall_core, + ) end + sub_x = _subscript_x_position( + core, + font_family, + tall_core, + ) return Group( [core, sub, super], 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 - ), - ( super_x, super_y)], - [1, shrink, super_shrink] + (sub_x, sub_y), + (super_x, super_y), + ], + [1, sub_shrink, super_shrink]; + slanted = is_slanted(core) || is_slanted(super), ) elseif head == :delimited elements = tex_layout.(args, state) left, content, right = elements - height = inkheight(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])...] + 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[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 + scales; + slanted = is_slanted(right), ) elseif head == :font modifier, content = args @@ -114,31 +366,41 @@ 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_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 - 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_left + argument_right) / 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) - ybottom = y0 - xh/2 - topinkbound(denominator) + 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 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 +409,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_math_operator_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) @@ -161,15 +430,13 @@ 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] + [1, shrink, shrink]; + slanted = is_slanted(int), ) elseif head == :lines length(args) == 1 && return tex_layout(only(args), state) @@ -177,7 +444,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 @@ -186,55 +453,56 @@ 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) 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)]) + 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 + 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 + y0 = line_top - topinkbound(radical) + line_y = line_top - rule_thickness / 2 - h = inkheight(sqrt) - - lw = thickness(font_family) - y0 = bottominkbound(content) - bottominkbound(sqrt) - pad - y = y0 + topinkbound(sqrt) - lw - - hline = HLine(inkwidth(content) + pad, lw) + 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 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(content), 0) - ] + (hline_x, line_y), + (rightinkbound(radical), 0), + (rightinkbound(content), 0), + ], ) elseif head == :text modifier, content = args @@ -260,9 +528,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 +556,106 @@ 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])...] + xs = [0, cumsum(dxs[1:(end - 1)])...] + + return Group(elements, Point2f.(xs, 0); slanted = is_slanted(last(elements))) +end + +function _add_math_operator_spacing(args, elements) + spaced = TeXElement[] + + for (i, elem) in enumerate(elements) + push!(spaced, elem) + if _is_spaced_math_operator(args[i]) && _operator_takes_space(args, i) + push!(spaced, Space(1 / 6)) + end + end + + return spaced +end + +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 + +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 + + 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) + 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 slanted_adjacent_offset(prev, elem) + top = min(topinkbound(prev), topinkbound(elem)) + bottom = max(bottominkbound(prev), bottominkbound(elem)) + top <= bottom && return 0.0 - return Group(elements, Point2f.(xs, 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) @@ -307,7 +671,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 = [] @@ -335,7 +699,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 5c9b8db..0d6d44d 100644 --- a/src/engine/layout_context.jl +++ b/src/engine/layout_context.jl @@ -2,28 +2,41 @@ 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 -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))") + 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) + 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) - return LayoutState(state.font_family, modifiers, state.tex_mode) + return LayoutState(state.font_family, modifiers, state.tex_mode, state.script_level) end -function get_font(state::LayoutState, char_type) +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) if state.tex_mode == :text char_type = :text end @@ -40,5 +53,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/new_computer_modern_data.jl b/src/engine/new_computer_modern_data.jl index 14736b4..cb85577 100644 --- a/src/engine/new_computer_modern_data.jl +++ b/src/engine/new_computer_modern_data.jl @@ -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 @@ -44,5 +44,5 @@ end # Special case : get hbar from the italic font _symbol_to_new_computer_modern['ħ'] = ( joinpath("NewComputerModern", "NewCM10-Italic.otf"), - 231 -) \ No newline at end of file + 231, +) diff --git a/src/engine/texelements.jl b/src/engine/texelements.jl index 2d32932..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) @@ -148,34 +148,63 @@ 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 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) + 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 -function TeXChar(name::AbstractString, state::LayoutState, char_type ; represented='?') +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) + glyph_id = glyph_index(font, name) + return TeXChar( - glyph_index(font, name), + glyph_id, font, font_family, - is_slanted(state.font_family, char_type), - represented) + is_slanted_font(font_id), + represented, + ) end for inkfunc in (:leftinkbound, :rightinkbound, :bottominkbound, :topinkbound) @@ -245,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 @@ -267,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 @@ -284,14 +313,21 @@ 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} 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 +370,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/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 881a8a0..a64a4d1 100644 --- a/test/layout.jl +++ b/test/layout.jl @@ -9,8 +9,16 @@ 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]) +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 expr = manual_texexpr((:decorated, 'x', 'b', 't')) @@ -31,6 +39,28 @@ 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]) + + @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 + + 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 @@ -40,6 +70,48 @@ end hs = inkheight.(layout.elements) .* layout.scales @test hs[1] >= hs[2] @test hs[3] >= hs[2] + + simple_elems = generate_tex_elements(L"\left(1 + 2\right)") + 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", + ) + @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 @@ -85,6 +157,170 @@ 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) + + 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 + end + + @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. + @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 + @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 + 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]) + @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_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 + @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()) + @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 + 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]) > 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 + 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))) + 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 (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]) + + # 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 +331,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 +343,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..e3a5c6f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,9 +8,10 @@ import MathTeXEngine: TeXParseError import MathTeXEngine: tex_layout, generate_tex_elements import MathTeXEngine: Space, TeXElement import MathTeXEngine: load_font -import MathTeXEngine: inkheight, inkwidth +import MathTeXEngine: hadvance, inkheight, inkwidth, xheight +import MathTeXEngine: bottominkbound, leftinkbound, rightinkbound, topinkbound include("texexpr.jl") include("parser.jl") include("fonts.jl") -include("layout.jl") \ No newline at end of file +include("layout.jl")