Skip to content

Select fonts by requested emoji presentation - #745

Open
kane50613 wants to merge 6 commits into
linebender:mainfrom
kane50613:emoji-variation-selector-selection
Open

kane50613 wants to merge 6 commits into
linebender:mainfrom
kane50613:emoji-variation-selector-selection

Conversation

@kane50613

@kane50613 kane50613 commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

LLM Contributions: Claude fact check + generates, Codex review.

Now font selection only checks for "who has the first char?". Variation selectors like U+FE0F are ignored, same for U+FE0E. It also happened in the downstream kane50613/takumi#1218.

Per candidate font, an exact cmap format 14 match for the whole sequence wins first. Otherwise U+FE0F prefers fonts with color tables and U+FE0E fonts without. When no font matches the requested presentation, the base character still renders from a mismatched font instead of tofu.

To check whether the font has color tables, I've mirrored ColorTableLookup from Blink.

#692 seems to be solving the same bug, but only by moving the emoji generic family to the front, which overrides user-selected fonts that have their own emoji glyphs. Checking each font's capability avoids that.
Suppressed by #811

#620 is the segmentation layer above this and complementary.

Changelog

  • Adds fontique::Charmap::map_variant, with MapVariant re-exported from read-fonts.
  • Adds FontInfo::has_color_tables().
  • Adds public QueryFont.has_color_tables.
  • [Breaking change:] QueryFont struct literals must provide the new has_color_tables field.

@DJMcNab

DJMcNab commented Aug 28, 2026

Copy link
Copy Markdown
Member

I do expect this to be valuable, but we haven't had the bandwidth to review this yet.

@kane50613
kane50613 force-pushed the emoji-variation-selector-selection branch from a2818f7 to c8e0497 Compare September 14, 2026 10:29
nicoburns pushed a commit to nicoburns/parley that referenced this pull request Sep 16, 2026
…er#811)

LLM Contributions: Unknown for linebender#620, c-emoji. I used them for review,
bootstrapping the new crate to follow
https://linebender.org/wiki/formatting-scheme/, and getting/implementing
the technique of sticking the properties into our existing property
infrastructure, plus rebasing/reordering the series.

Adaptation of linebender#620 by @fundon.

