Skip to content

feat(fonts): bundle families for Georgian, Armenian and Korean - #539

Merged
DemchaAV merged 6 commits into
developfrom
feat/fonts-more-scripts
Aug 12, 2026
Merged

feat(fonts): bundle families for Georgian, Armenian and Korean#539
DemchaAV merged 6 commits into
developfrom
feat/fonts-more-scripts

Conversation

@DemchaAV

@DemchaAVDemchaAV commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Why

Georgian, Armenian and Korean had no covering family in the bundled set. A document
written in any of them rendered as rows of question marks — the substitution is logged at
WARN and the PDF still opens, so nothing about the output announced that the font was the
problem — unless the author registered a font of their own.

What

Three families join the catalog, shipping in the same graph-compose-fonts 1.1.0 as the
Arabic and Hebrew pair: NOTO_SANS_GEORGIAN, NOTO_SANS_ARMENIAN and GOTHIC_A1.

Each covers the second case its script has, which is the part that is easy to lose:
Armenian capitals and the և ligature that is the word "and"; Georgian Mtavruli, the
capitals a heading is set in, which Unicode encodes in a block of its own (U+1C90) far from
the lowercase range; and for Korean both the 11 172 precomposed syllables and the
conjoining jamo a decomposed (NFD) string is made of.

Gothic A1 was chosen over the better-known Korean families for its Latin. A paragraph is
drawn in one family, so a Korean sentence holding a European name is drawn entirely in the
Korean font — Nanum Gothic covers 17 % of Latin-1 and no Cyrillic, which turns Müller
into M?ller while the Korean around it renders perfectly. Gothic A1 covers Latin-1,
Latin Extended-A, Cyrillic and Greek in full.

Georgian and Armenian upstream publish only as variable fonts with no static face to take.
The bundled file is that variable font; a PDF applies no instancing and draws the fvar
default, which for these two is regular — so bold resolves to it and renders unemboldened.
That default is now asserted rather than assumed: the Noto CJK variable fonts default to
weight 100, so the same vendoring done with one of those would have shipped a family that
renders every page hairline Thin with full coverage and nothing failing.

Chinese and Japanese stay unbundled and the docs say why rather than leaving it looking
like an omission: the official static Noto CJK faces use CFF outlines, which the PDF
backend cannot embed at all, and the variable ones carry the weight-100 default above.

fonts/README.md gains a "which script needs which family" table, measured against the
shipped binaries. Two rows written from reputation rather than from the cmap were wrong
and are corrected: Hebrew is carried by Tinos and Cousine as well as David Libre, and
Latin Extended-A and Vietnamese are not covered by "any bundled family" — nine families
cover almost none of either.

Three guards also stop keying on the absence of a marker and start keying on the signal
they exist for — the script demos assert their script reached the page rather than only
that no ? did; the stale-artifact diagnostic is driven through every family that needs
it instead of one sample; and the fonts and emoji version pins are discovered across the
poms that declare them rather than listed two at a time, which was already two short.

Tests

  • BundledScriptCoverageTest — holds every row of the README table to the binaries, so
    the table fails a build rather than going stale.
  • KoreanFontCoverageTest — all 11 172 syllables in both faces, both jamo forms, and the
    Latin-1 / Latin Extended-A / Cyrillic / Greek the docs promise; bold is a distinct face.
  • GeorgianArmenianFontCoverageTest — both cases of each script, and that the variable
    fonts resolve to their regular instance.
  • BundledFamiliesTest — the families are listed, the faces upstream does not ship
    collapse onto a real resource, and every family needing the stale-artifact diagnostic
    gets it.
  • KoreanGlyphsDemoTest, GeorgianArmenianGlyphsDemoTest — render each script (Korean
    alongside accented Latin, and decomposed) and read the PDF back to prove the glyphs
    reached the page.
  • available_fonts_preview layout snapshot refreshed: the showcase grows from five pages
    to six.
  • Full reactor gate green (clean verify across core, render-pdf/docx/pptx, templates,
    testing, qa, coverage).

@DemchaAVDemchaAV changed the title feat(fonts): bundle families for Georgian and Armenianfeat(fonts): bundle families for Georgian, Armenian and KoreanAug 11, 2026
@DemchaAV
DemchaAVforce-pushed the feat/fonts-more-scripts branch from 6efa059 to d9466dbCompareAugust 11, 2026 07:15
@DemchaAV

