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

scale height to (ascender-descender), preserve aspect, center width #255

Merged
merged 9 commits into from
Mar 11, 2021
135 changes: 84 additions & 51 deletions src/nanoemoji/color_glyph.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,52 +37,60 @@
import ufoLib2


def _scale_viewbox_to_emsquare(view_box: Rect, upem: int) -> Tuple[float, float]:
# scale to font upem
return (upem / view_box.w, upem / view_box.h)


def _shift_origin_0_0(
view_box: Rect, x_scale: float, y_scale: float
) -> Tuple[float, float]:
# shift so origin is 0,0
return (-view_box.x * x_scale, -view_box.y * y_scale)
def _scale_viewbox_to_font_metrics(
view_box: Rect, ascender: int, descender: int, width: int
):
assert descender <= 0
# scale height to (ascender - descender)
scale = (ascender - descender) / view_box.h
anthrotype marked this conversation as resolved.
Show resolved Hide resolved
# shift so width is centered
dx = (width - scale * view_box.w) / 2
return Affine2D.compose_ltr(
(
# first normalize viewbox origin
Affine2D(1, 0, 0, 1, -view_box.x, -view_box.y),
Affine2D(scale, 0, 0, scale, dx, 0),
)
)


def map_viewbox_to_font_emsquare(
view_box: Rect, upem: int, user_transform: Affine2D
def map_viewbox_to_font_space(
view_box: Rect, ascender: int, descender: int, width: int, user_transform: Affine2D
) -> Affine2D:
x_scale, y_scale = _scale_viewbox_to_emsquare(view_box, upem)
# flip y axis
y_scale = -y_scale
# shift so things are in the right place
dx, dy = _shift_origin_0_0(view_box, x_scale, y_scale)
dy = dy + upem
affine = Affine2D(x_scale, 0, 0, y_scale, dx, dy)
return Affine2D.compose_ltr((affine, user_transform))
return Affine2D.compose_ltr(
[
_scale_viewbox_to_font_metrics(view_box, ascender, descender, width),
# flip y axis and shift so things are in the right place
Affine2D(1, 0, 0, -1, 0, ascender),
user_transform,
]
)


# https://docs.microsoft.com/en-us/typography/opentype/spec/svg#coordinate-systems-and-glyph-metrics
def map_viewbox_to_otsvg_emsquare(
view_box: Rect, upem: int, user_transform: Affine2D
def map_viewbox_to_otsvg_space(
view_box: Rect, ascender: int, descender: int, width: int, user_transform: Affine2D
) -> Affine2D:
x_scale, y_scale = _scale_viewbox_to_emsquare(view_box, upem)
dx, dy = _shift_origin_0_0(view_box, x_scale, y_scale)

# shift so things are in the right place
dy = dy - upem
affine = Affine2D(x_scale, 0, 0, y_scale, dx, dy)
return Affine2D.compose_ltr((affine, user_transform))
return Affine2D.compose_ltr(
[
_scale_viewbox_to_font_metrics(view_box, ascender, descender, width),
# shift things in the [+x,-y] quadrant where OT-SVG expects them
Affine2D(1, 0, 0, 1, 0, -ascender),
user_transform,
]
)


def _get_gradient_transform(
upem: int,
user_transform: Affine2D,
config: FontConfig,
grad_el: etree.Element,
shape_bbox: Rect,
view_box: Rect,
glyph_width: int,
) -> Affine2D:
transform = map_viewbox_to_font_emsquare(view_box, upem, user_transform)
transform = map_viewbox_to_font_space(
view_box, config.ascender, config.descender, glyph_width, config.transform
)

gradient_units = grad_el.attrib.get("gradientUnits", "objectBoundingBox")
if gradient_units == "objectBoundingBox":
Expand All @@ -102,6 +110,7 @@ def _parse_linear_gradient(
grad_el: etree.Element,
shape_bbox: Rect,
view_box: Rect,
glyph_width: int,
shape_opacity: float = 1.0,
):
gradient = SVGLinearGradient.from_element(grad_el, view_box)
Expand All @@ -113,7 +122,7 @@ def _parse_linear_gradient(
p2 = p0 + (p1 - p0).perpendicular()

transform = _get_gradient_transform(
config.upem, config.transform, grad_el, shape_bbox, view_box
config, grad_el, shape_bbox, view_box, glyph_width
)

p0 = transform.map_point(p0)
Expand All @@ -131,6 +140,7 @@ def _parse_radial_gradient(
grad_el: etree.Element,
shape_bbox: Rect,
view_box: Rect,
glyph_width: int,
shape_opacity: float = 1.0,
):
gradient = SVGRadialGradient.from_element(grad_el, view_box)
Expand All @@ -140,7 +150,9 @@ def _parse_radial_gradient(
c1 = Point(gradient.cx, gradient.cy)
r1 = gradient.r

transform = map_viewbox_to_font_emsquare(view_box, config.upem, config.transform)
transform = map_viewbox_to_font_space(
view_box, config.ascender, config.descender, glyph_width, config.transform
)

gradient_units = grad_el.attrib.get("gradientUnits", "objectBoundingBox")
if gradient_units == "objectBoundingBox":
Expand All @@ -150,7 +162,7 @@ def _parse_radial_gradient(

assert transform[1:3] == (0, 0), (
f"{transform} contains unexpected skew/rotation:"
" upem, view_box, shape_bbox are all rectangles"
" (ascender-descender), view_box, shape_bbox are all rectangles"
)

# if viewBox is not square or if gradientUnits="objectBoundingBox" and the bbox
Expand Down Expand Up @@ -237,7 +249,9 @@ def shape_cache_key(self):
return (self.path, self.reuses)


def _paint(debug_hint: str, config: FontConfig, picosvg: SVG, shape: SVGPath) -> Paint:
def _paint(
debug_hint: str, config: FontConfig, picosvg: SVG, shape: SVGPath, glyph_width: int
) -> Paint:
if shape.fill.startswith("url("):
el = picosvg.resolve_url(shape.fill, "*")
try:
Expand All @@ -246,6 +260,7 @@ def _paint(debug_hint: str, config: FontConfig, picosvg: SVG, shape: SVGPath) ->
el,
shape.bounding_box(),
picosvg.view_box(),
glyph_width,
shape.opacity,
)
except ValueError as e:
Expand All @@ -257,12 +272,12 @@ def _paint(debug_hint: str, config: FontConfig, picosvg: SVG, shape: SVGPath) ->


def _in_glyph_reuse_key(
debug_hint: str, config: FontConfig, picosvg: SVG, shape: SVGPath
debug_hint: str, config: FontConfig, picosvg: SVG, shape: SVGPath, glyph_width: int
) -> Tuple[Paint, SVGPath]:
"""Within a glyph reuse shapes only when painted consistently.
paint+normalized shape ensures this."""
return (
_paint(debug_hint, config, picosvg, shape),
_paint(debug_hint, config, picosvg, shape, glyph_width),
normalize(shape, config.reuse_tolerance),
)

Expand All @@ -271,18 +286,21 @@ def _painted_layers(
debug_hint: str,
config: FontConfig,
picosvg: SVG,
glyph_width: int,
) -> Generator[PaintedLayer, None, None]:
if config.reuse_tolerance < 0:
# shape reuse disabled
for path in picosvg.shapes():
yield PaintedLayer(_paint(debug_hint, config, picosvg, path), path.d)
yield PaintedLayer(
_paint(debug_hint, config, picosvg, path, glyph_width), path.d
)
return

# Don't sort; we only want to find groups that are consecutive in the picosvg
# to ensure we don't mess up layer order
for (paint, normalized), paths in groupby(
picosvg.shapes(),
key=lambda s: _in_glyph_reuse_key(debug_hint, config, picosvg, s),
key=lambda s: _in_glyph_reuse_key(debug_hint, config, picosvg, s, glyph_width),
):
paths = list(paths)
transforms = ()
Expand Down Expand Up @@ -311,6 +329,13 @@ def _painted_layers(
yield PaintedLayer(paint, path.d)


def _color_glyph_advance_width(view_box: Rect, config: FontConfig) -> int:
# Scale advance width proportionally to viewbox aspect ratio.
# Use the default advance width if it's larger than the proportional one.
font_height = config.ascender - config.descender # descender <= 0
return max(config.width, round(font_height * view_box.w / view_box.h))


class ColorGlyph(NamedTuple):
ufo: ufoLib2.Font
filename: str
Expand All @@ -333,7 +358,13 @@ def create(
logging.debug(" ColorGlyph for %s (%s)", filename, codepoints)
glyph_name = glyph.glyph_name(codepoints)
base_glyph = ufo.newGlyph(glyph_name)
base_glyph.width = font_config.width

# non-square aspect ratio == proportional width; square == monospace
view_box = svg.view_box()
if view_box is not None:
base_glyph.width = _color_glyph_advance_width(view_box, font_config)
else:
base_glyph.width = font_config.width

# Setup direct access to the glyph if possible
if len(codepoints) == 1:
Expand All @@ -347,6 +378,7 @@ def create(
filename,
font_config,
svg,
base_glyph.width,
)
)

Expand All @@ -369,21 +401,22 @@ def _has_viewbox_for_transform(self) -> bool:
)
return view_box is not None

def transform_for_font_space(self):
"""Creates a Transform to map SVG coords to font coords"""
def _transform(self, map_fn):
if not self._has_viewbox_for_transform():
return Affine2D.identity()
return map_viewbox_to_font_emsquare(
self.svg.view_box(), self.ufo.info.unitsPerEm, self.user_transform
return map_fn(
self.svg.view_box(),
self.ufo.info.ascender,
self.ufo.info.descender,
self.ufo[self.glyph_name].width,
self.user_transform,
)

def transform_for_otsvg_space(self):
"""Creates a Transform to map SVG coords OT-SVG coords"""
if not self._has_viewbox_for_transform():
return Affine2D.identity()
return map_viewbox_to_otsvg_emsquare(
self.svg.view_box(), self.ufo.info.unitsPerEm, self.user_transform
)
return self._transform(map_viewbox_to_otsvg_space)

def transform_for_font_space(self):
return self._transform(map_viewbox_to_font_space)

def paints(self):
"""Set of Paint used by this glyph."""
Expand Down
41 changes: 29 additions & 12 deletions src/nanoemoji/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@
flags.DEFINE_string("config", None, "Config file.")
flags.DEFINE_integer("upem", None, "Units per em.")
flags.DEFINE_integer("width", None, "Width.")
flags.DEFINE_integer("ascent", None, "Ascender")
flags.DEFINE_integer("descent", None, "Descender.")
flags.DEFINE_integer("ascender", None, "Ascender")
flags.DEFINE_integer("descender", None, "Descender.")
flags.DEFINE_string("transform", None, "User transform, in font coordinates.")
flags.DEFINE_integer("version_major", None, "Major version.")
flags.DEFINE_integer("version_minor", None, "Minor version.")
Expand Down Expand Up @@ -112,9 +112,9 @@ class FontConfig(NamedTuple):
output_file: str = "AnEmojiFamily.ttf"
color_format: str = "glyf_colr_1"
upem: int = 1024
width: int = 1024
ascent: int = 950 # default based on Noto Emoji
descent: int = -250 # default based on Noto Emoji
width: int = 1275 # default based on Noto Emoji
ascender: int = 950 # default based on Noto Emoji
descender: int = -250 # default based on Noto Emoji
transform: Affine2D = Affine2D.identity()
version_major: int = 1
version_minor: int = 0
Expand All @@ -137,6 +137,23 @@ def output_format(self):
def has_picosvgs(self):
return not self.color_format.startswith("untouchedsvg")

def validate(self):
for attr_name in (
"upem",
"width",
"ascender",
"version_major",
"version_minor",
):
value = getattr(self, attr_name)
if value < 0:
raise ValueError(f"'{attr_name}' must be zero or positive")

if self.descender > 0:
raise ValueError("'descender' must be zero or negative")

return self


def write(dest: Path, config: FontConfig):
toml_cfg = {
Expand All @@ -145,8 +162,8 @@ def write(dest: Path, config: FontConfig):
"color_format": config.color_format,
"upem": config.upem,
"width": config.width,
"ascent": config.ascent,
"descent": config.descent,
"ascender": config.ascender,
"descender": config.descender,
"transform": config.transform.tostring(),
"version_major": config.version_major,
"version_minor": config.version_minor,
Expand Down Expand Up @@ -224,8 +241,8 @@ def load(config_file: Path = None, additional_srcs: Tuple[Path] = None) -> FontC
color_format = _pop_flag(config, "color_format")
upem = int(_pop_flag(config, "upem"))
width = int(_pop_flag(config, "width"))
ascent = int(_pop_flag(config, "ascent"))
descent = int(_pop_flag(config, "descent"))
ascender = int(_pop_flag(config, "ascender"))
descender = int(_pop_flag(config, "descender"))
transform = _pop_flag(config, "transform")
if not isinstance(transform, Affine2D):
assert isinstance(transform, str)
Expand Down Expand Up @@ -301,8 +318,8 @@ def load(config_file: Path = None, additional_srcs: Tuple[Path] = None) -> FontC
color_format=color_format,
upem=upem,
width=width,
ascent=ascent,
descent=descent,
ascender=ascender,
descender=descender,
transform=transform,
version_major=version_major,
version_minor=version_minor,
Expand All @@ -316,4 +333,4 @@ def load(config_file: Path = None, additional_srcs: Tuple[Path] = None) -> FontC
axes=tuple(axes),
masters=tuple(masters),
source_names=tuple(sorted(source_names)),
)
).validate()
4 changes: 2 additions & 2 deletions src/nanoemoji/write_font.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ def _ufo(config: FontConfig) -> ufoLib2.Font:
ufo.info.familyName = config.family
# set various font metadata; see the full list of fontinfo attributes at
# https://unifiedfontobject.org/versions/ufo3/fontinfo.plist/#generic-dimension-information
ufo.info.ascender = config.ascent
ufo.info.descender = config.descent
ufo.info.ascender = config.ascender
ufo.info.descender = config.descender
ufo.info.unitsPerEm = config.upem
# version
ufo.info.versionMajor = config.version_major
Expand Down
Loading