Source code for simvx.core.text.msdf

"""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] def median_channel(atlas: np.ndarray) -> np.ndarray: """Collapse an MSDF atlas to the single distance field the shader reads. The MSDF fragment shaders decode a glyph as ``median(r, g, b)``; the three channels only differ within a texel or two of a corner, where the median is what reconstructs the sharp intersection. Evaluating that median per texel yields one channel carrying the same field, at a quarter of the bytes. Storing the result and letting it expand back to ``r = g = b`` on decode (any greyscale image source does this) leaves the shaders untouched: the same trick ``BitmapAtlas`` uses to feed plain coverage through the median. The one thing lost is sub-texel corner reconstruction, because the shader now interpolates the median instead of taking the median of interpolated channels. That difference is bounded by the atlas texel size. Args: atlas: ``(h, w, 3)`` or ``(h, w, 4)`` uint8 MSDF atlas. Returns: ``(h, w)`` uint8 array. """ r, g, b = atlas[:, :, 0], atlas[:, :, 1], atlas[:, :, 2] return np.maximum(np.minimum(r, g), np.minimum(np.maximum(r, g), b))
[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]