From b2fe3f493efc12dd300f0e2b2ede5660ee70c193 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 7 Oct 2026 11:01:27 +0200 Subject: fix(card): draw emoji in Noto Emoji instead of tofu Cairo's toy text API binds one face with no per-glyph fallback, so an emoji in a calendar event title rendered as a box in Noto Sans. text/measure/advance now split strings into runs and draw emoji runs (U+1F000-1FAFF, U+2600-27BF) in monochrome Noto Emoji, restoring the caller's face afterwards. Nerd Font PUA icons stay in FONT_MONO. Plain strings and invalid UTF-8 take the old single-call path. No shaping: ZWJ sequences and flags draw as their parts. Pango via lgi (built for Lua 5.4) is the upgrade path. Co-Authored-By: Claude Opus 5.5 --- lib/card.lua | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 7 deletions(-) (limited to 'lib') diff --git a/lib/card.lua b/lib/card.lua index dde4121..7fd40f8 100644 --- a/lib/card.lua +++ b/lib/card.lua @@ -27,12 +27,72 @@ M.FONT_HEAVY = 'Noto Sans Black' -- with `fc-match` before trusting it. M.FONT_CLOCK = 'Oswald' +-- Cairo's toy text API binds ONE face and has no per-glyph fallback, so an +-- emoji in a calendar title drew as tofu in Noto Sans. text/measure/advance +-- therefore split a string into runs and draw the emoji runs in this face. +-- Monochrome on purpose: it takes the source colour like any other glyph, so +-- emoji follow the palette instead of pasting fixed colours onto the card. +M.FONT_EMOJI = 'Noto Emoji' + +-- The face last chosen by M.font(), restored after each emoji run. +local cur_family, cur_weight + function M.font(cr, family, size, bold) - cairo_select_font_face(cr, family, CAIRO_FONT_SLANT_NORMAL, - bold and CAIRO_FONT_WEIGHT_BOLD or CAIRO_FONT_WEIGHT_NORMAL) + cur_family = family + cur_weight = bold and CAIRO_FONT_WEIGHT_BOLD or CAIRO_FONT_WEIGHT_NORMAL + cairo_select_font_face(cr, family, CAIRO_FONT_SLANT_NORMAL, cur_weight) cairo_set_font_size(cr, size) end +-- ponytail: codepoint ranges, not real glyph coverage, and the toy API has no +-- shaping, so a ZWJ family or a flag draws as its parts. Pango (via lgi) is +-- the upgrade path if that ever matters. 0x1F000-0x1FAFF only: the Nerd Font +-- icons the card titles use live in PUA (0xE000+, 0xF0000+) and must stay in +-- FONT_MONO. +local function is_emoji(cp) + return (cp >= 0x1F000 and cp <= 0x1FAFF) or (cp >= 0x2600 and cp <= 0x27BF) +end + +-- { {text, is_emoji}, ... }, dropping VS16 and ZWJ, which only carry meaning +-- to a shaper. Plain ASCII and Latin text, and anything that is not valid +-- UTF-8, come back as a single non-emoji run: no allocation per frame for +-- the common case, and no error for a bad string (an error is a blank screen). +local function runs(s) + if not s:find('[\226\240-\244]') then return nil end + local ok, out = pcall(function() + local out, cur, ce = {}, {}, nil + for _, cp in utf8.codes(s) do + if cp ~= 0xFE0F and cp ~= 0x200D then + local e = is_emoji(cp) + if e ~= ce and #cur > 0 then + out[#out + 1] = { table.concat(cur), ce } + cur = {} + end + cur[#cur + 1], ce = utf8.char(cp), e + end + end + if #cur > 0 then out[#out + 1] = { table.concat(cur), ce } end + return out + end) + return ok and out or nil +end + +-- Calls fn(text) for each run with its face selected, then restores the +-- caller's face so the next draw call is unaffected. +local function each_run(cr, list, fn) + for _, r in ipairs(list) do + local swap = r[2] and cur_family + if swap then + cairo_select_font_face(cr, M.FONT_EMOJI, CAIRO_FONT_SLANT_NORMAL, + CAIRO_FONT_WEIGHT_NORMAL) + end + fn(r[1]) + if swap then + cairo_select_font_face(cr, cur_family, CAIRO_FONT_SLANT_NORMAL, cur_weight) + end + end +end + function M.rgba(cr, c, alpha) cairo_set_source_rgba(cr, c[1], c[2], c[3], alpha or c[4] or 1) end @@ -40,7 +100,10 @@ end -- Text at (x, y), where y is the BASELINE, not the top of the glyphs. function M.text(cr, x, y, s) cairo_move_to(cr, x, y) - cairo_show_text(cr, s) + local list = runs(s) + if not list then return cairo_show_text(cr, s) end + -- show_text leaves the current point after the run, so runs chain. + each_run(cr, list, function(t) cairo_show_text(cr, t) end) end -- Measured width and height of a string under the current font. @@ -61,8 +124,27 @@ local extents = cairo_text_extents_t:create() -- Ink size of a string: how much space the glyphs actually cover. -- Use this to CENTRE or RIGHT-ALIGN text, never to advance a cursor. function M.measure(cr, s) - cairo_text_extents(cr, s, extents) - return extents.width, extents.height + local list = runs(s) + if not list then + cairo_text_extents(cr, s, extents) + return extents.width, extents.height + end + -- Union of each run's ink box, offset by the advances before it. A run + -- with no ink (spaces) moves the pen but adds nothing to the box. + local pen, x0, x1, y0, y1 = 0, nil, nil, nil, nil + each_run(cr, list, function(t) + cairo_text_extents(cr, t, extents) + if extents.width > 0 then + local l = pen + extents.x_bearing + x0, x1 = math.min(x0 or l, l), math.max(x1 or l, l + extents.width) + y0 = math.min(y0 or extents.y_bearing, extents.y_bearing) + local b = extents.y_bearing + extents.height + y1 = math.max(y1 or b, b) + end + pen = pen + extents.x_advance + end) + if not x0 then return 0, 0 end + return x1 - x0, y1 - y0 end -- How far the cursor moves after drawing a string. Use this to lay out runs of @@ -74,8 +156,17 @@ end -- advance, so a date drawn in segments came out as "16 /SEP /2026" with each -- slash jammed into the next glyph. function M.advance(cr, s) - cairo_text_extents(cr, s, extents) - return extents.x_advance + local list = runs(s) + if not list then + cairo_text_extents(cr, s, extents) + return extents.x_advance + end + local pen = 0 + each_run(cr, list, function(t) + cairo_text_extents(cr, t, extents) + pen = pen + extents.x_advance + end) + return pen end -- Right-aligned text: x is the RIGHT edge. -- cgit v1.2.3