From a6dae77c01a2aef4d44c14e4ead5082405ec7375 Mon Sep 17 00:00:00 2001 From: manuelbb-upb Date: Fri, 17 Oct 2025 20:37:40 +0200 Subject: [PATCH 1/2] granular decorations --- src/engine/fonts.jl | 19 +++ src/engine/layout.jl | 135 ++++++++++++++++--- src/engine/layout_context.jl | 14 +- src/engine/opentype_mathtable.jl | 200 ++++++++++++++++++++++++++++ src/parser/commands_registration.jl | 4 + src/parser/parser.jl | 24 +++- 6 files changed, 373 insertions(+), 23 deletions(-) create mode 100644 src/engine/opentype_mathtable.jl diff --git a/src/engine/fonts.jl b/src/engine/fonts.jl index 677bfc2..e13b942 100644 --- a/src/engine/fonts.jl +++ b/src/engine/fonts.jl @@ -1,3 +1,6 @@ +include("opentype_mathtable.jl") +import .OpenTypeMathTable: MathTable, get_math_constant + const FONTS = RelocatableFolders.@path joinpath(@__DIR__, "..", "..", "assets", "fonts") function full_fontpath(fontname::AbstractString) @@ -7,6 +10,8 @@ end const _cached_fonts = Dict{String, FTFont}() +const _cached_math_tables = Dict{String, Union{Nothing, MathTable}}() + """ load_font(str) @@ -22,6 +27,14 @@ function load_font(str) end end +function load_math_table(str) + path = full_fontpath(str) + face = load_font(str) + get!(_cached_math_tables, path) do + MathTable(face; throw_error=false) + end +end + # Loading the font directly here lead to FreeTypeAbstraction to fail with error # code 35, because handles to fonts are C pointer that cannot be fully # serialized at compile time @@ -225,6 +238,12 @@ function get_font(font_family::FontFamily, fontstyle::Symbol) end get_font(fontstyle::Symbol) = get_font(FontFamily(), fontstyle) +function get_math_table(font_family::FontFamily) + math_font = get(font_family.fonts, :math, nothing) + isnothing(math_font) && return math_font + return load_math_table(math_font) +end + """ texfont(font_desc=:text) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index d30c340..6b6a61f 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -27,6 +27,9 @@ function tex_layout(expr, state) args = [expr.args...] shrink = 0.6 + math_table = get_math_table(font_family) + default_rule_thickness = get_math_constant(math_table, :fractionRuleThickness, thickness(font_family), true) + try if isleaf(expr) # :char, :delimiter, :digit, :punctuation, :symbol char = args[1] @@ -57,30 +60,115 @@ function tex_layout(expr, state) [1, 1] ) elseif head == :decorated - core, sub, super = tex_layout.(args, state) - - if !isnothing(args[3]) && args[3].head == :primes - super_x = min(hadvance(core), rightinkbound(core)) - 0.1 - super_y = 0.1 - super_shrink = 1 + ## within this conditional we determine several layouting constants; + ## for the typesetting heuristics refer to + ## Appendix G in https://visualmatheditor.equatheque.net/doc/texbook.pdfl + ## for information on the OpenType Math names refer to + ## https://learn.microsoft.com/en-us/typography/opentype/spec/math#math-table-structures + ## for name mapping between OpenType and Tex refer to + ## 7.4.2 in https://mirrors.ibiblio.org/CTAN/systems/doc/luatex/luatex.pdf + + ## layout nucleus + core = tex_layout(args[1], state) + + ## before layouting sub- and superscript, we have to determine the current scaling factors + ### special treatment for primes + sup_is_primes = (!isnothing(args[3]) && args[3].head == :primes) + + script_shrink = if state.script_level[] <= 0 + get_math_constant(math_table, :scriptPercentScaleDown, 80) / 100 else - super_x = max(hadvance(core), rightinkbound(core)) - super_y = xheight(font_family) - super_shrink = shrink + get_math_constant(math_table, :scriptScriptPercentScaleDown, 60) / 100 end - + sub_shrink = script_shrink / state.script_scale[] + sup_shrink = (sup_is_primes ? state.script_scale[] : script_shrink) / state.script_scale[] + + ## layout sub- and superscript + sub = tex_layout(args[2], inc_script_level(state, sub_shrink)) + super = tex_layout(args[3], inc_script_level(state, sup_shrink)) + + ## Y-Positions + _1_5_x_height = abs(1/5 * xheight(font_family)) + sub1 = get_math_constant(math_table, :subscriptShiftDown, 0, true) + sub2 = get_math_constant(math_table, :subscriptShiftDownWithSuperscript, sub1, true) # not (yet) in OpenType standard, so will equal sub1 + sub_drop = get_math_constant(math_table, :subscriptBaselineDropMin, 0, true) + sub_topmax = get_math_constant(math_table, :subscriptTopMax, 4 * _1_5_x_height, true) + + sup1 = sup_is_primes ? 0 : get_math_constant(math_table, :superscriptShiftUp, 0, true) + sup_drop = get_math_constant(math_table, :superscriptBaselineDropMax, 0, true) + sup_botmin = get_math_constant(math_table, :superscriptBottomMin, _1_5_x_height, true) + sup_botmax = get_math_constant(math_table, :superscriptBottomMaxWithSubscript, 4 * sup_botmin, true) + + gap_min = get_math_constant(math_table, :subSuperscriptGapMin, 4 * default_rule_thickness, true) + + ### 18a -- Appendix G in TeXBook + atomic_core = isa(core, TeXChar) || isa(core, Space) + ### preliminary (positive) offsets + v = atomic_core ? 0 : -bottominkbound(core) + sub_drop + u = atomic_core ? 0 : topinkbound(core) - sup_drop + + sub_height = topinkbound(sub) * sub_shrink + sup_depth = -bottominkbound(super) * sup_shrink + + empty_elem = Space(0) + + if sub != empty_elem && super == empty_elem + ### 18b no superscript + v = max(v, sub1, sub_height - sub_topmax) + else + v = max(v, sub2) + ### 18c + u = max(u, sup1, sup_depth + sup_botmin) + if sub != empty_elem + ### nontrivial sub- and superscripts + _u = u - sup_depth + _v = sub_height - v + if _u - _v < gap_min + ### 18e + psi = sup_botmax - _u + if psi > 0 + u += psi + v -= psi + else + #### TODO unsure about this + psi = gap_min - (_u - _v) + v += psi + end + end + end + end + + ## add post spaces in script boxes + script_space = get_math_constant(math_table, :spaceAfterScript, 1/24, true) + if state.script_level[] > 0 + ## TODO this is not standard + script_space * .6 + end + sub = sub == empty_elem ? sub : Group([sub, Space(script_space / sub_shrink)], [Point2f(0, 0), Point2f(hadvance(sub), 0)]) + super = super == empty_elem ? super : Group([super, Space(script_space / sup_shrink)], [Point2f(0, 0), Point2f(hadvance(super), 0)]) + + super_y = u + sub_y = -v + + ## X-Positions + sup_delta = 0 # TODO proper kerning/italic correction usign OpenType data + sub_delta = 0 + #= + old logic: + `sub_delta = (1 - sub_shrink) * leftinkbound(sub)` + # The logic is to have the ink of the subscript starts + # where the ink of the unshrink glyph would + =# + + super_x = max(hadvance(core), rightinkbound(core)) + sup_delta + sub_x = hadvance(core) + sub_delta 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, sup_shrink] ) elseif head == :delimited elements = tex_layout.(args, state) @@ -198,7 +286,16 @@ function tex_layout(expr, state) ] ) elseif head == :primes - primes = [TeXExpr(:char, ''') for _ in 1:only(args)] + len = only(args) + primes = if len == 1 + [TeXExpr(:symbol, '′'),] + elseif len == 2 + [TeXExpr(:symbol, '″'),] + elseif len == 3 + [TeXExpr(:symbol, '‴'),] + else + reduce(vcat, [Space(-3/36), TeXExpr(:char, '′')] for _ in 1:len) + end return horizontal_layout(tex_layout.(primes, state)) elseif head == :space return Space(args[1]) diff --git a/src/engine/layout_context.jl b/src/engine/layout_context.jl index 5c9b8db..e89ff3d 100644 --- a/src/engine/layout_context.jl +++ b/src/engine/layout_context.jl @@ -2,9 +2,11 @@ struct LayoutState font_family::FontFamily font_modifiers::Vector{Symbol} tex_mode::Symbol + script_level::Ref{UInt8} + script_scale::Ref{Float32} end -LayoutState(font_family::FontFamily, modifiers::Vector) = LayoutState(font_family, modifiers, :text) +LayoutState(font_family::FontFamily, modifiers::Vector) = LayoutState(font_family, modifiers, :text, Ref{UInt8}(0), Ref{Float32}(1)) LayoutState(font_family::FontFamily) = LayoutState(font_family, Symbol[]) LayoutState() = LayoutState(FontFamily()) @@ -15,12 +17,18 @@ 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, state.script_scale) 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, state.script_scale) +end + +function inc_script_level(state::LayoutState, scale=1) + script_level = Ref{UInt8}(state.script_level[] + 1) + script_scale = Ref{Float32}(state.script_scale[] * scale) + return LayoutState(state.font_family, state.font_modifiers, state.tex_mode, script_level, script_scale) end function get_font(state::LayoutState, char_type) diff --git a/src/engine/opentype_mathtable.jl b/src/engine/opentype_mathtable.jl new file mode 100644 index 0000000..9fcb3e9 --- /dev/null +++ b/src/engine/opentype_mathtable.jl @@ -0,0 +1,200 @@ +# Utilities to read (parts of) the MATH table stored in OpenType Math fonts +# See +# https://learn.microsoft.com/en-us/typography/opentype/spec/math +# https://freetype.org/freetype2/docs/reference/ft2-truetype_tables.html +module OpenTypeMathTable + +import FreeTypeAbstraction: FreeType, FTFont, check_error +import FreeTypeAbstraction.FreeType: libfreetype, FT_Face, FT_Error, FT_ULong, FT_Long, + FT_UInt32, FT_Byte, FT_FWord, FT_UFWord + +const FT_TAG = FT_UInt32 + +function FT_MAKE_TAG(_x1, _x2, _x3, _x4) + return convert(FT_TAG, ( + ( convert( FT_TAG, _x1 ) << 24 ) | + ( convert( FT_TAG, _x2 ) << 16 ) | + ( convert( FT_TAG, _x3 ) << 8 ) | + convert( FT_TAG, _x4 ) + )) +end + +const TTAG_MATH = FT_MAKE_TAG('M','A','T','H') + +struct MathHeaderTable + major_version :: UInt16 + minor_version :: UInt16 + math_constants_offset :: UInt16 + math_glyph_info_offset :: UInt16 + math_variants_offset :: UInt16 +end + +function MathHeaderTable(buffer::IOBuffer) + seekstart(buffer) + major_version = read(buffer, UInt16) |> ntoh + minor_version = read(buffer, UInt16) |> ntoh + math_constants_offset = read(buffer, UInt16) |> ntoh + math_glyph_info_offset = read(buffer, UInt16) |> ntoh + math_math_variants_offset = read(buffer, UInt16) |> ntoh + return MathHeaderTable( + major_version, minor_version, math_constants_offset, math_glyph_info_offset, math_math_variants_offset + ) +end + +struct MathValueRecord + value :: FT_FWord + offset :: UInt16 +end + +struct MathTable + face :: FTFont + buffer :: IOBuffer + header :: MathHeaderTable + constants :: Dict{Symbol, Union{Int16, FT_UFWord, MathValueRecord}} +end + +function Base.show(io::IO, mtable::MathTable) + print(io, "MathTable (with constants $(length(mtable.constants)))") +end + +function MathTable(face::FTFont; throw_error::Bool=true) + buffer = _get_math_table_buffer(face; throw_error) + if isnothing(buffer) + return buffer + end + header = MathHeaderTable(buffer) + constants = _read_math_constants(buffer, header) + return MathTable(face, buffer, header, constants) +end + +function get_math_constant(::Nothing, symb, default, scaled=true) + return default +end +function get_math_constant(mtab::MathTable, symb, default, scaled=true) + v = get(mtab.constants, symb, nothing) + isnothing(v) && return default + if isa(v, MathValueRecord) + v = v.value + if scaled + v /= mtab.face.units_per_EM + end + end + return v +end + +function _get_math_table_buffer(face::FTFont; throw_error::Bool=true) + tag = TTAG_MATH + offset = 0 + length = Ref(zero(UInt64)) + buffer = Ptr{Cvoid}() + + ## first call, determine length + err = @lock face.lock ccall( + (:FT_Load_Sfnt_Table, libfreetype), + FT_Error, + (FT_Face, FT_TAG, FT_Long, Ptr{FT_Byte}, Ptr{FT_ULong},), + face, tag, offset, buffer, length + ) + if err != 0 + if throw_error + error("Could not load MATH table, error code = $(err).") + else + return nothing + end + end + + ## allocate memory for second call to actually load the table + n = Int(length[]) + buffer = Vector{FT_Byte}(undef, n) + err = @lock face.lock ccall( + (:FT_Load_Sfnt_Table, libfreetype), + FT_Error, + (FT_Face, FT_TAG, FT_Long, Ptr{FT_Byte}, Ptr{FT_ULong},), + face, tag, offset, buffer, length + ) + if err != 0 + if throw_error + error("Could not load MATH table, error code = $(err).") + else + return nothing + end + end + + return IOBuffer(buffer) +end + +function _read_math_constants(buffer::IOBuffer, header::MathHeaderTable) + constants = Dict{Symbol, Union{Int16, FT_UFWord, MathValueRecord}}() + + seek(buffer, header.math_constants_offset) + + constants[:scriptPercentScaleDown] = read(buffer, Int16) |> ntoh + constants[:scriptScriptPercentScaleDown] = read(buffer, Int16) |> ntoh + + constants[:delimitedSubFormulaMinHeight] = read(buffer, FT_UFWord) |> ntoh + constants[:displayOperatorMinHeight] = read(buffer, FT_UFWord) |> ntoh + + constants[:mathLeading] = _read_math_value_record(buffer) + constants[:axisHeight] = _read_math_value_record(buffer) + constants[:accentBaseHeight] = _read_math_value_record(buffer) + constants[:flattenedAccentBaseHeight] = _read_math_value_record(buffer) + constants[:subscriptShiftDown] = _read_math_value_record(buffer) + constants[:subscriptTopMax] = _read_math_value_record(buffer) + constants[:subscriptBaselineDropMin] = _read_math_value_record(buffer) + constants[:superscriptShiftUp] = _read_math_value_record(buffer) + constants[:superscriptShiftUpCramped] = _read_math_value_record(buffer) + constants[:superscriptBottomMin] = _read_math_value_record(buffer) + constants[:superscriptBaselineDropMax] = _read_math_value_record(buffer) + constants[:subSuperscriptGapMin] = _read_math_value_record(buffer) + constants[:superscriptBottomMaxWithSubscript] = _read_math_value_record(buffer) + constants[:spaceAfterScript] = _read_math_value_record(buffer) + constants[:upperLimitGapMin] = _read_math_value_record(buffer) + constants[:upperLimitBaselineRiseMin] = _read_math_value_record(buffer) + constants[:lowerLimitGapMin] = _read_math_value_record(buffer) + constants[:lowerLimitBaselineDropMin] = _read_math_value_record(buffer) + constants[:stackTopShiftUp] = _read_math_value_record(buffer) + constants[:stackTopDisplayStyleShiftUp] = _read_math_value_record(buffer) + constants[:stackBottomShiftDown] = _read_math_value_record(buffer) + constants[:stackBottomDisplayStyleShiftDown] = _read_math_value_record(buffer) + constants[:stackGapMin] = _read_math_value_record(buffer) + constants[:stackDisplayStyleGapMin] = _read_math_value_record(buffer) + constants[:stretchStackTopShiftUp] = _read_math_value_record(buffer) + constants[:stretchStackBottomShiftDown] = _read_math_value_record(buffer) + constants[:stretchStackGapAboveMin] = _read_math_value_record(buffer) + constants[:stretchStackGapBelowMin] = _read_math_value_record(buffer) + constants[:fractionNumeratorShiftUp] = _read_math_value_record(buffer) + constants[:fractionNumeratorDisplayStyleShiftUp] = _read_math_value_record(buffer) + constants[:fractionDenominatorShiftDown] = _read_math_value_record(buffer) + constants[:fractionDenominatorDisplayStyleShiftDown] = _read_math_value_record(buffer) + constants[:fractionNumeratorGapMin] = _read_math_value_record(buffer) + constants[:fractionNumDisplayStyleGapMin] = _read_math_value_record(buffer) + constants[:fractionRuleThickness] = _read_math_value_record(buffer) + constants[:fractionDenominatorGapMin] = _read_math_value_record(buffer) + constants[:fractionDenomDisplayStyleGapMin] = _read_math_value_record(buffer) + constants[:skewedFractionHorizontalGap] = _read_math_value_record(buffer) + constants[:skewedFractionVerticalGap] = _read_math_value_record(buffer) + constants[:overbarVerticalGap] = _read_math_value_record(buffer) + constants[:overbarRuleThickness] = _read_math_value_record(buffer) + constants[:overbarExtraAscender] = _read_math_value_record(buffer) + constants[:underbarVerticalGap] = _read_math_value_record(buffer) + constants[:underbarRuleThickness] = _read_math_value_record(buffer) + constants[:underbarExtraDescender] = _read_math_value_record(buffer) + constants[:radicalVerticalGap] = _read_math_value_record(buffer) + constants[:radicalDisplayStyleVerticalGap] = _read_math_value_record(buffer) + constants[:radicalRuleThickness] = _read_math_value_record(buffer) + constants[:radicalExtraAscender] = _read_math_value_record(buffer) + constants[:radicalKernBeforeDegree] = _read_math_value_record(buffer) + constants[:radicalKernAfterDegree] = _read_math_value_record(buffer) + + constants[:radicalDegreeBottomRaisePercent] = read(buffer, Int16) |> ntoh + + return constants +end + +function _read_math_value_record(buffer) + value = read(buffer, FT_FWord) |> ntoh + offset = read(buffer, UInt16) |> ntoh + return MathValueRecord(value, offset) +end + +end#module \ No newline at end of file diff --git a/src/parser/commands_registration.jl b/src/parser/commands_registration.jl index dea53cf..4c04aa8 100644 --- a/src/parser/commands_registration.jl +++ b/src/parser/commands_registration.jl @@ -65,6 +65,10 @@ const command_definitions = Dict( raw"\$" => (TeXExpr(:symbol, '$'), 0), raw"\#" => (TeXExpr(:symbol, '#'), 0), raw"\&" => (TeXExpr(:symbol, '&'), 0), + raw"\dprime" => (TeXExpr(:symbol, '″'), 0), + raw"\backdprime" => (TeXExpr(:symbol, '‶'), 0), + raw"\backtrprime" => (TeXExpr(:symbol, '‷'), 0), + raw"\trprime" => (TeXExpr(:symbol, '‴'), 0), raw"\fontfamily" => (TeXExpr(:fontfamily), 1), raw"\glyph" => (TeXExpr(:glyph), 2), raw"\unicode" => (TeXExpr(:unicode), 2), diff --git a/src/parser/parser.jl b/src/parser/parser.jl index ed13f3a..0adb59e 100644 --- a/src/parser/parser.jl +++ b/src/parser/parser.jl @@ -67,7 +67,12 @@ function push_down!(stack) top = TeXExpr(:space, 0.0) # Unroll group with single elements elseif length(top.args) == 1 - top = only(top.args) + _top = only(top.args) + ## don't unroll group if it contains something with sub- or superscripts; + ## someone might intentionally type `{x^y}^z` and unrolling would cause problems + if head(_top) != :decorated + top = _top + end end end push!(first(stack), top) @@ -142,6 +147,23 @@ function texparse(tex ; root = TeXExpr(:lines), showdebug = false) !isvalid(tex, pos) && continue try + ## before the actual parsing logic starts, make prime symbols active; + ## this way, they act as superscripts; + ## TODO do this for other unicode glyphs acting as sub- or superscripts? (e.g., '⁰': Unicode U+2070) + if token == char + c = tex[pos] + if c == '′' || c == '‵' + token = primes + len = 1 + elseif c == '″' || c == '‶' + token = primes + len = 2 + elseif c == '‴' || c == '‷' + token = primes + len = 3 + end + end + if token == dollar if head(first(stack)) == :inline_math inside_math = false From 989ba2977906f375a821e655a3cd13d2d2e8ce27 Mon Sep 17 00:00:00 2001 From: manuelbb-upb Date: Tue, 21 Oct 2025 15:35:42 +0200 Subject: [PATCH 2/2] decrease script space --- src/engine/layout.jl | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/engine/layout.jl b/src/engine/layout.jl index 6b6a61f..9df967e 100644 --- a/src/engine/layout.jl +++ b/src/engine/layout.jl @@ -137,31 +137,32 @@ function tex_layout(expr, state) end end end - - ## add post spaces in script boxes - script_space = get_math_constant(math_table, :spaceAfterScript, 1/24, true) - if state.script_level[] > 0 - ## TODO this is not standard - script_space * .6 - end - sub = sub == empty_elem ? sub : Group([sub, Space(script_space / sub_shrink)], [Point2f(0, 0), Point2f(hadvance(sub), 0)]) - super = super == empty_elem ? super : Group([super, Space(script_space / sup_shrink)], [Point2f(0, 0), Point2f(hadvance(super), 0)]) - super_y = u sub_y = -v - + ## X-Positions sup_delta = 0 # TODO proper kerning/italic correction usign OpenType data - sub_delta = 0 + sub_delta = 0 # min(0, -leftinkbound(sub) * sub_shrink) * 0.8f0 # with italic glyphs and positive left bearing, there is too much space #= old logic: `sub_delta = (1 - sub_shrink) * leftinkbound(sub)` # The logic is to have the ink of the subscript starts # where the ink of the unshrink glyph would =# - + super_x = max(hadvance(core), rightinkbound(core)) + sup_delta sub_x = hadvance(core) + sub_delta + + ## add post spaces in script boxes + script_space = get_math_constant(math_table, :spaceAfterScript, 1/24, true) + if state.nesting_state.level > 1 + ## TODO this is not standard + script_space *= .25f0 + end + + sub = sub == empty_elem ? sub : Group([sub, Space(script_space / sub_shrink)], [Point2f(0, 0), Point2f(hadvance(sub), 0)]) + super = super == empty_elem ? super : Group([super, Space(script_space / sup_shrink)], [Point2f(0, 0), Point2f(hadvance(super), 0)]) + return Group( [core, sub, super], Point2f[