-- 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