"""True MSDF atlas generation from vector contours.
Generates multi-channel signed distance fields from FreeType glyph outlines.
Each RGB channel encodes distance to a different set of edges, enabling
sharp corner reconstruction in the fragment shader via median filtering.
"""
import logging
import unicodedata
from bisect import bisect_right
from collections.abc import Callable
from dataclasses import dataclass
import numpy as np
from .font import Font, GlyphMetrics
log = logging.getLogger(__name__)
#: Atlas key the missing-glyph box is packed under. U+FDD0 is a permanent
#: noncharacter: Unicode guarantees it is never assigned to anything, so no font
#: can supply a competing glyph for it and no meaningful text can collide with
#: it. Text renderers draw this region in place of a character no font they can
#: reach has a glyph for, so such a character shows as a visible box instead of
#: an invisible gap.
MISSING_GLYPH_KEY = "\ufdd0"
#: Atlas key the hex-form missing-glyph box is packed under, U+FDD1 being the
#: next permanent noncharacter after :data:`MISSING_GLYPH_KEY`. It is a narrow,
#: thinly stroked box holding one byte of the codepoint, written as two digits
#: one above the other. Text large enough to read them draws one of these per
#: byte; smaller text draws the plain box.
MISSING_GLYPH_HEX_KEY = "\ufdd1"
#: Advance of the plain missing-glyph box, as a fraction of the font's pixel
#: size, and the room a missing character in proportional text is reserved
#: wherever that box is what gets drawn. The box fills it: text at body size
#: shows a box the width of an ordinary character, not a small one adrift in a
#: wide gap. Text on a character grid reserves a cell instead and fits the box to
#: it (see :func:`missing_glyph_advance`).
MISSING_GLYPH_ADVANCE = 0.6
#: Advance of one hex box, in the same units, and so the room a missing
#: character in proportional text is reserved once the text is large enough to
#: write its codepoint out: this fraction times
#: :func:`missing_glyph_byte_count`. It is smaller than
#: :data:`MISSING_GLYPH_ADVANCE` because one byte needs a single column of
#: digits, not a grid of them, but several of them in a row are wider than one
#: plain box, which is why the room a missing character takes depends on the
#: size it is drawn at (see :func:`missing_glyph_advance`).
MISSING_GLYPH_HEX_ADVANCE = 0.42
#: The box's shape inside that advance, as fractions of the advance (width), the
#: font's ascender (height) and the font's pixel size (stroke thickness). The
#: proportions follow what browsers and other engines draw: a rectangle standing
#: on the baseline, narrower than the advance so consecutive boxes stay apart,
#: and stroked thickly enough to survive being drawn at body-text size.
_BOX_WIDTH = 0.68
_BOX_HEIGHT = 0.74
_BOX_STROKE = 0.07
#: The hex box's shape, as fractions of its own advance (width), the font's
#: ascender (height) and the font's pixel size (stroke thickness). It gives up
#: most of the stroke weight to interior room, because everything that buys goes
#: into the digits, which are the smallest thing drawn anywhere in the engine and
#: set the size at which the whole form stops being readable. It stands on the
#: baseline like the plain box and is inset inside its advance, so consecutive
#: boxes stay apart.
_HEX_BOX_WIDTH = 0.88
_HEX_BOX_HEIGHT = 0.80
_HEX_BOX_STROKE = 0.035
#: Digits in one hex box, one above the other: the two that write one byte. A
#: box is taller than it is wide, so a single column of two fills that aspect
#: instead of fighting it, and a codepoint of any length is then the same shape
#: repeated rather than a grid that changes proportions with the codepoint.
_HEX_ROWS = 2
#: Room kept clear around the digits inside a hex box, as a fraction of the digit
#: height. It is one number for all four walls and for the gap between the two
#: rows, so no side of a digit is tighter than another and the form cannot come
#: out cramped on one axis and airy on the other.
#:
#: It is stated relative to the digit rather than in the font's units because
#: that is what makes it mean the same thing at every size. The digits are only
#: ever drawn at :data:`MISSING_GLYPH_HEX_MIN_DIGIT_PIXELS` or more, so this is a
#: known number of pixels of background at the smallest size the form appears at,
#: and it has to be enough for one: the box wall and the digit each spread their
#: distance field over about a pixel as they resolve, so a clearance under two
#: pixels leaves the two ramps meeting and the digit welded to the wall.
_HEX_DIGIT_CLEARANCE = 0.22
#: Every digit a codepoint can be written with. The size the digits are drawn at
#: is fitted to the largest of these the font carries rather than to the ones
#: this particular codepoint needs, so every box in a line of text draws its
#: digits at one size and the size threshold falls in the same place for all of
#: them, instead of a box holding an ``A`` sizing differently from one holding a
#: ``1``.
_HEX_DIGITS = "0123456789ABCDEF"
#: Height a digit must reach for the hex boxes to be drawn at all; below it the
#: plain box is drawn instead. Chosen by forcing the hex form at every size text
#: is drawn at, rendering all sixteen digits through the real text pass on both a
#: dark and a light ground, and reading them back: at seven pixels every one of
#: them is separable from the ones it is easiest to confuse it with, and half a
#: pixel under that the distance field can no longer hold their counters apart
#: and a zero reads as a D, which says less than the clean box it replaced. The
#: dark ground is the harder of the two and set the number.
#:
#: Stating the threshold as a digit height rather than as a text size costs
#: nothing now that every box holds the same two digits, and it keeps the number
#: meaningful: it is what was actually looked at on screen.
#:
#: The height is measured in the pixels text is laid out in, which are the
#: window's logical pixels and not the device pixels the result finally covers.
#: A display with a content scale of two draws that text across twice as many
#: device pixels and can genuinely resolve the digits sooner, but the threshold
#: does not consult the content scale and holds them back until the logical size
#: reaches it. That conservatism is deliberate: it is the one reading of "big
#: enough" both backends can apply, since the browser works in the same logical
#: coordinates and leaves device-pixel-ratio scaling to the canvas, and desktop
#: and browser have to agree on what a missing glyph looks like.
#:
#: What it comes to in practice, for an atlas baked at the usual 48px: any
#: codepoint, of four digits or six, is written out from 30 pixels of text on, so
#: body text draws the plain box and anything from a subheading up reads the
#: codepoint out.
MISSING_GLYPH_HEX_MIN_DIGIT_PIXELS = 7.0
#: Unicode categories of the characters that are invisible by definition: the
#: control characters, the format characters (a soft hyphen, a zero-width
#: joiner, a bidi mark) and the space separators. A font having no glyph for one
#: of these is not a missing glyph, so they advance the cursor and draw nothing
#: rather than showing a box.
_INVISIBLE_CATEGORIES = frozenset({"Cc", "Cf", "Zl", "Zp", "Zs"})
#: Codepoint ranges Unicode gives the Default_Ignorable_Code_Point property that
#: the categories above do not already cover. These characters exist to steer
#: the shaping of their neighbours and are defined to leave no mark of their
#: own: the variation selectors, the combining grapheme joiner, the Mongolian
#: free variation selectors, the Hangul fillers and the tag characters. Unicode
#: classifies several of them as combining marks rather than as format
#: characters, so category alone is not enough to recognise them, and treating
#: one as a missing glyph would put a box beside every emoji written with an
#: explicit emoji presentation (which ends in U+FE0F).
_IGNORABLE_RANGES: tuple[tuple[int, int], ...] = (
(0x034F, 0x034F), # combining grapheme joiner
(0x115F, 0x1160), # Hangul choseong / jungseong filler
(0x17B4, 0x17B5), # Khmer inherent vowels
(0x180B, 0x180F), # Mongolian free variation selectors, vowel separator
(0x3164, 0x3164), # Hangul filler
(0xFE00, 0xFE0F), # variation selectors 1-16
(0xFFA0, 0xFFA0), # halfwidth Hangul filler
(0x1BCA0, 0x1BCA3), # shorthand format controls
(0x1D173, 0x1D17A), # musical notation format controls
(0xE0000, 0xE0FFF), # tag characters and variation selectors 17-256
)
_IGNORABLE_STARTS = tuple(lo for lo, _ in _IGNORABLE_RANGES)
_IGNORABLE_ENDS = tuple(hi for _, hi in _IGNORABLE_RANGES)
[docs]
def is_default_ignorable(ch: str) -> bool:
"""Whether Unicode defines *ch* to leave no mark of its own."""
if unicodedata.category(ch) == "Cf":
return True
cp = ord(ch)
if cp < 0x034F:
return False
index = bisect_right(_IGNORABLE_STARTS, cp) - 1
return index >= 0 and cp <= _IGNORABLE_ENDS[index]
[docs]
def is_invisible_character(ch: str) -> bool:
"""Whether *ch* is meant to draw nothing, rather than be missing a glyph.
True for the characters that carry no mark of their own: the control,
format and space characters, and the ones Unicode defines as
default-ignorable. A font having no glyph for one of these is not a missing
glyph, so it must not be drawn as a box.
A combining mark is not one of them, deliberately. It carries ink of its own,
so a mark that no font on the machine can draw is a missing glyph like any
other and is boxed: seeing which codepoint the text carries is what lets a
developer fix it, and an unexplained gap is not.
"""
return unicodedata.category(ch) in _INVISIBLE_CATEGORIES or is_default_ignorable(ch)
[docs]
def is_zero_width_character(ch: str) -> bool:
"""Whether *ch* occupies no horizontal space at all.
The default-ignorable characters, which is every invisible character except
the spaces and the control characters. Text measurement and text layout both
give one of these no advance, so a heart followed by U+FE0F is exactly as
wide as the heart alone and the browser, whose own text engine measures them
the same way, agrees.
"""
return is_default_ignorable(ch)
def _box_stroke(size: float, fraction: float, w: float, h: float) -> float:
"""Stroke thickness of a ``w`` x ``h`` box drawn at font pixel size *size*.
A fraction of the font size, so the stroke keeps its weight as text grows,
clamped to one pixel at the bottom and to half the box at the top so it can
neither disappear nor close the hole it is meant to leave.
"""
return max(1.0, min(round(size * fraction), (min(w, h) - 1) / 2))
def _box_shape(font, advance_fraction: float, width_fraction: float, height_fraction: float, stroke_fraction: float):
"""``(left, width, height, stroke)`` of a box inset inside its own advance."""
size = float(font.size)
advance = size * advance_fraction
w = max(3, round(advance * width_fraction))
h = max(3, round(font.ascender * height_fraction))
return round((advance - w) / 2), w, h, _box_stroke(size, stroke_fraction, w, h)
[docs]
def missing_glyph_metrics(font, *, hex_form: bool = False) -> GlyphMetrics:
"""Outline of the box drawn in place of a character no font can supply.
A hollow rectangle standing on the baseline, inset inside the advance that
text measurement reserves for an unrenderable character. It is built as an
ordinary glyph outline (an outer rectangle, and an inner one wound the other
way to punch the hole) so it packs, rasterises and draws through exactly the
same path as a real glyph: it therefore takes the colour of the text it
stands in and can never come out invisible against its own background.
*hex_form* returns the narrow box one byte of the codepoint is written
inside, two digits one above the other. A missing character is drawn as one
of these per byte, so this one's advance is a fraction of the character's.
"""
if hex_form:
shape = (MISSING_GLYPH_HEX_ADVANCE, _HEX_BOX_WIDTH, _HEX_BOX_HEIGHT, _HEX_BOX_STROKE)
left, w, h, stroke = _box_shape(font, *shape)
advance = float(font.size) * MISSING_GLYPH_HEX_ADVANCE
else:
left, w, h, stroke = _box_shape(font, MISSING_GLYPH_ADVANCE, _BOX_WIDTH, _BOX_HEIGHT, _BOX_STROKE)
advance = float(font.size) * MISSING_GLYPH_ADVANCE
right = left + w
# Outer rectangle anticlockwise, hole clockwise: opposite windings cancel
# inside the hole, which is what makes the box hollow.
outer = [(left, 0.0, True), (right, 0.0, True), (right, h, True), (left, h, True)]
hole = [
(left + stroke, stroke, True),
(left + stroke, h - stroke, True),
(right - stroke, h - stroke, True),
(right - stroke, stroke, True),
]
return GlyphMetrics(
char=MISSING_GLYPH_HEX_KEY if hex_form else MISSING_GLYPH_KEY,
advance_x=advance,
bearing_x=left,
bearing_y=h,
width=w,
height=h,
contours=[outer, hole],
)
[docs]
def missing_glyph_hex_digits(ch: str) -> str:
"""The hex digits drawn inside the box for *ch*, as they are laid out.
Four digits for a codepoint in the Basic Multilingual Plane and six above
it, which is the whole range Unicode defines. Five-digit codepoints are
written with a leading zero rather than in a ragged five-cell grid: it is the
same number, U+01F600 is a spelling of U+1F600 that any lookup accepts, and
it leaves only two grid shapes to keep legible instead of three.
"""
cp = ord(ch)
return f"{cp:04X}" if cp <= 0xFFFF else f"{cp:06X}"
[docs]
def missing_glyph_byte_count(ch: str) -> int:
"""Boxes drawn in place of *ch*: one per byte of its codepoint, so two or three."""
return len(missing_glyph_hex_digits(ch)) // _HEX_ROWS
@dataclass(frozen=True)
class _HexLayout:
"""Where the two digits sit inside one hex box, in the font's own units."""
box: GlyphMetrics
stroke: float
clearance: float
scale: float
digit_w: float
digit_h: float
ink_top: float
margin_x: float
margin_y: float
def _solve_hex_layout(font) -> _HexLayout | None:
"""Fit two stacked digits inside the hex box *font* would be given one of.
``None`` when the font carries no digit to size them by, or when the box
comes out too small to hold anything.
The digits are sized by the tallest and widest of every digit a codepoint can
be written with, not by the ones this particular codepoint needs, so every
box in a line of text draws its digits at one size. They are then placed as
one grid centred in the box rather than one at a time inside cells of their
own: the room the fit leaves over becomes clearance from the walls, not space
in the middle.
Both axes are fitted with :data:`_HEX_DIGIT_CLEARANCE` already taken out, so
whichever axis binds still keeps its clearance instead of running the digits
into the wall, and the axis that does not binds ends up with slightly more.
"""
box = missing_glyph_metrics(font, hex_form=True)
w, h = float(box.width), float(box.height)
# The same stroke the box was drawn with, so the digits are placed against
# the edge that is actually there rather than one computed a second way.
stroke = _box_stroke(float(font.size), _HEX_BOX_STROKE, w, h)
inner_w, inner_h = w - 2 * stroke, h - 2 * stroke
# Asked for by metrics rather than by ``has_glyph``: the browser's font reads
# a miss there as "rasterise this next frame", and a character being measured
# at body size must not queue every hex digit for a form it will not draw.
# A digit the font cannot supply comes back with no ink and is left out, so
# a font with no digits at all cannot be written with, which is the same
# answer the atlas gives when it has no cell to draw one from.
reference = [m for m in map(font.get_glyph, _HEX_DIGITS) if m.width > 0 and m.height > 0]
if not reference:
return None
ink_w = max(m.width for m in reference)
# Measured from the baseline the digits share: a round digit overshoots that
# baseline, and taking the overshoot into account is what keeps the bottom
# row as clear of the wall as the top row is.
ink_top = max(m.bearing_y for m in reference)
ink_h = ink_top - min(m.bearing_y - m.height for m in reference)
if ink_w <= 0 or ink_h <= 0:
return None
c = _HEX_DIGIT_CLEARANCE
# Height h of one digit from the two axes at once. Down the box: the rows
# plus a clearance above, below and between them. Across it: the digit's own
# width at that height, plus a clearance either side.
digit_h = min(inner_h / (_HEX_ROWS + (_HEX_ROWS + 1) * c), inner_w / (ink_w / ink_h + 2 * c))
if digit_h <= 0:
return None
scale = digit_h / ink_h
digit_w = ink_w * scale
clearance = c * digit_h
grid_h = _HEX_ROWS * digit_h + (_HEX_ROWS - 1) * clearance
return _HexLayout(
box=box,
stroke=stroke,
clearance=clearance,
scale=scale,
digit_w=digit_w,
digit_h=digit_h,
ink_top=ink_top,
margin_x=(inner_w - digit_w) / 2,
margin_y=(inner_h - grid_h) / 2,
)
[docs]
def hex_digit_height(font) -> float:
"""Height the digits in a hex box reach, as a fraction of the em.
Zero when *font* has no digit to draw them from. It depends on the font
alone, which is what lets text measurement and the renderer agree on which
form a missing character takes without measurement having to see an atlas.
"""
layout = _solve_hex_layout(font)
return 0.0 if layout is None else layout.digit_h / float(font.size)
def _written_out(layout: _HexLayout | None, font, em_pixels: float) -> bool:
""":func:`writes_out_codepoint` for a layout that has already been solved."""
if layout is None:
return False
return layout.digit_h / float(font.size) * em_pixels >= MISSING_GLYPH_HEX_MIN_DIGIT_PIXELS
[docs]
def writes_out_codepoint(font, em_pixels: float) -> bool:
"""Whether text at *em_pixels* is large enough to write a codepoint out.
*em_pixels* is the size the text is laid out at, before any camera or parent
transform (see :data:`MISSING_GLYPH_HEX_MIN_DIGIT_PIXELS` for why that, and
not the device pixels finally covered, is what the choice is made on).
"""
return _written_out(_solve_hex_layout(font), font, em_pixels)
[docs]
def missing_glyph_advances(ch: str) -> tuple[float, float]:
"""The room *ch* is reserved in each form, as a fraction of the em.
The plain box's own advance first, then one hex box per byte of the
codepoint. :func:`missing_glyph_advance` picks between them by size; text
measurement takes both at once so that it can cache the width of a string
without caching it once per size.
"""
return MISSING_GLYPH_ADVANCE, MISSING_GLYPH_HEX_ADVANCE * missing_glyph_byte_count(ch)
def _cell_em(cell_width: float, em_pixels: float) -> float:
"""One grid cell as a fraction of the em, or ``0.0`` for text on no grid.
*cell_width* is in the pixels text is laid out in, the same ones *em_pixels*
is stated in, which is what lets a caller hand over the cell width it already
computed for its own font size without converting anything.
"""
return cell_width / em_pixels if cell_width > 0.0 and em_pixels > 0.0 else 0.0
def _fit_scale(natural_advance: float, cell_em: float) -> float:
"""What a form *natural_advance* ems wide is scaled by to fit one cell.
``1.0`` off a grid (*cell_em* of zero) and for any form already narrower than
a cell, so a form is only ever shrunk, never stretched to fill a cell it does
not need.
"""
return 1.0 if cell_em <= 0.0 else min(1.0, cell_em / natural_advance)
def _missing_glyph_form(ch: str, layout: _HexLayout | None, font, em_pixels: float, cell_em: float):
"""``(written_out, scale)``: which form is drawn for *ch*, and how it is fitted.
*scale* is the uniform factor the whole form is drawn at. It is 1.0 off a
grid and wherever the form is already no wider than a cell, so this only ever
shrinks a form that would otherwise reach into the next column.
The written-out form is preferred whenever its digits still reach
:data:`MISSING_GLYPH_HEX_MIN_DIGIT_PIXELS` *after* being fitted to the cell,
which is the same threshold measured on the size the digits are really drawn
at. On a grid the whole row of boxes has to fit one cell, so the digits come
out smaller than they would be in proportional text of that size and the text
has to be correspondingly larger before they can be read: a codepoint above
the Basic Multilingual Plane, written with a box more, crosses later than one
inside it.
"""
if layout is not None:
scale = _fit_scale(MISSING_GLYPH_HEX_ADVANCE * missing_glyph_byte_count(ch), cell_em)
if _written_out(layout, font, em_pixels * scale):
return True, scale
return False, _fit_scale(MISSING_GLYPH_ADVANCE, cell_em)
[docs]
def missing_glyph_advance(ch: str, font, em_pixels: float, *, cell_width: float = 0.0) -> float:
"""Room reserved for *ch* when nothing can draw it, as a fraction of the em.
One hex box per byte of the codepoint where the text is large enough for the
digits inside them to be read, and the plain box's own advance where it is
not, so what is reserved is what gets drawn and neither form sits in a gap
wider than itself. Text measurement, the layout that draws it and the
hit-testing that reads it back all take their width from here, so all three
agree; they all decide on the same untransformed *em_pixels*, so a camera
zoom cannot move the boundary under them.
The choice is made on the size alone, not on whether an atlas happens to
carry the digits: an atlas that cannot supply them draws the plain box in the
room the digits would have had, rather than measuring one width and drawing
another.
*cell_width* is the pitch of the character grid the text sits on, in the same
pixels as *em_pixels*, and is how a caller that has a grid says so: a
terminal, a code view, a tabular readout. On a grid a character nothing can
draw occupies exactly one cell, whatever form goes in it, so a run of text
keeps every later column where the grid puts it and a renderer that places
each character in a cell of its own never has a box reaching into the next
one. :func:`missing_glyph_cells` fits the form to that cell rather than
letting it spill. Text that is not on a grid leaves *cell_width* at zero and
is unaffected.
"""
cell_em = _cell_em(cell_width, em_pixels)
if cell_em > 0.0:
return cell_em
plain, written_out = missing_glyph_advances(ch)
return written_out if writes_out_codepoint(font, em_pixels) else plain
[docs]
@dataclass
class GlyphRegion:
"""Atlas region for a packed glyph."""
char: str
x: int
y: int
w: int
h: int
metrics: GlyphMetrics
u0: float = 0.0
v0: float = 0.0
u1: float = 0.0
v1: float = 0.0
[docs]
class MSDFAtlas:
"""MSDF font atlas with incremental shelf-based bin packing.
Glyphs are rendered on demand and appended to the atlas. ASCII is
pre-seeded at init time so Latin text works without re-uploads.
"""
_MAX_ATLAS_SIZE = 4096
def __init__(
self,
font: Font,
atlas_size: int = 1024,
glyph_padding: int = 4,
sdf_range: float = 4.0,
charset: str | None = None,
):
self.font = font
self.atlas_size = atlas_size
self.glyph_padding = glyph_padding
self.sdf_range = sdf_range
# Shelf packing state
self._shelf_y = 0
self._shelf_h = 0
self._cursor_x = 0
# Version tracking for GPU re-upload
self.version = 0
self.dirty = False
if charset is None:
charset = (
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" "0123456789 .!?:;,'-\"()[]{}/<>@#$%^&*+=_~`\\|"
)
self.atlas = np.zeros((atlas_size, atlas_size, 4), dtype=np.uint8)
self.regions: dict[str, GlyphRegion] = {}
# Pre-seed with initial charset (sorted tallest-first for packing)
self._seed(charset)
self._pack_missing_glyph()
def _pack_missing_glyph(self) -> None:
"""Pack the boxes drawn in place of a character no font can supply.
Packed up front, alongside the seed charset, for two reasons: a renderer
that meets an unrenderable character must be able to draw something
immediately rather than modify the atlas mid-frame, and an atlas baked
for export carries the boxes with it, so a character the exporter could
not predict is visible in the browser too.
Both shapes are packed: the plain box small text draws, and the narrow
one large text repeats, a box per byte of the codepoint.
"""
pad = self.glyph_padding
for key, gm in (
(MISSING_GLYPH_KEY, missing_glyph_metrics(self.font)),
(MISSING_GLYPH_HEX_KEY, missing_glyph_metrics(self.font, hex_form=True)),
):
self._pack_glyph(key, gm, gm.width + pad * 2, gm.height + pad * 2)
self.version += 1
self.dirty = True
def _seed(self, charset: str) -> None:
"""Batch-add initial charset sorted by height for optimal packing."""
pad = self.glyph_padding
glyphs = []
for ch in charset:
if ch in self.regions:
continue
gm = self.font.get_glyph(ch)
w = max(gm.width + pad * 2, pad * 2 + 1)
h = max(gm.height + pad * 2, pad * 2 + 1)
glyphs.append((ch, gm, w, h))
glyphs.sort(key=lambda g: -g[3])
for ch, gm, w, h in glyphs:
self._pack_glyph(ch, gm, w, h)
self.version += 1
self.dirty = True
def _pack_glyph(self, ch: str, gm: GlyphMetrics, w: int, h: int) -> bool:
"""Pack a single glyph into the atlas. Returns True on success."""
if self._cursor_x + w > self.atlas_size:
self._shelf_y += self._shelf_h
self._shelf_h = 0
self._cursor_x = 0
if self._shelf_y + h > self.atlas_size:
if not self._grow_atlas():
return False
if h > self._shelf_h:
self._shelf_h = h
x, y = self._cursor_x, self._shelf_y
region = GlyphRegion(
char=ch,
x=x,
y=y,
w=w,
h=h,
metrics=gm,
u0=x / self.atlas_size,
v0=y / self.atlas_size,
u1=(x + w) / self.atlas_size,
v1=(y + h) / self.atlas_size,
)
self.regions[ch] = region
msdf = _render_glyph_msdf(gm, w, h, self.glyph_padding, self.sdf_range)
self.atlas[y : y + h, x : x + w, :3] = msdf
self.atlas[y : y + h, x : x + w, 3] = 255
self._cursor_x += w
return True
def _grow_atlas(self) -> bool:
"""Double atlas size (up to _MAX_ATLAS_SIZE), preserving existing data."""
new_size = self.atlas_size * 2
if new_size > self._MAX_ATLAS_SIZE:
return False
new_atlas = np.zeros((new_size, new_size, 4), dtype=np.uint8)
old = self.atlas_size
new_atlas[:old, :old, :] = self.atlas
self.atlas = new_atlas
self.atlas_size = new_size
# Recompute UVs for all existing regions
for r in self.regions.values():
r.u0 = r.x / new_size
r.v0 = r.y / new_size
r.u1 = (r.x + r.w) / new_size
r.v1 = (r.y + r.h) / new_size
return True
[docs]
def ensure_glyphs(self, text: str) -> bool:
"""Ensure all glyphs in *text* are in the atlas.
Returns True if the atlas was modified (caller should re-upload).
Glyphs missing from the underlying font are silently skipped.
"""
missing = [ch for ch in text if ch not in self.regions and ch not in (" ", "\n", "\t")]
if not missing:
return False
pad = self.glyph_padding
added = False
for ch in missing:
if not self.font.has_glyph(ch):
continue
gm = self.font.get_glyph(ch)
w = max(gm.width + pad * 2, pad * 2 + 1)
h = max(gm.height + pad * 2, pad * 2 + 1)
self._pack_glyph(ch, gm, w, h)
added = True
if added:
self.version += 1
self.dirty = True
return added
[docs]
def ensure_glyphs_from(self, chars: str, font: Font) -> bool:
"""Pack glyphs for *chars* using an external *font* into this atlas.
Used by the fallback chain: the primary atlas borrows glyphs from
a fallback font so all text renders from a single GPU texture.
Returns True if the atlas was modified.
"""
missing = [ch for ch in chars if ch not in self.regions and ch not in (" ", "\n", "\t")]
if not missing:
return False
pad = self.glyph_padding
added = False
for ch in missing:
if not font.has_glyph(ch):
continue
gm = font.get_glyph(ch)
w = max(gm.width + pad * 2, pad * 2 + 1)
h = max(gm.height + pad * 2, pad * 2 + 1)
self._pack_glyph(ch, gm, w, h)
added = True
if added:
self.version += 1
self.dirty = True
return added
[docs]
def missing_glyphs(self, text: str) -> list[str]:
"""Return characters from *text* that this atlas's font cannot render."""
return [
ch
for ch in dict.fromkeys(text)
if ch not in self.regions and ch not in (" ", "\n", "\t") and not self.font.has_glyph(ch)
]
[docs]
def get_uv(self, char: str) -> tuple[float, float, float, float]:
if char not in self.regions:
char = "?"
if char not in self.regions:
return (0, 0, 0, 0)
r = self.regions[char]
return (r.u0, r.v0, r.u1, r.v1)
[docs]
class BitmapAtlas:
"""Font atlas using FreeType hinted bitmap rendering (no SDF).
Produces pixel-perfect glyphs at a fixed target size. The atlas format
is RGBA with R=G=B=coverage so the MSDF shader's median(r,g,b) acts as
a simple alpha blend passthrough.
"""
def __init__(
self,
font_path: str,
target_size: int,
atlas_size: int = 512,
charset: str | None = None,
):
from .font import Font
self.font = Font(font_path, size=target_size)
self.atlas_size = atlas_size
self.glyph_padding = 0
self.sdf_range = 0.5 # Small value: shader acts as simple alpha blend
self.version = 0
self.dirty = False
if charset is None:
charset = (
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" "0123456789 .!?:;,'-\"()[]{}/<>@#$%^&*+=_~`\\|"
)
self.atlas = np.zeros((atlas_size, atlas_size, 4), dtype=np.uint8)
self.regions: dict[str, GlyphRegion] = {}
# Shelf packing state
self._shelf_y = 0
self._shelf_h = 0
self._cursor_x = 0
self._seed(charset)
def _seed(self, charset: str) -> None:
glyphs = []
for ch in charset:
if ch in self.regions:
continue
bitmap, gm = self.font.render_bitmap(ch)
if bitmap.size == 0:
continue
glyphs.append((ch, bitmap, gm))
glyphs.sort(key=lambda g: -g[1].shape[0])
for ch, bitmap, gm in glyphs:
self._pack_glyph(ch, bitmap, gm)
self.version += 1
self.dirty = True
def _pack_glyph(self, ch: str, bitmap: np.ndarray, gm) -> bool:
h, w = bitmap.shape if bitmap.size > 0 else (1, 1)
if self._cursor_x + w > self.atlas_size:
self._shelf_y += self._shelf_h
self._shelf_h = 0
self._cursor_x = 0
if self._shelf_y + h > self.atlas_size:
return False
if h > self._shelf_h:
self._shelf_h = h
x, y = self._cursor_x, self._shelf_y
region = GlyphRegion(
char=ch,
x=x,
y=y,
w=w,
h=h,
metrics=gm,
u0=x / self.atlas_size,
v0=y / self.atlas_size,
u1=(x + w) / self.atlas_size,
v1=(y + h) / self.atlas_size,
)
self.regions[ch] = region
if bitmap.size > 0:
# R=G=B=coverage, A=255: median(r,g,b) passthrough for MSDF shader
self.atlas[y : y + h, x : x + w, 0] = bitmap
self.atlas[y : y + h, x : x + w, 1] = bitmap
self.atlas[y : y + h, x : x + w, 2] = bitmap
self.atlas[y : y + h, x : x + w, 3] = 255
self._cursor_x += w
return True
[docs]
def ensure_glyphs(self, text: str) -> bool:
missing = [ch for ch in text if ch not in self.regions and ch not in (" ", "\n", "\t")]
if not missing:
return False
added = False
for ch in missing:
if not self.font.has_glyph(ch):
continue
bitmap, gm = self.font.render_bitmap(ch)
if bitmap.size > 0:
self._pack_glyph(ch, bitmap, gm)
added = True
if added:
self.version += 1
self.dirty = True
return added
[docs]
def missing_glyphs(self, text: str) -> list[str]:
"""Return characters from *text* that this atlas's font cannot render."""
return [
ch
for ch in dict.fromkeys(text)
if ch not in self.regions and ch not in (" ", "\n", "\t") and not self.font.has_glyph(ch)
]
[docs]
def get_uv(self, char: str) -> tuple[float, float, float, float]:
if char not in self.regions:
char = "?"
if char not in self.regions:
return (0, 0, 0, 0)
r = self.regions[char]
return (r.u0, r.v0, r.u1, r.v1)
# --- Edge types ---
class _Line:
__slots__ = ("p0", "p1")
def __init__(self, p0: tuple[float, float], p1: tuple[float, float]):
self.p0 = p0
self.p1 = p1
class _Quad:
__slots__ = ("p0", "p1", "p2")
def __init__(self, p0, p1, p2):
self.p0 = p0
self.p1 = p1
self.p2 = p2
def _resolve_contour(contour):
"""Insert implicit on-curve midpoints between consecutive off-curve points.
TrueType contours use quadratic Beziers where two adjacent off-curve
control points imply an on-curve point at their midpoint. After
resolution the point list strictly alternates on/off so edge
extraction becomes trivial.
"""
n = len(contour)
if n < 2:
return []
resolved = []
for i in range(n):
x, y, on = contour[i]
resolved.append((x, y, on))
if not on:
xn, yn, on_n = contour[(i + 1) % n]
if not on_n:
resolved.append(((x + xn) * 0.5, (y + yn) * 0.5, True))
return resolved
def _build_edges_per_contour(contours):
"""Convert contour points to edge segments, grouped by contour.
Returns list[list[_Line | _Quad]]: one inner list per contour.
Properly handles consecutive off-curve points via midpoint resolution.
"""
result = []
for contour in contours:
resolved = _resolve_contour(contour)
n = len(resolved)
if n < 2:
continue
# Rotate to start from an on-curve point
start = next((i for i, (_, _, on) in enumerate(resolved) if on), None)
if start is None:
continue
resolved = resolved[start:] + resolved[:start]
n = len(resolved)
edges: list[_Line | _Quad] = []
i = 0
while i < n:
x0, y0, on0 = resolved[i]
if not on0:
i += 1
continue
j = (i + 1) % n
x1, y1, on1 = resolved[j]
if on1:
edges.append(_Line((x0, y0), (x1, y1)))
i += 1
else:
k = (j + 1) % n
x2, y2, _ = resolved[k]
edges.append(_Quad((x0, y0), (x1, y1), (x2, y2)))
i += 2
if edges:
result.append(edges)
return result
def _tessellate_contour(contour, steps: int = 8) -> list[tuple[float, float]]:
"""Convert a contour with off-curve control points to a polyline.
Subdivides quadratic Bezier curves into line segments.
"""
poly: list[tuple[float, float]] = []
n = len(contour)
if n < 2:
return poly
i = 0
while i < n:
x0, y0, on0 = contour[i]
x1, y1, on1 = contour[(i + 1) % n]
if on0 and on1:
# Line segment
poly.append((x0, y0))
i += 1
elif on0 and not on1:
# Quadratic Bezier: find endpoint
x2, y2, on2 = contour[(i + 2) % n]
if not on2:
# Implicit on-curve point between two off-curve
x2, y2 = (x1 + x2) * 0.5, (y1 + y2) * 0.5
i += 1
else:
i += 2
# Subdivide the quadratic Bezier
for si in range(steps):
t = si / steps
s = 1.0 - t
bx = s * s * x0 + 2 * s * t * x1 + t * t * x2
by = s * s * y0 + 2 * s * t * y1 + t * t * y2
poly.append((bx, by))
elif not on0:
# Off-curve start: need implicit on-curve from previous
# This handles the case where contour starts with off-curve
xp, yp, _ = contour[(i - 1) % n]
mx, my = (xp + x0) * 0.5, (yp + y0) * 0.5
if on1:
for si in range(steps):
t = si / steps
s = 1.0 - t
bx = s * s * mx + 2 * s * t * x0 + t * t * x1
by = s * s * my + 2 * s * t * y0 + t * t * y1
poly.append((bx, by))
i += 1
else:
mx2, my2 = (x0 + x1) * 0.5, (y0 + y1) * 0.5
for si in range(steps):
t = si / steps
s = 1.0 - t
bx = s * s * mx + 2 * s * t * x0 + t * t * mx2
by = s * s * my + 2 * s * t * y0 + t * t * my2
poly.append((bx, by))
i += 1
else:
i += 1
return poly
def _winding_number(px: float, py: float, contours) -> int:
"""Compute winding number of point relative to all contours.
Tessellates Bezier curves before testing. Nonzero winding = inside.
"""
wn = 0
for contour in contours:
poly = _tessellate_contour(contour)
n = len(poly)
if n < 2:
continue
for i in range(n):
x0, y0 = poly[i]
x1, y1 = poly[(i + 1) % n]
if y0 <= py:
if y1 > py:
cross = (x1 - x0) * (py - y0) - (px - x0) * (y1 - y0)
if cross > 0:
wn += 1
else:
if y1 <= py:
cross = (x1 - x0) * (py - y0) - (px - x0) * (y1 - y0)
if cross < 0:
wn -= 1
return wn
def _dist_line_vec(gx, gy, p0, p1):
"""Vectorized distance from pixel grid to a line segment."""
ax, ay = p0
bx, by = p1
dx, dy = bx - ax, by - ay
len_sq = dx * dx + dy * dy
if len_sq < 1e-10:
return np.sqrt((gx - ax) ** 2 + (gy - ay) ** 2)
t = np.clip(((gx - ax) * dx + (gy - ay) * dy) / len_sq, 0.0, 1.0)
cx = ax + t * dx
cy = ay + t * dy
return np.sqrt((gx - cx) ** 2 + (gy - cy) ** 2)
def _dist_quad_vec(gx, gy, p0, p1, p2):
"""Vectorized distance from pixel grid to a quadratic Bezier."""
# Coarse sampling to find best t
ts = np.linspace(0, 1, 9).reshape(-1, 1, 1) # (9, 1, 1)
ss = 1.0 - ts
bx = ss * ss * p0[0] + 2 * ss * ts * p1[0] + ts * ts * p2[0]
by = ss * ss * p0[1] + 2 * ss * ts * p1[1] + ts * ts * p2[1]
d_sq = (gx - bx) ** 2 + (gy - by) ** 2 # (9, h, w)
best_idx = np.argmin(d_sq, axis=0) # (h, w)
best_t = best_idx / 8.0
# Newton refinement (3 iterations)
for _ in range(3):
s = 1.0 - best_t
bx = s * s * p0[0] + 2 * s * best_t * p1[0] + best_t * best_t * p2[0]
by = s * s * p0[1] + 2 * s * best_t * p1[1] + best_t * best_t * p2[1]
tx = 2 * (s * (p1[0] - p0[0]) + best_t * (p2[0] - p1[0]))
ty = 2 * (s * (p1[1] - p0[1]) + best_t * (p2[1] - p1[1]))
dpx = gx - bx
dpy = gy - by
dot = dpx * tx + dpy * ty
tang_sq = tx * tx + ty * ty
mask = tang_sq > 1e-10
best_t = np.where(mask, np.clip(best_t + dot / np.maximum(tang_sq, 1e-10), 0, 1), best_t)
s = 1.0 - best_t
bx = s * s * p0[0] + 2 * s * best_t * p1[0] + best_t * best_t * p2[0]
by = s * s * p0[1] + 2 * s * best_t * p1[1] + best_t * best_t * p2[1]
return np.sqrt((gx - bx) ** 2 + (gy - by) ** 2)
def _winding_number_vec(gx, gy, contours):
"""Vectorized winding number for entire pixel grid.
Returns int array (h, w): nonzero = inside glyph.
"""
wn = np.zeros_like(gx, dtype=np.int32)
for contour in contours:
poly = _tessellate_contour(contour)
n = len(poly)
if n < 2:
continue
for i in range(n):
x0, y0 = poly[i]
x1, y1 = poly[(i + 1) % n]
# Upward crossing
up = (y0 <= gy) & (y1 > gy)
cross_up = (x1 - x0) * (gy - y0) - (gx - x0) * (y1 - y0)
wn += (up & (cross_up > 0)).astype(np.int32)
# Downward crossing
down = (y0 > gy) & (y1 <= gy)
cross_down = (x1 - x0) * (gy - y0) - (gx - x0) * (y1 - y0)
wn -= (down & (cross_down < 0)).astype(np.int32)
return wn
def _render_glyph_msdf(gm, w, h, pad, sdf_range):
"""Render a single glyph as MSDF (RGB uint8).
Vectorized with NumPy: processes all pixels at once per edge.
Channel assignment cycles per-contour with wraparound fix so
adjacent edges (including first↔last) always differ.
"""
if not gm.contours:
return np.zeros((h, w, 3), dtype=np.uint8)
contour_edges = _build_edges_per_contour(gm.contours)
if not contour_edges:
return np.zeros((h, w, 3), dtype=np.uint8)
msdf = np.full((h, w, 3), 128, dtype=np.uint8)
# Build coordinate grids in glyph space
px = np.arange(w, dtype=np.float64)
py = np.arange(h, dtype=np.float64)
px_grid, py_grid = np.meshgrid(px, py)
gx = (px_grid - pad) + gm.bearing_x
gy = pad + gm.bearing_y - py_grid
# Winding number for inside/outside
wn = _winding_number_vec(gx, gy, gm.contours)
sign = np.where(wn != 0, 1.0, -1.0)
# Per-channel minimum distance
ch_dist = np.full((3, h, w), np.inf, dtype=np.float64)
# Channel pairs: edge i → channels ch_pairs[i % 3]
# Adjacent edges share exactly one channel, enabling corner detection.
ch_pairs = [[0, 1], [1, 2], [2, 0]]
for edges in contour_edges:
ne = len(edges)
for ei, edge in enumerate(edges):
ch_idx = ei % 3
# Wraparound fix: when ne % 3 == 1 the last edge would get the
# same pair as the first, merging both channels at their corner.
# Shift it to share only one channel instead.
if ne > 1 and ei == ne - 1 and ch_idx == 0:
ch_idx = 1
d = (
_dist_quad_vec(gx, gy, edge.p0, edge.p1, edge.p2)
if isinstance(edge, _Quad)
else _dist_line_vec(gx, gy, edge.p0, edge.p1)
)
for ch in ch_pairs[ch_idx]:
ch_dist[ch] = np.minimum(ch_dist[ch], d)
# Convert to signed distance and map to [0, 255]
for ch in range(3):
sd = ch_dist[ch] * sign
val = (sd / sdf_range + 1.0) * 0.5
msdf[:, :, ch] = np.clip(val * 255, 0, 255).astype(np.uint8)
return msdf
def _missing_glyph_quads(
atlas: MSDFAtlas, ch: str, resolver: Callable[[str], bool] | None, em_pixels: float
) -> list[tuple[GlyphRegion, float, float, float]]:
"""The cells to draw for *ch*, or an empty list to draw nothing in its place.
Asked only about a character the atlas has no cell for, so text whose glyphs
are all packed never runs any of this. The box is the last resort, in this
order: a character the font itself has is not missing (it draws blank, or is
simply not packed here); one that is invisible by definition is not missing
either; one *resolver* says something else can still supply is left for that
face; and what is left is boxed.
"""
if atlas.font.has_glyph(ch) or is_invisible_character(ch):
return []
if resolver is not None and not resolver(ch):
return []
return missing_glyph_cells(ch, atlas, em_pixels)
def _hex_box_cells(
ch: str, atlas: MSDFAtlas, layout: _HexLayout, origin_x: float = 0.0, group_scale: float = 1.0
) -> list[tuple[GlyphRegion, float, float, float]] | None:
"""One hex box per byte of *ch*, digits included, or ``None``.
*group_scale* shrinks the whole row about the pen position and the baseline,
boxes and digits together, so that a row too wide for a character grid's cell
is fitted into it rather than reaching into the next column. *origin_x* then
shifts what that leaves over, in the atlas font's own units, which is what
centres the row in a reservation wider than it. Off a grid the row fills its
reservation exactly, so both are left at their defaults and every number below
comes out as it did before either existed.
``None`` means this atlas cannot draw the hex form and must fall back to the
plain box: it predates the hex box (an export baked before it existed), or
one of the digits the codepoint is written with has no cell.
The second of those is the whole recursion guard. Drawing hex needs glyphs
for the digits it is written with, and a missing digit that were itself drawn
in hex would need digits again. The digits are taken from the atlas by a
plain lookup that asks no font, consults no fallback chain and reaches no
rasteriser, so nothing here can re-enter the missing-glyph path: a digit is
either already packed or the whole character falls back to the plain box.
Falling back for the whole character rather than per box is deliberate too,
since a codepoint missing a byte reads as a different codepoint.
"""
box = atlas.regions.get(MISSING_GLYPH_HEX_KEY)
if box is None:
return None
cells = []
for digit in missing_glyph_hex_digits(ch):
region = atlas.regions.get(digit)
if region is None:
return None
cells.append(region)
pad = atlas.glyph_padding
gm = box.metrics
h = float(gm.height)
step = layout.digit_h + layout.clearance
advance = float(atlas.font.size) * MISSING_GLYPH_HEX_ADVANCE
quads = []
for index, region in enumerate(cells):
byte, row = divmod(index, _HEX_ROWS)
origin = byte * advance
if row == 0:
quads.append((box, origin + gm.bearing_x - pad, -(gm.bearing_y + pad), 1.0))
dm = region.metrics
# Glyph space has y up from the baseline the box stands on, so rows count
# down from the top of the grid. The digits share a baseline, as digits do,
# and each is centred across the column the widest of them sets.
left = origin + gm.bearing_x + layout.stroke + layout.margin_x + (layout.digit_w - dm.width * layout.scale) / 2
row_top = h - layout.stroke - layout.margin_y - row * step
baseline = row_top - layout.ink_top * layout.scale
quads.append(
(region, left - pad * layout.scale, -(baseline + (dm.bearing_y + pad) * layout.scale), layout.scale)
)
# The row is built at its natural size around the pen and the baseline, then
# taken to where it is drawn as one piece, so shrinking it to fit a cell
# cannot pull the digits out of their boxes.
return [(r, origin_x + x * group_scale, y * group_scale, s * group_scale) for r, x, y, s in quads]
[docs]
def missing_glyph_cells(
ch: str, atlas: MSDFAtlas, em_pixels: float, *, cell_width: float = 0.0
) -> list[tuple[GlyphRegion, float, float, float]]:
"""Every atlas cell that makes up what is drawn in place of *ch*.
Each byte of the codepoint in turn, as its box followed by the two digits
that write it, top to bottom. One entry per quad, as ``(region, x, y,
scale)``: *x* and *y* place the quad's top-left corner relative to the pen
position and the baseline, in the atlas font's own units and including the
atlas padding, and *scale* multiplies the region's size. A renderer
multiplies all three by whatever takes it from atlas units to laid-out
pixels, which is the same factor it already uses for an ordinary glyph, and
draws each quad in the text colour.
*em_pixels* is the size the text is being laid out at, in the same pixels,
and before any camera or parent transform (see
:data:`MISSING_GLYPH_HEX_MIN_DIGIT_PIXELS` for why that, and not the device
pixels finally covered, is what the choice is made on). Where it leaves room
for digits of at least that height, the result is the codepoint written out,
so a developer can read which character is missing instead of guessing; below
it, where the digits would be too small to tell apart, it is a single plain
box.
*cell_width* is the pitch of the character grid the text sits on, in those
same pixels. On a grid the character is given exactly one cell, the largest
form that fits it is drawn, and it is fitted by scaling the whole form down
uniformly rather than by letting it spill: nothing a boxed character draws
can reach the column beside it, which matters most where each character is
drawn into a cell of its own and the next one lands at a fixed pitch whatever
was reserved. Off a grid the scale is 1.0 and the form fills its own advance
exactly as it always has.
Either way what is drawn fills the room :func:`missing_glyph_advance`
reserves for the same *em_pixels* and *cell_width*, so measurement and layout
never disagree, and it is centred in that room.
Empty only when the atlas carries no box at all.
"""
font = atlas.font
cell_em = _cell_em(cell_width, em_pixels)
layout = _solve_hex_layout(font)
written_out, scale = _missing_glyph_form(ch, layout, font, em_pixels, cell_em)
natural = MISSING_GLYPH_HEX_ADVANCE * missing_glyph_byte_count(ch) if written_out else MISSING_GLYPH_ADVANCE
room = float(font.size) * (cell_em if cell_em > 0.0 else natural)
if written_out and layout is not None:
boxes = float(font.size) * MISSING_GLYPH_HEX_ADVANCE * missing_glyph_byte_count(ch) * scale
hex_cells = _hex_box_cells(ch, atlas, layout, (room - boxes) / 2, scale)
if hex_cells is not None:
return hex_cells
# This atlas has no cell to write the codepoint from, so the plain box
# goes in the room the digits would have had, at its own fit.
scale = _fit_scale(MISSING_GLYPH_ADVANCE, cell_em)
box = atlas.regions.get(MISSING_GLYPH_KEY)
if box is None:
return []
gm = box.metrics
pad = atlas.glyph_padding
# Centred in the reserved room. Off a grid, and at the sizes the plain box is
# the chosen form, that room is the box's own advance and this leaves it
# exactly where its own bearing would; it moves only where the room is wider,
# which is a grid cell wider than the box, or the room the digits would have
# had on an atlas that turned out unable to supply them.
left = gm.bearing_x * scale + (room - gm.advance_x * scale) / 2
return [(box, left - pad * scale, -(gm.bearing_y + pad) * scale, scale)]
[docs]
def rasterize_text(
text: str,
atlas: MSDFAtlas,
font_size: int,
width: int,
height: int,
colour: tuple,
*,
missing_glyph_resolver: Callable[[str], bool] | None = None,
) -> np.ndarray:
"""Rasterize text from an MSDF atlas into an RGBA uint8 image (CPU, backend-agnostic).
Samples the MSDF atlas per-pixel, applies median thresholding, and composites
coloured glyphs onto a transparent background. The result is an RGBA array
suitable for upload as a texture (e.g. text-on-3D via ``create_text_texture``,
on both the Vulkan and web backends).
*missing_glyph_resolver* answers "no font this backend can reach draws this
character", and is asked only about a character with no atlas cell. It is how
the caller's font fallback chain gets consulted, so a character some other
face can draw is left for that face to supply instead of being declared
missing and boxed. Without one, the atlas font is the only judge, and a
character it lacks is boxed straight away.
"""
pixels = np.zeros((height, width, 4), dtype=np.uint8)
font = atlas.font
scale = font_size / font.size
r8 = int(min(255, max(0, colour[0] * 255)))
g8 = int(min(255, max(0, colour[1] * 255)))
b8 = int(min(255, max(0, colour[2] * 255)))
cursor_x = 2.0 # small left margin
baseline_y = font.ascender * scale + 2.0
pad = atlas.glyph_padding
for ch in text:
region = atlas.regions.get(ch)
if region is not None:
gm = region.metrics
_blit_region(
pixels,
atlas,
region,
cursor_x + gm.bearing_x * scale,
baseline_y - gm.bearing_y * scale,
region.w * scale,
region.h * scale,
(r8, g8, b8),
)
cursor_x += gm.advance_x * scale
continue
cells = _missing_glyph_quads(atlas, ch, missing_glyph_resolver, font_size)
if not cells:
# Nothing to draw here: a character the font has but that is not
# packed, or one something else is still expected to supply.
# It keeps its advance, so the text around it does not move.
if not is_zero_width_character(ch):
cursor_x += font.get_glyph(ch).advance_x * scale
continue
# One box per byte with that byte's digits in it when the text is large
# enough to read them, a single plain box when it is not. Cell offsets
# carry the atlas padding, which the glyph path above folds into the
# bearing instead, so it is added back here.
for cell, cx, cy, cs in cells:
_blit_region(
pixels,
atlas,
cell,
cursor_x + (cx + pad) * scale,
baseline_y + (cy + pad) * scale,
cell.w * cs * scale,
cell.h * cs * scale,
(r8, g8, b8),
)
cursor_x += font.size * missing_glyph_advance(ch, font, font_size) * scale
return np.ascontiguousarray(np.flipud(pixels))
def _blit_region(pixels, atlas, region, qx, qy, qw, qh, rgb) -> None:
"""Composite one atlas region into *pixels* at ``(qx, qy)``, ``qw`` x ``qh``."""
r8, g8, b8 = rgb
height, width = pixels.shape[0], pixels.shape[1]
# Sample atlas region for each output pixel
for py in range(max(0, int(qy)), min(height, int(qy + qh))):
# V coordinate in atlas
v = region.v0 + (py - qy) / qh * (region.v1 - region.v0)
av = int(v * atlas.atlas_size)
if av < 0 or av >= atlas.atlas_size:
continue
for px in range(max(0, int(qx)), min(width, int(qx + qw))):
# U coordinate in atlas
u = region.u0 + (px - qx) / qw * (region.u1 - region.u0)
au = int(u * atlas.atlas_size)
if au < 0 or au >= atlas.atlas_size:
continue
# Sample MSDF: median of RGB
sr = float(atlas.atlas[av, au, 0]) / 255.0
sg = float(atlas.atlas[av, au, 1]) / 255.0
sb = float(atlas.atlas[av, au, 2]) / 255.0
median = max(min(sr, sg), min(max(sr, sg), sb))
# Smoothstep around 0.5 threshold
edge = 0.5
smooth = 0.1
lo, hi = edge - smooth, edge + smooth
if median <= lo:
alpha = 0.0
elif median >= hi:
alpha = 1.0
else:
t = (median - lo) / (hi - lo)
alpha = t * t * (3.0 - 2.0 * t)
if alpha > 0.01:
a8 = int(alpha * 255)
pixels[py, px] = [r8, g8, b8, a8]