Refuse a fallback face that cannot be the size it was asked for
Adding Noto Color Emoji behind the UI font got the icons drawing and made the compose bar a hundred and forty pixels tall. A colour emoji font is a BITMAP with one fixed strike: ask it for fourteen and it answers a hundred and twenty-eight, and SDL_ttf draws it at that. One emoji in a row reshapes the window around it. So a fallback is measured before it is trusted, and dropped if it comes back more than twice the face's own height. A symbol face runs a little tall and is fine; a bitmap strike is six times over and is not. Outline faces are tried first and colour emoji last, so a system where SDL_ttf CAN scale one still gets it. Noto Emoji — the monochrome outline companion to NotoColorEmoji — is first in the list now. It is the one that works at a UI size, and a machine that has it gets emoji rather than the missing-glyph box. The check needs real fonts, so it is not in the headless set: those measure with a stub of eight pixels a character and cannot see a font metric at all, which is why nothing caught this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0336529 parent: 7d907d0 modified
jvui/src/jvui/font.clj +32 -9 | @@ -45,16 +45,25 @@ | ||
| 45 | 45 | broken\". SDL_ttf keeps a list of fallback faces and asks each in turn, |
| 46 | 46 | so this costs nothing for text that the main face already has. |
| 47 | 47 | |
| 48 | - Emoji first: a face that has both a symbol and its emoji presentation | |
| 49 | - should give the emoji one, which is what a message means by it. | |
| 48 | + Outline faces first, and colour emoji last, because a colour emoji | |
| 49 | + font is usually BITMAP — one fixed strike, commonly 109 or 128 pixels | |
| 50 | + — and a face that cannot be scaled to the size asked for is worse than | |
| 51 | + no face at all. `usable?` below is what actually decides; the order | |
| 52 | + only settles who wins when both would do. | |
| 53 | + | |
| 50 | 54 | JVUI_FALLBACK_FONTS overrides, colon-separated, for a machine whose |
| 51 | 55 | fonts live somewhere else." |
| 52 | - ["/usr/share/fonts/noto/NotoColorEmoji.ttf" | |
| 53 | - "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf" | |
| 54 | - "/run/current-system/sw/share/X11/fonts/NotoColorEmoji.ttf" | |
| 56 | + [;; Monochrome and scalable. Noto Emoji is the outline companion to | |
| 57 | + ;; NotoColorEmoji and is the one that works at a UI size. | |
| 58 | + "/usr/share/fonts/noto/NotoEmoji-Regular.ttf" | |
| 59 | + "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf" | |
| 55 | 60 | "/usr/share/fonts/noto/NotoSansSymbols2-Regular.ttf" |
| 56 | 61 | "/usr/share/fonts/noto/NotoSansSymbols-Regular.ttf" |
| 57 | - "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf"]) | |
| 62 | + "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf" | |
| 63 | + "/usr/share/fonts/truetype/noto/NotoSansSymbols-Regular.ttf" | |
| 64 | + ;; Last, and only where SDL_ttf can scale it. | |
| 65 | + "/usr/share/fonts/noto/NotoColorEmoji.ttf" | |
| 66 | + "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"]) | |
| 58 | 67 | |
| 59 | 68 | (defn- fallback-paths [] |
| 60 | 69 | (if-let [env (System/getenv "JVUI_FALLBACK_FONTS")] |
| @@ -76,12 +85,26 @@ | ||
| 76 | 85 | ;; Fallbacks are per FACE, not per family, so each size opens its |
| 77 | 86 | ;; own and they are kept for as long as the face is — SDL_ttf |
| 78 | 87 | ;; holds the pointer and does not copy the font. |
| 79 | - (let [fbs (into [] | |
| 88 | + (let [want (sdl/font-height f) | |
| 89 | + fbs (into [] | |
| 80 | 90 | (keep (fn [path] |
| 81 | 91 | (let [g (sdl/open-font path (float size))] |
| 82 | 92 | (when-not (ffi-null? g) |
| 83 | - (sdl/add-fallback-font! f g) | |
| 84 | - g)))) | |
| 93 | + ;; A face that answers a size it was | |
| 94 | + ;; not asked for cannot be used. A | |
| 95 | + ;; colour emoji font is a bitmap with | |
| 96 | + ;; one strike — ask for 14 and it | |
| 97 | + ;; still gives 128 — and SDL_ttf draws | |
| 98 | + ;; it at that, so one emoji in a | |
| 99 | + ;; message makes a row a hundred and | |
| 100 | + ;; twenty-eight pixels tall and pushes | |
| 101 | + ;; the rest of the window out of the | |
| 102 | + ;; way. Twice the face's own height is | |
| 103 | + ;; the line: a symbol face runs a | |
| 104 | + ;; little tall and is fine. | |
| 105 | + (if (> (sdl/font-height g) (* 2 want)) | |
| 106 | + (do (sdl/close-font! g) nil) | |
| 107 | + (do (sdl/add-fallback-font! f g) g)))))) | |
| 85 | 108 | (fallback-paths))] |
| 86 | 109 | (swap! cache update :fallbacks (fnil into []) fbs)) |
| 87 | 110 | (swap! cache assoc-in [:faces k] f) |
| @@ -45,16 +45,25 @@ | |||
| 45 | broken\". SDL_ttf keeps a list of fallback faces and asks each in turn, | 45 | broken\". SDL_ttf keeps a list of fallback faces and asks each in turn, |
| 46 | so this costs nothing for text that the main face already has. | 46 | so this costs nothing for text that the main face already has. |
| 47 | 47 | ||
| 48 | - Emoji first: a face that has both a symbol and its emoji presentation | 48 | + Outline faces first, and colour emoji last, because a colour emoji |
| 49 | - should give the emoji one, which is what a message means by it. | 49 | + font is usually BITMAP — one fixed strike, commonly 109 or 128 pixels |
| 50 | + — and a face that cannot be scaled to the size asked for is worse than | ||
| 51 | + no face at all. `usable?` below is what actually decides; the order | ||
| 52 | + only settles who wins when both would do. | ||
| 53 | + | ||
| 50 | JVUI_FALLBACK_FONTS overrides, colon-separated, for a machine whose | 54 | JVUI_FALLBACK_FONTS overrides, colon-separated, for a machine whose |
| 51 | fonts live somewhere else." | 55 | fonts live somewhere else." |
| 52 | - ["/usr/share/fonts/noto/NotoColorEmoji.ttf" | 56 | + [;; Monochrome and scalable. Noto Emoji is the outline companion to |
| 53 | - "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf" | 57 | + ;; NotoColorEmoji and is the one that works at a UI size. |
| 54 | - "/run/current-system/sw/share/X11/fonts/NotoColorEmoji.ttf" | 58 | + "/usr/share/fonts/noto/NotoEmoji-Regular.ttf" |
| 59 | + "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf" | ||
| 55 | "/usr/share/fonts/noto/NotoSansSymbols2-Regular.ttf" | 60 | "/usr/share/fonts/noto/NotoSansSymbols2-Regular.ttf" |
| 56 | "/usr/share/fonts/noto/NotoSansSymbols-Regular.ttf" | 61 | "/usr/share/fonts/noto/NotoSansSymbols-Regular.ttf" |
| 57 | - "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf"]) | 62 | + "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf" |
| 63 | + "/usr/share/fonts/truetype/noto/NotoSansSymbols-Regular.ttf" | ||
| 64 | + ;; Last, and only where SDL_ttf can scale it. | ||
| 65 | + "/usr/share/fonts/noto/NotoColorEmoji.ttf" | ||
| 66 | + "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"]) | ||
| 58 | 67 | ||
| 59 | (defn- fallback-paths [] | 68 | (defn- fallback-paths [] |
| 60 | (if-let [env (System/getenv "JVUI_FALLBACK_FONTS")] | 69 | (if-let [env (System/getenv "JVUI_FALLBACK_FONTS")] |
| @@ -76,12 +85,26 @@ | |||
| 76 | ;; Fallbacks are per FACE, not per family, so each size opens its | 85 | ;; Fallbacks are per FACE, not per family, so each size opens its |
| 77 | ;; own and they are kept for as long as the face is — SDL_ttf | 86 | ;; own and they are kept for as long as the face is — SDL_ttf |
| 78 | ;; holds the pointer and does not copy the font. | 87 | ;; holds the pointer and does not copy the font. |
| 79 | - (let [fbs (into [] | 88 | + (let [want (sdl/font-height f) |
| 89 | + fbs (into [] | ||
| 80 | (keep (fn [path] | 90 | (keep (fn [path] |
| 81 | (let [g (sdl/open-font path (float size))] | 91 | (let [g (sdl/open-font path (float size))] |
| 82 | (when-not (ffi-null? g) | 92 | (when-not (ffi-null? g) |
| 83 | - (sdl/add-fallback-font! f g) | 93 | + ;; A face that answers a size it was |
| 84 | - g)))) | 94 | + ;; not asked for cannot be used. A |
| 95 | + ;; colour emoji font is a bitmap with | ||
| 96 | + ;; one strike — ask for 14 and it | ||
| 97 | + ;; still gives 128 — and SDL_ttf draws | ||
| 98 | + ;; it at that, so one emoji in a | ||
| 99 | + ;; message makes a row a hundred and | ||
| 100 | + ;; twenty-eight pixels tall and pushes | ||
| 101 | + ;; the rest of the window out of the | ||
| 102 | + ;; way. Twice the face's own height is | ||
| 103 | + ;; the line: a symbol face runs a | ||
| 104 | + ;; little tall and is fine. | ||
| 105 | + (if (> (sdl/font-height g) (* 2 want)) | ||
| 106 | + (do (sdl/close-font! g) nil) | ||
| 107 | + (do (sdl/add-fallback-font! f g) g)))))) | ||
| 85 | (fallback-paths))] | 108 | (fallback-paths))] |
| 86 | (swap! cache update :fallbacks (fnil into []) fbs)) | 109 | (swap! cache update :fallbacks (fnil into []) fbs)) |
| 87 | (swap! cache assoc-in [:faces k] f) | 110 | (swap! cache assoc-in [:faces k] f) |
added
jvui/test/jvui/font_check.clj +32 -0 | new file mode 100644 | ||
| @@ -0,0 +1,32 @@ | ||
| 1 | +(ns jvui.font-check | |
| 2 | + "A fallback face must not change the line. | |
| 3 | + | |
| 4 | + The headless tests measure with a stub — eight pixels a character — so | |
| 5 | + they cannot see this at all. What it guards is the thing that made the | |
| 6 | + compose bar a hundred and forty pixels tall: a colour emoji font is a | |
| 7 | + bitmap with one fixed strike, and asked for fourteen it answers a | |
| 8 | + hundred and twenty-eight. SDL_ttf then draws it at that, so one emoji | |
| 9 | + in a message reshapes the window around it. | |
| 10 | + | |
| 11 | + Needs the real fonts, which is why it is not in the headless set." | |
| 12 | + (:require [jvui.sdl :as sdl] [jvui.font :as font])) | |
| 13 | + | |
| 14 | +(defn -main [& _] | |
| 15 | + (sdl/ensure-loaded!) | |
| 16 | + (sdl/ttf-init!) | |
| 17 | + (let [c (font/open (font/find-font)) | |
| 18 | + h (font/line-height c 14) | |
| 19 | + out (atom []) | |
| 20 | + ck! (fn [n ok?] (swap! out conj [n (boolean ok?)]))] | |
| 21 | + (ck! "the face has a sane line height" (< 8 h 40)) | |
| 22 | + ;; Each of these goes to a fallback on most systems. Whatever answers, | |
| 23 | + ;; it must answer at the line the text sits on. | |
| 24 | + (doseq [[what s] [["an arrow" "←"] ["a picture glyph" "🖼"] ["plain text" "Chats"]]] | |
| 25 | + (let [[w gh] (font/size-of c s 14)] | |
| 26 | + (ck! (str what " measures on the line") | |
| 27 | + (and (pos? w) (<= gh (* 2 h)))))) | |
| 28 | + (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n)) | |
| 29 | + (let [bad (remove second @out)] | |
| 30 | + (println (if (seq bad) (str (count bad) " of " (count @out) " checks FAILED") | |
| 31 | + (str "all " (count @out) " checks passed"))) | |
| 32 | + (when (seq bad) (System/exit 1))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,32 @@ | |||
| 1 | +(ns jvui.font-check | ||
| 2 | + "A fallback face must not change the line. | ||
| 3 | + | ||
| 4 | + The headless tests measure with a stub — eight pixels a character — so | ||
| 5 | + they cannot see this at all. What it guards is the thing that made the | ||
| 6 | + compose bar a hundred and forty pixels tall: a colour emoji font is a | ||
| 7 | + bitmap with one fixed strike, and asked for fourteen it answers a | ||
| 8 | + hundred and twenty-eight. SDL_ttf then draws it at that, so one emoji | ||
| 9 | + in a message reshapes the window around it. | ||
| 10 | + | ||
| 11 | + Needs the real fonts, which is why it is not in the headless set." | ||
| 12 | + (:require [jvui.sdl :as sdl] [jvui.font :as font])) | ||
| 13 | + | ||
| 14 | +(defn -main [& _] | ||
| 15 | + (sdl/ensure-loaded!) | ||
| 16 | + (sdl/ttf-init!) | ||
| 17 | + (let [c (font/open (font/find-font)) | ||
| 18 | + h (font/line-height c 14) | ||
| 19 | + out (atom []) | ||
| 20 | + ck! (fn [n ok?] (swap! out conj [n (boolean ok?)]))] | ||
| 21 | + (ck! "the face has a sane line height" (< 8 h 40)) | ||
| 22 | + ;; Each of these goes to a fallback on most systems. Whatever answers, | ||
| 23 | + ;; it must answer at the line the text sits on. | ||
| 24 | + (doseq [[what s] [["an arrow" "←"] ["a picture glyph" "🖼"] ["plain text" "Chats"]]] | ||
| 25 | + (let [[w gh] (font/size-of c s 14)] | ||
| 26 | + (ck! (str what " measures on the line") | ||
| 27 | + (and (pos? w) (<= gh (* 2 h)))))) | ||
| 28 | + (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n)) | ||
| 29 | + (let [bad (remove second @out)] | ||
| 30 | + (println (if (seq bad) (str (count bad) " of " (count @out) " checks FAILED") | ||
| 31 | + (str "all " (count @out) " checks passed"))) | ||
| 32 | + (when (seq bad) (System/exit 1))))) | ||