Skip to content

Commit 860f46a

Browse files
authored
Merge pull request #165 from AshtonSBradley/asb/fix-sqrt-bbox
Improve TeX spacing and bounds
2 parents 23b783e + b1674a3 commit 860f46a

6 files changed

Lines changed: 356 additions & 46 deletions

File tree

reference/compare.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ image_difference(img1, img2) = colordiff.(img1, img2) ./ 100
6666
refimg = rotr90(load(joinpath(reference_images, image_path)))
6767
img = rotr90(load(joinpath(comparison_images, image_path)))
6868

69-
if (failed = n_bad_pixels(img, refimg) >= 10)
70-
@info "Saving the reference comparison for '$image_path' (image difference $(n_bad_pixels(img, refimg)))"
69+
if (failed = (size(img) != size(refimg) || n_bad_pixels(img, refimg) >= 10))
70+
@info "Saving the reference comparison for '$image_path'"
7171
fig = Figure(size = (3*size(img, 1), size(img, 2)))
7272
Label(fig[1, 1], "Reference $(size(refimg))", tellwidth=false)
7373
axref = Axis(fig[2, 1], aspect = DataAspect())

reference/data/spacing.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const SPACING = Dict(
66
raw"(t)",
77
raw"\eta(t)",
88
raw"\alpha(t)",
9+
raw"W(\alpha,\alpha^*)",
910
raw"g(f(x))",
1011
raw"\mathrm{y}(x)",
1112
raw"\mathrm{g}t",
@@ -91,4 +92,4 @@ const SPACING = Dict(
9192
raw"\left(\alpha_{(i+j)_k}\right)^2",
9293
raw"\frac{\partial^2 f}{\partial x_i\partial x_j}",
9394
],
94-
)
95+
)

src/engine/layout.jl

Lines changed: 217 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,19 @@ const _SCRIPT_FRACTION_RULE_WIDTH = 0.45
2222
const _SCRIPT_FRACTION_RULE_SHIFT = 0.18
2323
const _TALL_SCRIPT_HEIGHT_FACTOR = 1.5
2424
const _SCRIPT_SHRINK_HEIGHT_FACTOR = 1.5
25-
const _SQRT_TALL_CONTENT_CLEARANCE_FACTOR = 0.25
26-
const _SQRT_RULE_CONTENT_DESCENT = 0.9
25+
const _SQRT_RULE_CONTENT_DESCENT = 0.25
26+
const _SQRT_TALL_CONTENT_DESCENT = 0.25
27+
const _SQRT_TALL_CONTENT_HEIGHT = 1.75
28+
const _SQRT_MAX_RADICAL_STRETCH = 1.25
29+
const _SQRT_MAX_BASE_RADICAL_HEIGHT = 1.4
30+
const _SQRT_SHORT_CONTENT_HEIGHT = 1.15
31+
const _SQRT_SHORT_CONTENT_MAX_DESCENT = 0.45
32+
const _SQRT_SIMPLE_CONTENT_HEIGHT_FACTOR = 1.05
2733
const _SQRT_RULE_PADDING = 0.12
34+
const _SQRT_TRAILING_PADDING = 0.06
2835
const _SLANTED_ADJACENT_GAP = 0.03
36+
const _LATIN_ITALIC_PAIR_GAP = 0.08
37+
const _DIGIT_ITALIC_LEFT_BEARING_CORRECTION = 0.5
2938
const _DISPLAY_OPERATOR_DELIMITER_HEIGHT = 1.35
3039
const _BRACE_RULE_AXIS_PADDING = 0.2
3140

@@ -97,29 +106,126 @@ function _has_rule_element(elem)
97106
return false
98107
end
99108

100-
function _sqrt_radical(state, target_height)
101-
font_family = state.font_family
102-
radicals = TeXElement[TeXChar('', state, :symbol)]
109+
const _sqrt_radical_name_sets = (
110+
("radical.v1", "radical.v2", "radical.v3", "radical.v4", "radical.v5", "radical.v6"),
111+
("sqrt.v1", "sqrt.v2", "sqrt.v3", "sqrt.v4"),
112+
)
113+
114+
function _sqrt_radical_variants(state)
115+
radicals = TeXElement[]
116+
117+
# Constructed square roots need size-specific radical glyphs. Ordinary
118+
# Unicode √ glyphs vary too much across fonts to be used as extenders.
119+
for radical_names in _sqrt_radical_name_sets
120+
for radical_name in radical_names
121+
candidate = TeXChar(radical_name, state, :symbol; represented = '')
122+
if candidate.glyph_id != 0 && inkheight(candidate) > 0
123+
push!(radicals, candidate)
124+
end
125+
end
126+
end
103127

