renderer: use direct FreeType with explicit font files

This commit is contained in:
2026-08-13 20:40:38 +09:00
parent 125e9349c0
commit f46d51b539
16 changed files with 314 additions and 5201 deletions

View File

@@ -2,6 +2,7 @@ FROM archlinux:base@sha256:b0deabeb3d283da2c7f7dbf0eea051b7b2cd0554e0b737cc457fd
RUN pacman -Syu --noconfirm --needed \
diffutils \
freetype2 \
gcc \
gtk3 \
libxcb \
@@ -10,6 +11,8 @@ RUN pacman -Syu --noconfirm --needed \
pkgconf \
plan9port \
python \
ttf-dejavu \
ttf-jigmo \
wayland \
which \
xcb-imdkit \

View File

@@ -2,6 +2,8 @@ CC = 9c
LD = 9l
PKG_CFLAGS = $(shell pkg-config --cflags dbus-1 wayland-client xkbcommon)
PKG_LIBS = $(shell pkg-config --libs dbus-1 wayland-client xkbcommon)
FT_CFLAGS = $(shell pkg-config --cflags freetype2)
FT_LIBS = $(shell pkg-config --libs freetype2)
CFLAGS = -Wall -Wextra -O2 -g $(PKG_CFLAGS)
PROG = strans
DOCKER_IMAGE = strans-build
@@ -14,12 +16,10 @@ OBJS = $(SRCS:.c=.o)
all: $(PROG) xim gtk
$(PROG): $(OBJS)
$(LD) -o $@ $(OBJS) -lthread -lbio -lxcb -lm $(PKG_LIBS)
$(LD) -o $@ $(OBJS) -lthread -lbio -lxcb $(PKG_LIBS) $(FT_LIBS)
$(OBJS): dat.h fn.h ipc.h
# Avoid Arch's GLIBC_2.43 sqrtf optimization for older glibc runtimes.
font.o: CFLAGS += -fno-builtin-sqrt
font.o: stb_truetype.h
font.o: CFLAGS += $(FT_CFLAGS)
wayland.o: input-method-unstable-v2-client-protocol.h \
virtual-keyboard-unstable-v1-client-protocol.h

View File

@@ -99,12 +99,25 @@ tab-separated data consumable by the runtime dictionary reader.
## Run and install
Start the daemon with the installed map and font directories:
Install one or more scalable outline fonts, then pass their files in fallback
order after the map directory. The daemon accepts at most four font files and
does not search the system font database. For example, the Docker-tested Arch
packages and paths are:
```sh
./strans map font &
doas pacman -S ttf-dejavu ttf-jigmo
./strans map \
/usr/share/fonts/TTF/DejaVuSans.ttf \
/usr/share/fonts/TTF/Jigmo.ttf \
/usr/share/fonts/TTF/Jigmo2.ttf &
```
These fonts are test fixtures, not runtime requirements. Any suitable
scalable outline files may be supplied; their argument order is the glyph
fallback order. A collection file uses its first face. If any named file is
absent or invalid, the popup disables itself cleanly while input processing
continues. `run.sh` and `bench.sh` accept the same font-file arguments.
The IPC socket is placed at `$XDG_RUNTIME_DIR/strans.sock` when
`XDG_RUNTIME_DIR` is an absolute path. Otherwise strans uses the per-user
fallback `/tmp/strans.<uid>`. A second daemon will not replace a live
@@ -153,7 +166,7 @@ and disconnect affect the engine only when they come from its active owner;
stale events cannot clear a newer owner's preedit.
The popup is an optional X window, including when the active frontend is
Wayland. It uses deterministic font fallback and fixed-width rune cells;
Wayland. It uses the explicit font-file order and fixed-width rune cells;
U+FE0E and U+FE0F variation selectors use zero cells. It does not perform
OpenType shaping, color-emoji rendering, or general grapheme clustering, and
there is no native Wayland popup renderer. Multi-rune emoji are committed

View File

@@ -22,14 +22,14 @@ cleanup()
trap cleanup 0 1 2 15
./strans map font &
./strans map "$@" &
strans_pid=$!
sleep 1
kill -0 "$strans_pid"
# warm up glyph cache
# warm up the renderer
./bench/bench bench/bench.keys 100
echo "cache warmed up"
echo "renderer warmed up"
perf record -g -o bench/perf.data -p "$strans_pid" &
perf_pid=$!

2
dat.h
View File

@@ -18,7 +18,7 @@ enum
LangVI = 0x16,
Fontsz = 32,
Nglyphs = 0x20000,
Maxfonts = 4,
Maxrunes = 64,
Maxutf = Maxrunes * UTFmax + 1,
};

