retex-lua 1.3.0-rc.3

Embedded Lua and the LuaTeX Lua API for retex
Documentation
//! The `fontloader` library: only `fontloader.info`, which packages use to
//! read a font's names (hyperxmp lists the document's fonts in the XMP
//! metadata).
//!
//! Field values follow LuaTeX 1.22 (black-box): for OpenType fonts,
//! `fontname`, `familyname` and `fullname` are the English name-table
//! entries 6, 1 and 4; `version` is entry 5 without its `Version ` prefix;
//! `weight` names the OS/2 weight class (400 is `Book`). For Type 1 fonts
//! they come from the font dictionary (`/FontName`, `/FamilyName`,
//! `/FullName`, `/version`, `/Weight`, `/ItalicAngle`).

use mlua::{Lua, Table, Value};

/// The fields `fontloader.info` returns.
#[derive(Debug, Default, PartialEq)]
struct Info {
    fontname: String,
    familyname: String,
    fullname: String,
    version: String,
    weight: String,
    italicangle: f64,
    units_per_em: u16,
}

/// FontForge's names of the OS/2 weight classes.
fn weight_name(class: u16) -> &'static str {
    match class {
        0..=150 => "Thin",
        151..=250 => "Extra-Light",
        251..=350 => "Light",
        351..=450 => "Book",
        451..=550 => "Medium",
        551..=650 => "Demi-Bold",
        651..=750 => "Bold",
        751..=850 => "Heavy",
        _ => "Black",
    }
}

fn opentype_info(data: &[u8]) -> Option<Info> {
    let font = retex_font::font::OpenTypeFont::parse(data, 0).ok()?;
    let name = |id| font.name(id).unwrap_or_default().to_owned();
    let version = name(5);
    Some(Info {
        fontname: name(6),
        familyname: name(1),
        fullname: name(4),
        version: version
            .strip_prefix("Version ")
            .unwrap_or(&version)
            .to_owned(),
        weight: weight_name(font.os2.map_or(400, |o| o.weight_class)).to_owned(),
        italicangle: font.post.as_ref().map_or(0.0, |p| p.italic_angle_degrees()),
        units_per_em: font.units_per_em(),
    })
}

/// The clear-text part of a Type 1 font (`.pfb` segments or `.pfa`).
fn type1_cleartext(data: &[u8]) -> &[u8] {
    if data.first() == Some(&0x80) && data.len() >= 6 {
        let len = u32::from_le_bytes([data[2], data[3], data[4], data[5]]) as usize;
        return data.get(6..6 + len).unwrap_or(&data[6..]);
    }
    let end = data
        .windows(5)
        .position(|w| w == b"eexec")
        .unwrap_or(data.len());
    &data[..end]
}

fn type1_info(data: &[u8]) -> Option<Info> {
    let text = String::from_utf8_lossy(type1_cleartext(data));
    if !text.starts_with("%!") {
        return None;
    }
    // `/Key (string)` or `/Key /name` or `/Key number`.
    let value = |key: &str| -> Option<String> {
        let at = text.find(&format!("/{key} "))? + key.len() + 2;
        let rest = text[at..].trim_start();
        if let Some(s) = rest.strip_prefix('(') {
            return s.find(')').map(|e| s[..e].to_owned());
        }
        let rest = rest.strip_prefix('/').unwrap_or(rest);
        let end = rest
            .find(|c: char| c.is_whitespace() || "/[{(".contains(c))
            .unwrap_or(rest.len());
        Some(rest[..end].to_owned())
    };
    Some(Info {
        fontname: value("FontName")?,
        familyname: value("FamilyName").unwrap_or_default(),
        fullname: value("FullName").unwrap_or_default(),
        version: value("version").unwrap_or_default(),
        weight: value("Weight").unwrap_or_default(),
        italicangle: value("ItalicAngle")
            .and_then(|v| v.parse().ok())
            .unwrap_or(0.0),
        units_per_em: 0,
    })
}

fn info(data: &[u8]) -> Option<Info> {
    opentype_info(data).or_else(|| type1_info(data))
}

fn to_table(lua: &Lua, i: Info) -> mlua::Result<Table> {
    let t = lua.create_table()?;
    t.set("fontname", i.fontname)?;
    t.set("familyname", i.familyname)?;
    t.set("fullname", i.fullname)?;
    t.set("version", i.version)?;
    t.set("weight", i.weight)?;
    t.set("italicangle", i.italicangle)?;
    t.set("units_per_em", i.units_per_em)?;
    Ok(t)
}

/// Installs `fontloader`.
pub fn install(lua: &Lua) -> mlua::Result<()> {
    let t = lua.create_table()?;
    t.set(
        "info",
        lua.create_function(|lua, name: String| {
            let fail = |why: &str| {
                Ok((
                    Value::Nil,
                    Value::String(
                        lua.create_string(format!("font loading failed for {name} ({why})"))?,
                    ),
                ))
            };
            let Ok(data) = std::fs::read(&name) else {
                return fail("read error");
            };
            match info(&data) {
                Some(i) => Ok((Value::Table(to_table(lua, i)?), Value::Nil)),
                None => fail("no font"),
            }
        })?,
    )?;
    lua.globals().set("fontloader", t)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn type1_dictionary() {
        let pfa = b"%!PS-AdobeFont-1.0: CMR10 003.002\n/FontInfo 7 dict dup begin\n\
            /version (003.002) readonly def\n/FullName (CMR10) readonly def\n\
            /FamilyName (Computer Modern) readonly def\n/Weight (Medium) readonly def\n\
            /ItalicAngle 0 def\nend readonly def\n/FontName /CMR10 def\ncurrentfile eexec";
        let i = info(pfa).unwrap();
        // LuaTeX 1.22 on cmr10.pfb.
        assert_eq!(
            (
                &*i.fontname,
                &*i.familyname,
                &*i.fullname,
                &*i.version,
                &*i.weight
            ),
            ("CMR10", "Computer Modern", "CMR10", "003.002", "Medium")
        );
    }

    #[test]
    fn weights() {
        assert_eq!(weight_name(400), "Book");
        assert_eq!(weight_name(700), "Bold");
    }
}