104-
for radical_name in ("radical.v1", "radical.v2", "radical.v3", "radical.v4")
105-
candidate = TeXChar(radical_name, state, :symbol; represented = '')
106-
candidate.glyph_id != 0 && push!(radicals, candidate)
128+
sort!(radicals; by = inkheight)
129+
return radicals
130+
end
107131

108-
fallback = default_math_texchar(radical_name, font_family, '')
109-
isnothing(fallback) || push!(radicals, fallback)
132+
function _base_sqrt_radical(state)
133+
radical = TeXChar('', state, :symbol)
134+
if radical.glyph_id != 0 && inkheight(radical) > 0
135+
return radical
136+
end
137+
138+
return nothing
139+
end
140+
141+
function _fallback_sqrt_radical_variants(font_family)
142+
radicals = TeXElement[]
143+
144+
for radical_name in first(_sqrt_radical_name_sets)
145+
radical = default_math_texchar(radical_name, font_family, '')
146+
isnothing(radical) || push!(radicals, radical)
147+
end
148+
149+
# Last-resort fallback for custom builds where the default radical variants
150+
# are unavailable. The bundled fonts provide the named variants above.
151+
if isempty(radicals)
152+
radical = default_math_texchar('', font_family, '')
153+
isnothing(radical) || push!(radicals, radical)
110154
end
111155

112156
sort!(radicals; by = inkheight)
157+
return radicals
158+
end
159+
160+
function _has_compact_base_radical(radicals)
161+
isempty(radicals) && return false
162+
return inkheight(first(radicals)) <= _SQRT_MAX_BASE_RADICAL_HEIGHT
163+
end
164+
165+
function _with_base_sqrt_radical(state, radicals)
166+
base_radical = _base_sqrt_radical(state)
167+
isnothing(base_radical) && return radicals
168+
169+
candidates = TeXElement[base_radical]
170+
append!(candidates, radicals)
171+
sort!(candidates; by = inkheight)
172+
return candidates
173+
end
174+
175+
function _select_sqrt_radical(radicals, target_height)
176+
previous = nothing
113177
for candidate in radicals
114-
if candidate.glyph_id == 0
115-
continue
178+
if inkheight(candidate) >= target_height
179+
if !isnothing(previous)
180+
stretch = target_height / inkheight(previous)
181+
stretch <= _SQRT_MAX_RADICAL_STRETCH && return previous, stretch
182+
end
183+
184+
return candidate, 1.0
185+
end
186+
187+
previous = candidate
188+
end
189+
190+
if isempty(radicals)
191+
return nothing
192+
end
193+
194+
radical = last(radicals)
195+
return radical, max(1.0, target_height / inkheight(radical))
196+
end
197+
198+
function _sqrt_radical_candidates(state, content)
199+
radicals = _sqrt_radical_variants(state)
200+
if !_has_compact_base_radical(radicals)
201+
radicals = _fallback_sqrt_radical_variants(state.font_family)
202+
end
203+
204+
if !_has_rule_element(content)
205+
if _is_tall_sqrt_content(content, state.font_family)
206+
taller_radicals = filter(radical -> inkheight(radical) > 1, radicals)
207+
isempty(taller_radicals) || return taller_radicals
116208
end
117-
inkheight(candidate) >= target_height && return candidate
209+
210+
return _with_base_sqrt_radical(state, radicals)
118211
end
119212

120-
return last(radicals)
213+
return radicals
214+
end
215+
216+
function _sqrt_radical(state, target_height, content)
217+
radical = _select_sqrt_radical(_sqrt_radical_candidates(state, content), target_height)
218+
isnothing(radical) || return radical
219+
220+
radical = _select_sqrt_radical(_fallback_sqrt_radical_variants(state.font_family), target_height)
221+
isnothing(radical) || return radical
222+
223+
throw(ArgumentError("No square-root radical glyph found"))
121224
end
122225

