font-shp.lua / last modification: 2020-01-30 14:16
if not modules then modules = { } end modules ['font-shp'] = {
    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 tonumber, next = tonumber, next
local concat = table.concat
local formatters = string.formatters

local otf          = fonts.handlers.otf
local afm          = fonts.handlers.afm
local pfb          = fonts.handlers.pfb

local hashes       = fonts.hashes
local identifiers  = hashes.identifiers

local version      = 0.009
local shapescache  = containers.define("fonts", "shapes",  version, true)
local streamscache = containers.define("fonts", "streams", version, true)

-- shapes (can be come a separate file at some point)

local compact_streams = false

directives.register("fonts.streams.compact", function(v) compact_streams = v end)

local function packoutlines(data,makesequence)
    local subfonts = data.subfonts
    if subfonts then
        for i=1,#subfonts do
            packoutlines(subfonts[i],makesequence)
        end
        return
    end
    local common = data.segments
    if common then
        return
    end
    local glyphs = data.glyphs
    if not glyphs then
        return
    end
    if makesequence then
        for index=0,#glyphs do
            local glyph = glyphs[index]
            if glyph then
                local segments = glyph.segments
                if segments then
                    local sequence    = { }
                    local nofsequence = 0
                    for i=1,#segments do
                        local segment    = segments[i]
                        local nofsegment = #segment
                        -- why last first ... needs documenting
                        nofsequence = nofsequence + 1
                        sequence[nofsequence] = segment[nofsegment]
                        for i=1,nofsegment-1 do
                            nofsequence = nofsequence + 1
                            sequence[nofsequence] = segment[i]
                        end
                    end
                    glyph.sequence = sequence
                    glyph.segments = nil
                end
            end
        end
    else
        local hash    = { }
        local common  = { }
        local reverse = { }
        local last    = 0
        for index=0,#glyphs do
            local glyph = glyphs[index]
            if glyph then
                local segments = glyph.segments
                if segments then
                    for i=1,#segments do
                        local h = concat(segments[i]," ")
                        hash[h] = (hash[h] or 0) + 1
                    end
                end
            end
        end
        for index=0,#glyphs do
            local glyph = glyphs[index]
            if glyph then
                local segments = glyph.segments
                if segments then
                    for i=1,#segments do
                        local segment = segments[i]
                        local h = concat(segment," ")
                        if hash[h] > 1 then -- minimal one shared in order to hash
                            local idx = reverse[h]
                            if not idx then
                                last = last + 1
                                reverse[h] = last
                                common[last] = segment
                                idx = last
                            end
                            segments[i] = idx
                        end
                    end
                end
            end
        end
        if last > 0 then
            data.segments = common
        end
    end
end

local function unpackoutlines(data)
    local subfonts = data.subfonts
    if subfonts then
        for i=1,#subfonts do
            unpackoutlines(subfonts[i])
        end
        return
    end
    local common = data.segments
    if not common then
        return
    end
    local glyphs = data.glyphs
    if not glyphs then
        return
    end
    for index=0,#glyphs do
        local glyph = glyphs[index]
        if glyph then
            local segments = glyph.segments
            if segments then
                for i=1,#segments do
                    local c = common[segments[i]]
                    if c then
                        segments[i] = c
                    end
                end
            end
        end
    end
    data.segments = nil
end

-- todo: loaders per format

local readers   = otf.readers
local cleanname = otf.readers.helpers.cleanname

local function makehash(filename,sub,instance)
    local name = cleanname(file.basename(filename))
    if instance then
        return formatters["%s-%s-%s"](name,sub or 0,cleanname(instance))
    else
        return formatters["%s-%s"]   (name,sub or 0)
    end
end

local function loadoutlines(cache,filename,sub,instance)
    local base = file.basename(filename)
    local name = file.removesuffix(base)
    local kind = file.suffix(filename)
    local attr = lfs.attributes(filename)
    local size = attr and attr.size or 0
    local time = attr and attr.modification or 0
    local sub  = tonumber(sub)

    -- fonts.formats

    if size > 0 and (kind == "otf" or kind == "ttf" or kind == "tcc") then
        local hash = makehash(filename,sub,instance)
        data = containers.read(cache,hash)
        if not data or data.time ~= time or data.size  ~= size then
            data = otf.readers.loadshapes(filename,sub,instance)
            if data then
                data.size   = size
                data.format = data.format or (kind == "otf" and "opentype") or "truetype"
                data.time   = time
                packoutlines(data)
                containers.write(cache,hash,data)
                data = containers.read(cache,hash) -- frees old mem
            end
        end
        unpackoutlines(data)
    elseif size > 0 and (kind == "pfb") then
        local hash = containers.cleanname(base) -- including suffix
        data = containers.read(cache,hash)
        if not data or data.time ~= time or data.size  ~= size then
            data = afm.readers.loadshapes(filename)
            if data then
                data.size   = size
                data.format = "type1"
                data.time   = time
                packoutlines(data)
                containers.write(cache,hash,data)
                data = containers.read(cache,hash) -- frees old mem
            end
        end
        unpackoutlines(data)
    else
        data = {
            filename = filename,
            size     = 0,
            time     = time,
            format   = "unknown",
            units    = 1000,
            glyphs   = { }
        }
    end
    return data
end

local function cachethem(cache,hash,data)
    containers.write(cache,hash,data,compact_streams) -- arg 4 aka fast
    return containers.read(cache,hash) -- frees old mem
end

local function loadstreams(cache,filename,sub,instance)
    local base = file.basename(filename)
    local name = file.removesuffix(base)
    local kind = file.suffix(filename)
    local attr = lfs.attributes(filename)
    local size = attr and attr.size or 0
    local time = attr and attr.modification or 0
    local sub  = tonumber(sub)
    if size > 0 and (kind == "otf" or kind == "ttf" or kind == "ttc") then
        local hash = makehash(filename,sub,instance)
        data = containers.read(cache,hash)
        if not data or data.time ~= time or data.size  ~= size then
            data = otf.readers.loadshapes(filename,sub,instance,true)
            if data then
                local glyphs  = data.glyphs
                local streams = { }
                if glyphs then
                    for i=0,#glyphs do
                        local glyph = glyphs[i]
                        if glyph then
                            streams[i] = glyph.stream or ""
                        else
                            streams[i] = ""
                        end
                    end
                end
                data.streams = streams
                data.glyphs  = nil
                data.size    = size
                data.format  = data.format or (kind == "otf" and "opentype") or "truetype"
                data.time    = time
                data = cachethem(cache,hash,data)
            end
        end
    elseif size > 0 and (kind == "pfb") then
        local hash = makehash(filename,sub,instance)
        data = containers.read(cache,hash)
        if not data or data.time ~= time or data.size  ~= size then
            local names, encoding, streams, metadata = pfb.loadvector(filename,false,true)
            if streams then
                local fontbbox = metadata.fontbbox or { 0, 0, 0, 0 }
                for i=0,#streams do
                    streams[i] = streams[i].stream or "\14"
                end
                data = {
                    filename   = filename,
                    size       = size,
                    time       = time,
                    format     = "type1",
                    streams    = streams,
                    fontheader = {
                        fontversion = metadata.version,
                        units       = 1000, -- can this be different?
                        xmin        = fontbbox[1],
                        ymin        = fontbbox[2],
                        xmax        = fontbbox[3],
                        ymax        = fontbbox[4],
                    },
                    horizontalheader = {
                        ascender  = 0,
                        descender = 0,
                    },
                    maximumprofile = {
                        nofglyphs = #streams + 1,
                    },
                    names = {
                        copyright = metadata.copyright,
                        family    = metadata.familyname,
                        fullname  = metadata.fullname,
                        fontname  = metadata.fontname,
                        subfamily = metadata.subfamilyname,
                        trademark = metadata.trademark,
                        notice    = metadata.notice,
                        version   = metadata.version,
                    },
                    cffinfo = {
                        familyname         = metadata.familyname,
                        fullname           = metadata.fullname,
                        italicangle        = metadata.italicangle,
                        monospaced         = metadata.isfixedpitch and true or false,
                        underlineposition  = metadata.underlineposition,
                        underlinethickness = metadata.underlinethickness,
                        weight             = metadata.weight,
                    },
                }
                data = cachethem(cache,hash,data)
            end
        end
    else
        data = {
            filename = filename,
            size     = 0,
            time     = time,
            format   = "unknown",
            streams  = { }
        }
    end
    return data
end

local loadedshapes  = { }
local loadedstreams = { }

local function loadoutlinedata(fontdata,streams)
    local properties = fontdata.properties
    local filename   = properties.filename
    local subindex   = fontdata.subindex
    local instance   = properties.instance
    local hash       = makehash(filename,subindex,instance)
    local loaded     = loadedshapes[hash]
    if not loaded then
        loaded = loadoutlines(shapescache,filename,subindex,instance)
        loadedshapes[hash] = loaded
    end
    return loaded
end

hashes.shapes = table.setmetatableindex(function(t,k)
    local f = identifiers[k]
    if f then
        return loadoutlinedata(f)
    end
end)

local function getstreamhash(fontid)
    local fontdata = identifiers[fontid]
    if fontdata then
        local properties = fontdata.properties
        return makehash(properties.filename,fontdata.subindex,properties.instance)
    end
end

local function loadstreamdata(fontdata)
    local properties = fontdata.properties
    local shared     = fontdata.shared
    local rawdata    = shared and shared.rawdata
    local metadata   = rawdata and rawdata.metadata
    local filename   = properties.filename
    local subindex   = metadata and metadata.subfontindex
    local instance   = properties.instance
    local hash       = makehash(filename,subindex,instance)
    local loaded     = loadedstreams[hash]
    if not loaded then
        loaded = loadstreams(streamscache,filename,subindex,instance)
        loadedstreams[hash] = loaded
    end
    return loaded
end

hashes.streams = table.setmetatableindex(function(t,k)
    local f = identifiers[k]
    if f then
        return loadstreamdata(f)
    end
end)

otf.loadoutlinedata = loadoutlinedata -- not public
otf.loadstreamdata  = loadstreamdata  -- not public
otf.loadshapes      = loadshapes
otf.getstreamhash   = getstreamhash   -- not public, might move to other namespace

local f_c = formatters["%.6N %.6N %.6N %.6N %.6N %.6N c"]
local f_l = formatters["%.6N %.6N l"]
local f_m = formatters["%.6N %.6N m"]

local function segmentstopdf(segments,factor,bt,et)
    local t = { }
    local m = 0
    local n = #segments
    local d = false
    for i=1,n do
        local s = segments[i]
        local w = s[#s]
        if w == "c" then
            m = m + 1
            t[m] = f_c(s[1]*factor,s[2]*factor,s[3]*factor,s[4]*factor,s[5]*factor,s[6]*factor)
        elseif w == "l" then
            m = m + 1
            t[m] = f_l(s[1]*factor,s[2]*factor)
        elseif w == "m" then
            m = m + 1
            t[m] = f_m(s[1]*factor,s[2]*factor)
        elseif w == "q" then
            local p = segments[i-1]
            local n = #p
            local l_x = factor*p[n-2]
            local l_y = factor*p[n-1]
            local m_x = factor*s[1]
            local m_y = factor*s[2]
            local r_x = factor*s[3]
            local r_y = factor*s[4]
            m = m + 1
            t[m] = f_c (
                l_x + 2/3 * (m_x-l_x), l_y + 2/3 * (m_y-l_y),
                r_x + 2/3 * (m_x-r_x), r_y + 2/3 * (m_y-r_y),
                r_x, r_y
            )
        end
    end
    m = m + 1
    t[m] = "h f" -- B*
    if bt and et then
        t[0]   = bt
        t[m+1] = et
        return concat(t,"\n",0,m+1)
    else
        return concat(t,"\n")
    end
end

local function initialize(tfmdata,key,value)
    if value then
        local shapes = otf.loadoutlinedata(tfmdata)
        if not shapes then
            return
        end
        local glyphs = shapes.glyphs
        if not glyphs then
            return
        end
        local characters = tfmdata.characters
        local parameters = tfmdata.parameters
        local hfactor    = parameters.hfactor * (7200/7227)
        local factor     = hfactor / 65536
        local getactualtext = otf.getactualtext
        for unicode, char in next, characters do
            if char.commands then
                -- can't happen as we're doing this before other messing around
            else
                local shape = glyphs[char.index]
                if shape then
                    local segments = shape.segments
                    if segments then
                     -- we need inline in order to support color
                        local bt, et = getactualtext(char.tounicode or char.unicode or unicode)
                        char.commands = {
                            { "pdf", "origin", segmentstopdf(segments,factor,bt,et) }
                        }
                    end
                end
            end
        end
    end
end

otf.features.register {
    name        = "variableshapes", -- enforced for now
    description = "variable shapes",
    manipulators = {
        base = initialize,
        node = initialize,
    }
}

-- In the end it is easier to just provide the new charstring (cff) and points (ttf). First
-- of all we already have the right information so there is no need to patch the already complex
-- backend code (we only need to make sure the cff is valid). Also, I prototyped support for
-- these fonts using (converted to) normal postscript shapes, a functionality that was already
-- present for a while for metafun. This solution even permits us to come up with usage of such
-- fonts in unexpected ways. It also opens the road to shapes generated with metafun includes
-- as real cff (or ttf) shapes instead of virtual in-line shapes.
--
-- This is probably a prelude to writing a complete backend font inclusion plugin in lua. After
-- all I already have most info. For this we just need to pass a list of used glyphs (or analyze
-- them ourselves).

local streams = fonts.hashes.streams

if callbacks.supported.glyph_stream_provider then

    callback.register("glyph_stream_provider",function(id,index,mode)
        if id > 0 then
            local streams = streams[id].streams
         -- print(id,index,streams[index])
            if streams then
                return streams[index] or ""
            end
        end
        return ""
     end)

end