Copy link
Copy Markdown
OwnerAuthor

Rebased onto the updated feat/rtl-arabic-shaping and pushed d9466db7 with the
coverage and runbook corrections.

Script blocks are swept by letter, not by hand-picked range. The Georgian assertions
stopped at U+10FA and U+1CBA, where those blocks used to end; Unicode has since assigned
letters past both and the bundled family carries them, so a test reporting complete
coverage had not looked at them. Extending the ranges then failed on U+1CBB and U+1CBC —
which are unassigned. A block is not a solid run of letters: it has gaps, and punctuation
and modifiers sit among them. Instead of a hand-kept list of holes needing an edit every
Unicode release, the sweep asks whether each code point is a letter and skips the rest.

Amiri is swept in all four faces it ships rather than three. The sibling assertion that
its faces never collapse onto one another meant the fourth was carrying the whole script
with nothing checking that it could draw it.

On the fonts version.graph-compose-fonts:1.1.0 is unreleased — fonts-v1.1.0 is
not tagged (only fonts-v1.0.0 exists) and Central returns 404 for it — so accumulating
Georgian, Armenian and Korean into that version is the intended shape, not a conflict
with the Arabic/Hebrew PR. What is real is the sequencing constraint, and the release
runbook now states it: the tag goes up only after every family meant for a version has
landed, because a published artifact is immutable while the catalog goes on naming that
version as the one a family arrived in.

The same runbook said three things about the fonts artifact that had stopped being true,
each in the direction of doing too little — that core carries no pin on it (it does, at
test scope, so a single-module build resolves it from the local repository), that the
engine needs no fonts bootstrap, and that two poms re-pin the version where six do.
Corrected, and VersionConsistencyGuardTest now discovers the declaring poms rather than
listing them, so the list cannot go short again.

Full reactor gate green.

@DemchaAV

Copy link
Copy Markdown
OwnerAuthor

Pushed 3528a4f2 and rebased onto the updated #538.

The Greek and Cyrillic claims were mine and they were too big. The Greek assertion ran
U+0391..U+03A1 — half the capitals and no lowercase at all — because that dodged the
unassigned U+03A2, while the Javadoc, the fonts README and the changelog all promised the
script. Cyrillic ran U+0410..U+044F, the Russian alphabet inside the block, with no Ё or Ї.

Both are swept by letter now, which skips the unassigned code points without shortening
the range: the modern Greek alphabet including the accented capitals, and the whole
Cyrillic block. Greek stops at the alphabet rather than covering its block, because the
archaic and Coptic letters from U+0370 are carried by no text face — so the prose says
"the modern Greek alphabet" instead of "Greek", which is what is actually measured.

On the version metadata in FontFamilyDefinition — the symptom is right, and five
entries in a generic class is the smell you say it is. I would rather not take the
proposed BundledFontDescriptor(..., "1.1.0") though: it moves the coupling into the
catalog instead of removing it, and the catalog would then be carrying the artifact's
release history, which it does not carry today. The version a family first shipped in is
derivable from the fact that the family postdates the artifact split, so it should not
need writing down at all. That is a separate change and I would rather not mix it into
the PR that adds a Korean font.

Full reactor gate green.

@DemchaAV

Copy link
Copy Markdown
OwnerAuthor

Pushed db75bd8c, and the version-metadata point is answered by
#540, opened after this review was
written. It does what the review recommends — a marker the artifact ships, read instead of
inferred — and goes one step further: with the artifact naming its own version there is
nothing left for a per-family map to say, so FAMILY_MINIMUM_FONTS_VERSION and the
Lato-Regular.ttf presence probe are both gone rather than relocated. The message names
the version the consumer has instead of the version that introduced the family, which is
the fact that identifies their problem and needs no maintenance as families are added.

The coverage table was measured on narrower ranges than it named, and the gap is bigger
than it looks.
The row said "Cyrillic" while the assertion swept U+0410..U+044F. Over the
whole block the count is not eighteen families but four — Lato, Tinos, Cousine and
Gothic A1 carry the extended letters the Central Asian and Caucasian languages need, and
nothing else in the set does. Those are two different claims, so the table makes both, each
with its own assertion. Greek is likewise the modern alphabet rather than its block, whose
archaic and Coptic letters no text face carries.