This uses a state machine based on @chansen's
[c-emoji](https://github.com/chansen/c-emoji) to implement detection of
Emoji sequences, as described in
[tr-51](https://www.unicode.org/reports/tr51/tr51-29.html). This is
Unicode 17 Emoji, as opposed to Unicode 18 (releasing tomorrow...), but
I don't think the difference has a huge impact for us.

The state machine lives in a new crate `parley-emoji`, so as to follow
c-emoji's license. We were unable to get its code relicensed under our
MIT/Apache 2.0 license.

With this, we are *not* completely compliant with Unicode Emoji, but
this gives us a significantly better jumping off point.
Things I've chosen to defer:
- Better testing. I have local changes which generate a suitable subset
of Noto Color Emoji, but I think end-to-end testing needs more care.
  - Also not yet tested using the upstream Unicode data.
- Splitting a single grapheme cluster into its 'text presentation/emoji
presentation' preferred sections. This can happen in three cases that
I'm aware of:
  - Prepend character then Emoji, all within one grapheme cluster
  - Non-Emoji modifier base, then Fitzgerald skin tone modifier
  - ZWJ sequence containing VS15 text variation selectors.
- Having the Emoji font in the fallback chain, even if text presentation
is preferred.
- Thinking through how the 'reject' case should be handled
- Doing the proper rendering of malformed tag sequences, which is
required by spec, but which as far as I know, no system implements.
- [#font-tech > Appearance of unsupported emoji
sequences](https://xi.zulipchat.com/#narrow/channel/296640-font-tech/topic/Appearance.20of.20unsupported.20emoji.20sequences/with/623471132).
- Verifying that the handling of RI sequences is correct - in
particular, the way that three regional indicators in a row merge into
grapheme clusters can lead to jank.
- Fix the editing behaviour to use this properly (see also linebender#693)
- Documenting our level of compliance with the Unicode Emoji spec.
- Handling legacy modifier sequences with an intervening VS16. There's a
TODO in the code for this.
- The 'emoji presentation locale' thing.

I don't believe any of those are blocking - this is still a better state
than before.

Fixes linebender#744.
Supersedes linebender#620, linebender#788, linebender#692.

cc @kane50613 (wrt linebender#745).

**Changelog**

> ### Fixed
>
> - Parley's detection of Emoji is now more robust.

---------

Co-authored-by: Fangdun Tsai <fundon@pindash.io>
Co-authored-by: Tom Churchman <thomas@churchman.nl>
@kane50613
kane50613 force-pushed the emoji-variation-selector-selection branch from c8e0497 to ce001ed Compare September 16, 2026 17:15
Each variation sequence now resolves independently: an exact cmap format
14 mapping short-circuits the color-table heuristic for that sequence
alone, and a font matches the requested presentation only when every
sequence in the cluster is satisfied. A variant mapping no longer claims
coverage for repeated occurrences of the same base character.

Fonts are now bucketed by presentation before coverage is compared, so a
zero-coverage mismatched font no longer blocks a later matching one. The
exact-mapping early stop was redundant with the completeness check below
it.
@kane50613

Copy link
Copy Markdown
Contributor Author

I've rebased from the latest main branch.

@nicoburns

Copy link
Copy Markdown
Collaborator

An initial AI-review is positive for correctness, but notes a 3-5% performance regression for non-emoji text.

Suggested causes and fixes:

  1. CharmapIndex::charmap() now runs Cmap14::read(...) eagerly for every candidate font on every cluster (fontique/src/charmap.rs). For fonts that have a format 14 subtable (Noto CJK, many system fonts) that is a parse + validation per call. Suggest storing the data slice/offset in Charmap and parsing lazily inside map_variant, or caching the parsed Cmap14 alongside the cmap in the query.
  2. select_font builds variation_sequences (windows scan + base_is_unique O(n²) scan) and, per candidate, a SmallVec plus variant_bases.contains(&ch) inside the hot coverage closure, even when the cluster has no selector. Add an early fast path: if no U+FE0E/U+FE0F in cluster.chars(), run the original closure/selection loop unchanged. select_font is called once per grapheme cluster, so this adds up on document-scale text.

@kane50613
kane50613 force-pushed the emoji-variation-selector-selection branch from fe0cb01 to 4960f41 Compare September 16, 2026 20:09
`CharCluster::fill` already classifies presentation selectors per
character, so the flag comes for free and replaces a scan of every
cluster in `select_font`.
@nicoburns

Copy link
Copy Markdown
Collaborator

The latest changes seem to have brought performance ~back to parity, although my LLM does have a note that:

If the author really wants to squeeze [performance]: the per-candidate SmallVec + variant_bases.contains(&ch) in the coverage closure still executes for every cluster. Guarding the closure with variation_sequences.is_empty() (or keeping the original closure for that case) would remove the last unconditional cost.

@kane50613

Copy link
Copy Markdown
Contributor Author

I've asked opus to squeeze it to the extreme. He agrees this PR is 2% slower than main branch, and mentioned one possible architectural change:

select_font calls font.charmap() inside self.query.matches_with(...). So it runs once per candidate font, per cluster.

CharmapIndex is built once per font and cached. But CharmapIndex::charmap() still redoes the work every call: FontRef::from_index, the table lookup, and CmapSubtable::read. The list of candidate fonts does not change within a segment, so the result is always the same.

One problem is that Charmap<'a> borrows the blob that QueryFont owns. Caching it on QueryFont would make the type refer to itself. A cache on the FontSelector side, keyed by blob id and face index, avoids that. So would a cache held by fontique's Query.

Should I still aim for the extreme 🤔

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants