4

The issue is that I have a font that has 9 different glyphs per characters, glyphs called e.g. a, a.2, … a.8 for the lowercase a; and the glyphs look similar per character and have the identical metrics. Those glyphs are rotated cyclically via the GSUB font table and there is a large kerning table for pairs for all glyphs. This works fine in most places today, including in LuaLaTeX with HarfBuzz renderer and in XeLaTeX, but it is not fully supported with LuaLaTeX with the default Node renderer, there kerning is only correct in part of the cases.

Since it looks like it would not be practical to fix this in the source codes, see this luaoftload github issue of mine from 2021, I was wondering if I could maybe find a workaround using Lua hooks, but have not found out so far if there would be such a hook.

Specifically, I know that kerning works fine if I reduce the font to just use one glyph per character, let me call that font "A". The idea would now be to use that font via fontspec and use a Lua hook late in the process, when "typesetting" has already happened (kerning, hyphenation, etc.), i.e. when it has been determined where each glyph will be placed on the output and then replace the glyphs with pseudo-randomly one from the full font "B", e.g. replace glyph a from font A with a.5 from font B.

Is there such a hook, or would anybody know of a different way achieving this in LuaLaTeX (with Node renderer)?

I guess, if not possible, it would in principle be possible to do this afterwards in the resulting pdf, and I could probably figure out how to do this (or ask elsewhere), but that would not be ideal, also because it would be two steps, e.g. not visible in TexShop while writing.

Added same day:

Here is a specific example.

The font with the GSUB rotation is Jackwrite.ttf and I have created a variant with just a single glyph per character, see links in the example. While I am at it, I am also showing an example with the opposite, a font Stoicheoin that has two chars per glyph, uppercase A and lowercase a for glyph A and also there I created a variant with one glyph (duplicated) for each char.

I have also added the output of the nodetree package for the Jackwrite case...

% !TEX TS-program = lualatex
\documentclass{article}

\usepackage{fontspec}
\usepackage{nodetree}

% https://jack-daw.com/fonts/stoicheion.zip => Stoicheion.ttf
\newfontfamily\stoicheion{Stoicheion.ttf}
% % https://jack-daw.com/fonts/StoicheionSingleCharPerGlyphDoNotDistribute.ttf
\newfontfamily\stoicheionsimple{StoicheionSingleCharPerGlyphDoNotDistribute.ttf}

% https://jack-daw.com/fonts/jackwrite.zip => Jackwrite.ttf
\newfontfamily\jackwrite{Jackwrite.ttf}
% https://jack-daw.com/fonts/JackwriteSingleGlyphPerCharDoNotDistribute.ttf
\newfontfamily\jackwritesimple{JackwriteSingleGlyphPerCharDoNotDistribute.ttf}

\begin{document}

\Large

\section*{\stoicheion{Full Featured Fonts Yes}}
\jackwrite{iiiiiiiii}

\section*{\stoicheionsimple{Full Featured Fonts No}}
\jackwritesimple{iiiiiiiii}

\end{document}

enter image description here

