diff --git a/mobile/assets/fonts/InterVariable-Italic.ttf b/mobile/assets/fonts/InterVariable-Italic.ttf index 48b98c33e2..28df842895 100644 Binary files a/mobile/assets/fonts/InterVariable-Italic.ttf and b/mobile/assets/fonts/InterVariable-Italic.ttf differ diff --git a/mobile/assets/fonts/InterVariable.ttf b/mobile/assets/fonts/InterVariable.ttf index 94adb4b78f..dea05f17fa 100644 Binary files a/mobile/assets/fonts/InterVariable.ttf and b/mobile/assets/fonts/InterVariable.ttf differ diff --git a/mobile/assets/fonts/README.md b/mobile/assets/fonts/README.md index 7e4992427c..baa8211974 100644 --- a/mobile/assets/fonts/README.md +++ b/mobile/assets/fonts/README.md @@ -1,8 +1,9 @@ -# Inter heart fallback +# Inter heart and warning fallback The two Inter 4.001 variable fonts are derived from the previously bundled -fonts by removing only U+2764 from their Unicode character maps. Flutter can -then choose a native font for heart characters, including emoji presentation +fonts by removing only U+2764 and U+26A0 from their Unicode character maps. +Flutter can then choose a native font for heart and warning characters, +including emoji presentation in reactions and message text. No glyphs, language coverage, other symbols, font metrics, or variation tables are removed. The license remains in `Inter-LICENSE.txt`. @@ -23,20 +24,20 @@ python3 -m venv /tmp/buzz-inter-tools The script checks pristine source hashes and verifies every character mapping and every other font table. Only the `cmap` table and the font checksum may change. Repeated generation from the same sources produces identical bytes. -Warning signs, ©, ®, ™, arrows, math symbols, accents, and supported languages +©, ®, ™, arrows, math symbols, accents, and supported languages keep their original Inter mappings. Use `test/visual/heart_sim_app.dart` on an actual iOS simulator to compare reactions, plain/emoji/text-presentation hearts, bold/italic message text, warning signs, and preserved symbols. Font fallback for plain and explicit -text-presentation hearts is platform-dependent; do not infer their appearance -from a macOS widget-test renderer. On iOS 26.5, all three heart presentations -fall back to the color emoji, including explicit VS15. Message line metrics +text-presentation hearts and warnings is platform-dependent; do not infer their appearance +from a macOS widget-test renderer. On iOS 26.5, all three heart and warning +presentations fall back to the color emoji, including explicit VS15. Message line metrics can change with the fallback font. The native screenshot regression check (requires Pillow) covers both reaction -states, each message-heart presentation, bold/italic text, and unchanged warning -signs and symbols: +states, each heart and warning presentation, bold/italic text, and unchanged +symbols: ```sh python3 mobile/test/visual/verify_heart_screenshots.py before.png after.png diff --git a/mobile/scripts/prepare-inter.py b/mobile/scripts/prepare-inter.py index abe2e40911..f0671b2f40 100644 --- a/mobile/scripts/prepare-inter.py +++ b/mobile/scripts/prepare-inter.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Remove only Inter's U+2764 mapping so hearts use native font fallback. +"""Remove Inter's heart and warning mappings for native emoji fallback. Requires fonttools==4.60.1. See assets/fonts/README.md for source and commands. """ @@ -15,7 +15,7 @@ SOURCES = { "InterVariable.ttf": "4989b125924991b90d05b2d16e0e388c48f7d5bb8b30539bbf9c755278d0ccaf", "InterVariable-Italic.ttf": "d6f1f6a172d9e588438db9f986fd5cfad7b30f644374080a8a9d4d91e344586f", } -HEART = 0x2764 +NATIVE_EMOJI = (0x2764, 0x26A0) def verify(source: Path, output: Path) -> None: @@ -41,9 +41,10 @@ def verify(source: Path, output: Path) -> None: raise ValueError("Character-map identity changed") expected = dict(old.cmap) if old.isUnicode(): - del expected[HEART] + for codepoint in NATIVE_EMOJI: + del expected[codepoint] if new.cmap != expected: - raise ValueError("Character mappings changed beyond U+2764") + raise ValueError("Character mappings changed beyond U+2764 and U+26A0") def main() -> None: @@ -61,15 +62,16 @@ def main() -> None: if not args.check: with TTFont(source, lazy=True, recalcTimestamp=False) as font: tables = [t for t in font["cmap"].tables if t.isUnicode()] - if not tables or any(HEART not in t.cmap for t in tables): - raise ValueError(f"Expected heart mapping in {name}") + if not tables or any(codepoint not in t.cmap for t in tables for codepoint in NATIVE_EMOJI): + raise ValueError(f"Expected heart and warning mappings in {name}") # FontTools shares dictionaries between equivalent subtables. for cmap in {id(t.cmap): t.cmap for t in tables}.values(): - del cmap[HEART] + for codepoint in NATIVE_EMOJI: + del cmap[codepoint] args.output_dir.mkdir(parents=True, exist_ok=True) font.save(output, reorderTables=False) verify(source, output) - print(f"{name}: only U+2764 mapping removed; other mappings and glyph data unchanged") + print(f"{name}: only U+2764 and U+26A0 mappings removed; other mappings and glyph data unchanged") if __name__ == "__main__": diff --git a/mobile/test/visual/heart_sim_app.dart b/mobile/test/visual/heart_sim_app.dart index c4b21c0045..2907aa2bd9 100644 --- a/mobile/test/visual/heart_sim_app.dart +++ b/mobile/test/visual/heart_sim_app.dart @@ -23,7 +23,7 @@ void main() => runApp( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 24), - const Text('Hearts and text rendering'), + const Text('Hearts, warnings, and text'), const SizedBox(height: 24), const Text('Selected'), _reactions(true), @@ -34,7 +34,7 @@ void main() => runApp( const Text('Message text'), const SizedBox(height: 8), _message('Plain ❤ Emoji ❤️ Text ❤︎'), - _message('**Bold ❤️** *Italic ❤️*'), + _message('**Bold ❤️ ⚠️** *Italic ❤️ ⚠️*'), _message('Warnings: ⚠ ⚠️ ⚠︎'), const SizedBox(height: 16), const Text('Preserved symbols and text'), @@ -43,7 +43,7 @@ void main() => runApp( _message('Ελληνικά · Кириллица · Tiếng Việt'), const SizedBox(height: 16), const Text('Emoji-only message'), - _message('❤️'), + _message('❤️ ⚠️'), ], ), ), @@ -56,7 +56,7 @@ void main() => runApp( Widget _reactions(bool selected) => ReactionRow( messageId: 'heart-$selected', reactions: [ - for (final emoji in ['❤️', '👍', '🎉']) + for (final emoji in ['❤️', '⚠️', '👍']) TimelineReaction( emoji: emoji, count: 5, diff --git a/mobile/test/visual/verify_heart_screenshots.py b/mobile/test/visual/verify_heart_screenshots.py index ba4b620191..becee9fb23 100644 --- a/mobile/test/visual/verify_heart_screenshots.py +++ b/mobile/test/visual/verify_heart_screenshots.py @@ -10,13 +10,13 @@ import sys from PIL import Image, ImageChops -def red_pixels(image, box): +def color_pixels(image, box, predicate): left, top, right, bottom = box return [ (x, y) for y in range(top, bottom) for x in range(left, right) - if is_red(image.getpixel((x, y))) + if predicate(image.getpixel((x, y))) ] @@ -25,6 +25,11 @@ def is_red(color): return red > 140 and red > green * 1.5 and red > blue * 1.5 +def is_yellow(color): + red, green, blue = color + return red > 180 and green > 130 and blue < 90 + + def main(): before, after = [Image.open(path).convert("RGB") for path in sys.argv[1:]] assert before.size == after.size == (1206, 2622), "Use the documented simulator and fixture" @@ -36,25 +41,35 @@ def main(): # Native iOS fallback currently renders VS15 as a color heart too. "text-presentation message heart": (550, 980, 620, 1035), "bold message heart": (170, 1040, 220, 1090), - "italic message heart": (355, 1040, 425, 1090), + "italic message heart": (430, 1040, 490, 1090), "emoji-only message": (65, 1490, 125, 1545), } - for name, box in regions.items(): - assert not red_pixels(before, box), f"{name}: original must reproduce monochrome heart" - pixels = red_pixels(after, box) - assert len(pixels) > 500, f"{name}: expected native red heart" - if "reaction" in name: - center = (min(y for _, y in pixels) + max(y for _, y in pixels)) / 2 - pill_center = (box[1] + box[3] - 1) / 2 - assert abs(center - pill_center) <= 1.5, f"{name}: heart is not centered" - print(f"PASS: {name}") - # The two preceding message lines gain one logical pixel each from the + warnings = { + "selected warning reaction": (260, 522, 325, 606), + "unselected warning reaction": (260, 744, 325, 828), + "plain message warning": (275, 1090, 335, 1150), + "emoji-presentation message warning": (350, 1090, 415, 1150), + "text-presentation message warning": (430, 1090, 490, 1150), + "bold message warning": (230, 1040, 290, 1090), + "italic message warning": (490, 1040, 550, 1090), + "emoji-only warning": (130, 1490, 190, 1550), + } + for cases, predicate in ((regions, is_red), (warnings, is_yellow)): + for name, box in cases.items(): + assert not color_pixels(before, box, predicate), f"{name}: original must reproduce monochrome glyph" + pixels = color_pixels(after, box, predicate) + assert len(pixels) > 500, f"{name}: expected native color emoji" + if "reaction" in name: + center = (min(y for _, y in pixels) + max(y for _, y in pixels)) / 2 + pill_center = (box[1] + box[3] - 1) / 2 + assert abs(center - pill_center) <= 1.5, f"{name}: emoji is not centered" + print(f"PASS: {name}") + # The three preceding message lines gain one logical pixel each from the # fallback font's metrics. Compare unchanged text after that translation. for name, box in { - "warning signs": (65, 1090, 1000, 1145), "preserved symbols and languages": (65, 1260, 1100, 1385), }.items(): - shifted = (box[0], box[1] + 6, box[2], box[3] + 6) + shifted = (box[0], box[1] + 9, box[2], box[3] + 9) difference = ImageChops.difference(before.crop(box), after.crop(shifted)) assert difference.getbbox() is None, f"{name}: unexpected rendering change" print(f"PASS: {name} is pixel-identical")