View File

@@ -24,7 +24,7 @@ identifiers are:
- input SHA-256: `dd44dcc856cf542b1022d0f39c2e9b9f8805fdcc5923be80f04849ed97ce0996`
`map/libhangul2hanja` keeps only a one-syllable modern Hangul reading paired
with one BMP Hanja rune supported by the popup. It writes one
with one BMP Hanja rune. It writes one
`Hanja<TAB>Hangul syllable` pair per row in upstream order; word entries are
not part of this data. `map/mkhanja` groups those rows by reading for the
runtime dictionary without changing candidate order, retaining the first 32

2
fn.h
View File

@@ -55,5 +55,5 @@ int wlstaterepeat(Wlstate*, uvlong, u32int*);
void* emalloc(ulong);
void* erealloc(void*, ulong);
int fontinit(char*);
int fontinit(char**, int);
void putfont(u32int*, int, int, int, int, Rune);

207
font.c
View File

@@ -1,29 +1,18 @@
#define STB_TRUETYPE_IMPLEMENTATION
#include "stb_truetype.h"
#include "dat.h"
#include <ft2build.h>
#include FT_FREETYPE_H
#include "fn.h"
typedef struct Glyph Glyph;
typedef struct Font Font;
struct Glyph
{
uchar *bmp;
int w, h, ox, oy, base;
};
struct Font
{
stbtt_fontinfo info;
uchar *data;
float scale;
FT_Face face;
int base;
};
enum { Maxfonts = 4 };
static FT_Library lib;
static Font fonts[Maxfonts];
static int nfonts;
static Glyph cache[Nglyphs];
static u32int blendtab[2][256];
static u32int
@@ -41,117 +30,139 @@ blend(u32int bg, u32int fg, int a)
static int
loadfont(char *path)
{
FT_Face face;
Font *f;
int ascent, descent, gap, fd;
long sz, n;
vlong height, pixels;
fd = open(path, OREAD);
if(fd < 0){
fprint(2, "strans: popup: can't open font: %s\n", path);
if(nfonts >= nelem(fonts))
return 0;
if(FT_New_Face(lib, path, 0, &face) != 0){
fprint(2, "strans: popup: can't load font: %s\n", path);
return 0;
}
sz = seek(fd, 0, 2);
seek(fd, 0, 0);
if(sz <= 0){
fprint(2, "strans: popup: empty font: %s\n", path);
close(fd);
height = (vlong)face->ascender - face->descender;
if(height <= 0 || face->units_per_EM == 0){
fprint(2, "strans: popup: invalid font metrics: %s\n", path);
FT_Done_Face(face);
return 0;
}
pixels = (vlong)Fontsz * face->units_per_EM / height;
if(pixels < 1)
pixels = 1;
if(FT_Set_Pixel_Sizes(face, 0, pixels) != 0){
fprint(2, "strans: popup: can't size font: %s\n", path);
FT_Done_Face(face);
return 0;
}
f = &fonts[nfonts];
f->data = emalloc(sz);
n = readn(fd, f->data, sz);
close(fd);
if(n != sz){
fprint(2, "strans: popup: can't read font: %s\n", path);
free(f->data);
f->data = nil;
return 0;
}
if(!stbtt_InitFont(&f->info, f->data,
stbtt_GetFontOffsetForIndex(f->data, 0))){
fprint(2, "strans: popup: can't init font: %s\n", path);
free(f->data);
f->data = nil;
return 0;
}
f->scale = stbtt_ScaleForPixelHeight(&f->info, Fontsz);
stbtt_GetFontVMetrics(&f->info, &ascent, &descent, &gap);
(void)descent;
(void)gap;
f->base = (int)(ascent * f->scale);
f->face = face;
f->base = (vlong)Fontsz * face->ascender / height;
nfonts++;
return 1;
}
int
fontinit(char *dir)
static void
clearfonts(void)
{
static char *names[] = {
"NotoSans-Regular.ttf",
"NotoSansMonoCJKjp-Regular.otf",
"NotoEmoji-Regular.ttf",
};
int i, a;
char path[256];
int i;
nfonts = 0;
memset(cache, 0, sizeof cache);
for(i = 0; i < nelem(names); i++){
if(strlen(dir) + 1 + strlen(names[i]) + 1 > sizeof path){
fprint(2, "strans: popup: font path is too long\n");
continue;
for(i = 0; i < nfonts; i++){
FT_Done_Face(fonts[i].face);
fonts[i].face = nil;
}
nfonts = 0;
}
int
fontinit(char **path, int npath)
{
int i, a;
if(path == nil || npath < 1 || npath > nelem(fonts)){
fprint(2, "strans: popup: need 1-%d font files\n", nelem(fonts));
clearfonts();
return 0;
}
if(lib == nil && FT_Init_FreeType(&lib) != 0){
fprint(2, "strans: popup: can't initialize FreeType\n");
return 0;
}
clearfonts();
for(i = 0; i < npath; i++){
if(path[i] == nil || !loadfont(path[i])){
clearfonts();
return 0;
}
snprint(path, sizeof path, "%s/%s", dir, names[i]);
loadfont(path);
}
for(a = 0; a < 256; a++){
blendtab[0][a] = blend(Colbg, Colfg, a);
blendtab[1][a] = blend(Colsel, Colfg, a);
}
return nfonts != 0;
return 1;
}
static int
drawglyph(Font *f, u32int *buf, int w, int h, int px, int py)
{
FT_Bitmap *b;
FT_GlyphSlot g;
uchar *row;
vlong pitch, x0, x1, y0, y1;
int a, sel, x, xa, xb, y, ya, yb;
u32int *p;
g = f->face->glyph;
b = &g->bitmap;
if(b->pixel_mode != FT_PIXEL_MODE_GRAY || b->num_grays != 256)
return 0;
if(b->width == 0 || b->rows == 0)
return 1;
pitch = b->pitch;
if(pitch < 0)
pitch = -pitch;
if(b->buffer == nil || pitch < b->width)
return 0;
x0 = (vlong)px + g->bitmap_left;
y0 = (vlong)py + f->base - g->bitmap_top;
x1 = x0 + b->width;
y1 = y0 + b->rows;
if(x1 <= 0 || y1 <= 0 || x0 >= w || y0 >= h)
return 1;
xa = x0 < 0 ? 0 : x0;
xb = x1 > w ? w : x1;
ya = y0 < 0 ? 0 : y0;
yb = y1 > h ? h : y1;
for(y = ya; y < yb; y++){
if(b->pitch < 0)
row = b->buffer + ((vlong)b->rows - 1 - (y - y0)) * pitch;
else
row = b->buffer + ((vlong)y - y0) * pitch;
for(x = xa; x < xb; x++){
a = row[(vlong)x - x0];
if(a != 0){
p = &buf[y * w + x];
sel = *p == Colsel;
*p = blendtab[sel][a];
}
}
}
return 1;
}
void
putfont(u32int *buf, int w, int h, int px, int py, Rune r)
{
Glyph *g;
int i, j, a, sel, f;
int y0, j0, j1, x0, i0, i1;
u32int *p;
FT_UInt g;
int f;
if(r >= Nglyphs)
if(buf == nil || w <= 0 || h <= 0)
return;
g = &cache[r];
if(g->bmp == nil){
for(f = 0; f < nfonts; f++){
if(stbtt_FindGlyphIndex(&fonts[f].info, r) == 0)
g = FT_Get_Char_Index(fonts[f].face, r);
if(g == 0 || FT_Load_Glyph(fonts[f].face, g, FT_LOAD_DEFAULT) != 0 ||
FT_Render_Glyph(fonts[f].face->glyph, FT_RENDER_MODE_NORMAL) != 0)
continue;
g->bmp = stbtt_GetCodepointBitmap(&fonts[f].info,
fonts[f].scale, fonts[f].scale, r,
&g->w, &g->h, &g->ox, &g->oy);
if(g->bmp != nil){
g->base = fonts[f].base;
break;
}
}
if(g->bmp == nil)
if(drawglyph(&fonts[f], buf, w, h, px, py))
return;
}
y0 = py + g->base + g->oy;
j0 = y0 < 0 ? -y0 : 0;
j1 = y0 + g->h > h ? h - y0 : g->h;
x0 = px + g->ox;
i0 = x0 < 0 ? -x0 : 0;
i1 = x0 + g->w > w ? w - x0 : g->w;
for(j = j0; j < j1; j++){
for(i = i0; i < i1; i++){
a = g->bmp[j * g->w + i];
if(a > 0){
p = &buf[(y0 + j) * w + x0 + i];
sel = (*p == Colsel) ? 1 : 0;
*p = blendtab[sel][a];
}
}
}
}

10
main.c
View File

@@ -5,12 +5,13 @@ Channel *drawc;
Channel *keyc;
Channel *dictreqc;
Channel *dictresc;
char *fontdir;
char **fontfiles;
int nfontfiles;
void
usage(void)
{
fprint(2, "usage: strans mapdir fontdir\n");
fprint(2, "usage: strans mapdir fontfile [fontfile ...]\n");
threadexitsall("usage");
}
@@ -50,10 +51,11 @@ erealloc(void *p, ulong n)
void
threadmain(int argc, char **argv)
{
if(argc != 3)
if(argc < 3 || argc > Maxfonts + 2)
usage();
fontdir = argv[2];
fontfiles = argv + 2;
nfontfiles = argc - 2;
drawc = chancreate(sizeof(Drawcmd), 4);
keyc = chancreate(sizeof(Keyreq), 0);
dictreqc = chancreate(sizeof(Dictreq), 4);

2
run.sh
View File

@@ -3,6 +3,6 @@
cd "$(dirname "$0")"
pkill strans
sleep 1
./strans map font &
./strans map "$@" &
sleep 1
xim/strans-xim &

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,17 @@
CC = 9c
LD = 9l
CFLAGS = -std=c99 -Wall -Wextra -O2 -g -I.. -I../cutest
LIBS = -lthread -lbio
FT_CFLAGS = $(shell pkg-config --cflags freetype2)
FT_LIBS = $(shell pkg-config --libs freetype2)
LIBS = -lthread -lbio $(FT_LIBS)
PROG = unit_test
TESTSRC = unit_test.c test_util.c str_test.c hash_test.c trie_test.c \
ko_test.c vi_test.c engine_test.c dict_test.c ipc_test.c wayland_test.c \
popup_test.c
popup_test.c font_test.c
TESTOBJ = $(TESTSRC:.c=.o)
PARENTSRC = str.c hash.c trie.c dict.c ko.c vi.c ipc.c wayland_state.c \
popup_layout.c
popup_layout.c font.c
PARENTOBJ = $(PARENTSRC:%.c=unit_%.o)
OBJS = $(TESTOBJ) $(PARENTOBJ)
@@ -23,6 +25,7 @@ $(PROG): $(OBJS)
$(TESTOBJ): test.h ../dat.h ../fn.h ../ipc.h ../cutest/cutest.h
engine_test.o: ../strans.c
unit_font.o: CFLAGS += $(FT_CFLAGS)
unit_%.o: ../%.c ../dat.h ../fn.h ../ipc.h
$(CC) $(CFLAGS) -c -o $@ $<

157
tests/font_test.c Normal file
View File

@@ -0,0 +1,157 @@
#include "test.h"
enum
{
Guard = 8,
Testw = 3 * Fontsz,
Testh = 3 * Fontsz,
Testn = Testw * Testh,
Missingn = Fontsz * Fontsz,
};
static u32int guardcolor = 0x5a5a5a5a;
static char *testfonts[] = {
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/TTF/Jigmo.ttf",
"/usr/share/fonts/TTF/Jigmo2.ttf",
};
static char *missingfonts[] = {
"/strans-test-no-such-font",
};
static void
fillpixels(u32int *p, int n, u32int color)
{
int i;
for(i = 0; i < n; i++)
p[i] = color;
}
static int
hasink(u32int *p, int n)
{
int i;
for(i = 0; i < n; i++)
if(p[i] != Colbg)
return 1;
return 0;
}
static int
renders(Rune r)
{
u32int buf[Fontsz * Fontsz];
fillpixels(buf, nelem(buf), Colbg);
putfont(buf, Fontsz, Fontsz, 0, 0, r);
return hasink(buf, nelem(buf));
}
static int
inkbounds(u32int *p, int *minx, int *miny, int *maxx, int *maxy)
{
int x, y;
*minx = Testw;
*miny = Testh;
*maxx = -1;
*maxy = -1;
for(y = 0; y < Testh; y++){
for(x = 0; x < Testw; x++){
if(p[y * Testw + x] == Colbg)
continue;
*minx = min(*minx, x);
*miny = min(*miny, y);
*maxx = max(*maxx, x);
*maxy = max(*maxy, y);
}
}
return *maxx >= 0;
}
static void
checkclip(struct ct *t, u32int *ref, int dx, int dy)
{
u32int *buf, *mem, *want;
int i, sx, sy, x, y;
mem = emalloc((Testn + 2 * Guard) * sizeof mem[0]);
want = emalloc(Testn * sizeof want[0]);
fillpixels(mem, Testn + 2 * Guard, guardcolor);
buf = mem + Guard;
fillpixels(buf, Testn, Colbg);
fillpixels(want, Testn, Colbg);
for(y = 0; y < Testh; y++){
for(x = 0; x < Testw; x++){
sx = x - dx;
sy = y - dy;
if(sx >= 0 && sx < Testw && sy >= 0 && sy < Testh)
want[y * Testw + x] = ref[sy * Testw + sx];
}
}
putfont(buf, Testw, Testh, Fontsz + dx, Fontsz + dy, 'A');
CT_CHECK(t, hasink(buf, Testn));
CT_EQ_MEM(t, want, buf, Testn * sizeof buf[0]);
for(i = 0; i < Guard; i++){
CT_EQ_UINT(t, guardcolor, mem[i]);
CT_EQ_UINT(t, guardcolor, mem[Guard + Testn + i]);
}
free(want);
free(mem);
}
void
font_render(struct ct *t)
{
u32int *buf, *missing, *ref;
int i, minx, miny, maxx, maxy;
missing = emalloc((Missingn + 2 * Guard) * sizeof missing[0]);
fillpixels(missing, Missingn + 2 * Guard, guardcolor);
buf = missing + Guard;
fillpixels(buf, Missingn, Colbg);
CT_CHECK(t, !fontinit(missingfonts, nelem(missingfonts)));
putfont(buf, Fontsz, Fontsz, 0, 0, 'A');
for(i = 0; i < Missingn; i++)
CT_EQ_UINT(t, Colbg, buf[i]);
for(i = 0; i < Guard; i++){
CT_EQ_UINT(t, guardcolor, missing[i]);
CT_EQ_UINT(t, guardcolor, missing[Guard + Missingn + i]);
}
if(!CT_CHECK(t, fontinit(testfonts, nelem(testfonts)))){
free(missing);
return;
}
CT_CHECK(t, renders('A'));
CT_CHECK(t, renders(0xac00));
CT_CHECK(t, renders(0x4e00));
CT_CHECK(t, renders(0x1f600));
CT_CHECK(t, renders(0x2000b));
fillpixels(missing, Missingn + 2 * Guard, guardcolor);
buf = missing + Guard;
fillpixels(buf, Missingn, Colbg);
putfont(buf, Fontsz, Fontsz, 0, 0, 0x10ffff);
for(i = 0; i < Missingn; i++)
CT_EQ_UINT(t, Colbg, buf[i]);
for(i = 0; i < Guard; i++){
CT_EQ_UINT(t, guardcolor, missing[i]);
CT_EQ_UINT(t, guardcolor, missing[Guard + Missingn + i]);
}
free(missing);
ref = emalloc(Testn * sizeof ref[0]);
fillpixels(ref, Testn, Colbg);
putfont(ref, Testw, Testh, Fontsz, Fontsz, 'A');
if(!CT_CHECK(t, inkbounds(ref, &minx, &miny, &maxx, &maxy))){
free(ref);
return;
}
checkclip(t, ref, -minx - 1, 0);
checkclip(t, ref, Testw - maxx, 0);
checkclip(t, ref, 0, -miny - 1);
checkclip(t, ref, 0, Testh - maxy);
free(ref);
}

View File

@@ -21,6 +21,7 @@ void wayland_press_release_state(struct ct*);
void wayland_repeat_state(struct ct*);
void wayland_repeat_cancellation(struct ct*);
void popup_layout(struct ct*);
void font_render(struct ct*);
void production_maps_load(struct ct*);
void transmap_states(struct ct*);
void korean_sequences(struct ct*);

View File

@@ -75,6 +75,7 @@ static const struct ct_test tests[] = {
{ "wayland/repeat", wayland_repeat_state },
{ "wayland/repeat-cancellation", wayland_repeat_cancellation },
{ "popup/layout", popup_layout },
{ "font/render", font_render },
{ "map/production-lifecycle", production_maps_load },
{ "transmap/states", transmap_states },
{ "hangul/sequences", korean_sequences },

5
win.c
View File

@@ -6,7 +6,8 @@ enum {
Asciitofull = 0xFEE0,
};
extern char *fontdir;
extern char **fontfiles;
extern int nfontfiles;
static xcb_connection_t *conn;
static xcb_screen_t *scr;
@@ -140,7 +141,7 @@ wininit(void)
mask = XCB_CW_BACK_PIXMAP;
xcb_change_window_attributes(conn, win, mask, &pix);
img = emalloc(Imgw * Imgh * sizeof(img[0]));
if(!fontinit(fontdir)){
if(!fontinit(fontfiles, nfontfiles)){
fprint(2, "strans: popup disabled: no usable fonts\n");
wincleanup();
return 0;