font-otc.lua / last modification: 2020-01-30 14:16
if not modules then modules = { } end modules ['font-otc'] = {
    version   = 1.001,
    comment   = "companion to font-ini.mkiv",
    author    = "Hans Hagen, PRAGMA-ADE, Hasselt NL",
    copyright = "PRAGMA ADE / ConTeXt Development Team",
    license   = "see context related readme files"
}

local insert, sortedkeys, sortedhash, tohash = table.insert, table.sortedkeys, table.sortedhash, table.tohash
local type, next, tonumber = type, next, tonumber
local lpegmatch = lpeg.match
local utfbyte, utflen = utf.byte, utf.len
local sortedhash = table.sortedhash

-- we assume that the other otf stuff is loaded already

local trace_loading       = false  trackers.register("otf.loading", function(v) trace_loading = v end)
local report_otf          = logs.reporter("fonts","otf loading")

local fonts               = fonts
local otf                 = fonts.handlers.otf
local registerotffeature  = otf.features.register
local setmetatableindex   = table.setmetatableindex

local fonthelpers         = fonts.helpers
local checkmerge          = fonthelpers.checkmerge
local checkflags          = fonthelpers.checkflags
local checksteps          = fonthelpers.checksteps

local normalized = {
    substitution      = "substitution",
    single            = "substitution",
    ligature          = "ligature",
    alternate         = "alternate",
    multiple          = "multiple",
    kern              = "kern",
    pair              = "pair",
    single            = "single",
    chainsubstitution = "chainsubstitution",
    chainposition     = "chainposition",
}

local types = {
    substitution      = "gsub_single",
    ligature          = "gsub_ligature",
    alternate         = "gsub_alternate",
    multiple          = "gsub_multiple",
    kern              = "gpos_pair",
    pair              = "gpos_pair",
    single            = "gpos_single",
    chainsubstitution = "gsub_contextchain",
    chainposition     = "gpos_contextchain",
}

local names = {
    gsub_single              = "gsub",
    gsub_multiple            = "gsub",
    gsub_alternate           = "gsub",
    gsub_ligature            = "gsub",
    gsub_context             = "gsub",
    gsub_contextchain        = "gsub",
    gsub_reversecontextchain = "gsub",
    gpos_single              = "gpos",
    gpos_pair                = "gpos",
    gpos_cursive             = "gpos",
    gpos_mark2base           = "gpos",
    gpos_mark2ligature       = "gpos",
    gpos_mark2mark           = "gpos",
    gpos_context             = "gpos",
    gpos_contextchain        = "gpos",
}

setmetatableindex(types, function(t,k) t[k] = k return k end) -- "key"

local everywhere = { ["*"] = { ["*"] = true } } -- or: { ["*"] = { "*" } }
local noflags    = { false, false, false, false }

-- beware: shared, maybe we should copy the sequence

local function getrange(sequences,category)
    local count = #sequences
    local first = nil
    local last  = nil
    for i=1,count do
        local t = sequences[i].type
        if t and names[t] == category then
            if not first then
                first = i
            end
            last  = i
        end
    end
    return first or 1, last or count
end

local function validspecification(specification,name)
    local dataset = specification.dataset
    if dataset then
        -- okay
    elseif specification[1] then
        dataset = specification
        specification = { dataset = dataset }
    else
        dataset = { { data = specification.data } }
        specification.data    = nil
        specification.dataset = dataset
    end
    local first = dataset[1]
    if first then
        first = first.data
    end
    if not first then
        report_otf("invalid feature specification, no dataset")
        return
    end
    if type(name) ~= "string" then
        name = specification.name or first.name
    end
    if type(name) ~= "string" then
        report_otf("invalid feature specification, no name")
        return
    end
    local n = #dataset
    if n > 0 then
        for i=1,n do
            setmetatableindex(dataset[i],specification)
        end
        return specification, name
    end
end

local function addfeature(data,feature,specifications)

    -- todo: add some validator / check code so that we're more tolerant to
    -- user errors

    if not specifications then
        report_otf("missing specification")
        return
    end

    local descriptions = data.descriptions
    local resources    = data.resources
    local features     = resources.features
    local sequences    = resources.sequences

    if not features or not sequences then
        report_otf("missing specification")
        return
    end

    local alreadydone = resources.alreadydone
    if not alreadydone then
        alreadydone = { }
        resources.alreadydone = alreadydone
    end
    if alreadydone[specifications] then
        return
    else
        alreadydone[specifications] = true
    end

    -- feature has to be unique but the name entry wins eventually

    local fontfeatures = resources.features or everywhere
    local unicodes     = resources.unicodes
    local splitter     = lpeg.splitter(" ",unicodes)
    local done         = 0
    local skip         = 0
    local aglunicodes  = false
    local privateslot  = fonthelpers.privateslot

    local specifications = validspecification(specifications,feature)
    if not specifications then
     -- report_otf("invalid specification")
        return
    end

    local p = lpeg.P("P")
            * (lpeg.patterns.hexdigit^1/function(s) return tonumber(s,16) end)
            * lpeg.P(-1)

    local function tounicode(code)
        if not code then
            return
        end
        if type(code) == "number" then
            return code
        end
        local u = unicodes[code]
        if u then
         -- unicodes[code] = u
            return u
        end
        if utflen(code) == 1 then
            u = utfbyte(code)
            if u then
                return u
            end
        end
        if privateslot then
            u = privateslot(code) -- no creation !
            if u then
             -- unicodes[code] = u
                return u
            end
        end
        local u = lpegmatch(p,code)
        if u then
         -- unicodes[code] = u
            return u
        end
        if not aglunicodes then
            aglunicodes = fonts.encodings.agl.unicodes -- delayed
        end
        local u = aglunicodes[code]
        if u then
         -- unicodes[code] = u
            return u
        end
    end

    local coverup      = otf.coverup
    local coveractions = coverup.actions
    local stepkey      = coverup.stepkey
    local register     = coverup.register

    local function prepare_substitution(list,featuretype,nocheck)
        local coverage = { }
        local cover    = coveractions[featuretype]
        for code, replacement in next, list do
            local unicode     = tounicode(code)
            local description = descriptions[unicode]
            if not nocheck and not description then
                skip = skip + 1
            else
                if type(replacement) == "table" then
                    replacement = replacement[1]
                end
                replacement = tounicode(replacement)
                if replacement and (nocheck or descriptions[replacement]) then
                    cover(coverage,unicode,replacement)
                    done = done + 1
                else
                    skip = skip + 1
                end
            end
        end
        return coverage
    end

    local function prepare_alternate(list,featuretype,nocheck)
        local coverage = { }
        local cover    = coveractions[featuretype]
        for code, replacement in next, list do
            local unicode     = tounicode(code)
            local description = descriptions[unicode]
            if not nocheck and not description then
                skip = skip + 1
            elseif type(replacement) == "table" then
                local r = { }
                for i=1,#replacement do
                    local u = tounicode(replacement[i])
                    r[i] = (nocheck or descriptions[u]) and u or unicode
                end
                cover(coverage,unicode,r)
                done = done + 1
            else
                local u = tounicode(replacement)
                if u then
                    cover(coverage,unicode,{ u })
                    done = done + 1
                else
                    skip = skip + 1
                end
            end
        end
        return coverage
    end

    local function prepare_multiple(list,featuretype,nocheck)
        local coverage = { }
        local cover    = coveractions[featuretype]
        for code, replacement in next, list do
            local unicode     = tounicode(code)
            local description = descriptions[unicode]
            if not nocheck and not description then
                skip = skip + 1
            elseif type(replacement) == "table" then
                local r = { }
                local n = 0
                for i=1,#replacement do
                    local u = tounicode(replacement[i])
                    if nocheck or descriptions[u] then
                        n = n + 1
                        r[n] = u
                    end
                end
                if n > 0 then
                    cover(coverage,unicode,r)
                    done = done + 1
                else
                    skip = skip + 1
                end
            else
                local u = tounicode(replacement)
                if u then
                    cover(coverage,unicode,{ u })
                    done = done + 1
                else
                    skip = skip + 1
                end
            end
        end
        return coverage
    end

    local function prepare_ligature(list,featuretype,nocheck)
        local coverage = { }
        local cover    = coveractions[featuretype]
        for code, ligature in next, list do
            local unicode     = tounicode(code)
            local description = descriptions[unicode]
            if not nocheck and not description then
                skip = skip + 1
            else
                if type(ligature) == "string" then
                    ligature = { lpegmatch(splitter,ligature) }
                end
                local present = true
                for i=1,#ligature do
                    local l = ligature[i]
                    local u = tounicode(l)
                    if nocheck or descriptions[u] then
                        ligature[i] = u
                    else
                        present = false
                        break
                    end
                end
                if present then
                    cover(coverage,unicode,ligature)
                    done = done + 1
                else
                    skip = skip + 1
                end
            end
        end
        return coverage
    end

    local function resetspacekerns()
        -- a bit of a hack, this nil setting but it forces a
        -- rehash of the resources needed .. the feature itself
        -- should be a kern (at least for now)
        data.properties.hasspacekerns = true
        data.resources .spacekerns    = nil
    end

    local function prepare_kern(list,featuretype)
        local coverage = { }
        local cover    = coveractions[featuretype]
        local isspace  = false
        for code, replacement in next, list do
            local unicode     = tounicode(code)
            local description = descriptions[unicode]
            if description and type(replacement) == "table" then
                local r = { }
                for k, v in next, replacement do
                    local u = tounicode(k)
                    if u then
                        r[u] = v
                        if u == 32 then
                            isspace = true
                        end
                    end
                end
                if next(r) then
                    cover(coverage,unicode,r)
                    done = done + 1
                    if unicode == 32 then
                        isspace = true
                    end
                else
                    skip = skip + 1
                end
            else
                skip = skip + 1
            end
        end
        if isspace then
            resetspacekerns()
        end
        return coverage
    end

    local function prepare_pair(list,featuretype)
        local coverage = { }
        local cover    = coveractions[featuretype]
        if cover then
            for code, replacement in next, list do
                local unicode     = tounicode(code)
                local description = descriptions[unicode]
                if description and type(replacement) == "table" then
                    local r = { }
                    for k, v in next, replacement do
                        local u = tounicode(k)
                        if u then
                            r[u] = v
                            if u == 32 then
                                isspace = true
                            end
                        end
                    end
                    if next(r) then
                        cover(coverage,unicode,r)
                        done = done + 1
                        if unicode == 32 then
                            isspace = true
                        end
                    else
                        skip = skip + 1
                    end
                else
                    skip = skip + 1
                end
            end
            if isspace then
                resetspacekerns()
            end
        else
            report_otf("unknown cover type %a",featuretype)
        end
        return coverage
    end

    local prepare_single = prepare_pair -- we could have a better test on the spec

    local function prepare_chain(list,featuretype,sublookups)
        -- todo: coveractions
        local rules    = list.rules
        local coverage = { }
        if rules then
            local rulehash   = { }
            local rulesize   = 0
            local lookuptype = types[featuretype]
            for nofrules=1,#rules do
                local rule         = rules[nofrules]
                local current      = rule.current
                local before       = rule.before
                local after        = rule.after
                local replacements = rule.replacements or false
                local sequence     = { }
                local nofsequences = 0
                if before then
                    for n=1,#before do
                        nofsequences = nofsequences + 1
                        sequence[nofsequences] = before[n]
                    end
                end
                local start = nofsequences + 1
                for n=1,#current do
                    nofsequences = nofsequences + 1
                    sequence[nofsequences] = current[n]
                end
                local stop = nofsequences
                if after then
                    for n=1,#after do
                        nofsequences = nofsequences + 1
                        sequence[nofsequences] = after[n]
                    end
                end
                local lookups = rule.lookups or false
                local subtype = nil
                if lookups and sublookups then
                    for k, v in sortedhash(lookups) do
                        local t = type(v)
                        if t == "table" then
                            -- already ok
                            for i=1,#v do
                                local vi = v[i]
                                if type(vi) ~= "table" then
                                    v[i] = { vi }
                                end
                            end
                        elseif t == "number" then
                            local lookup = sublookups[v]
                            if lookup then
                                lookups[k] = { lookup }
                                if not subtype then
                                    subtype = lookup.type
                                end
                            elseif v == 0 then
                                lookups[k] = { { type = "gsub_remove" } }
                            else
                                lookups[k] = false -- { false } -- new
                            end
                        else
                            lookups[k] = false -- { false } -- new
                        end
                    end
                end
                if nofsequences > 0 then -- we merge coverage into one
                    -- we copy as we can have different fonts
                    local hashed = { }
                    for i=1,nofsequences do
                        local t = { }
                        local s = sequence[i]
                        for i=1,#s do
                            local u = tounicode(s[i])
                            if u then
                                t[u] = true
                            end
                        end
                        hashed[i] = t
                    end
                    sequence = hashed
                    -- now we create the rule
                    rulesize = rulesize + 1
                    rulehash[rulesize] = {
                        nofrules,     -- 1
                        lookuptype,   -- 2
                        sequence,     -- 3
                        start,        -- 4
                        stop,         -- 5
                        lookups,      -- 6 (6/7 also signal of what to do)
                        replacements, -- 7
                        subtype,      -- 8
                    }