The sweep goes by letter now, so a range can span the unassigned gaps and the punctuation a
block really contains instead of being trimmed until it passes — which is how the Cyrillic
row came to be measured on the alphabet in the first place.

On the release order — the runbook was fair to criticise. "Bump, tag, then update the
pins" sat beside a separate rule that the tag is cut last; both were true, and a reader
following the first in order publishes early. It is one numbered sequence now: land every
font change for the version, bump the module and every declaring pom, merge, then tag.
fonts-v1.1.0 is still untagged and 404 on Central, so the stack can merge through #540
before anything is published.

Full reactor gate green here and at the tip of the stack.

@DemchaAV
DemchaAVforce-pushed the feat/rtl-arabic-shaping branch from 1b99cfb to 3c7b4f2CompareAugust 12, 2026 05:55
@DemchaAV
DemchaAVforce-pushed the feat/fonts-more-scripts branch from db75bd8 to d738b53CompareAugust 12, 2026 05:55
@DemchaAV
DemchaAVforce-pushed the feat/rtl-arabic-shaping branch from 3c7b4f2 to 25d7aeeCompareAugust 12, 2026 06:07
@DemchaAV
DemchaAVforce-pushed the feat/fonts-more-scripts branch from d738b53 to a0ca04cCompareAugust 12, 2026 06:07
@DemchaAV

Copy link
Copy Markdown
OwnerAuthor

Pushed a0ca04c1. Two corrections from a fresh review of the stack, both mine.

The table's preamble overclaimed what the test pins. It said BundledScriptCoverageTest
holds every row "to those exact families". It holds the single-family rows that way; the
wider rows it holds to a count, which is a weaker claim and the one worth stating, since a
reader deciding whether to trust a row deserves to know which kind it is.

And the Greek constant left behind when the wide rows moved to a letters-only sweep was
unreferenced, so it goes.

Full reactor gate green here and at the tip.

