Files
HyperionOS/Src/gfxterm/data/lib/psf
T
2026-08-14 20:44:11 -04:00

233 lines
6.1 KiB
Plaintext

-- psf.lua
-- NeetComputers PSF1 & PSF2 font renderer
-- Copyright 2026 SpartanSoftware
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
-- MODULE API
-- psf.open(path, disk = 0) Returns a Font object, or nil + errmsg
--
-- FONT OBJECT API
-- font:print(x, y, text) Draw text in white
-- font:print(x, y, text, r, g, b) Draw text in given color
-- font:measure(text) Width of string in pixels
-- font:lineHeight() Font height in pixels
-- font:setSpacing(w) Set px width of glyph spacing
-- font:close() Free all layers
local psf = {}
local function readUInt(header, n)
local bytes = header.read(n)
if not bytes or #bytes < n then return nil end
local value = 0
for i = 1, n do
value = value + string.byte(bytes, i) * (256 ^ (i - 1))
end
return math.floor(value)
end
local function parseGlyph(bytes, w, h)
local bytesPerRow = math.floor((w + 7) / 8)
local pixels = {}
for y = 1, h do
pixels[y] = {}
for x = 1, w do
local byteIdx = (y - 1) * bytesPerRow + math.floor((x - 1) / 8) + 1
local bitPos = 7 - ((x - 1) % 8)
local b = string.byte(bytes, byteIdx) or 0
pixels[y][x] = (math.floor(b / (2 ^ bitPos)) % 2 == 1)
end
end
return pixels
end
local OFF_PIXEL = "\0\0\0\0"
local function getGlyphBuffer(glyph, r, g, b)
local cache = glyph._bufCache
if not cache then
cache = {}
glyph._bufCache = cache
end
local key = r * 65536 + g * 256 + b
local buf = cache[key]
if buf then return buf end
local onPixel = string.char(r, g, b, 255)
local pixels = glyph.pixels
local chunks = {}
local n = 0
for y = 1, glyph.height do
local row = pixels[y]
for x = 1, glyph.width do
n = n + 1
chunks[n] = row[x] and onPixel or OFF_PIXEL
end
end
buf = table.concat(chunks)
cache[key] = buf
return buf
end
local function parsePSF1(header, glyphs)
local mode = readUInt(header, 1)
local charsize = readUInt(header, 1)
if not charsize then return false, "truncated PSF1 header" end
local count = (mode and (mode % 4 >= 2)) and 512 or 256
for i = 0, count - 1 do
local raw = header.read(charsize)
if not raw or #raw < charsize then break end
glyphs[i] = { width = 8, height = charsize,
pixels = parseGlyph(raw, 8, charsize) }
end
return true, 8, charsize
end
local function parsePSF2(header, glyphs)
readUInt(header, 4)
local headersize = readUInt(header, 4)
readUInt(header, 4)
local numglyph = readUInt(header, 4)
local bytesperglyph = readUInt(header, 4)
local h = readUInt(header, 4)
local w = readUInt(header, 4)
if not w or not h then return false, "truncated PSF2 header" end
local consumed = 32
if headersize > consumed then
header.seek('cur', headersize - consumed)
end
for i = 0, numglyph - 1 do
local raw = header.read(bytesperglyph)
if not raw or #raw < bytesperglyph then break end
glyphs[i] = { width = w, height = h,
pixels = parseGlyph(raw, w, h) }
end
return true, w, h
end
local Font = {}
Font.__index = Font
function Font:print(x, y, text, r, g, b)
assert(not self._closed, "psf: font has been closed")
r = r or 255
g = g or 255
b = b or 255
local sw = screen.getSize()
local cur = x
local spacing = self._spacing
for i = 1, #text do
if cur >= sw then break end
local code = string.byte(text, i)
local glyph = self._glyphs[code]
if glyph then
local buf = getGlyphBuffer(glyph, r, g, b)
screen.drawPixels(cur, y, buf, glyph.width, glyph.height)
cur = cur + glyph.width + spacing
end
end
end
function Font:measure(text)
local w = 0
local count = 0
for i = 1, #text do
local g = self._glyphs[string.byte(text, i)]
if g then
w = w + g.width
count = count + 1
end
end
if count > 1 then
w = w + self._spacing * (count - 1)
end
return w
end
function Font:lineHeight()
return self._height
end
function Font:close()
if self._closed then return end
for _, glyph in pairs(self._glyphs) do
glyph._bufCache = nil
end
self._glyphs = {}
self._closed = true
end
function Font:setSpacing(px)
self._spacing = px
end
function psf.open(path, disk)
disk = disk or 0
if not files.exists(path, disk) then
return nil, "file not found: " .. path
end
local ok, header = pcall(files.open, path, 'rb', disk)
if not ok or not header then
return nil, "could not open: " .. path
end
local magic = header.read(2)
if not magic or #magic < 2 then
header.close()
return nil, "could not read magic bytes"
end
local b1, b2 = string.byte(magic, 1), string.byte(magic, 2)
local glyphs = {}
local ok, w, h
if b1 == 0x36 and b2 == 0x04 then
ok, w, h = parsePSF1(header, glyphs)
elseif b1 == 0x72 and b2 == 0xb5 then
header.read(2)
ok, w, h = parsePSF2(header, glyphs)
else
header.close()
return nil, string.format("not a PSF file (magic: %02x %02x)", b1, b2)
end
header.close()
if not ok then
return nil, w
end
return setmetatable({
_glyphs = glyphs,
_width = w,
_height = h,
_spacing = 0,
_closed = false,
}, Font)
end
return psf