Skip to main content

retex_lua/
luaotfload.rs

1//! A native replacement for luaotfload.
2//!
3//! The LaTeX kernel loads luaotfload in `\everyjob` with
4//! `require('luaotfload-main')` and `luaotfload.main()`. Here that module is
5//! provided natively: `main()` makes the same `luatexbase` registrations as
6//! luaotfload (so logs show the same lines), and the registered functions
7//! are implemented in Rust on top of `retex-font`:
8//!
9//! * `luaotfload.define_font` loads OpenType fonts with luaotfload's
10//!   request syntax and metrics,
11//! * `luaotfload.node_processor` shapes glyph runs (ligatures, kerning) in
12//!   node mode.
13//!
14//! The registration sequence and messages were taken from the log of a
15//! lualatex run.
16
17use std::cell::RefCell;
18use std::collections::HashMap;
19use std::rc::Rc;
20use std::sync::Arc;
21
22use mlua::{Lua, Value};
23use retex_engine::Engine;
24use retex_engine::fonts::{CharMetrics, FontData, FontInfo, FontKind};
25use retex_engine::nodes::NodeRef;
26use retex_font::LoadedFont;
27
28use crate::with_engine;
29
30/// Host-side state of the font loader.
31#[derive(Default)]
32pub struct FontState {
33    loader: Option<retex_font::FontLoader>,
34    /// Engine font id of each loaded font (by `LoadedFont` identity, and
35    /// whether the request was `scaled`): luaotfload returns the id of a
36    /// font already loaded for an equivalent request (e.g. `Latin Modern
37    /// Math/B` when there is no bold) instead of a new font. Observed: a
38    /// `scaled` request (a computed size) and an `at` request for the same
39    /// size give two fonts.
40    ids: HashMap<(usize, bool), u32>,
41    /// Engine font id of each TeX metric file request (as written, and
42    /// its size or negated scale): luaotfload returns the font of an
43    /// identical request (observed: `cmr10` and `cmr10 scaled 1000` are
44    /// one font, `cmr10 at 10pt` and `file:cmr10` others).
45    tfm_ids: HashMap<(String, i32), u32>,
46    /// Whether the installed luaotfload is older than 3.29 (read once
47    /// from `luaotfload.sty`): those versions give fonts their design
48    /// size (so `\fontname` has no `at` clause at that size), 3.29 gives
49    /// them 0 (observed with 3.28 and 3.29).
50    legacy_design_size: Option<bool>,
51    /// Whether the log tells that the font name database was loaded.
52    db_announced: bool,
53    /// Whether a font was looked up by name (luaotfload then loads its
54    /// lookup cache, if there is one, and says so in the log).
55    lookup_announced: bool,
56}
57
58/// The Lua glue: the module table and `main`, which performs luaotfload's
59/// registrations through `luatexbase` in the order lualatex shows them.
60const MAIN_LUA: &str = r#"
61local native = ...
62local lb = luatexbase
63local function noop(...) return ... end
64local function log(cat, msg)
65  texio.write_nl("log", "luaotfload | " .. cat .. " : " .. msg)
66end
67luaotfload = luaotfload or {}
68luaotfload.define_font = native.define_font
69luaotfload.node_processor = native.node_processor
70luaotfload.version = luaotfload.version or "3.29"
71-- Letterspacing options (microtype replaces keepligature).
72luaotfload.letterspace = luaotfload.letterspace or
73  { keepligature = true, keeptogether = false, keepwordspacing = false }
74luaotfload.features = luaotfload.features or { defaults = {}, global = {} }
75local aux = luaotfload.aux or {}
76luaotfload.aux = aux
77for _, k in ipairs{"provides_script", "provides_language", "provides_feature",
78    "get_math_dimension", "font_has_glyph", "slot_of_name", "gid_of_name"} do
79  aux[k] = native["aux_" .. k]
80end
81aux.sprint_math_dimension = function(f, name)
82  tex.sprint(-2, tostring(aux.get_math_dimension(f, name)))
83end
84-- The parts of the fontloader's `fonts` table that packages touch.
85fonts = fonts or {}
86fonts.names = fonts.names or {}
87fonts.names.set_location_precedence = fonts.names.set_location_precedence or function() end
88fonts.hashes = fonts.hashes or {}
89fonts.hashes.identifiers = fonts.hashes.identifiers or setmetatable({}, {
90  __index = function(_, id) if type(id) == "number" then return font.getfont(id) end end })
91-- The fontloader's parameters (unrounded) and characters of a font, by
92-- id, kept once asked for; nil for fonts it did not load (polyglossia's
93-- punctuation spacing reads the parameters).
94local function font_hash(get)
95  return setmetatable({}, { __index = function(t, id)
96    if type(id) ~= "number" then return nil end
97    local v = get(id)
98    if v ~= nil then rawset(t, id, v) end
99    return v
100  end })
101end
102fonts.hashes.parameters = fonts.hashes.parameters or font_hash(native.font_parameters)
103fonts.hashes.characters = fonts.hashes.characters or font_hash(function(id)
104  if native.font_parameters(id) then return font.getfont(id).characters end
105end)
106fonts.hashes.quads = fonts.hashes.quads or {}
107local initialized = false
108luaotfload.main = function()
109  if initialized then
110    log("load", "Luaotfload initialization requested but is already loaded, ignoring.")
111    return
112  end
113  initialized = true
114  lb.provides_module({ name = "luaotfload", date = "2024-12-03", version = "3.29",
115    description = "Lua based OpenType font support" })
116  lb.provides_module({ name = "lualibs", date = "2023-07-13", version = "2.76",
117    description = "ConTeXt Lua standard libraries." })
118  lb.provides_module({ name = "lualibs-extended", date = "2023-07-13", version = "2.76",
119    description = "ConTeXt Lua libraries -- extended collection." })
120  -- lualibs' global `utf` (luamml uses it).
121  if not utf then utf = native.lualibs_utf() end
122  -- lualibs' `table.copy(t)`: a deep copy, keys included, that keeps
123  -- shared and cyclic tables shared and the metatables (luapstricks).
124  if not table.copy then
125    local function copy(t, tables)
126      tables = tables or {}
127      local c = {}
128      if not tables[t] then tables[t] = c end
129      for k, v in next, t do
130        if type(k) == "table" then k = tables[k] or copy(k, tables) end
131        if type(v) == "table" then v = tables[v] or copy(v, tables) end
132        c[k] = v
133      end
134      local mt = getmetatable(t)
135      if mt then setmetatable(c, mt) end
136      return c
137    end
138    table.copy = copy
139  end
140  -- The fontloader's definer, which luapstricks loads its fonts with: a
141  -- font defined here is registered already, so `read` gives its id.
142  fonts.definers = fonts.definers or {}
143  fonts.definers.read = fonts.definers.read or function(name, size)
144    return native.define_font(name, size)
145  end
146  fonts.definers.register = fonts.definers.register or function(_, id) return id end
147  local cache = native.cache_dir()
148  log("conf", 'Root cache directory is "' .. cache .. '".')
149  -- As luaotfload does before loading the fontloader, which wants the
150  -- attribute with index zero to be zero.
151  tex.attribute[0] = 0
152  -- luaotfload's set-up holds six Lua references (observed in the
153  -- numbers box displays show for Lua data).
154  native.reserve_lua_refs(6)
155  local loader = kpse.find_file("fontloader-2023-12-28.lua", "lua") or "fontloader-2023-12-28.lua"
156  log("init", 'Loading fontloader "fontloader-2023-12-28.lua" from kpse-resolved path "' .. loader .. '".')
157  -- As the fontloader does: node properties are flushed with their
158  -- nodes and inherited by copies, for good.
159  node.direct.set_properties_mode(true, true)
160  function node.direct.set_properties_mode() end
161  lb.new_attribute("luaotfload@noligature")
162  log("init", "Context OpenType loader version 3.134")
163  -- luaotfload's node processor runs the pre_shaping_filter and
164  -- post_shaping_filter callbacks around the shaping. They are only
165  -- called once something is added to them.
166  if not lb.callbacktypes.pre_shaping_filter then
167    lb.create_callback("pre_shaping_filter", "list")
168    lb.create_callback("post_shaping_filter", "reverselist")
169  end
170  -- Letterspacing (`kernfactor=f` or `letterspace=p`, p = 100 f, as
171  -- microtype's \textls asks for): after shaping, a glyph of such a font
172  -- that follows a glyph of one gets the font's quad times f before it.
173  -- Observed with luaotfload 3.29: an adjacent glyph or whatsit gets a
174  -- new font kern; a kern (not an italic correction) is enlarged; glue of
175  -- positive width too, its stretch and shrink in proportion (not with
176  -- `keepwordspacing`); penalties, rules, boxes and math get nothing.
177  -- Discretionaries get the kerns inside their lists: the pre-break text
178  -- after the glyph before, the post-break text before the glyph after,
179  -- the replacement on both sides. Ligatures are split into their
180  -- characters unless `keepligature` (true or a function of the direct
181  -- node) keeps them. Processed glyphs get the attribute
182  -- `luaotfload.letterspace_done`, so that a list packed again is not
183  -- spaced twice.
184  local letterspaced = {}
185  local any_letterspaced = false
186  local GLYPH_ID, KERN_ID, GLUE_ID, DISC_ID, WHATSIT_ID =
187    node.id("glyph"), node.id("kern"), node.id("glue"), node.id("disc"), node.id("whatsit")
188  local ITALIC = 3
189  local ls_done
190  local function ls_kern(amount, model)
191    local k = node.new(KERN_ID, 0)
192    k.kern = amount
193    k.attr = model.attr
194    return k
195  end
196  local function keep_ligature(n)
197    local keep = luaotfload.letterspace.keepligature
198    if type(keep) == "function" then
199      return keep(node.direct.todirect(n))
200    end
201    return keep
202  end
203  -- The characters of ligature `n`, ligatures among them split too.
204  local function split_ligature(n)
205    local first, last
206    local c = n.components
207    while c do
208      local nxt = c.next
209      local parts = c.components and split_ligature(c) or node.copy(c)
210      if c.components then
211        parts.prev = nil
212      else
213        parts.next = nil
214        parts.prev = nil
215      end
216      if last then
217        last.next = parts
218        parts.prev = last
219      else
220        first = parts
221      end
222      last = node.tail(parts)
223      c = nxt
224    end
225    return first
226  end
227  local letterspace_list
228  -- Adds the space before the glyph `after` that follows the list `head`
229  -- (a discretionary's): the list's last node is to it what a glyph's
230  -- predecessor is.
231  local function space_after(head, after)
232    local amount = letterspaced[after.font]
233    local t = node.tail(head)
234    local id = t.id
235    if id == KERN_ID and t.subtype ~= ITALIC then
236      t.kern = t.kern + amount
237    elseif id == GLUE_ID then
238      local w = t.width
239      if w > 0 and not luaotfload.letterspace.keepwordspacing then
240        local nw = w + amount
241        t.stretch = math.floor(t.stretch * nw / w + 0.5)
242        t.shrink = math.floor(t.shrink * nw / w + 0.5)
243        t.width = nw
244      end
245    elseif id == GLYPH_ID or id == WHATSIT_ID then
246      node.insert_after(head, t, ls_kern(amount, after))
247    end
248  end
249  -- Spaces the glyph `n` after `last` (the glyph before it, if any).
250  local function space_glyph(head, n, last)
251    local amount = letterspaced[n.font]
252    if not amount or not last or not letterspaced[last.font]
253        or node.has_attribute(n, ls_done) then
254      return head
255    end
256    local p = n.prev
257    local id = p and p.id
258    -- The first glyph of a discretionary's list follows `last` outside.
259    if not p or id == GLYPH_ID or id == WHATSIT_ID then
260      head = node.insert_before(head, n, ls_kern(amount, n))
261    elseif id == KERN_ID and p.subtype ~= ITALIC then
262      p.kern = p.kern + amount
263    elseif id == GLUE_ID then
264      local w = p.width
265      if w <= 0 or luaotfload.letterspace.keepwordspacing then return head end
266      local nw = w + amount
267      p.stretch = math.floor(p.stretch * nw / w + 0.5)
268      p.shrink = math.floor(p.shrink * nw / w + 0.5)
269      p.width = nw
270    else
271      return head
272    end
273    node.set_attribute(n, ls_done, 1)
274    return head
275  end
276  -- Letterspaces the list `head` whose glyph before is `last`; returns
277  -- the new head and the last glyph.
278  letterspace_list = function(head, last)
279    local n = head
280    while n do
281      local id = n.id
282      if id == GLYPH_ID then
283        if n.components and letterspaced[n.font] and not keep_ligature(n) then
284          local parts = split_ligature(n)
285          local first = parts
286          head = node.insert_before(head, n, parts)
287          head = node.remove(head, n)
288          node.free(n)
289          n = first
290        end
291        head = space_glyph(head, n, last)
292        last = n
293      elseif id == DISC_ID then
294        -- A discretionary before any glyph is left alone (observed).
295        if last and not node.has_attribute(n, ls_done) then
296          local nxt = n.next
297          local after = nxt and nxt.id == GLYPH_ID and letterspaced[nxt.font] and nxt
298          local pre, post, replace = n.pre, n.post, n.replace
299          local tail
300          pre = letterspace_list(pre, last)
301          post, tail = letterspace_list(post, nil)
302          if post and tail and after and letterspaced[tail.font] then
303            space_after(post, after)
304          end
305          if replace then
306            replace, tail = letterspace_list(replace, last)
307            if tail and after and letterspaced[tail.font] then
308              space_after(replace, after)
309            end
310          elseif after and letterspaced[last.font] then
311            replace = ls_kern(letterspaced[after.font], after)
312          end
313          n.pre, n.post, n.replace = pre, post, replace
314          node.set_attribute(n, ls_done, 1)
315        end
316      end
317      n = n.next
318    end
319    return head, last
320  end
321  local native_shape = native.node_processor
322  local function shape(head, groupcode)
323    local result = native_shape(head, groupcode)
324    if not any_letterspaced or result == false then
325      return result
326    end
327    local h = result == true and head or result
328    if not h then return result end
329    return (letterspace_list(h, nil))
330  end
331  local dispatch = shape
332  local call_callback = lb.call_callback
333  local function with_shaping_filters(head, groupcode, _, _, direction)
334    if not direction then
335      direction = tex.get("textdirection")
336    else
337      direction = direction == "TRT" and 1 or 0
338    end
339    local result = call_callback("pre_shaping_filter", head, groupcode, direction)
340    if result == false then
341      return false
342    elseif result ~= true then
343      head = result
344    end
345    result = shape(head, groupcode)
346    if result == false then
347      node.flush_list(head)
348      return nil
349    elseif result ~= true then
350      head = result
351    end
352    result = call_callback("post_shaping_filter", head, groupcode, direction)
353    if result == false then
354      node.flush_list(head)
355      return nil
356    elseif result == true then
357      return head
358    else
359      return result
360    end
361  end
362  local add_to_callback = lb.add_to_callback
363  function lb.add_to_callback(name, ...)
364    if name == "pre_shaping_filter" or name == "post_shaping_filter" then
365      dispatch = with_shaping_filters
366    end
367    return add_to_callback(name, ...)
368  end
369  local function node_processor(head, groupcode, size, packtype, direction)
370    return dispatch(head, groupcode, size, packtype, direction)
371  end
372  lb.add_to_callback("pre_linebreak_filter", node_processor, "luaotfload.node_processor")
373  lb.add_to_callback("hpack_filter", node_processor, "luaotfload.node_processor")
374  lb.add_to_callback("glyph_stream_provider", noop, "luaotfload.glyph_stream")
375  -- Font colours (`color=RRGGBB[AA]`): a font with one adds the colour
376  -- handler to the callbacks below, which brackets its glyphs with
377  -- colour stack 0 whatsits (observed; the alpha part is not supported).
378  local colors = {}
379  local color_handler
380  -- The colour literal for `value`, a colour in hex (RRGGBB, the alpha
381  -- of RRGGBBAA left out) or a name: the `luaotfload.parse_color`
382  -- callback (LaTeX's l3color) makes it if set, else hex is converted.
383  local function color_literal(value)
384    local hex = value:match("^%x%x%x%x%x%x$") or value:match("^(%x%x%x%x%x%x)%x%x$")
385    local ok, s = pcall(lb.call_callback, "luaotfload.parse_color", hex or value)
386    if ok and type(s) == "string" then return s end
387    if not hex then return nil end
388    local function c(i)
389      local v = tonumber(hex:sub(i, i + 1), 16) / 255
390      return (string.format("%.3f", v):gsub("0+$", ""):gsub("%.$", ""))
391    end
392    return c(1) .. " " .. c(3) .. " " .. c(5) .. " rg"
393  end
394  local function define_font(name, size, id)
395    local f = native.define_font(name, size, id)
396    if type(f) == "number" and type(name) == "string" then
397      local factor = tonumber(name:match("[:;]kernfactor=([-%d.]+)") or "")
398      local percent = tonumber(name:match("[:;]letterspace=([-%d.]+)") or "")
399      factor = factor or (percent and percent / 100)
400      if factor and factor ~= 0 then
401        local quad = font.getfont(f).parameters.quad
402        letterspaced[f] = math.floor(factor * quad + 0.5)
403        ls_done = ls_done or luatexbase.attributes["luaotfload.letterspace_done"]
404        any_letterspaced = true
405      end
406      local value = name:match("[:;]color={?([^;}]+)}?")
407      local literal = value and not colors[f] and color_literal(value)
408      if literal then
409        colors[f] = literal
410        if not color_handler then
411          color_handler = luaotfload.color_handler
412          lb.add_to_callback("post_linebreak_filter", color_handler, "luaotfload.color_handler")
413          lb.add_to_callback("hpack_filter", color_handler, "luaotfload.color_handler")
414          lb.add_to_callback("post_mlist_to_hlist_filter", color_handler, "luaotfload.color_handler")
415        end
416      end
417    end
418    return f
419  end
420  local GLYPH, HLIST, VLIST, WHATSIT = node.id("glyph"), node.id("hlist"), node.id("vlist"), node.id("whatsit")
421  local LINE = 1
422  local function stack(command, data)
423    local w = node.new("whatsit", "pdf_colorstack")
424    w.stack = 0
425    w.command = command
426    if data then w.data = data end
427    return w
428  end
429  -- Colours the glyphs of `head` starting with colour `current`; returns
430  -- the new head and the colour at its end. Paragraph lines are coloured
431  -- on their own; other boxes and whatsits end a coloured run.
432  local function colorize(head, current)
433    local n = head
434    while n do
435      local id = n.id
436      if id == GLYPH then
437        local c = colors[n.font]
438        if c ~= current then
439          local w = not c and stack(2) or stack(current and 0 or 1, c)
440          head = node.insert_before(head, n, w)
441          current = c
442        end
443      elseif id == HLIST and n.subtype == LINE then
444        if current then
445          head = node.insert_before(head, n, stack(2))
446          current = nil
447        end
448        local list, last = colorize(n.list, nil)
449        if last then
450          list = node.insert_after(list, node.tail(list), stack(2))
451        end
452        n.list = list
453      elseif id == HLIST or id == VLIST or id == WHATSIT then
454        if current then
455          head = node.insert_before(head, n, stack(2))
456          current = nil
457        end
458      end
459      n = n.next
460    end
461    return head, current
462  end
463  function luaotfload.color_handler(head)
464    if not head then return head end
465    local h, last = colorize(head, nil)
466    if last then
467      h = node.insert_after(h, node.tail(h), stack(2))
468    end
469    return h
470  end
471  lb.add_to_callback("define_font", define_font, "luaotfload.define_font")
472  lb.new_attribute("luaotfload_color_attribute")
473  log("conf", 'Root cache directory is "' .. cache .. '".')
474  lb.add_to_callback("find_opentype_file", noop, "luaotfload.harf.strip_prefix")
475  lb.add_to_callback("find_truetype_file", noop, "luaotfload.harf.strip_prefix")
476  lb.remove_from_callback("glyph_stream_provider", "luaotfload.glyph_stream")
477  lb.add_to_callback("glyph_stream_provider", noop, "luaotfload.harf.glyphstream")
478  lb.add_to_callback("post_linebreak_filter", noop, "luaotfload.harf.finalize_vlist")
479  lb.add_to_callback("hpack_filter", noop, "luaotfload.harf.finalize_hlist")
480  lb.add_to_callback("wrapup_run", noop, "luaotfload.cleanup_files")
481  lb.add_to_callback("finish_pdffile", noop, "luaotfload.harf.finalize_unicode")
482  -- Box displays show the character as UTF-8 (observed: "^^@" for 0
483  -- and ^^^^^^ notation beyond Unicode).
484  lb.add_to_callback("glyph_info", function(n)
485    -- mode=harf glyphs show their text (a ligature's characters).
486    local text = native.harf_glyph_text(n.font, n.char)
487    if text then return text end
488    local c = n.char
489    if c == 0 then return "^^@" end
490    if c > 0x10FFFF then return string.format("^^^^^^%06x", c) end
491    return utf8.char(c)
492  end, "luaotfload.glyphinfo")
493  lb.new_attribute("luaotfload.letterspace_done")
494  lb.create_callback("luaotfload.patch_font", "simple", noop)
495  lb.create_callback("luaotfload.patch_font_unsafe", "simple", noop)
496  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.aux.set_sscale_dimens")
497  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.aux.set_font_index")
498  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.aux.patch_cambria_domh")
499  lb.add_to_callback("luaotfload.patch_font_unsafe", noop, "luaotfload.aux.fixup_fontdata")
500  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.aux.set_capheight")
501  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.aux.set_xheight")
502  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.rewrite_fontname")
503  -- The colour interface of luaotfload's colors module, set up by
504  -- main() (l3backend checks for set_transparent_colorstack before
505  -- hooking into parse_color).
506  function luaotfload.add_colorscheme() end
507  function luaotfload.set_colorhandler() end
508  function luaotfload.set_transparenthandler() end
509  function luaotfload.set_transparent_colorstack() end
510  lb.create_callback("luaotfload.split_color", "exclusive", noop)
511  lb.create_callback("luaotfload.parse_color", "exclusive", noop)
512  lb.create_callback("luaotfload.parse_transparent", "exclusive", noop)
513  return true
514end
515return luaotfload
516"#;
517
518/// lualibs' `utf` table: the functions packages call, with the behaviour
519/// observed in lualatex (`utf.byte` gives the first code point only and
520/// nothing for an empty string; `utf.sub` counts characters as
521/// `string.sub` counts bytes).
522const UTF_LUA: &str = r#"
523local u = {}
524u.char = utf8.char
525u.len = utf8.len
526function u.byte(s)
527  if s and #s > 0 then return utf8.codepoint(s, 1) end
528end
529function u.sub(s, i, j)
530  local n = utf8.len(s) or 0
531  i = i or 1
532  j = j or -1
533  if i < 0 then i = math.max(n + i + 1, 1) elseif i == 0 then i = 1 end
534  if j < 0 then j = n + j + 1 elseif j > n then j = n end
535  if i > j then return "" end
536  local b = utf8.offset(s, i)
537  local e = utf8.offset(s, j + 1)
538  return s:sub(b, (e or #s + 1) - 1)
539end
540function u.characters(s)
541  return s:gmatch(utf8.charpattern)
542end
543function u.values(s)
544  local f = s:gmatch(utf8.charpattern)
545  return function()
546    local c = f()
547    if c then return utf8.codepoint(c) end
548  end
549end
550return u
551"#;
552
553/// Installs the `luaotfload-main` module (in `package.preload`).
554pub fn install(lua: &Lua) -> mlua::Result<()> {
555    lua.set_app_data(Rc::new(RefCell::new(FontState::default())));
556    let native = lua.create_table()?;
557    native.set(
558        "define_font",
559        lua.create_function(|lua, (name, size, _id): (mlua::String, i64, Option<i64>)| {
560            let name = name.to_str()?.to_owned();
561            let t0 = std::time::Instant::now();
562            let before = with_engine(lua, |e| {
563                e.work_begin(retex_engine::profile::Work::Fonts);
564                e.fonts.fonts.len()
565            })?;
566            let r = define_font(lua, &name, size as i32);
567            with_engine(lua, Engine::work_end)?;
568            retex_engine::timing::FONTS.with(|a| a.add(t0));
569            // A new font holds Lua references, as in LuaTeX (observed in
570            // the numbers box displays show for Lua data).
571            with_engine(lua, |e| {
572                for _ in before..e.fonts.fonts.len() {
573                    e.nodes.lua_refs.font_defined(true);
574                }
575            })?;
576            r
577        })?,
578    )?;
579    native.set(
580        "reserve_lua_refs",
581        lua.create_function(|lua, n: i64| {
582            with_engine(lua, |e| {
583                for _ in 0..n {
584                    e.nodes.lua_refs.take();
585                }
586            })
587        })?,
588    )?;
589    native.set(
590        "node_processor",
591        lua.create_function(|lua, (head, _group): (Value, Value)| {
592            let h = match &head {
593                Value::UserData(u) => u
594                    .borrow::<crate::nodelib::LNode>()
595                    .ok()
596                    .map(|n| NodeRef(n.0)),
597                _ => None,
598            };
599            if let Some(h) = h {
600                let nh = with_engine(lua, |e| process_list(e, h))?;
601                if nh != h {
602                    return Ok(Value::UserData(
603                        lua.create_userdata(crate::nodelib::LNode(nh.0))?,
604                    ));
605                }
606            }
607            Ok(Value::Boolean(true))
608        })?,
609    )?;
610    native.set(
611        "harf_glyph_text",
612        lua.create_function(|lua, (font, c): (i64, i64)| {
613            let text = with_engine(lua, |e| {
614                let f = u32::try_from(font)
615                    .ok()
616                    .filter(|&f| e.font_exists(f as i32))?;
617                let lf = loaded_font(e, f)?;
618                if lf.request.mode != retex_font::request::Mode::Harf {
619                    return None;
620                }
621                let t = lf.tex.tounicode(u32::try_from(c).ok()?);
622                (t.len() > 1).then(|| t.into_iter().filter_map(char::from_u32).collect::<String>())
623            })?;
624            Ok(text)
625        })?,
626    )?;
627    native.set(
628        "font_parameters",
629        lua.create_function(|lua, id: i64| {
630            let Some(lf) = with_engine(lua, |e| {
631                u32::try_from(id)
632                    .ok()
633                    .filter(|&f| e.font_exists(f as i32))
634                    .and_then(|f| loaded_font(e, f))
635            })?
636            else {
637                return Ok(None);
638            };
639            let t = &lf.tex;
640            let p = lua.create_table()?;
641            for (k, v) in retex_font::texfont::LOADER_PARAMS
642                .iter()
643                .zip(t.loader_params)
644            {
645                let spacing = matches!(*k, "space_stretch" | "space_shrink" | "extra_space");
646                let v = if spacing && v == 0.0 {
647                    // Monospaced fonts: integer zeros (observed).
648                    Value::Integer(0)
649                } else {
650                    Value::Number(v)
651                };
652                match k.parse::<i64>() {
653                    Ok(n) => p.set(n, v)?,
654                    Err(_) => p.set(*k, v)?,
655                }
656            }
657            let factor = t.size_f64 / f64::from(t.units_per_em);
658            for k in ["factor", "hfactor", "vfactor"] {
659                p.set(k, factor)?;
660            }
661            for k in ["size", "scaledpoints", "textsize"] {
662                p.set(k, t.size)?;
663            }
664            p.set("units", t.units_per_em)?;
665            p.set("extrafactor", 1)?;
666            for k in ["mathsize", "mode", "width"] {
667                p.set(k, 0)?;
668            }
669            Ok(Some(p))
670        })?,
671    )?;
672    native.set(
673        "cache_dir",
674        lua.create_function(|lua, ()| with_engine(lua, names_cache_dir))?,
675    )?;
676    let utf = lua
677        .load(UTF_LUA)
678        .set_name("=lualibs-unicode")
679        .into_function()?;
680    native.set("lualibs_utf", utf)?;
681    crate::otfaux::install(lua, &native)?;
682    let loader = lua.create_function(move |lua, ()| {
683        // Like `require` of the real module: it fails where luaotfload
684        // cannot be found (l3build sandboxes without the search tree).
685        let kpse: mlua::Table = lua.globals().get("kpse")?;
686        let find: mlua::Function = kpse.get("find_file")?;
687        if find.call::<Value>(("luaotfload-main.lua", "lua"))?.is_nil() {
688            return Err(mlua::Error::runtime("module 'luaotfload-main' not found"));
689        }
690        let f = lua
691            .load(MAIN_LUA)
692            .set_name("=luaotfload-main")
693            .into_function()?;
694        let native: mlua::Table = lua.named_registry_value("retex.luaotfload.native")?;
695        f.call::<Value>(native)
696    })?;
697    lua.set_named_registry_value("retex.luaotfload.native", native)?;
698    let package: mlua::Table = lua.globals().get("package")?;
699    let preload: mlua::Table = package.get("preload")?;
700    preload.set("luaotfload-main", loader)?;
701    // retex stands in for luahbtex, whose `luaharfbuzz` module packages
702    // probe for (expl3's \c_sys_engine_exec_str is `luahbtex` when
703    // `require 'luaharfbuzz'` succeeds). Shaping is native, so the module
704    // only has the version information; its classes are empty.
705    let harfbuzz = lua
706        .load(
707            r#"local hb = { shapers = function() return "graphite2", "ot", "fallback" end,
708                            version = function() return "12.3.2" end }
709               for _, k in ipairs{"Blob", "Buffer", "Direction", "Face", "Feature", "Font",
710                                  "Language", "Script", "Tag", "Variation", "ot", "unicode"} do
711                 hb[k] = {}
712               end
713               return function() return hb end"#,
714        )
715        .set_name("=[luaharfbuzz]")
716        .call::<mlua::Function>(())?;
717    preload.set("luaharfbuzz", harfbuzz)?;
718    Ok(())
719}
720
721/// The directory of luaotfload's font names database: in the first
722/// directory of `TEXMFCACHE` that can be written (observed: with
723/// `TEXMFCACHE=$TEXMFSYSVAR:$TEXMFVAR`, the default, and a system tree
724/// that is not writable, `TEXMFVAR`), and that `openout_any` accepts.
725fn names_cache_dir(e: &mut retex_engine::Engine) -> String {
726    let Some(k) = e.kpse.as_ref() else {
727        return String::new();
728    };
729    let k = k.borrow();
730    let writable = |dir: &str| {
731        let probe =
732            std::path::Path::new(dir).join(format!(".retex-probe-{}", retex_kpath::process_id()));
733        let ok = std::fs::File::create(&probe).is_ok();
734        let _ = std::fs::remove_file(&probe);
735        ok
736    };
737    let root = k
738        .var_value("TEXMFCACHE")
739        .unwrap_or_default()
740        .split(retex_kpath::SEP)
741        .find(|d| {
742            !d.is_empty() && k.name_ok(d, retex_kpath::Access::Write, true).is_ok() && writable(d)
743        })
744        .map(str::to_owned)
745        .or_else(|| k.var_value("TEXMFVAR"))
746        .unwrap_or_default();
747    format!("{root}/luatex-cache/generic/names")
748}
749
750/// Whether the `luaotfload.sty` found is older than version 3.29 (its
751/// `\ProvidesPackage` line, `[2024/02/14 v3.28 …]`).
752fn legacy_luaotfload(e: &mut Engine) -> bool {
753    let Some(path) = e.kpse.as_ref().and_then(|k| {
754        k.borrow_mut()
755            .find_file("luaotfload.sty", retex_kpath::FileFormat::Tex)
756    }) else {
757        return false;
758    };
759    let Ok(text) = std::fs::read(path) else {
760        return false;
761    };
762    let text = String::from_utf8_lossy(&text);
763    let Some(at) = text.find("\\ProvidesPackage{luaotfload}") else {
764        return false;
765    };
766    let rest = &text[at..];
767    let Some(v) = rest.find(" v").map(|i| &rest[i + 2..]) else {
768        return false;
769    };
770    let ver: String = v
771        .chars()
772        .take_while(|c| c.is_ascii_digit() || *c == '.')
773        .collect();
774    let mut parts = ver.split('.').map(|p| p.parse::<u32>().unwrap_or(0));
775    let (major, minor) = (parts.next().unwrap_or(0), parts.next().unwrap_or(0));
776    (major, minor) < (3, 29) && major > 0
777}
778
779/// Loads a font for the `define_font` callback and registers it with the
780/// engine. Returns the engine font id, or nil if the font cannot be loaded.
781fn define_font(lua: &Lua, name: &str, size: i32) -> mlua::Result<Value> {
782    let state = lua
783        .app_data_ref::<Rc<RefCell<FontState>>>()
784        .map(|s| s.clone())
785        .ok_or_else(|| mlua::Error::runtime("font state missing"))?;
786    if let Some(&id) = state.borrow().tfm_ids.get(&(name.to_owned(), size)) {
787        return Ok(Value::Integer(i64::from(id)));
788    }
789    let remember = |id: u32| {
790        state
791            .borrow_mut()
792            .tfm_ids
793            .insert((name.to_owned(), size), id);
794    };
795    // An anonymous request is a TeX metric file if there is one (the
796    // first step of luaotfload's "tex,path,name" anonymous lookup).
797    if let Ok(req) = retex_font::FontRequest::parse(name)
798        && let retex_font::FontSource::Any(base) = &req.source
799    {
800        let (first_new, id) = with_engine(lua, |e| {
801            let first_new = e.fonts.fonts.len() as u32;
802            let f = e.load_tfm_font(base.as_bytes(), size, name.as_bytes());
803            if let Some(f) = f {
804                e.check_lua_loaded_font(f);
805            }
806            (first_new, f)
807        })?;
808        if let Some(i) = id {
809            remember(i);
810            // Observed: the local fonts a virtual font loads are shared
811            // with later requests for the same name at the same size
812            // (`aer9` loads `cmr9`, which `\font\x=cmr9 at 9pt` then
813            // gets); not the loaded fonts it reuses (at 10pt, the format's
814            // `cmr10`).
815            let locals = with_engine(lua, |e| e.virtual_font_locals(i))?;
816            let mut st = state.borrow_mut();
817            for (n, s, l) in locals.into_iter().filter(|l| l.2 >= first_new) {
818                st.tfm_ids
819                    .entry((String::from_utf8_lossy(&n).into_owned(), s))
820                    .or_insert(l);
821            }
822            return Ok(Value::Integer(i64::from(i)));
823        }
824    }
825    // luaotfload loads its font name database for the first font that is
826    // no TeX metric file (also for file lookups) and says so in the log.
827    {
828        if !state.borrow().db_announced {
829            state.borrow_mut().db_announced = true;
830            let dir = with_engine(lua, names_cache_dir)?;
831            let texio: mlua::Table = lua.globals().get("texio")?;
832            let write: mlua::Function = texio.get("write_nl")?;
833            write.call::<()>((
834                "log",
835                format!(
836                    "luaotfload | db : Font names database loaded from {dir}/luaotfload-names.luc.gz"
837                ),
838            ))?;
839        }
840        // A lookup by name: `name:`, or an anonymous request that is no
841        // file's path.
842        let by_name = match retex_font::FontRequest::parse(name).map(|r| r.source) {
843            Ok(retex_font::FontSource::Name(_)) => true,
844            Ok(retex_font::FontSource::Any(s)) => !std::path::Path::new(&s).is_file(),
845            _ => false,
846        };
847        if by_name && !state.borrow().lookup_announced {
848            state.borrow_mut().lookup_announced = true;
849            let cache = format!(
850                "{}/luaotfload-lookup-cache.luc",
851                with_engine(lua, names_cache_dir)?
852            );
853            if std::path::Path::new(&cache).is_file() {
854                let texio: mlua::Table = lua.globals().get("texio")?;
855                let write: mlua::Function = texio.get("write_nl")?;
856                write.call::<()>((
857                    "log",
858                    format!("luaotfload | cache : Lookup cache loaded from {cache}."),
859                ))?;
860            }
861        }
862    }
863    let loaded: Result<Arc<LoadedFont>, String> = with_engine(lua, |e| {
864        let mut st = state.borrow_mut();
865        if st.loader.is_none() {
866            let k = e
867                .kpse
868                .clone()
869                .ok_or_else(|| "kpathsea unavailable".to_owned())?;
870            st.loader = Some(retex_font::FontLoader::new(k));
871        }
872        st.loader
873            .as_mut()
874            .expect("loader initialized")
875            .load(name, size)
876            .map_err(|e| e.to_string())
877    })?;
878    let Ok(font) = loaded else {
879        // luaotfload falls back to TeX metric files (e.g. `cmr10`).
880        let plain = name.strip_prefix("file:").unwrap_or(name);
881        let plain = plain.split(':').next().unwrap_or(plain).to_owned();
882        let id = with_engine(lua, |e| {
883            let f = e.load_tfm_font(plain.as_bytes(), size, name.as_bytes());
884            if let Some(f) = f {
885                e.check_lua_loaded_font(f);
886            }
887            f
888        })?;
889        if let Some(i) = id {
890            remember(i);
891        }
892        return Ok(id.map_or(Value::Nil, |i| Value::Integer(i64::from(i))));
893    };
894    // The font file is one the job depends on, as luatex's -recorder
895    // lists it (observed).
896    with_engine(lua, |e| {
897        if !e.files.watched.contains(&font.tex.path) {
898            e.files.watched.push(font.tex.path.clone());
899        }
900    })?;
901    let key = (Arc::as_ptr(&font) as usize, size < 0);
902    if let Some(&id) = state.borrow().ids.get(&key) {
903        return Ok(Value::Integer(i64::from(id)));
904    }
905    let known = state.borrow().legacy_design_size;
906    let legacy = match known {
907        Some(l) => l,
908        None => with_engine(lua, legacy_luaotfload)?,
909    };
910    state.borrow_mut().legacy_design_size = Some(legacy);
911    let id = with_engine(lua, |e| {
912        let tex = &font.tex;
913        let design_size = if legacy {
914            retex_font::texfont::design_size(&font.file).round() as i32
915        } else {
916            tex.design_size
917        };
918        let mut params = vec![0; tex.params.len() + 1];
919        for (i, p) in tex.params.iter().enumerate() {
920            params[i + 1] = *p;
921        }
922        let mut chars = HashMap::with_capacity_and_hasher(
923            tex.num_chars(),
924            retex_engine::hash::FastHash::default(),
925        );
926        for (code, m) in tex.chars() {
927            chars.insert(
928                code,
929                CharMetrics {
930                    width: m.width,
931                    height: m.height,
932                    depth: m.depth,
933                    italic: m.italic,
934                    index: u32::from(m.glyph),
935                },
936            );
937        }
938        // luaotfload quotes a name with a space (observed in the font
939        // table and so in \fontname), which LaTeX's font substitution
940        // relies on: it reloads the font with `\font\x=\fontname\font`.
941        let name = if tex.name.contains(' ') {
942            format!("\"{}\"", tex.name).into_bytes()
943        } else {
944            tex.name.clone().into_bytes()
945        };
946        let info = FontInfo {
947            name,
948            size: tex.size,
949            design_size,
950            checksum: 0,
951            params,
952            chars,
953            hyphen_char: i32::MIN,
954            skew_char: i32::MIN,
955            kind: FontKind::OpenType,
956            handle: 0,
957            char_codes: Default::default(),
958            ignore_ligatures: 0,
959            data: Some(FontData(Rc::new(font.clone()))),
960            lig_kern: Default::default(),
961            right_boundary: None,
962            expansion: None,
963            size_spec: i32::MIN,
964            vf: None,
965            math_chars: {
966                let mc = tex.math_chars(&font.file);
967                (!mc.is_empty()).then(|| Rc::new(convert_math_chars(mc)))
968            },
969            math_constants: tex.math.as_ref().map(|m| {
970                Rc::new(
971                    m.iter()
972                        .map(|(k, v)| ((*k).to_owned(), v.to_sp()))
973                        .collect(),
974                )
975            }),
976        };
977        e.register_font(info)
978    })?;
979    state.borrow_mut().ids.insert(key, id);
980    Ok(Value::Integer(i64::from(id)))
981}
982
983/// Converts the font crate's math data to the engine's, rounding the
984/// dimensions to scaled points.
985fn convert_math_chars(
986    mc: HashMap<u32, retex_font::texfont::MathChar>,
987) -> HashMap<u32, retex_engine::fonts::MathCharInfo> {
988    use retex_engine::fonts::{ExtPart, MathCharInfo};
989    let parts = |v: &Option<Vec<retex_font::texfont::MathPart>>| {
990        v.as_ref().map(|ps| {
991            ps.iter()
992                .map(|p| ExtPart {
993                    code: p.code,
994                    start: retex_font::texfont::round_sp(p.start),
995                    end: retex_font::texfont::round_sp(p.end),
996                    advance: retex_font::texfont::round_sp(p.advance),
997                    extender: p.extender,
998                })
999                .collect::<Rc<[ExtPart]>>()
1000        })
1001    };
1002    mc.into_iter()
1003        .map(|(c, m)| {
1004            (
1005                c,
1006                MathCharInfo {
1007                    next: m.next,
1008                    top_accent: m.top_accent.map(retex_font::texfont::round_sp),
1009                    vert: parts(&m.vert),
1010                    horiz: parts(&m.horiz),
1011                },
1012            )
1013        })
1014        .collect()
1015}
1016
1017/// The OpenType font behind engine font `f`, if any.
1018pub fn loaded_font(e: &Engine, f: u32) -> Option<Arc<LoadedFont>> {
1019    let data = e.font(f).data.as_ref()?;
1020    data.0.downcast_ref::<Arc<LoadedFont>>().cloned()
1021}
1022
1023pub use crate::otfnodes::process_list;
1024
1025#[cfg(test)]
1026mod tests {
1027    #[test]
1028    fn lualibs_utf() {
1029        let lua = mlua::Lua::new();
1030        let utf: mlua::Table = lua.load(super::UTF_LUA).eval().expect("utf");
1031        lua.globals().set("utf", utf).expect("set");
1032        let r: String = lua
1033            .load(
1034                r##"local o = {}
1035                   local function add(...) o[#o + 1] = table.concat({...}, ",") end
1036                   add(utf.char(65, 0x3B1), utf.byte("\u{3B1}b"), select("#", utf.byte("")))
1037                   add(utf.len("\u{3B1}b"), utf.sub("\u{3B1}\u{3B2}c", 2, 3),
1038                       utf.sub("\u{3B1}\u{3B2}c", 2), utf.sub("\u{3B1}\u{3B2}c", -1))
1039                   for c in utf.characters("a\u{3B1}") do add(c) end
1040                   for c in utf.values("a\u{3B1}") do add(c) end
1041                   return table.concat(o, "|")"##,
1042            )
1043            .eval()
1044            .expect("run");
1045        // Observed in lualatex.
1046        assert_eq!(r, "A\u{3B1},945,0|2,\u{3B2}c,\u{3B2}c,c|a|\u{3B1}|97|945");
1047    }
1048}