Use native color emoji for warning signs too

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-09-23 17:43:07 -07:00
parent 965d7027c2
commit dd1460b38c
6 changed files with 54 additions and 36 deletions

View File

@@ -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

View File

@@ -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__":

View File

@@ -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,

View File

@@ -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")