@DemchaAV
DemchaAVforce-pushed the feat/rtl-arabic-shaping branch from 25d7aee to 6f704a6CompareAugust 12, 2026 06:28
Base automatically changed from feat/rtl-arabic-shaping to developAugust 12, 2026 06:34
Georgian and Armenian had no covering family in the bundled set, so a
document written in either rendered as rows of question marks unless the
author registered a font of their own.
Noto Sans Georgian and Noto Sans Armenian join the catalog, shipping in
the same graph-compose-fonts 1.1.0 as the Arabic and Hebrew pair. Both
cover their script in full — Georgian in Mkhedruli and in Mtavruli, the
capitals a heading is set in, which Unicode encodes in a block of its own
far from the lowercase range. Upstream publishes them as variable fonts
with no static weights to take, so the artifact carries the regular
instance and every other face resolves to it: bold renders unemboldened
rather than failing, which the tests state rather than leave to be found.
The family list the measurement/render width-parity test walks is now
read off the catalog instead of repeated, so a family added to the
catalog is covered the day it lands.
Korean had no covering family, so a document written in it rendered as
rows of question marks unless the author registered a font of their own.
Gothic A1 joins the catalog with a drawn regular and bold. It carries all
11 172 precomposed Hangul syllables, both jamo forms, and — unlike the
better-known Korean families — Latin-1, Latin Extended-A, Cyrillic and
Greek. That last part decided it: a paragraph is drawn in one family, so
a Korean sentence holding a European name is drawn in the Korean font,
and a family with ASCII but no accents turns Müller into M?ller with the
Korean around it rendering perfectly. Hanja are not covered.
Chinese and Japanese stay unbundled, and the docs now say why rather
than leaving it looking like an omission: the official static Noto CJK
faces use CFF outlines, which the PDF backend cannot embed at all, and
the variable ones default to weight 100 — the weight a PDF draws, since
it applies no instancing.
The script table in the fonts README was measured against the shipped
binaries and two of its rows were wrong. Hebrew was listed as reachable
only through David Libre, while Tinos and Cousine carry the whole block;
Latin Extended-A and Vietnamese were listed as covered by any bundled
family, when nine families cover almost none of either. Both rows are
corrected and the whole table is now held to the binaries by a test, so
it fails a build instead of going quietly stale.
Three guards stop keying on the absence of a marker and start keying on
the signal they exist for: the script demos assert their script actually
reached the page rather than only that no '?' did, the stale-artifact
diagnostic is driven through every family that needs it instead of one
sample, and the fonts and emoji version pins are discovered across the
poms that declare them rather than listed two at a time.
The Georgian and Armenian binaries are the variable fonts upstream
publishes, not static instances, and the comments describing them said
otherwise. What makes them safe is that their wght axis defaults to 400,
so that is now asserted — the same vendoring done with a Noto CJK
variable font would have shipped hairline Thin with nothing failing.
The Georgian assertions stopped at U+10FA and U+1CBA, where the blocks
used to end. Unicode has since assigned letters past both, and the
bundled family carries them — so a test that reported complete coverage
had not looked at them. Extending the ranges then failed on U+1CBB and
U+1CBC, which are unassigned: a block is not a solid run of letters, it
has gaps, and punctuation and modifiers sit among them.
Rather than carry a hand-kept list of holes — a list that needs an edit
every Unicode release — the sweep asks whether each code point is a
letter and skips the rest. The range stays whole and the claim stays
honest without anything to maintain.
Amiri is swept in all four of the faces it ships, not three. The sibling
assertion that the faces never collapse onto one another meant the
fourth carried the whole script with nothing checking it could draw it.
The release runbook said three things about the fonts artifact that had
stopped being true, each in the direction of doing too little: that core
carries no pin on it (it does, at test scope, so a single-module build
resolves it from the local repository), that the engine needs no fonts
bootstrap, and that two poms re-pin the version where six do. It also
now says when the tag may be cut — after every family meant for that
version has landed, since a published artifact is immutable while the
catalog goes on naming it as the version a family arrived in.
The Greek assertion ran U+0391..U+03A1 — half the capitals, no lowercase
— because that dodged the unassigned U+03A2, while the Javadoc and both
docs promised the script. Cyrillic ran U+0410..U+044F, the Russian
alphabet inside the block, without Ё or Ї.
Both are swept by letter now, which skips the unassigned code points
without shortening the range: the modern Greek alphabet including the
accented capitals, and the whole Cyrillic block. Greek stops there rather
than covering its block, because the archaic and Coptic letters at
U+0370 are carried by no text face — so the prose says "the modern Greek
alphabet" instead of "Greek", which is what was measured.
The table row said "Cyrillic" and the assertion behind it swept
U+0410..U+044F — the Russian and Ukrainian letters, not the block. Over
the whole block the count is not eighteen families but four: the extended
letters the Central Asian and Caucasian languages need are carried by
Lato, Tinos, Cousine and Gothic A1 and by nothing else in the set. Those
are different claims and the table now makes both, each with its own
assertion. Greek is likewise the modern alphabet rather than its block,
whose archaic and Coptic letters no text face carries.
The sweep goes by letter, so a range can span the gaps and the
punctuation a block really contains instead of being trimmed until it
passes — which is how the Cyrillic row came to be measured on the
alphabet in the first place.
The changelog also states a shaping limit found while checking a review
claim, rather than leaving it to be discovered: a word too long for its
column is broken with the contextual forms it was given while whole, so
the letters either side of the break keep their connecting strokes.
Shaping runs before wrapping because wrapping measures widths and the
forms carry them, so re-shaping the halves would change the widths the
wrap already spent.
The fonts release procedure is one numbered sequence now. It read as
"bump, tag, then update the pins" beside a separate rule that the tag is
cut last; both were true, and a reader following the first in order
publishes an artifact that the catalog then promises families it does not
carry.
The table's own preamble promised the test holds every row "to those
exact families". It holds the single-family rows that way; the wider ones
it holds to a count, which is a weaker claim and the one worth stating,
since a reader deciding whether to trust a row deserves to know which
kind it is.
The Greek constant the wide rows used before they moved to a letters-only
sweep is unreferenced now, so it goes.
@DemchaAV
DemchaAVforce-pushed the feat/fonts-more-scripts branch from a0ca04c to 9a9717eCompareAugust 12, 2026 06:34
@DemchaAV
DemchaAV merged commit fed897d into developAug 12, 2026
14 checks passed
@DemchaAV
DemchaAV deleted the feat/fonts-more-scripts branch August 12, 2026 06:41
Sign up for freeto 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.

1 participant

@DemchaAV