--                     for unic in next, sequence[start] do
                    for unic in sortedhash(sequence[start]) do
                        local cu = coverage[unic]
                        if not cu then
                            coverage[unic] = rulehash -- can now be done cleaner i think
                        end
                    end
                    sequence.n = nofsequences
                end
            end
            rulehash.n = rulesize
        end
        return coverage
    end

    local dataset = specifications.dataset

    local function report(name,category,position,first,last,sequences)
        report_otf("injecting name %a of category %a at position %i in [%i,%i] of [%i,%i]",
            name,category,position,first,last,1,#sequences)
    end

    local function inject(specification,sequences,sequence,first,last,category,name)
        local position = specification.position or false
        if not position then
            position = specification.prepend
            if position == true then
                if trace_loading then
                    report(name,category,first,first,last,sequences)
                end
                insert(sequences,first,sequence)
                return
            end
        end
        if not position then
            position = specification.append
            if position == true then
                if trace_loading then
                    report(name,category,last+1,first,last,sequences)
                end
                insert(sequences,last+1,sequence)
                return
            end
        end
        local kind = type(position)
        if kind == "string" then
            local index = false
            for i=first,last do
                local s = sequences[i]
                local f = s.features
                if f then
                    for k in sortedhash(f) do -- next, f do
                        if k == position then
                            index = i
                            break
                        end
                    end
                    if index then
                        break
                    end
                end
            end
            if index then
                position = index
            else
                position = last + 1
            end
        elseif kind == "number" then
            if position < 0 then
                position = last - position + 1
            end
            if position > last then
                position = last + 1
            elseif position < first then
                position = first
            end
        else
            position = last + 1
        end
        if trace_loading then
            report(name,category,position,first,last,sequences)
        end
        insert(sequences,position,sequence)
    end

    for s=1,#dataset do
        local specification = dataset[s]
        local valid = specification.valid -- nowhere used
        local feature = specification.name or feature
        if not feature or feature == "" then
            report_otf("no valid name given for extra feature")
        elseif not valid or valid(data,specification,feature) then -- anum uses this
            local initialize = specification.initialize
            if initialize then
                -- when false is returned we initialize only once
                specification.initialize = initialize(specification,data) and initialize or nil
            end
            local askedfeatures = specification.features or everywhere
            local askedsteps    = specification.steps or specification.subtables or { specification.data } or { }
            local featuretype   = normalized[specification.type or "substitution"] or "substitution"
            local featureflags  = specification.flags or noflags
            local nocheck       = specification.nocheck
            local featureorder  = specification.order or { feature }
            local featurechain  = (featuretype == "chainsubstitution" or featuretype == "chainposition") and 1 or 0
            local nofsteps      = 0
            local steps         = { }
            local sublookups    = specification.lookups
            local category      = nil
            --
            checkflags(specification,resources)
            --
            if sublookups then
                local s = { }
                for i=1,#sublookups do
                    local specification = sublookups[i]
                    local askedsteps    = specification.steps or specification.subtables or { specification.data } or { }
                    local featuretype   = normalized[specification.type or "substitution"] or "substitution"
                    local featureflags  = specification.flags or noflags
                    local nofsteps      = 0
                    local steps         = { }
                    for i=1,#askedsteps do
                        local list     = askedsteps[i]
                        local coverage = nil
                        local format   = nil
                        if featuretype == "substitution" then
                            coverage = prepare_substitution(list,featuretype,nocheck)
                        elseif featuretype == "ligature" then
                            coverage = prepare_ligature(list,featuretype,nocheck)
                        elseif featuretype == "alternate" then
                            coverage = prepare_alternate(list,featuretype,nocheck)
                        elseif featuretype == "multiple" then
                            coverage = prepare_multiple(list,featuretype,nocheck)
                        elseif featuretype == "kern" or featuretype == "move" then
                            format   = featuretype
                            coverage = prepare_kern(list,featuretype)
                        elseif featuretype == "pair" then
                            format   = "pair"
                            coverage = prepare_pair(list,featuretype)
                        elseif featuretype == "single" then
                            format   = "single"
                            coverage = prepare_single(list,featuretype)
                        end
                        if coverage and next(coverage) then
                            nofsteps = nofsteps + 1
                            steps[nofsteps] = register(coverage,featuretype,format,feature,nofsteps,descriptions,resources)
                        end
                    end
                    --
                    checkmerge(specification)
                    checksteps(specification)
                    --
                    s[i] = {
                        [stepkey] = steps,
                        nofsteps  = nofsteps,
                        flags     = featureflags,
                        type      = types[featuretype],
                    }
                end
                sublookups = s
            end
            for i=1,#askedsteps do
                local list     = askedsteps[i]
                local coverage = nil
                local format   = nil
                if featuretype == "substitution" then
                    category = "gsub"
                    coverage = prepare_substitution(list,featuretype,nocheck)
                elseif featuretype == "ligature" then
                    category = "gsub"
                    coverage = prepare_ligature(list,featuretype,nocheck)
                elseif featuretype == "alternate" then
                    category = "gsub"
                    coverage = prepare_alternate(list,featuretype,nocheck)
                elseif featuretype == "multiple" then
                    category = "gsub"
                    coverage = prepare_multiple(list,featuretype,nocheck)
                elseif featuretype == "kern" or featuretype == "move" then
                    category = "gpos"
                    format   = featuretype
                    coverage = prepare_kern(list,featuretype)
                elseif featuretype == "pair" then
                    category = "gpos"
                    format   = "pair"
                    coverage = prepare_pair(list,featuretype)
                elseif featuretype == "single" then
                    category = "gpos"
                    format   = "single"
                    coverage = prepare_single(list,featuretype)
                elseif featuretype == "chainsubstitution" then
                    category = "gsub"
                    coverage = prepare_chain(list,featuretype,sublookups)
                elseif featuretype == "chainposition" then
                    category = "gpos"
                    coverage = prepare_chain(list,featuretype,sublookups)
                else
                    report_otf("not registering feature %a, unknown category",feature)
                    return
                end
                if coverage and next(coverage) then
                    nofsteps = nofsteps + 1
                    steps[nofsteps] = register(coverage,featuretype,format,feature,nofsteps,descriptions,resources)
                end
            end
            if nofsteps > 0 then
                -- script = { lang1, lang2, lang3 } or script = { lang1 = true, ... }
                for k, v in next, askedfeatures do
                    if v[1] then
                        askedfeatures[k] = tohash(v)
                    end
                end
                --
                if featureflags[1] then featureflags[1] = "mark" end
                if featureflags[2] then featureflags[2] = "ligature" end
                if featureflags[3] then featureflags[3] = "base" end
                local steptype = types[featuretype]
                local sequence = {
                    chain     = featurechain,
                    features  = { [feature] = askedfeatures },
                    flags     = featureflags,
                    name      = feature, -- redundant
                    order     = featureorder,
                    [stepkey] = steps,
                    nofsteps  = nofsteps,
                    type      = steptype,
                }
                --
                checkflags(sequence,resources)
                checkmerge(sequence)
                checksteps(sequence)
                -- position | prepend | append
                local first, last = getrange(sequences,category)
                inject(specification,sequences,sequence,first,last,category,feature)
                -- register in metadata (merge as there can be a few)
                local features = fontfeatures[category]
                if not features then
                    features  = { }
                    fontfeatures[category] = features
                end
                local k = features[feature]
                if not k then
                    k = { }
                    features[feature] = k
                end
                --
                for script, languages in next, askedfeatures do
                    local kk = k[script]
                    if not kk then
                        kk = { }
                        k[script] = kk
                    end
                    for language, value in next, languages do
                        kk[language] = value
                    end
                end
            end
        end
    end
    if trace_loading then
        report_otf("registering feature %a, affected glyphs %a, skipped glyphs %a",feature,done,skip)
    end
end

otf.enhancers.addfeature = addfeature

local extrafeatures = { }
local knownfeatures = { }

function otf.addfeature(name,specification)
    if type(name) == "table" then
        specification = name
    end
    if type(specification) ~= "table" then
        report_otf("invalid feature specification, no valid table")
        return
    end
    specification, name = validspecification(specification,name)
    if name and specification then
        local slot = knownfeatures[name]
        if not slot then
            -- we have a new one
            slot = #extrafeatures + 1
            knownfeatures[name] = slot
        elseif specification.overload == false then
            -- we add an extre one
            slot = #extrafeatures + 1
            knownfeatures[name] = slot
        else
            -- we overload a previous one
        end
        specification.name  = name -- to be sure
        extrafeatures[slot] = specification
     -- report_otf("adding feature %a @ %i",name,slot)
    end
end

-- for feature, specification in next, extrafeatures do
--     addfeature(data,feature,specification)
-- end

local function enhance(data,filename,raw)
    for slot=1,#extrafeatures do
        local specification = extrafeatures[slot]
        addfeature(data,specification.name,specification)
    end
end

otf.enhancers.enhance = enhance

otf.enhancers.register("check extra features",enhance)

-- fonts.handlers.otf.features.register {
--     name        = 'hangulfix',
--     description = 'fixes for hangul',
-- }

-- fonts.handlers.otf.addfeature {
--     name = "stest",
--     type = "substitution",
--     data = {
--         a = "X",
--         b = "P",
--     }
-- }
-- fonts.handlers.otf.addfeature {
--     name = "atest",
--     type = "alternate",
--     data = {
--         a = { "X", "Y" },
--         b = { "P", "Q" },
--     }
-- }
-- fonts.handlers.otf.addfeature {
--     name = "mtest",
--     type = "multiple",
--     data = {
--         a = { "X", "Y" },
--         b = { "P", "Q" },
--     }
-- }
-- fonts.handlers.otf.addfeature {
--     name = "ltest",
--     type = "ligature",
--     data = {
--         X = { "a", "b" },
--         Y = { "d", "a" },
--     }
-- }
-- fonts.handlers.otf.addfeature {
--     name = "ktest",
--     type = "kern",
--     data = {
--         a = { b = -500 },
--     }
-- }