Skip to content

Commit

Permalink
Merge pull request #400 from googlefonts/maximum_bitmap
Browse files Browse the repository at this point in the history
Add CBDT to maximum_color
  • Loading branch information
rsheeter authored Apr 11, 2022
2 parents 80168f3 + 335f056 commit 20ae8c4
Show file tree
Hide file tree
Showing 35 changed files with 1,048 additions and 589 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,33 @@ Requires Python 3.7 or greater.
| [sbix](https://docs.microsoft.com/en-us/typography/opentype/spec/sbix) | Yes | Only for Mac Safari due to https://github.com/harfbuzz/harfbuzz/issues/2679#issuecomment-1021419864. Only square bitmaps. Uses [`resvg`](https://github.com/RazrFalcon/resvg).|
| [CBDT](https://docs.microsoft.com/en-us/typography/opentype/spec/cbdt) | Yes | Only square bitmaps. Uses [`resvg`](https://github.com/RazrFalcon/resvg).|

### Adding color tables to existing fonts

:warning: _under active development, doubtless full of bugs_

Given at least one vector color table (COLR or SVG) the other vector color table and bitmap table(s)
can be generated:

```shell
# Adds COLR to a font with SVG and vice versa
maxmium_color my_colr_font.ttf

# Adds COLR to a font with SVG and vice versa, and generates a CBDT table
maxmium_color --bitmaps my_colr_font.ttf
```

The intended result is a font that will Just Work in any modern browser:

| Color table | Target browser | Notes |
| --- | --- | --- |
| COLR | Chrome 98+ | https://developer.chrome.com/blog/colrv1-fonts/ |
| SVG | Firefox, Safari | |
| CBDT | Chrome <98 | Only generated if you pass `--bitmaps` to `maximum_color`|

Note that at time of writing Chrome 98+ prefers CBDT to COLR. Also CBDT is
huge. So ... maybe take the resulting font and subset it per-browser if at
all possible. Wouldn't it be nice if Google Fonts did that for you?

## Releasing

See https://googlefonts.github.io/python#make-a-release.
Expand Down
216 changes: 144 additions & 72 deletions src/nanoemoji/bitmap_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Helps with bitmap tables."""
"""Helps with bitmap tables.
CBDT inspired by https://github.com/googlefonts/noto-emoji/blob/main/third_party/color_emoji/emoji_builder.py.
"""
from fontTools import ttLib
from fontTools.ttLib.tables.BitmapGlyphMetrics import BigGlyphMetrics, SmallGlyphMetrics
from fontTools.ttLib.tables.sbixGlyph import Glyph as SbixGlyph
Expand All @@ -25,8 +28,11 @@
)
from fontTools.ttLib.tables.C_B_D_T_ import cbdt_bitmap_format_17 as CbdtBitmapFormat17
from functools import reduce
from io import BytesIO
from nanoemoji.config import FontConfig
from nanoemoji.color_glyph import ColorGlyph
from nanoemoji.png import PNG
from nanoemoji.util import only
from typing import (
List,
NamedTuple,
Expand All @@ -40,7 +46,7 @@
_UINT8_RANGE = range(0, 255 + 1)

# https://docs.microsoft.com/en-us/typography/opentype/spec/cbdt#table-structure
_CBDT_HEADER_SIZE = 4
CBDT_HEADER_SIZE = 4

# https://docs.microsoft.com/en-us/typography/opentype/spec/cbdt#format-17-small-metrics-png-image-data
_CBDT_SMALL_METRIC_PNGS = 17
Expand All @@ -49,54 +55,83 @@
_CBDT_SMALL_METRIC_PNG_HEADER_SIZE = 5 + 4


def _nudge_into_range(arange: range, value: int, max_move: int = 1) -> int:
if value in arange:
return value
if value > max(arange) and value - max_move <= max(arange):
return max(arange)
if value < min(arange) and value + max_move >= min(arange):
return min(arange)
return value


class BitmapMetrics(NamedTuple):
x_offset: int
y_offset: int
line_height: int
line_ascent: int

@classmethod
def create(cls, config: FontConfig, ppem: int) -> "BitmapMetrics":
# https://github.com/googlefonts/noto-emoji/blob/9a5261d871451f9b5183c93483cbd68ed916b1e9/third_party/color_emoji/emoji_builder.py#L109
def create(cls, config: FontConfig, image_data: PNG, ppem: int) -> "BitmapMetrics":
ascent = config.ascender
descent = -config.descender

line_height = round((ascent + descent) * ppem / float(config.upem))
line_ascent = ascent * ppem / float(config.upem)

# center within advance
metrics = BitmapMetrics(
x_offset=max(
round((_width_in_pixels(config) - config.bitmap_resolution) / 2), 0
x_offset=_nudge_into_range(
_INT8_RANGE,
max(
round(
(
_width_in_pixels(config, image_data)
- config.bitmap_resolution
)
/ 2
),
0,
),
),
y_offset=round(
line_ascent - 0.5 * (line_height - config.bitmap_resolution)
y_offset=_nudge_into_range(
_INT8_RANGE,
round(line_ascent - 0.5 * (line_height - config.bitmap_resolution)),
),
line_height=line_height,
line_ascent=round(line_ascent),
)

# The FontTools errors when values are out of bounds are a bit nasty
# so check here for earlier and more helpful termination
assert (
config.bitmap_resolution in _UINT8_RANGE
), f"bitmap_resolution out of bounds: {config.bitmap_resolution}"
assert metrics.y_offset in _INT8_RANGE, f"y_offset out of bounds: {metrics}"

return metrics


def _width_in_pixels(config: FontConfig) -> int:
return round(
config.bitmap_resolution * config.width / (config.ascender - config.descender)
)
def _pixels_to_funits(config: FontConfig, bitmap_pixel_height: int) -> Tuple[int, int]:
# the bitmap is vertically scaled to fill the space desc to asc
# this gives us a ratio between pixels and upem
funits = config.ascender - config.descender
return (bitmap_pixel_height, funits)


# https://github.com/googlefonts/noto-emoji/blob/9a5261d871451f9b5183c93483cbd68ed916b1e9/third_party/color_emoji/emoji_builder.py#L53
def _ppem(config: FontConfig, advance: int) -> int:
return round(_width_in_pixels(config) * config.upem / advance)
def _width_in_pixels(config: FontConfig, image_data: PNG) -> int:
pixels, funits = _pixels_to_funits(config, image_data.size[1])

width_funits = image_data.size[0] * funits / pixels
width_funits = max(config.width, width_funits)

assert width_funits > 0
return round(width_funits * pixels / funits)

def _advance(ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph]) -> int:
# let's go ahead and fail miserably if advances are not all the same
# proportional bitmaps can wait for a second pass :)
advances = {
ttfont["hmtx"].metrics[ttfont.getGlyphName(c.glyph_id)][0] for c in color_glyphs
}
assert len(advances) == 1, "Proportional bitmaps not supported yet"
return next(iter(advances))

def _ppem(config: FontConfig, bitmap_pixel_height: int) -> int:
pixels, funits = _pixels_to_funits(config, bitmap_pixel_height)
return round(config.upem * pixels / funits)


def _cbdt_record_size(image_format: int, image_data: bytes) -> int:
Expand All @@ -105,11 +140,11 @@ def _cbdt_record_size(image_format: int, image_data: bytes) -> int:


def _cbdt_bitmapdata_offsets(
image_format: int, color_glyphs: Sequence[ColorGlyph]
initial_offset: int, image_format: int, color_glyphs: Sequence[ColorGlyph]
) -> List[Tuple[int, int]]:
# TODO is this right? - feels dumb. But ... compile crashes if locations are unknown.
offsets = []
offset = _CBDT_HEADER_SIZE
offset = initial_offset
for color_glyph in color_glyphs:
offsets.append(offset)
offset += _cbdt_record_size(image_format, color_glyph.bitmap)
Expand All @@ -118,17 +153,15 @@ def _cbdt_bitmapdata_offsets(


def _cbdt_bitmap_data(
config: FontConfig, metrics: BitmapMetrics, image_data: bytes
config: FontConfig, metrics: BitmapMetrics, image_data: PNG
) -> CbdtBitmapFormat17:

bitmap_data = CbdtBitmapFormat17(b"", None)
bitmap_data.metrics = SmallGlyphMetrics()
bitmap_data.metrics.height = config.bitmap_resolution
bitmap_data.metrics.width = config.bitmap_resolution
# center within advance
bitmap_data.metrics.width, bitmap_data.metrics.height = image_data.size
bitmap_data.metrics.BearingX = metrics.x_offset
bitmap_data.metrics.BearingY = metrics.y_offset
bitmap_data.metrics.Advance = _width_in_pixels(config)
bitmap_data.metrics.Advance = _width_in_pixels(config, image_data)
bitmap_data.imageData = image_data
return bitmap_data

Expand All @@ -142,18 +175,18 @@ def make_sbix_table(
sbix = ttLib.newTable("sbix")
ttfont[sbix.tableTag] = sbix

ppem = _ppem(config, _advance(ttfont, color_glyphs))
bitmap_pixel_height = only({c.bitmap.size[1] for c in color_glyphs})
ppem = _ppem(config, bitmap_pixel_height)

strike = SbixStrike()
strike.ppem = ppem
strike.resolution = 72 # pixels per inch
sbix.strikes[strike.ppem] = strike

metrics = BitmapMetrics.create(config, strike.ppem)

for color_glyph in color_glyphs:
# TODO: if we've seen these bytes before set graphicType "dupe", referenceGlyphName <name of glyph>
image_data = color_glyph.bitmap
metrics = BitmapMetrics.create(config, image_data, strike.ppem)

glyph_name = ttfont.getGlyphName(color_glyph.glyph_id)
glyph = SbixGlyph(
Expand All @@ -166,31 +199,19 @@ def make_sbix_table(
strike.glyphs[glyph_name] = glyph


# Ref https://github.com/googlefonts/noto-emoji/blob/main/third_party/color_emoji/emoji_builder.py
def make_cbdt_table(
def _make_cbdt_strike(
config: FontConfig,
ttfont: ttLib.TTFont,
data_offset: int,
color_glyphs: Sequence[ColorGlyph],
):

# bitmap tables don't like it when we're out of order
color_glyphs = sorted(color_glyphs, key=lambda c: c.glyph_id)

min_gid, max_gid = color_glyphs[0].glyph_id, color_glyphs[-1].glyph_id
assert max_gid - min_gid + 1 == len(
color_glyphs
), "Below assumes color gyphs gids are consecutive"

advance = _advance(ttfont, color_glyphs)
ppem = _ppem(config, advance)

cbdt = ttLib.newTable("CBDT")
ttfont[cbdt.tableTag] = cbdt

cblc = ttLib.newTable("CBLC")
ttfont[cblc.tableTag] = cblc

cblc.version = cbdt.version = 3.0
bitmap_pixel_height = only({c.bitmap.size[1] for c in color_glyphs})
ppem = _ppem(config, bitmap_pixel_height)

strike = CblcStrike()
strike.bitmapSizeTable.startGlyphIndex = min_gid
Expand All @@ -214,24 +235,26 @@ def make_cbdt_table(
line_metrics.pad1 = 0
line_metrics.pad2 = 0

metrics = BitmapMetrics.create(config, ppem)
# The FontTools errors when values are out of bounds are a bit nasty
# so check here for earlier and more helpful termination
assert (
config.bitmap_resolution in _UINT8_RANGE
), f"bitmap_resolution out of bounds: {config.bitmap_resolution}"
assert metrics.y_offset in _INT8_RANGE, f"y_offset out of bounds: {metrics}"
metrics = {
c.glyph_id: BitmapMetrics.create(config, c.bitmap, ppem) for c in color_glyphs
}
data = {
ttfont.getGlyphName(c.glyph_id): _cbdt_bitmap_data(
config, metrics[c.glyph_id], c.bitmap
)
for c in color_glyphs
}

line_height = only({m.line_height for m in metrics.values()})

line_metrics.ascender = round(config.ascender * ppem / config.upem)
line_metrics.descender = -(metrics.line_height - line_metrics.ascender)
line_metrics.widthMax = _width_in_pixels(config)
line_metrics.descender = -(line_height - line_metrics.ascender)
line_metrics.widthMax = max(d.metrics.Advance for d in data.values())

strike.bitmapSizeTable.hori = line_metrics
strike.bitmapSizeTable.vert = line_metrics

# Simplifying assumption: identical metrics
# https://docs.microsoft.com/en-us/typography/opentype/spec/eblc#indexsubtables

# Apparently you cannot build a CBLC index subtable w/o providing bytes & font?!
# If we build from empty bytes and fill in the fields all is well
index_subtable = CblcIndexSubTable1(b"", ttfont)
Expand All @@ -241,19 +264,68 @@ def make_cbdt_table(
index_subtable.imageFormat = _CBDT_SMALL_METRIC_PNGS
index_subtable.imageSize = config.bitmap_resolution
index_subtable.names = [ttfont.getGlyphName(c.glyph_id) for c in color_glyphs]

index_subtable.locations = _cbdt_bitmapdata_offsets(
index_subtable.imageFormat, color_glyphs
data_offset, index_subtable.imageFormat, color_glyphs
)

strike.indexSubTables = [index_subtable]
cblc.strikes = [strike]

# Now register all the data
cbdt.strikeData = [
{
ttfont.getGlyphName(c.glyph_id): _cbdt_bitmap_data(
config, metrics, c.bitmap
)
for c in color_glyphs
}
]

return strike, data


def raise_if_too_big_for_cbdt(color_glyphs: Sequence[ColorGlyph]):
too_big = sorted(
(c for c in color_glyphs if max(c.bitmap.size) not in _UINT8_RANGE),
key=lambda c: c.bitmap_filename,
)
if not too_big:
return
raise ValueError(
"Bitmap is too big for CBDT, try lowering bitmap_resolution: "
+ ",".join(c.bitmap_filename for c in too_big)
)


def make_cbdt_table(
config: FontConfig,
ttfont: ttLib.TTFont,
color_glyphs: Sequence[ColorGlyph],
):
# CBDT is a wee bit limited in pixel size
raise_if_too_big_for_cbdt(color_glyphs)

# bitmap tables don't like it when we're out of order
color_glyphs = sorted(color_glyphs, key=lambda c: c.glyph_id)

cbdt = ttLib.newTable("CBDT")
ttfont[cbdt.tableTag] = cbdt

cblc = ttLib.newTable("CBLC")
ttfont[cblc.tableTag] = cblc

cblc.version = cbdt.version = 3.0

cblc.strikes = []
cbdt.strikeData = []

data_offset = CBDT_HEADER_SIZE

while color_glyphs:
# grab the next run w/consecutive gids
min_gid = color_glyphs[0].glyph_id
end = 1
while (
len(color_glyphs) > end
and color_glyphs[end].glyph_id == color_glyphs[end - 1].glyph_id + 1
):
end += 1
color_glyph_run = color_glyphs[:end]
color_glyphs = color_glyphs[end:]

strike, data = _make_cbdt_strike(config, ttfont, data_offset, color_glyph_run)
for sub_table in strike.indexSubTables:
data_offset = max(sub_table.locations[-1][-1], data_offset)

cblc.strikes.append(strike)
cbdt.strikeData.append(data)
Loading

0 comments on commit 20ae8c4

Please sign in to comment.