226+
_is_tall_sqrt_content(content, font_family) =
227+
topinkbound(content) > _SQRT_TALL_CONTENT_HEIGHT * xheight(font_family)
228+
123229
const _math_delimiter_chars = Set(['(', ')', '[', ']', '{', '}', '', '', '|', ''])
124230
const _display_operator_chars = Set(['', '', ''])
125231
const _delimiter_axis_operator_chars =
@@ -234,13 +340,36 @@ end
234340

235341
function _sqrt_clearance(content, font_family)
236342
xh = xheight(font_family)
237-
clearance = _has_rule_element(content) ? xh / 3 : xh / 2
343+
clearance = xh / 2
238344
return max(thickness(font_family), clearance)
239345
end
240346

241-
function _sqrt_radical_extra_height(content, font_family)
242-
_has_rule_element(content) || return 0.0
243-
return _SQRT_TALL_CONTENT_CLEARANCE_FACTOR * xheight(font_family)
347+
function _simple_sqrt_line_top(content, radical, radical_scale, clearance)
348+
line_top = topinkbound(content) + clearance
349+
radical_height = radical_scale * inkheight(radical)
350+
content_height = inkheight(content)
351+
pad = max((radical_height - _SQRT_SIMPLE_CONTENT_HEIGHT_FACTOR * content_height) / 2, 0.0)
352+
centered_line_top = bottominkbound(content) + radical_height - pad
353+
return max(line_top, centered_line_top)
354+
end
355+
356+
function _short_sqrt_radical_scale(content, radical, radical_scale, clearance, font_family)
357+
content_height = inkheight(content)
358+
if content_height <= 0 || content_height > _SQRT_SHORT_CONTENT_HEIGHT * xheight(font_family)
359+
return radical_scale
360+
end
361+
362+
radical_height = radical_scale * inkheight(radical)
363+
line_top = _simple_sqrt_line_top(content, radical, radical_scale, clearance)
364+
root_descent = bottominkbound(content) - (line_top - radical_height)
365+
max_descent = _SQRT_SHORT_CONTENT_MAX_DESCENT * xheight(font_family)
366+
root_descent <= max_descent && return radical_scale
367+
368+
max_height = min(
369+
2max_descent + _SQRT_SIMPLE_CONTENT_HEIGHT_FACTOR * content_height,
370+
max_descent + content_height + clearance,
371+
)
372+
return min(radical_scale, max_height / inkheight(radical))
244373
end
245374

