diff --git a/shell.nix b/shell.nix index 61b5538..cb6f1af 100644 --- a/shell.nix +++ b/shell.nix @@ -3,7 +3,7 @@ }: pkgs.mkShell { packages = [ - (pkgs.python3.withPackages (python: [ python.dbus-next ])) + (pkgs.python3.withPackages (python: [ python.dbus-next python.pillow ])) pkgs.dbus pkgs.wayland-scanner pkgs.clang-tools diff --git a/src/linux/Bitmap.zig b/src/linux/Bitmap.zig new file mode 100644 index 0000000..55d4dcc --- /dev/null +++ b/src/linux/Bitmap.zig @@ -0,0 +1,96 @@ +//! Fixed 16px menu font and a clipped, opaque software canvas. No runtime font +//! parsing, allocation, GPU work, or dependency on an application's renderer. +const std = @import("std"); +const data = @embedFile("font/roboto-16.bin"); +fn word(offset: usize) u32 { + return std.mem.readInt(u32, data[offset..][0..4], .little); +} +const count = word(4); +const Glyph = struct { offset: usize, width: u8, height: u8, x: i8, y: i8, advance: u8 }; +fn glyph(codepoint: u21) Glyph { + var lo: usize = 0; + var hi: usize = count; + while (lo < hi) { + const mid = (lo + hi) / 2; + const key = word(8 + mid * 16); + if (key < codepoint) lo = mid + 1 else hi = mid; + } + const record = 8 + (if (lo < count and word(8 + lo * 16) == codepoint) lo else count - 1) * 16; + return .{ .offset = word(record + 4), .width = data[record + 8], .height = data[record + 9], .x = @bitCast(data[record + 10]), .y = @bitCast(data[record + 11]), .advance = data[record + 12] }; +} +pub fn width(text: []const u8) i32 { + var it = std.unicode.Utf8View.initUnchecked(text).iterator(); + var result: i32 = 0; + while (it.nextCodepoint()) |cp| { + result += glyph(cp).advance; + if (result >= 1024) break; // Menus constrain/ellipsize long labels. + } + return result; +} +pub const Canvas = struct { + pixels: []u32, + width: i32, + height: i32, + scale: i32, + pub fn rect(self: Canvas, x: i32, y: i32, w: i32, h: i32, color: u32) void { + const x0 = std.math.clamp(x, 0, self.width) * self.scale; + const y0 = std.math.clamp(y, 0, self.height) * self.scale; + const x1 = std.math.clamp(x + w, 0, self.width) * self.scale; + const y1 = std.math.clamp(y + h, 0, self.height) * self.scale; + if (x1 <= x0 or y1 <= y0) return; + var row = y0; + while (row < y1) : (row += 1) { + const offset: usize = @intCast(row * self.width * self.scale); + @memset(self.pixels[offset + @as(usize, @intCast(x0)) .. offset + @as(usize, @intCast(x1))], color | 0xff000000); + } + } + fn pixel(self: Canvas, x: i32, y: i32, color: u32, alpha: u32) void { + if (x < 0 or y < 0 or x >= self.width or y >= self.height or alpha == 0) return; + const index: usize = @intCast((y * self.width * self.scale + x) * self.scale); + const background = self.pixels[index]; + var blended: u32 = 0xff000000; + inline for (.{ 0, 8, 16 }) |shift| { + blended |= ((((color >> shift) & 255) * alpha + ((background >> shift) & 255) * (255 - alpha) + 127) / 255) << shift; + } + self.rect(x, y, 1, 1, blended); + } + fn drawGlyph(self: Canvas, value: Glyph, x: i32, baseline: i32, color: u32) void { + for (0..value.height) |row| for (0..value.width) |column| { + self.pixel(x + value.x + @as(i32, @intCast(column)), baseline + value.y + @as(i32, @intCast(row)), color, data[value.offset + row * value.width + column]); + }; + } + pub fn text(self: Canvas, value: []const u8, x: i32, baseline: i32, limit: i32, color: u32) void { + var it = std.unicode.Utf8View.initUnchecked(value).iterator(); + var cursor = x; + const ellipsis = glyph(0x2026); + const clipped = width(value) > limit; + while (it.nextCodepoint()) |cp| { + const g = glyph(cp); + if (cursor + g.advance > x + limit - (if (clipped) @as(i32, ellipsis.advance) else 0)) { + if (limit >= ellipsis.advance) self.drawGlyph(ellipsis, cursor, baseline, color); + return; + } + self.drawGlyph(g, cursor, baseline, color); + cursor += g.advance; + } + } +}; +test "bitmap bounds, coverage and clipping" { + try std.testing.expectEqualStrings("UF16", data[0..4]); + var previous: u32 = 0; + for (0..count) |i| { + const cp = word(8 + i * 16); + try std.testing.expect(cp > previous); + previous = cp; + const g = glyph(@intCast(cp)); + try std.testing.expect(g.offset + @as(usize, g.width) * g.height <= data.len); + } + try std.testing.expectEqual(@as(u32, 0xfffd), previous); + try std.testing.expectEqual(width("�"), width("漢")); + var pixels: [64 * 32]u32 = @splat(0xff000000); + const canvas: Canvas = .{ .pixels = &pixels, .width = 64, .height = 32, .scale = 1 }; + canvas.text("Roboto café", -2, 17, 60, 0xffffff); + try std.testing.expect(!std.mem.allEqual(u32, &pixels, 0xff000000)); + canvas.rect(-100, -100, 500, 500, 0x123456); + try std.testing.expect(std.mem.allEqual(u32, &pixels, 0xff123456)); +} diff --git a/src/linux/font/OFL.txt b/src/linux/font/OFL.txt new file mode 100644 index 0000000..9c48e05 --- /dev/null +++ b/src/linux/font/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/linux/font/README.md b/src/linux/font/README.md new file mode 100644 index 0000000..4f8af7a --- /dev/null +++ b/src/linux/font/README.md @@ -0,0 +1,32 @@ +# Linux menu bitmap + +`roboto-16.bin` contains Roboto Regular rasterized at 16 pixels, with 8-bit +antialiased coverage. It is embedded into the Linux backend; no font parser, +font discovery, image decoder, or runtime rasterizer is linked into UHDK. + +Coverage: printable ASCII, Latin-1, selected punctuation/currency/arrows, and +U+FFFD. Other codepoints use the replacement glyph. There is no shaping, bidi, +kerning, or combining-mark placement. Larger output scales enlarge the bitmap; +they do not rerasterize the font. This is an intentionally fixed, small menu font. + +The source is the unmodified Google Fonts Roboto variable TTF, pinned in +`manifest.json`, with weight 400 and width 100. The generated bitmap is a font +derivative distributed under the accompanying SIL Open Font License (`OFL.txt`). +The TTF is downloaded only for maintainer regeneration and is not shipped here. + +Binary format, little-endian: `UF16`, u32 glyph count, sorted 16-byte records +(u32 codepoint, u32 absolute pixel offset, u8 width/height, i8 x/y bearing relative +to the baseline, u8 advance, three padding bytes), followed by tightly packed, +row-major grayscale glyph bitmaps. + +From the package directory: + +```sh +python3 src/linux/tools/bitmap-font.py --check +# Optional nix-shell provides pinned Pillow/FreeType: +python3 src/linux/tools/bitmap-font.py --verify-generation +python3 src/linux/tools/bitmap-font.py --regenerate +``` + +Source URLs/checksums, rasterizer versions and output checksum are recorded in +`manifest.json`. Normal builds require only the checked-in bitmap and Zig. diff --git a/src/linux/font/manifest.json b/src/linux/font/manifest.json new file mode 100644 index 0000000..8e518c9 --- /dev/null +++ b/src/linux/font/manifest.json @@ -0,0 +1,23 @@ +{ + "revision": "1c627bfa375fc51cf86fabeca4f6e08a95f0aa5c", + "size": 16, + "sources": [ + { + "url": "https://raw.githubusercontent.com/google/fonts/1c627bfa375fc51cf86fabeca4f6e08a95f0aa5c/ofl/roboto/Roboto%5Bwdth%2Cwght%5D.ttf", + "sha256": "d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134", + "name": "Roboto.ttf" + }, + { + "url": "https://raw.githubusercontent.com/google/fonts/1c627bfa375fc51cf86fabeca4f6e08a95f0aa5c/ofl/roboto/OFL.txt", + "sha256": "061402327a96aadb0bfb694a960ed289ecd38d383e396243831ab81feb109c41", + "name": "OFL.txt" + } + ], + "rasterizer": { + "pillow": "12.2.0", + "freetype": "2.13.3" + }, + "generated": { + "roboto-16.bin": "23bfe858a9ff398b3826d836bb815255ced0e50ac7f695b2d9309a15b32d335e" + } +} diff --git a/src/linux/font/roboto-16.bin b/src/linux/font/roboto-16.bin new file mode 100644 index 0000000..6e652e6 Binary files /dev/null and b/src/linux/font/roboto-16.bin differ diff --git a/src/linux/tools/bitmap-font.py b/src/linux/tools/bitmap-font.py new file mode 100644 index 0000000..4701f95 --- /dev/null +++ b/src/linux/tools/bitmap-font.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Maintainer-only generation/checking of the Linux menu's 16px Roboto bitmap. + +Ordinary builds embed the checked-in bytes. Generation needs Pillow/FreeType +from the optional shell.nix and downloads only checksum-pinned font sources. +""" +import argparse +import hashlib +import io +import json +from pathlib import Path +import struct +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] / 'font' + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def generate(manifest, font_path): + import PIL + from PIL import Image, ImageDraw, ImageFont, features + versions = {'pillow': PIL.__version__, 'freetype': features.version('freetype2')} + if versions != manifest['rasterizer']: + raise SystemExit('Use the pinned shell.nix rasterizer: ' + str(manifest['rasterizer'])) + source = manifest['sources'][0] + data = Path(font_path).read_bytes() if font_path else urllib.request.urlopen(source['url'], timeout=30).read() + assert digest(data) == source['sha256'], 'Font source checksum differs' + font = ImageFont.truetype(io.BytesIO(data), manifest['size'], layout_engine=ImageFont.Layout.BASIC) + # Explicit variation axes: regular weight and normal width, in font order. + axes = font.get_variation_axes() + font.set_variation_by_axes([400 if a['name'] == b'Weight' else 100 if a['name'] == b'Width' else a['default'] for a in axes]) + codepoints = sorted(set(range(32, 127)) | set(range(160, 256)) | set(map(ord, '–—‘’“”…•€←↑→↓�'))) + header_size = 8 + len(codepoints) * 16 + records, pixels = bytearray(), bytearray() + for cp in codepoints: + character = chr(cp) + x, y, right, bottom = font.getbbox(character, anchor='ls') + width, height = right - x, bottom - y + image = Image.new('L', (width, height)) + if width and height: + ImageDraw.Draw(image).text((-x, -y), character, font=font, fill=255, anchor='ls') + advance = int(font.getlength(character) + .5) + records += struct.pack('