And here the output of fontree, first for the simpler font because there the result seems to be simpler (Jackwrite case, the 9 i's):

├─GLUE (baselineskip) wd 9.26pt
└─HLIST (line) wd 345pt, dp 0.33pt, ht 8.41pt
  ╚═head
    ├─LOCAL_PAR
    ├─GLYPH (glyph) 'i', font 30, wd 7.88pt, ht 8.41pt, dp 0.33pt
    ├─KERN (fontkern) -2.88pt
    ├─GLYPH (glyph) 'i', font 30, wd 7.88pt, ht 8.41pt, dp 0.33pt
    │ ╚═  props {['injections'] = {['leftkern'] = -188743.6}}
    ├─DISC (regular) penalty 50
    │ ╠═replace
    │ ║ └─KERN (fontkern) -2.88pt
    │ ╚═pre
    │   ├─KERN (fontkern) -1.44pt
    │   └─GLYPH (glyph) '-', font 30, wd 7.88pt, ht 3.61pt
    │       props {['preinjections'] = {['leftkern'] = -94371.8}}
    ├─GLYPH (glyph) 'i', font 30, wd 7.88pt, ht 8.41pt, dp 0.33pt
    │ ╚═  props {['emptyinjections'] = {['leftkern'] = -188743.6}}
    ├─DISC (regular) penalty 50
    │ ╠═replace
    │ ║ └─KERN (fontkern) -2.88pt
    │ ╚═pre
    │   ├─KERN (fontkern) -1.44pt
    │   └─GLYPH (glyph) '-', font 30, wd 7.88pt, ht 3.61pt
    │       props {['preinjections'] = {['leftkern'] = -94371.8}}
    ├─GLYPH (glyph) 'i', font 30, wd 7.88pt, ht 8.41pt, dp 0.33pt
    │ ╚═  props {['emptyinjections'] = {['leftkern'] = -188743.6}}
    ├─DISC (regular) penalty 50
    │ ╠═replace
    │ ║ └─KERN (fontkern) -2.88pt
    │ ╚═pre
    │   ├─KERN (fontkern) -1.44pt
    │   └─GLYPH (glyph) '-', font 30, wd 7.88pt, ht 3.61pt
    │       props {['preinjections'] = {['leftkern'] = -94371.8}}
    ├─GLYPH (glyph) 'i', font 30, wd 7.88pt, ht 8.41pt, dp 0.33pt
    │ ╚═  props {['emptyinjections'] = {['leftkern'] = -188743.6}}
    ├─DISC (regular) penalty 50
    │ ╠═replace
    │ ║ └─KERN (fontkern) -2.88pt
    │ ╚═pre
    │   ├─KERN (fontkern) -1.44pt
    │   └─GLYPH (glyph) '-', font 30, wd 7.88pt, ht 3.61pt
    │       props {['preinjections'] = {['leftkern'] = -94371.8}}
    ├─GLYPH (glyph) 'i', font 30, wd 7.88pt, ht 8.41pt, dp 0.33pt
    │ ╚═  props {['emptyinjections'] = {['leftkern'] = -188743.6}}
    ├─DISC (regular) penalty 50
    │ ╠═replace
    │ ║ └─KERN (fontkern) -2.88pt
    │ ╚═pre
    │   ├─KERN (fontkern) -1.44pt
    │   └─GLYPH (glyph) '-', font 30, wd 7.88pt, ht 3.61pt
    │       props {['preinjections'] = {['leftkern'] = -94371.8}}
    ├─GLYPH (glyph) 'i', font 30, wd 7.88pt, ht 8.41pt, dp 0.33pt
    │ ╚═  props {['emptyinjections'] = {['leftkern'] = -188743.6}}
    ├─KERN (fontkern) -2.88pt
    ├─GLYPH (glyph) 'i', font 30, wd 7.88pt, ht 8.41pt, dp 0.33pt
    │ ╚═  props {['injections'] = {['leftkern'] = -188743.6}}
    ├─KERN (fontkern) -2.88pt
    ├─GLYPH (glyph) 'i', font 30, wd 7.88pt, ht 8.41pt, dp 0.33pt
    │ ╚═  props {['injections'] = {['leftkern'] = -188743.6}}
    ├─PENALTY (linepenalty) 10000
    ├─GLUE (parfillskip) plus +1fil
    └─GLUE (rightskip) 

Looks like it would simply allow to hyphenate anywhere except between first and last pairs of "ii", which makes sense.

And here the output with the regular font with GSUB rotation (again Jackwrite case, the 9 i's):

├─GLUE (baselineskip) wd 9.27pt
└─HLIST (line) wd 345pt, dp 0.42pt, ht 8.42pt
  ╚═head
    ├─LOCAL_PAR
    ├─GLYPH (glyph) 'i', font 28, wd 7.88pt, ht 8.41pt, dp 0.33pt
    ├─KERN (fontkern) -2.88pt
    ├─GLYPH (glyph) '󰀋', font 28, wd 7.88pt, ht 8.32pt, dp 0.42pt
    │ ╚═  props {['injections'] = {['leftkern'] = -188743.6}}
    ├─KERN (fontkern) -2.88pt
    ├─GLYPH (glyph) '󰁧', font 28, wd 7.88pt, ht 8.37pt, dp 0.35pt
    │ ╚═  props {['injections'] = {['leftkern'] = -188743.6}}
    ├─KERN (fontkern) -2.88pt
    ├─GLYPH (glyph) '󰃃', font 28, wd 7.88pt, ht 8.32pt, dp 0.33pt
    │ ╚═  props {['injections'] = {['leftkern'] = -188743.6}}
    ├─KERN (fontkern) -2.88pt
    ├─GLYPH (glyph) '󰄟', font 28, wd 7.88pt, ht 8.41pt, dp 0.36pt
    │ ╚═  props {['injections'] = {['leftkern'] = -188743.6}}
    ├─KERN (fontkern) -2.88pt
    ├─GLYPH (glyph) '󰅻', font 28, wd 7.88pt, ht 8.32pt, dp 0.35pt
    │ ╚═  props {['injections'] = {['leftkern'] = -188743.6}}
    ├─DISC (regular) penalty 50
    │ ╠═replace
    │ ║ ├─KERN (fontkern) -2.88pt
    │ ║ ├─GLYPH (glyph) '󰇗', font 28, wd 7.88pt, ht 8.42pt, dp 0.39pt
    │ ║ │   props {['replaceinjections'] = {['leftkern'] = -188743.6}}
    │ ║ ├─GLYPH (glyph) 'i', font 28, wd 7.88pt, ht 8.41pt, dp 0.33pt
    │ ║ ├─KERN (fontkern) -2.88pt
    │ ║ └─GLYPH (glyph) '󰀋', font 28, wd 7.88pt, ht 8.32pt, dp 0.42pt
    │ ║     props {['injections'] = {['leftkern'] = -188743.6}}
    │ ╠═post
    │ ║ ├─GLYPH (glyph) 'i', font 28, wd 7.88pt, ht 8.41pt, dp 0.33pt
    │ ║ ├─KERN (fontkern) -2.88pt
    │ ║ ├─GLYPH (glyph) '󰀋', font 28, wd 7.88pt, ht 8.32pt, dp 0.42pt
    │ ║ │   props {['injections'] = {['leftkern'] = -188743.6}}
    │ ║ └─GLYPH (glyph) 'i', font 28, wd 7.88pt, ht 8.41pt, dp 0.33pt
    │ ╚═pre
    │   └─GLYPH (glyph) '󰁓', font 28, wd 7.88pt, ht 3.6pt
    ├─PENALTY (linepenalty) 10000
    ├─GLUE (parfillskip) plus +1fil
    └─GLUE (rightskip) 

I do not really understand what it does, but looks like I if I just used the simpler font and found out how to replace the GLYPH settings with the desired glyph (identify it how exactly?) and font 28 instead of 30 (get these numbers from where?), this could yield the desired result…

Added same day as asked:

Based on Max Cherkoff's answer I managed to make a quick demo that his answer would also work in my case. I am just replacing one of the rs in the example with one with a more prominent "typewriter" glitch, using for the demo just the numbers of the fonts and the glyphs, but would certainly also work along what Max did:

% !TEX TS-program = lualatex
\documentclass{article}

\usepackage{fontspec}
\usepackage{luacode}

\begin{luacode}

    local function recurse(head, indent)
        for n in node.traverse(head) do
            print(string.rep("-", indent) .. ">>")
            if n.id == node.id("glyph") then
                print(string.rep("-", indent) .. "char " .. n.char .. " of font " .. n.font)
                if n.char == 114 and n.font == 23 then
                  n.char = 983520
                  n.font = 22
                end
            elseif n.head or n.replace then
                if n.head then
                    n.head = recurse(n.head, indent+2)
                end
                if n.replace then
                    if n.pre then
                      n.pre     = recurse(n.pre, indent+2)
                    end
                    if n.post then
                      n.post    = recurse(n.post, indent+2)
                    end
                    n.replace = recurse(n.replace, indent+2)
                end
            end
            print(string.rep("-", indent) .. "<<")
        end
        return head
    end
    
    local function show_fonts_and_chars(head)
      print()
      print("show fonts and chars...")
      return recurse(head, 0)
    end

   luatexbase.add_to_callback("post_linebreak_filter", show_fonts_and_chars, "demo")
    
\end{luacode}

% https://jack-daw.com/fonts/jackwrite.zip => Jackwrite.ttf
\newfontfamily\jackwrite{Jackwrite.ttf}
% https://jack-daw.com/fonts/JackwriteSingleGlyphPerCharDoNotDistribute.ttf
\newfontfamily\jackwritesimple{JackwriteSingleGlyphPerCharDoNotDistribute.ttf}

\begin{document}

\Huge

\jackwrite{iiiiiiiiii rrrrrrrrrr}

\jackwritesimple{iiiiiiiiii rrrrrrrrrr}

\end{document}

Output:

enter image description here

3
  • you can manipulate the node list with callbacks, probably. (that is the term I would search for.) you need to look at luaotfload's interface and that latex interface to base luatex, as some things doable in e.g. plain are blocked in latex. but you will more likely get help if you post a minimal example, preferably one which doesn't require installing anything. (but if you can't setup without the special font, so be it.) Commented 15 hours ago
  • @cfr: I have added an example, plus nodetree output, looks like might be possible that way, even if I don't know, yet, how to do it in full detail (how to modify the node tree, and with which values for glyphs and fonts). Commented 13 hours ago
  • AI helped, even though it invented a post_par_filter, while it seems to be post_linebreak_filter , but seems to have gotten the basic code structure otherwise right and helpful, will see... Commented 13 hours ago

2 Answers 2

3

If you want to use a callback and manually traverse the node list, then pre_shaping_filter (or possibly post_shaping_filter) is the callback that you're looking for.

%%%%%%%%%%%%%%%%%%%%%%
%%% Implementation %%%
%%%%%%%%%%%%%%%%%%%%%%

\documentclass{article}

\usepackage{luacode}
\begin{luacode*}
    -- Helper function to replace a node in a list with another node
    local function replace_node(head, old, new)
        local head, current = node.remove(head, old)
        if current then
            head, new = node.insert_before(head, current, new)
        else
            head, new = node.insert_after(head, node.slide(head), new)
        end
        return head, new
    end

    -- Helper function to memoize another function
    local function memoized(func)
        return setmetatable({}, { __index = function(cache, key)
            local ret = func(key, cache)
            cache[key] = ret
            return ret
        end })
    end

    -- Read and parse a font file
    local _define_font = memoized(function(name_size)
        local name, size = unpack(name_size:split(":"))
        local tfmdata = fonts.definers.read {
            lookup = "file",
            name = name,
            size = tonumber(size),
            features = {},
        }
        return font.define(tfmdata)
    end)

    local function define_font(name, size)
        return _define_font[name .. ":" .. tostring(size)]
    end

    -- Get a fonts name from its id
    local font_attributes = memoized(function(id)
        local tfmdata = font.getfont(id)
        return {
            (tfmdata.fullname or tfmdata.name or "unknown"):lower():split(".")[1],
            tfmdata.size,
            tfmdata.specification.features.raw,
        }
    end)

    -- The characters to substitute
    local code = utf8.codepoint

    local substitutions = {
        ["lmroman10-regular"] = {
            [code "a"]       = { "texgyreadventor-bold",       code "A" },
            [code "e"]       = { "texgyreadventor-bold",       code "E" },
            [code "i"]       = { "texgyreadventor-bold",       code "I" },
            [code "o"]       = { "texgyreadventor-bold",       code "O" },
            [code "u"]       = { "texgyreadventor-bold",       code "U" },
            [code "y"]       = { "texgyreadventor-bold",       code "Y" },
            [code "A"]       = { "texgyreadventor-bold",       code "a" },
            [code "E"]       = { "texgyreadventor-bold",       code "e" },
            [code "I"]       = { "texgyreadventor-bold",       code "i" },
            [code "O"]       = { "texgyreadventor-bold",       code "o" },
            [code "U"]       = { "texgyreadventor-bold",       code "u" },
            [code "Y"]       = { "texgyreadventor-bold",       code "y" },
            [code "h"]       = { "texgyrechorus-mediumitalic", code "H" },
            [code "l"]       = { "texgyrechorus-mediumitalic", code "L" },
            [code "m"]       = { "texgyrechorus-mediumitalic", code "M" },
            [code "n"]       = { "texgyrechorus-mediumitalic", code "N" },
            [code "r"]       = { "texgyrechorus-mediumitalic", code "R" },
            [code "H"]       = { "texgyrechorus-mediumitalic", code "h" },
            [code "L"]       = { "texgyrechorus-mediumitalic", code "l" },
            [code "M"]       = { "texgyrechorus-mediumitalic", code "m" },
            [code "N"]       = { "texgyrechorus-mediumitalic", code "n" },
            [code "R"]       = { "texgyrechorus-mediumitalic", code "r" },
        }
    }

    -- Define the callback function for character substitution
    luatexbase.add_to_callback("pre_shaping_filter", function(head)
        for n, char, font in node.traverse_glyph(head) do
            local font_name, font_size, font_features = unpack(font_attributes[font])

            local font_substitutions = substitutions[font_name]
            if not font_substitutions then goto continue end

            local substitution = font_substitutions[char]
            if not substitution then goto continue end

            if not font_features["example-substitution"] then goto continue end

            local substitute_font_name, substitute_char = unpack(substitution)
            local substitute_font = define_font(substitute_font_name, font_size)
            local glyph = node.copy(n)
            glyph.char = substitute_char
            glyph.font = substitute_font
            head = replace_node(head, n, glyph)

            ::continue::
        end
        return head
    end, "example-substitution")
\end{luacode*}


%%%%%%%%%%%%%%%%%%%%%
%%% Demonstration %%%
%%%%%%%%%%%%%%%%%%%%%

\usepackage{fontspec}
\setmainfont{Latin Modern Roman}

\pagestyle{empty}
\begin{document}
    The quick brown fox jumps over the lazy dog.

    {
        \addfontfeatures{RawFeature=+example-substitution}
        The quick brown fox jumps over the lazy dog.
    }
\end{document}

output

If you specifically want to only change the characters just as TeX is shipping out the page, after all the typesetting is finished, you can use pre_shipout_filter:

%%%%%%%%%%%%%%%%%%%%%%
%%% Implementation %%%
%%%%%%%%%%%%%%%%%%%%%%

\documentclass{article}

\usepackage{luacode}
\begin{luacode*}
    local TARGET_EX_HEIGHT = 0.430554 -- Based off of LM

    -- Helper function to replace a node in a list with another node
    local function replace_node(head, old, new)
        local head, current = node.remove(head, old)
        if current then
            head, new = node.insert_before(head, current, new)
        else
            head, new = node.insert_after(head, node.slide(head), new)
        end
        return head, new
    end

    -- Helper function to memoize another function
    local function memoized(func)
        return setmetatable({}, { __index = function(cache, key)
            local ret = func(key, cache)
            cache[key] = ret
            return ret
        end })
    end

    -- Read and parse a font file
    local _define_font = memoized(function(name_size)
        -- Get the parameters
        local name, size = unpack(name_size:split(":"))
        size = tonumber(size)
        local specification = {
            lookup = "file",
            name = name,
            size = tonumber(size),
            features = {},
        }

        -- Load the font data
        local tfmdata = fonts.definers.read(specification)

        -- Rescale by the ex-height
        local ex_height = tfmdata.parameters.x_height
        size = size^2 * TARGET_EX_HEIGHT / ex_height

        -- Reload the font with the new size
        specification.size = size
        tfmdata = fonts.definers.read(specification)

        return font.define(tfmdata)
    end)

    local function define_font(name, size)
        return _define_font[name .. ":" .. tostring(size)]
    end

    -- Get a fonts name from its id
    local font_attributes = memoized(function(id)
        local tfmdata = font.getfont(id)
        if not tfmdata then
            return { 0, {} }
        end
        return {
            tfmdata.size,
            ((tfmdata.specification or {}).features or {}).raw or {},
        }
    end)

    -- The fonts to use
    local font_names = {
        "texgyreadventor-bold",
        "texgyreadventor-bolditalic",
        "texgyreadventor-italic",
        "texgyreadventor-regular",
        "texgyrebonum-bold",
        "texgyrebonum-bolditalic",
        "texgyrebonum-italic",
        "texgyrebonum-regular",
        "texgyrechorus-mediumitalic",
        "texgyrecursor-bold",
        "texgyrecursor-bolditalic",
        "texgyrecursor-italic",
        "texgyrecursor-regular",
        "texgyreheros-bold",
        "texgyreheros-bolditalic",
        "texgyreheros-italic",
        "texgyreheros-regular",
        "texgyreheroscn-bold",
        "texgyreheroscn-bolditalic",
        "texgyreheroscn-italic",
        "texgyreheroscn-regular",
        "texgyrepagella-bold",
        "texgyrepagella-bolditalic",
        "texgyrepagella-italic",
        "texgyrepagella-regular",
        "texgyreschola-bold",
        "texgyreschola-bolditalic",
        "texgyreschola-italic",
        "texgyreschola-regular",
        "texgyretermes-bold",
        "texgyretermes-bolditalic",
        "texgyretermes-italic",
        "texgyretermes-regular",
    }

    math.randomseed(0)
    local function random_font()
        local name = font_names[math.random(#font_names)]
        return name
    end

    -- Define the hss node
    local hss = node.new("glue")
    hss.width = 0
    hss.stretch = 1
    hss.stretch_order = 2
    hss.shrink = 1
    hss.shrink_order = 2

    -- Define the callback function for character substitution
    local function recurse(head)
        for n in node.traverse(head) do
            if n.id == node.id("glyph") then
                -- Get the character and font
                local char, font = n.char, n.font
                local font_size, font_features = unpack(font_attributes[font])

                if not font_features["example-substitution"] then
                    goto continue
                end

                -- Create the substitute glyph
                local substitute_font = define_font(random_font(), font_size)
                local glyph = node.copy(n)
                glyph.font = substitute_font

                -- Box the glyph and adjust its dimensions
                glyph.next = node.copy(hss)
                local hbox = node.hpack(glyph)
                hbox.width = n.width
                hbox.height = n.height
                hbox.depth = n.depth

                -- Replace the node
                head, n = replace_node(head, n, hbox)
            elseif n.head or n.replace then
                if n.head then
                    n.head = recurse(n.head)
                end
                if n.replace then
                    n.pre     = recurse(n.pre)
                    n.post    = recurse(n.post)
                    n.replace = recurse(n.replace)
                end
            end
            ::continue::
        end
        return head
    end

    luatexbase.add_to_callback(
        "pre_output_filter", recurse, "example-substitution"
    )
\end{luacode*}


%%%%%%%%%%%%%%%%%%%%%
%%% Demonstration %%%
%%%%%%%%%%%%%%%%%%%%%

\usepackage{fontspec}
\setmainfont{Latin Modern Roman}

\usepackage[english]{babel}
\usepackage{blindtext}

\usepackage[landscape, letterpaper, margin=1cm]{geometry}
\usepackage{multicol}
\usepackage{microtype}
\pagestyle{empty}

\hyphenpenalty=-2000
\raggedcolumns

\begin{document}
    The quick brown fox jumps over the lazy dog.

    {
        \addfontfeatures{RawFeature=+example-substitution}
        The quick brown fox jumps over the lazy dog.
    }

    \clearpage

    \begin{multicols}{6}
        \Blindtext[4]
        \columnbreak

        \addfontfeatures{RawFeature={+example-substitution}}
        \Blindtext[4]
    \end{multicols}
\end{document}

output

output

8
  • @@Max Chernoff: In the approach I sketched, the replacement would have to happen after the paragraph is rendered incl. hyphenation and kerning (and ligaturing, but there are no ligatures in the fonts I used in the example), which I guess would be the post_linebreak_filter, but I also had a different idea, which would not require a simplified font, just the original one, see next comment… Commented 10 hours ago
  • @@Max Chernoff: If the main issue is that the node renderer gets hyphentation and kerning wrong for glyph pairs like i.2/i.3, maybe I can use just the original font and implement hyphenate and kerning by first in each callback transforming the pair to i/i, then calling the default functions, I guess lang.hyphenate() and node.kerning(), and then setting back the pair to i.2/i.3. Commented 10 hours ago
  • @AlainStalder Ok, see the edit Commented 9 hours ago
  • The examples are very valuable also because I could not really find the implementations, yet (looked in luaoftload and luatex [edit: no, not in luaotfload yet]), would be good to know where that is, because seems otherwise not be much documented except examples at tex.stackexchange. With the recursion I finally got to the unicode numbers in Jackwrite: The glyph i is 105 as usual, i.2 is 983051 and then up in steps of 92. Will try again with the kerning callback, seems like I might have to recurse down to glyph there, too, for each node pair. Commented 7 hours ago
  • Interesting (and rather unexpected): At kerning and hyphenate callbacks it is still all 105 (i), but in the post_linebreak or pre_output_filter callbacks are already the large numbers. I wonder how/why it fails then, because seems to get at least the kerning wrong, but maybe that is a wrong assumption from the apparent output. Commented 7 hours ago
1

The best solution is to use the built-in font code for this. If you just want to swap characters within a single font, you can define a new substitution feature:

%%%%%%%%%%%%%%%%%%%%%%
%%% Implementation %%%
%%%%%%%%%%%%%%%%%%%%%%

\documentclass{article}

\usepackage{luacode}
\begin{luacode*}
    fonts.handlers.otf.addfeature {
        name = "example-substitution",
        type = "substitution",
        data = {
            ["a"] = "Ä",
            ["e"] = "Ë",
            ["i"] = "Ï",
            ["o"] = "Ö",
            ["u"] = "Ü",
            ["y"] = "Ÿ",
        }
    }
\end{luacode*}


%%%%%%%%%%%%%%%%%%%%%
%%% Demonstration %%%
%%%%%%%%%%%%%%%%%%%%%

\usepackage{fontspec}
\setmainfont{Latin Modern Roman}

\pagestyle{empty}
\begin{document}
    The quick brown fox jumps over the lazy dog.

    {
        \addfontfeatures{RawFeature=+example-substitution}
        The quick brown fox jumps over the lazy dog.
    }
\end{document}

output

If you need to swap characters between fonts, you can instead define a new virtual font that selects characters from the desired “real” fonts:

%%%%%%%%%%%%%%%%%%%%%%
%%% Implementation %%%
%%%%%%%%%%%%%%%%%%%%%%

\documentclass{article}

\begin{filecontents}[overwrite, noheader]{\jobname.lua}
    -- Read and parse a font file
    local function get_font(read_data, size)
        local tfmdata = fonts.definers.read {
            lookup = "file",
            name = read_data.file,
            size = read_data.scale * size,
            features = {},
        }
        local font_id = font.define(tfmdata)
        return { id = font_id, tfmdata = tfmdata }
    end

    -- Process the characters for the virtual font
    local function process_virtual_characters(subfont_data, chars_by_font)
        local default_font = subfont_data[chars_by_font.default]
        local default_subfont_index = subfont_data[chars_by_font.default].subfont_index

        -- Copy everything from the default font
        local characters = {}
        for char_index, char_data in pairs(default_font.tfmdata.characters) do
            local char_data = table.copy(char_data)
            char_data.commands = {
                { "slot", default_font.subfont_index, char_index },
            }
            characters[char_index] = char_data
        end

        -- Override with characters from other fonts
        for char, font_name in pairs(chars_by_font) do
            if char == "default" then
                goto continue
            end

            local codepoint = utf8.codepoint(char)
            local char_data = subfont_data[font_name].tfmdata.characters[codepoint]
            if not char_data then
                luatexbase.module_warning(
                    "example",
                    ('Font "%s" does not have character "%s" (U+%04X)'):format(font_name, char, codepoint)
                )
                goto continue
            end

            char_data = table.copy(char_data)
            char_data.commands = {
                { "slot", subfont_data[font_name].subfont_index, codepoint },
            }
            characters[codepoint] = char_data

            ::continue::
        end

        return characters
    end

    -- Define the virtual font
    local function define_virtual_font(base_fonts, chars_by_font, size)
        local virtual_subfonts = {}
        local subfont_data = {}
        for name, data in pairs(base_fonts) do
            local base_font = get_font(data, size)
            virtual_subfonts[#virtual_subfonts+1] = {
                id = base_font.id,
            }
            subfont_data[name] = {
                tfmdata = base_font.tfmdata,
                subfont_index = #virtual_subfonts,
            }
        end

        local default_font = subfont_data[chars_by_font.default].tfmdata

        return {
            name = "example-substitution",
            type = "virtual",
            fonts = virtual_subfonts,
            characters = process_virtual_characters(
                subfont_data, chars_by_font
                ),
            parameters = default_font.parameters,
            properties = default_font.properties,
        }
    end

    -- Get a global Lua table from a font feature string
    local function get_global_from_feature(specification, feature_name)
        local feature_value = specification.features.raw[feature_name]
        if not feature_value then
            luatexbase.module_error(
                "example",
                ('Feature "%s" is required but not provided'):format(feature_name)
            )
        end
        local parts = feature_value:split(".")
        local current = _G
        for _, part in ipairs(parts) do
            current = current[part]
            if not current then
                luatexbase.module_error(
                    "example",
                    ('Could not find global "%s" for feature "%s"'):format(feature_value, feature_name)
                )
            end
        end
        return current
    end

    -- The font loader function
    return function(specification)
        if not specification.features.raw.language then
            -- If no language is specified, then luaotfload is just testing
            -- to see if the font file exists. We'll just return a dummy font
            -- here.
            return {
                parameters = {},
                properties = {},
                name = "fake-font",
                characters = {{}},
            }
        end
        local character_table = get_global_from_feature(
            specification, "character-table"
        )
        local base_fonts = get_global_from_feature(
            specification, "base-fonts"
        )
        return define_virtual_font(
            base_fonts, character_table, specification.size
        )
    end
\end{filecontents}


%%%%%%%%%%%%%%%%%%%%%
%%% Demonstration %%%
%%%%%%%%%%%%%%%%%%%%%

\usepackage{fontspec}
\setmainfont{Latin Modern Roman}

\usepackage{luacode}
\begin{luacode*}
    example = {
        base_fonts = {
            latinmodern = { file = "lmroman10-regular",          scale = 1.00 },
            adventor    = { file = "texgyreadventor-bold",       scale = 0.80 },
            chorus      = { file = "texgyrechorus-mediumitalic", scale = 1.10 },
        },
        chars_by_font = {
            ["default"] = "latinmodern",
            ["a"]       = "adventor",
            ["e"]       = "adventor",
            ["i"]       = "adventor",
            ["o"]       = "adventor",
            ["u"]       = "adventor",
            ["y"]       = "adventor",
            ["A"]       = "adventor",
            ["E"]       = "adventor",
            ["I"]       = "adventor",
            ["O"]       = "adventor",
            ["U"]       = "adventor",
            ["Y"]       = "adventor",
            ["h"]       = "chorus",
            ["l"]       = "chorus",
            ["m"]       = "chorus",
            ["n"]       = "chorus",
            ["r"]       = "chorus",
            ["H"]       = "chorus",
            ["L"]       = "chorus",
            ["M"]       = "chorus",
            ["N"]       = "chorus",
            ["R"]       = "chorus",
        },
    }
\end{luacode*}

\newfontface\ExampleFont{\jobname.lua}[
    RawFeature={
        character-table=example.chars_by_font,
        base-fonts=example.base_fonts,
    },
]

\pagestyle{empty}
\begin{document}
    The quick brown fox jumps over the lazy dog.

    {\ExampleFont The quick brown fox jumps over the lazy dog.}
\end{document}

output

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.