246375
"""
@@ -392,9 +521,19 @@ function tex_layout(expr, state)
392521
ytop = y0 + xh / 2 - bottominkbound(numerator)
393522
ybottom = y0 - xh / 2 - topinkbound(denominator)
394523

524+
elements = [line, numerator, denominator]
525+
positions = Point2f[(xline, y0), (x1, ytop), (x2, ybottom)]
526+
fraction_left = minimum(
527+
position[1] + leftinkbound(element) for
528+
(element, position) in zip(elements, positions)
529+
)
530+
if fraction_left < 0
531+
positions = positions .+ Ref(Point2f(-fraction_left, 0))
532+
end
533+
395534
return Group(
396-
[line, numerator, denominator],
397-
Point2f[(xline, y0), (x1, ytop), (x2, ybottom)];
535+
elements,
536+
positions;
398537
slanted = is_slanted(numerator) || is_slanted(denominator),
399538
)
400539
elseif head == :function
@@ -476,33 +615,56 @@ function tex_layout(expr, state)
476615
rule_thickness = thickness(font_family)
477616
xh = xheight(font_family)
478617
clearance = _sqrt_clearance(content, font_family)
479-
radical_clearance = _sqrt_radical_extra_height(content, font_family)
480-
target_height = inkheight(content) + radical_clearance
481-
radical = _sqrt_radical(state, target_height)
482-
483618
line_top = topinkbound(content) + clearance
619+
if _has_rule_element(content)
620+
desired_bottom = bottominkbound(content) - _SQRT_RULE_CONTENT_DESCENT * xh
621+
target_height = max(inkheight(content), line_top - desired_bottom)
622+
elseif _is_tall_sqrt_content(content, font_family)
623+
desired_bottom = bottominkbound(content) - _SQRT_TALL_CONTENT_DESCENT * xh
624+
target_height = max(inkheight(content), line_top - desired_bottom)
625+
else
626+
target_height = inkheight(content)
627+
end
628+
radical, radical_scale = _sqrt_radical(state, target_height, content)
629+
484630
if _has_rule_element(content)
485631
radical_bottom = bottominkbound(content) - _SQRT_RULE_CONTENT_DESCENT * xh
486-
line_top = max(line_top, radical_bottom + inkheight(radical))
632+
line_top = max(line_top, radical_bottom + radical_scale * inkheight(radical))
633+
elseif !_is_tall_sqrt_content(content, font_family)
634+
radical_scale = _short_sqrt_radical_scale(
635+
content,
636+
radical,
637+
radical_scale,
638+
clearance,
639+
font_family,
640+
)
641+
line_top = _simple_sqrt_line_top(content, radical, radical_scale, clearance)
487642
end
488-
y0 = line_top - topinkbound(radical)
643+
y0 = line_top - radical_scale * topinkbound(radical)
489644
line_y = line_top - rule_thickness / 2
490645

491646
hline_width =
492647
max(rightinkbound(content), xheight(font_family) / 2) +
493648
_SQRT_RULE_PADDING * xh +
494649
rule_thickness
495650
hline = HLine(hline_width, rule_thickness)
496-
hline_x = rightinkbound(radical) - rule_thickness / 2
651+
radical_right = radical_scale * rightinkbound(radical)
652+
hline_x = radical_right - rule_thickness / 2
653+
hline_right = hline_x + rightinkbound(hline)
654+
content_right = rightinkbound(content)
655+
target_hadvance = hline_right + _SQRT_TRAILING_PADDING * xh
656+
trailing_space_x = min(content_right, target_hadvance)
657+
trailing_space = target_hadvance - trailing_space_x
497658

498659
return Group(
499-
[radical, hline, content, Space(1.2)],
660+
[radical, hline, content, Space(trailing_space)],
500661
Point2f[
501662
(0, y0),
502663
(hline_x, line_y),
503-
(rightinkbound(radical), 0),
504-
(rightinkbound(content), 0),
664+
(radical_right, 0),
665+
(trailing_space_x, 0),
505666
],
667+
[radical_scale, 1, 1, 1],
506668
)
507669
elseif head == :text
508670
modifier, content = args
@@ -641,23 +803,48 @@ function italic_transition_offset(prev, elem)
641803

642804
# Positive left bearings on italic glyphs make e.g. "(t)" look
643805
# asymmetric. Remove that extra font-side gap at roman-to-italic edges.
806+
if prev isa TeXChar
807+
if isdigit(prev.represented_char)
808+
if _is_lowercase_latin(elem)
809+
gap = hadvance(prev) + bearing - rightinkbound(prev)
810+
target_gap = _latin_italic_target_gap(prev, elem)
811+
gap > target_gap && return target_gap - gap
812+
end
813+
return -_DIGIT_ITALIC_LEFT_BEARING_CORRECTION * bearing
814+
elseif _is_math_punctuation(prev.represented_char)
815+
return 0.0
816+
end
817+
end
644818
return -bearing
645819
end
646820

647821
return 0.0
648822
end
649823

824+
_is_math_punctuation(char) = char in (',', ';', '.', '!')
825+
650826
function slanted_adjacent_offset(prev, elem)
651827
top = min(topinkbound(prev), topinkbound(elem))
652828
bottom = max(bottominkbound(prev), bottominkbound(elem))
653829
top <= bottom && return 0.0
654830

655831
gap = hadvance(prev) + leftinkbound(elem) - rightinkbound(prev)
656832
min_gap = _SLANTED_ADJACENT_GAP * min(inkheight(prev), inkheight(elem))
833+
if _is_lowercase_latin(prev) && _is_lowercase_latin(elem)
834+
target_gap = max(min_gap, _latin_italic_target_gap(prev, elem))
835+
gap > target_gap && return target_gap - gap
836+
end
837+
657838
offset = min_gap - gap
658839
return max(0.0, min(offset, 2min_gap))
659840
end
660841

842+
_is_lowercase_latin(elem) =
843+
elem isa TeXChar && 'a' <= elem.represented_char <= 'z'
844+
845+
_latin_italic_target_gap(prev, elem) =
846+
_LATIN_ITALIC_PAIR_GAP * min(inkheight(prev), inkheight(elem))
847+
661848
function layout_text(string, font_family)
662849
isempty(string) && return Space(0)
663850

0 commit comments

Comments
 (0)