Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CBDT to maximum_color #400

Merged
merged 1 commit into from
Apr 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:
anthrotype marked this conversation as resolved.
Show resolved Hide resolved

| 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the function is named _pixels_to_funits, so i'd expect it to actually return the ratio pixels/funits (flaot) instead of passing them through as a tuple. It's not doing much besides subtracting ascender-descender

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I should make a type. I'm under the impression that by delaying the computation and always doing multiple (enbiggen) then divide we minimize float damage.



# 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