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):
35    /// luaotfload returns the id of a font already loaded for an
36    /// equivalent request (e.g. `Latin Modern Math/B` when there is no
37    /// bold) instead of a new font.
38    ids: HashMap<usize, u32>,
39    /// Whether the log tells that the font name database was loaded.
40    db_announced: bool,
41}
42
43/// The Lua glue: the module table and `main`, which performs luaotfload's
44/// registrations through `luatexbase` in the order lualatex shows them.
45const MAIN_LUA: &str = r#"
46local native = ...
47local lb = luatexbase
48local function noop(...) return ... end
49local function log(cat, msg)
50  texio.write_nl("log", "luaotfload | " .. cat .. " : " .. msg)
51end
52luaotfload = luaotfload or {}
53luaotfload.define_font = native.define_font
54luaotfload.node_processor = native.node_processor
55luaotfload.version = luaotfload.version or "3.29"
56-- Letterspacing options (microtype replaces keepligature).
57luaotfload.letterspace = luaotfload.letterspace or
58  { keepligature = true, keeptogether = false, keepwordspacing = false }
59luaotfload.features = luaotfload.features or { defaults = {}, global = {} }
60local aux = luaotfload.aux or {}
61luaotfload.aux = aux
62for _, k in ipairs{"provides_script", "provides_language", "provides_feature",
63    "get_math_dimension", "font_has_glyph", "slot_of_name", "gid_of_name"} do
64  aux[k] = native["aux_" .. k]
65end
66aux.sprint_math_dimension = function(f, name)
67  tex.sprint(-2, tostring(aux.get_math_dimension(f, name)))
68end
69-- The parts of the fontloader's `fonts` table that packages touch.
70fonts = fonts or {}
71fonts.names = fonts.names or {}
72fonts.names.set_location_precedence = fonts.names.set_location_precedence or function() end
73fonts.hashes = fonts.hashes or {}
74fonts.hashes.identifiers = fonts.hashes.identifiers or setmetatable({}, {
75  __index = function(_, id) if type(id) == "number" then return font.getfont(id) end end })
76local initialized = false
77luaotfload.main = function()
78  if initialized then
79    log("load", "Luaotfload initialization requested but is already loaded, ignoring.")
80    return
81  end
82  initialized = true
83  lb.provides_module({ name = "luaotfload", date = "2024-12-03", version = "3.29",
84    description = "Lua based OpenType font support" })
85  lb.provides_module({ name = "lualibs", date = "2023-07-13", version = "2.76",
86    description = "ConTeXt Lua standard libraries." })
87  lb.provides_module({ name = "lualibs-extended", date = "2023-07-13", version = "2.76",
88    description = "ConTeXt Lua libraries -- extended collection." })
89  -- lualibs' global `utf` (luamml uses it).
90  if not utf then utf = native.lualibs_utf() end
91  local cache = native.cache_dir()
92  log("conf", 'Root cache directory is "' .. cache .. '".')
93  -- As luaotfload does before loading the fontloader, which wants the
94  -- attribute with index zero to be zero.
95  tex.attribute[0] = 0
96  -- luaotfload's set-up holds six Lua references (observed in the
97  -- numbers box displays show for Lua data).
98  native.reserve_lua_refs(6)
99  local loader = kpse.find_file("fontloader-2023-12-28.lua", "lua") or "fontloader-2023-12-28.lua"
100  log("init", 'Loading fontloader "fontloader-2023-12-28.lua" from kpse-resolved path "' .. loader .. '".')
101  -- As the fontloader does: node properties are flushed with their
102  -- nodes and inherited by copies, for good.
103  node.direct.set_properties_mode(true, true)
104  function node.direct.set_properties_mode() end
105  lb.new_attribute("luaotfload@noligature")
106  log("init", "Context OpenType loader version 3.134")
107  -- luaotfload's node processor runs the pre_shaping_filter and
108  -- post_shaping_filter callbacks around the shaping. They are only
109  -- called once something is added to them.
110  if not lb.callbacktypes.pre_shaping_filter then
111    lb.create_callback("pre_shaping_filter", "list")
112    lb.create_callback("post_shaping_filter", "reverselist")
113  end
114  local shape = native.node_processor
115  local dispatch = shape
116  local call_callback = lb.call_callback
117  local function with_shaping_filters(head, groupcode, _, _, direction)
118    if not direction then
119      direction = tex.get("textdirection")
120    else
121      direction = direction == "TRT" and 1 or 0
122    end
123    local result = call_callback("pre_shaping_filter", head, groupcode, direction)
124    if result == false then
125      return false
126    elseif result ~= true then
127      head = result
128    end
129    result = shape(head, groupcode)
130    if result == false then
131      node.flush_list(head)
132      return nil
133    elseif result ~= true then
134      head = result
135    end
136    result = call_callback("post_shaping_filter", head, groupcode, direction)
137    if result == false then
138      node.flush_list(head)
139      return nil
140    elseif result == true then
141      return head
142    else
143      return result
144    end
145  end
146  local add_to_callback = lb.add_to_callback
147  function lb.add_to_callback(name, ...)
148    if name == "pre_shaping_filter" or name == "post_shaping_filter" then
149      dispatch = with_shaping_filters
150    end
151    return add_to_callback(name, ...)
152  end
153  local function node_processor(head, groupcode, size, packtype, direction)
154    return dispatch(head, groupcode, size, packtype, direction)
155  end
156  lb.add_to_callback("pre_linebreak_filter", node_processor, "luaotfload.node_processor")
157  lb.add_to_callback("hpack_filter", node_processor, "luaotfload.node_processor")
158  lb.add_to_callback("glyph_stream_provider", noop, "luaotfload.glyph_stream")
159  lb.add_to_callback("define_font", native.define_font, "luaotfload.define_font")
160  lb.new_attribute("luaotfload_color_attribute")
161  log("conf", 'Root cache directory is "' .. cache .. '".')
162  lb.add_to_callback("find_opentype_file", noop, "luaotfload.harf.strip_prefix")
163  lb.add_to_callback("find_truetype_file", noop, "luaotfload.harf.strip_prefix")
164  lb.remove_from_callback("glyph_stream_provider", "luaotfload.glyph_stream")
165  lb.add_to_callback("glyph_stream_provider", noop, "luaotfload.harf.glyphstream")
166  lb.add_to_callback("post_linebreak_filter", noop, "luaotfload.harf.finalize_vlist")
167  lb.add_to_callback("hpack_filter", noop, "luaotfload.harf.finalize_hlist")
168  lb.add_to_callback("wrapup_run", noop, "luaotfload.cleanup_files")
169  lb.add_to_callback("finish_pdffile", noop, "luaotfload.harf.finalize_unicode")
170  -- Box displays show the character as UTF-8 (observed: "^^@" for 0
171  -- and ^^^^^^ notation beyond Unicode).
172  lb.add_to_callback("glyph_info", function(n)
173    local c = n.char
174    if c == 0 then return "^^@" end
175    if c > 0x10FFFF then return string.format("^^^^^^%06x", c) end
176    return utf8.char(c)
177  end, "luaotfload.glyphinfo")
178  lb.new_attribute("luaotfload.letterspace_done")
179  lb.create_callback("luaotfload.patch_font", "simple", noop)
180  lb.create_callback("luaotfload.patch_font_unsafe", "simple", noop)
181  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.aux.set_sscale_dimens")
182  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.aux.set_font_index")
183  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.aux.patch_cambria_domh")
184  lb.add_to_callback("luaotfload.patch_font_unsafe", noop, "luaotfload.aux.fixup_fontdata")
185  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.aux.set_capheight")
186  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.aux.set_xheight")
187  lb.add_to_callback("luaotfload.patch_font", noop, "luaotfload.rewrite_fontname")
188  -- The colour interface of luaotfload's colors module, set up by
189  -- main() (l3backend checks for set_transparent_colorstack before
190  -- hooking into parse_color).
191  function luaotfload.add_colorscheme() end
192  function luaotfload.set_colorhandler() end
193  function luaotfload.set_transparenthandler() end
194  function luaotfload.set_transparent_colorstack() end
195  lb.create_callback("luaotfload.split_color", "exclusive", noop)
196  lb.create_callback("luaotfload.parse_color", "exclusive", noop)
197  lb.create_callback("luaotfload.parse_transparent", "exclusive", noop)
198  return true
199end
200return luaotfload
201"#;
202
203/// lualibs' `utf` table: the functions packages call, with the behaviour
204/// observed in lualatex (`utf.byte` gives the first code point only and
205/// nothing for an empty string; `utf.sub` counts characters as
206/// `string.sub` counts bytes).
207const UTF_LUA: &str = r#"
208local u = {}
209u.char = utf8.char
210u.len = utf8.len
211function u.byte(s)
212  if s and #s > 0 then return utf8.codepoint(s, 1) end
213end
214function u.sub(s, i, j)
215  local n = utf8.len(s) or 0
216  i = i or 1
217  j = j or -1
218  if i < 0 then i = math.max(n + i + 1, 1) elseif i == 0 then i = 1 end
219  if j < 0 then j = n + j + 1 elseif j > n then j = n end
220  if i > j then return "" end
221  local b = utf8.offset(s, i)
222  local e = utf8.offset(s, j + 1)
223  return s:sub(b, (e or #s + 1) - 1)
224end
225function u.characters(s)
226  return s:gmatch(utf8.charpattern)
227end
228function u.values(s)
229  local f = s:gmatch(utf8.charpattern)
230  return function()
231    local c = f()
232    if c then return utf8.codepoint(c) end
233  end
234end
235return u
236"#;
237
238/// Installs the `luaotfload-main` module (in `package.preload`).
239pub fn install(lua: &Lua) -> mlua::Result<()> {
240    lua.set_app_data(Rc::new(RefCell::new(FontState::default())));
241    let native = lua.create_table()?;
242    native.set(
243        "define_font",
244        lua.create_function(|lua, (name, size, _id): (mlua::String, i64, Option<i64>)| {
245            let name = name.to_str()?.to_owned();
246            let t0 = std::time::Instant::now();
247            let before = with_engine(lua, |e| e.fonts.fonts.len())?;
248            let r = define_font(lua, &name, size as i32);
249            retex_engine::timing::FONTS.with(|a| a.add(t0));
250            // A new font holds Lua references, as in LuaTeX (observed in
251            // the numbers box displays show for Lua data).
252            with_engine(lua, |e| {
253                for _ in before..e.fonts.fonts.len() {
254                    e.nodes.lua_refs.font_defined(true);
255                }
256            })?;
257            r
258        })?,
259    )?;
260    native.set(
261        "reserve_lua_refs",
262        lua.create_function(|lua, n: i64| {
263            with_engine(lua, |e| {
264                for _ in 0..n {
265                    e.nodes.lua_refs.take();
266                }
267            })
268        })?,
269    )?;
270    native.set(
271        "node_processor",
272        lua.create_function(|lua, (head, _group): (Value, Value)| {
273            let h = match &head {
274                Value::UserData(u) => u
275                    .borrow::<crate::nodelib::LNode>()
276                    .ok()
277                    .map(|n| NodeRef(n.0)),
278                _ => None,
279            };
280            if let Some(h) = h {
281                let nh = with_engine(lua, |e| process_list(e, h))?;
282                if nh != h {
283                    return Ok(Value::UserData(
284                        lua.create_userdata(crate::nodelib::LNode(nh.0))?,
285                    ));
286                }
287            }
288            Ok(Value::Boolean(true))
289        })?,
290    )?;
291    native.set(
292        "cache_dir",
293        lua.create_function(|lua, ()| with_engine(lua, names_cache_dir))?,
294    )?;
295    let utf = lua
296        .load(UTF_LUA)
297        .set_name("=lualibs-unicode")
298        .into_function()?;
299    native.set("lualibs_utf", utf)?;
300    crate::otfaux::install(lua, &native)?;
301    let loader = lua.create_function(move |lua, ()| {
302        // Like `require` of the real module: it fails where luaotfload
303        // cannot be found (l3build sandboxes without the search tree).
304        let kpse: mlua::Table = lua.globals().get("kpse")?;
305        let find: mlua::Function = kpse.get("find_file")?;
306        if find.call::<Value>(("luaotfload-main.lua", "lua"))?.is_nil() {
307            return Err(mlua::Error::runtime("module 'luaotfload-main' not found"));
308        }
309        let f = lua
310            .load(MAIN_LUA)
311            .set_name("=luaotfload-main")
312            .into_function()?;
313        let native: mlua::Table = lua.named_registry_value("retex.luaotfload.native")?;
314        f.call::<Value>(native)
315    })?;
316    lua.set_named_registry_value("retex.luaotfload.native", native)?;
317    let package: mlua::Table = lua.globals().get("package")?;
318    let preload: mlua::Table = package.get("preload")?;
319    preload.set("luaotfload-main", loader)?;
320    // retex stands in for luahbtex, whose `luaharfbuzz` module packages
321    // probe for (expl3's \c_sys_engine_exec_str is `luahbtex` when
322    // `require 'luaharfbuzz'` succeeds). Shaping is native, so the module
323    // only has the version information; its classes are empty.
324    let harfbuzz = lua
325        .load(
326            r#"local hb = { shapers = function() return "graphite2", "ot", "fallback" end,
327                            version = function() return "12.3.2" end }
328               for _, k in ipairs{"Blob", "Buffer", "Direction", "Face", "Feature", "Font",
329                                  "Language", "Script", "Tag", "Variation", "ot", "unicode"} do
330                 hb[k] = {}
331               end
332               return function() return hb end"#,
333        )
334        .set_name("=[luaharfbuzz]")
335        .call::<mlua::Function>(())?;
336    preload.set("luaharfbuzz", harfbuzz)?;
337    Ok(())
338}
339
340/// The directory of luaotfload's font names database: in the first
341/// directory of `TEXMFCACHE` that can be written (observed: with
342/// `TEXMFCACHE=$TEXMFSYSVAR:$TEXMFVAR`, the default, and a system tree
343/// that is not writable, `TEXMFVAR`), and that `openout_any` accepts.
344fn names_cache_dir(e: &mut retex_engine::Engine) -> String {
345    let Some(k) = e.kpse.as_ref() else {
346        return String::new();
347    };
348    let k = k.borrow();
349    let writable = |dir: &str| {
350        let probe = std::path::Path::new(dir).join(format!(".retex-probe-{}", std::process::id()));
351        let ok = std::fs::File::create(&probe).is_ok();
352        let _ = std::fs::remove_file(&probe);
353        ok
354    };
355    let root = k
356        .var_value("TEXMFCACHE")
357        .unwrap_or_default()
358        .split(':')
359        .find(|d| {
360            !d.is_empty() && k.name_ok(d, retex_kpath::Access::Write, true).is_ok() && writable(d)
361        })
362        .map(str::to_owned)
363        .or_else(|| k.var_value("TEXMFVAR"))
364        .unwrap_or_default();
365    format!("{root}/luatex-cache/generic/names")
366}
367
368/// Loads a font for the `define_font` callback and registers it with the
369/// engine. Returns the engine font id, or nil if the font cannot be loaded.
370fn define_font(lua: &Lua, name: &str, size: i32) -> mlua::Result<Value> {
371    let state = lua
372        .app_data_ref::<Rc<RefCell<FontState>>>()
373        .map(|s| s.clone())
374        .ok_or_else(|| mlua::Error::runtime("font state missing"))?;
375    // An anonymous request is a TeX metric file if there is one (the
376    // first step of luaotfload's "tex,path,name" anonymous lookup).
377    if let Ok(req) = retex_font::FontRequest::parse(name)
378        && let retex_font::FontSource::Any(base) = &req.source
379    {
380        let id = with_engine(lua, |e| {
381            e.load_tfm_font(base.as_bytes(), size, name.as_bytes())
382        })?;
383        if let Some(i) = id {
384            return Ok(Value::Integer(i64::from(i)));
385        }
386    }
387    // luaotfload loads its font name database for the first font that is
388    // no TeX metric file (also for file lookups) and says so in the log.
389    {
390        if !state.borrow().db_announced {
391            state.borrow_mut().db_announced = true;
392            let dir = with_engine(lua, names_cache_dir)?;
393            let texio: mlua::Table = lua.globals().get("texio")?;
394            let write: mlua::Function = texio.get("write_nl")?;
395            write.call::<()>((
396                "log",
397                format!(
398                    "luaotfload | db : Font names database loaded from {dir}/luaotfload-names.luc.gz"
399                ),
400            ))?;
401        }
402    }
403    let loaded: Result<Arc<LoadedFont>, String> = with_engine(lua, |e| {
404        let mut st = state.borrow_mut();
405        if st.loader.is_none() {
406            let k = e
407                .kpse
408                .clone()
409                .ok_or_else(|| "kpathsea unavailable".to_owned())?;
410            st.loader = Some(retex_font::FontLoader::new(k));
411        }
412        st.loader
413            .as_mut()
414            .expect("loader initialized")
415            .load(name, size)
416            .map_err(|e| e.to_string())
417    })?;
418    let Ok(font) = loaded else {
419        // luaotfload falls back to TeX metric files (e.g. `cmr10`).
420        let plain = name.strip_prefix("file:").unwrap_or(name);
421        let plain = plain.split(':').next().unwrap_or(plain).to_owned();
422        let id = with_engine(lua, |e| {
423            e.load_tfm_font(plain.as_bytes(), size, name.as_bytes())
424        })?;
425        return Ok(id.map_or(Value::Nil, |i| Value::Integer(i64::from(i))));
426    };
427    let key = Arc::as_ptr(&font) as usize;
428    if let Some(&id) = state.borrow().ids.get(&key) {
429        return Ok(Value::Integer(i64::from(id)));
430    }
431    let id = with_engine(lua, |e| {
432        let tex = &font.tex;
433        let mut params = vec![0; tex.params.len() + 1];
434        for (i, p) in tex.params.iter().enumerate() {
435            params[i + 1] = *p;
436        }
437        let mut chars = HashMap::with_capacity_and_hasher(
438            tex.num_chars(),
439            retex_engine::hash::FastHash::default(),
440        );
441        for (code, m) in tex.chars() {
442            chars.insert(
443                code,
444                CharMetrics {
445                    width: m.width,
446                    height: m.height,
447                    depth: m.depth,
448                    italic: m.italic,
449                    index: u32::from(m.glyph),
450                },
451            );
452        }
453        let info = FontInfo {
454            name: tex.name.clone().into_bytes(),
455            size: tex.size,
456            design_size: tex.design_size,
457            checksum: 0,
458            params,
459            chars,
460            hyphen_char: i32::MIN,
461            skew_char: i32::MIN,
462            kind: FontKind::OpenType,
463            handle: 0,
464            char_codes: Default::default(),
465            ignore_ligatures: 0,
466            data: Some(FontData(Rc::new(font.clone()))),
467            lig_kern: Default::default(),
468            right_boundary: None,
469            expansion: None,
470            size_spec: i32::MIN,
471            vf: None,
472            math_chars: {
473                let mc = tex.math_chars(&font.file);
474                (!mc.is_empty()).then(|| Rc::new(convert_math_chars(mc)))
475            },
476            math_constants: tex.math.as_ref().map(|m| {
477                Rc::new(
478                    m.iter()
479                        .map(|(k, v)| ((*k).to_owned(), v.to_sp()))
480                        .collect(),
481                )
482            }),
483        };
484        e.register_font(info)
485    })?;
486    state.borrow_mut().ids.insert(key, id);
487    Ok(Value::Integer(i64::from(id)))
488}
489
490/// Converts the font crate's math data to the engine's, rounding the
491/// dimensions to scaled points.
492fn convert_math_chars(
493    mc: HashMap<u32, retex_font::texfont::MathChar>,
494) -> HashMap<u32, retex_engine::fonts::MathCharInfo> {
495    use retex_engine::fonts::{ExtPart, MathCharInfo};
496    let parts = |v: &Option<Vec<retex_font::texfont::MathPart>>| {
497        v.as_ref().map(|ps| {
498            ps.iter()
499                .map(|p| ExtPart {
500                    code: p.code,
501                    start: retex_font::texfont::round_sp(p.start),
502                    end: retex_font::texfont::round_sp(p.end),
503                    advance: retex_font::texfont::round_sp(p.advance),
504                    extender: p.extender,
505                })
506                .collect::<Rc<[ExtPart]>>()
507        })
508    };
509    mc.into_iter()
510        .map(|(c, m)| {
511            (
512                c,
513                MathCharInfo {
514                    next: m.next,
515                    top_accent: m.top_accent.map(retex_font::texfont::round_sp),
516                    vert: parts(&m.vert),
517                    horiz: parts(&m.horiz),
518                },
519            )
520        })
521        .collect()
522}
523
524/// The OpenType font behind engine font `f`, if any.
525pub fn loaded_font(e: &Engine, f: u32) -> Option<Arc<LoadedFont>> {
526    let data = e.font(f).data.as_ref()?;
527    data.0.downcast_ref::<Arc<LoadedFont>>().cloned()
528}
529
530pub use crate::otfnodes::process_list;
531
532#[cfg(test)]
533mod tests {
534    #[test]
535    fn lualibs_utf() {
536        let lua = mlua::Lua::new();
537        let utf: mlua::Table = lua.load(super::UTF_LUA).eval().expect("utf");
538        lua.globals().set("utf", utf).expect("set");
539        let r: String = lua
540            .load(
541                r##"local o = {}
542                   local function add(...) o[#o + 1] = table.concat({...}, ",") end
543                   add(utf.char(65, 0x3B1), utf.byte("\u{3B1}b"), select("#", utf.byte("")))
544                   add(utf.len("\u{3B1}b"), utf.sub("\u{3B1}\u{3B2}c", 2, 3),
545                       utf.sub("\u{3B1}\u{3B2}c", 2), utf.sub("\u{3B1}\u{3B2}c", -1))
546                   for c in utf.characters("a\u{3B1}") do add(c) end
547                   for c in utf.values("a\u{3B1}") do add(c) end
548                   return table.concat(o, "|")"##,
549            )
550            .eval()
551            .expect("run");
552        // Observed in lualatex.
553        assert_eq!(r, "A\u{3B1},945,0|2,\u{3B2}c,\u{3B2}c,c|a|\u{3B1}|97|945");
554    }
555}