Skip to content

Commit

Permalink
Merge pull request #403 from googlefonts/colr_glyph_to_svg
Browse files Browse the repository at this point in the history
Support colr glyph => svg
  • Loading branch information
rsheeter authored Apr 8, 2022
2 parents 599f0f5 + 85fbdb8 commit 80168f3
Show file tree
Hide file tree
Showing 2 changed files with 180 additions and 12 deletions.
47 changes: 35 additions & 12 deletions src/nanoemoji/colr_to_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
ReuseCache,
)
from nanoemoji.svg_path import SVGPathPen
from nanoemoji.util import only
from picosvg.svg import SVG
from picosvg.svg_meta import ntos
from picosvg.svg_transform import Affine2D
Expand Down Expand Up @@ -190,6 +191,23 @@ def _colr_v0_glyph_to_svg(
return svg_root


def _apply_transform(
transform: Affine2D, font_to_vbox: Affine2D, el: etree.Element
) -> Affine2D:
if transform == Affine2D.identity():
return Affine2D.identity()

svg_transform = Affine2D.compose_ltr(
(font_to_vbox.inverse(), transform, font_to_vbox)
)
el.attrib["transform"] = _svg_matrix(svg_transform)
# we must reset the current user space when setting the 'transform'
# attribute on a <path>, since that already affects the gradients used
# and we don't want the transform to be applied twice to gradients:
# https://github.com/googlefonts/nanoemoji/issues/334
return Affine2D.identity()


def _colr_v1_paint_to_svg(
ttfont: ttLib.TTFont,
glyph_set: Mapping[str, Any],
Expand Down Expand Up @@ -222,25 +240,17 @@ def descend(parent: etree.Element, paint: otTables.Paint):
layer_glyph = ot_paint.Glyph
svg_path = etree.SubElement(parent_el, "path")

# This only occurs if path is reused; we could wire up use. But for now ... not.
if transform != Affine2D.identity():
svg_transform = Affine2D.compose_ltr(
(font_to_vbox.inverse(), transform, font_to_vbox)
)
svg_path.attrib["transform"] = _svg_matrix(svg_transform)
# we must reset the current user space when setting the 'transform'
# attribute on a <path>, since that already affects the gradients used
# and we don't want the transform to be applied twice to gradients:
# https://github.com/googlefonts/nanoemoji/issues/334
transform = Affine2D.identity()
# Transform only occurs with reuse; we could wire up use. But for now ... not.
transform = _apply_transform(transform, font_to_vbox, svg_path)

descend(svg_path, ot_paint.Paint)

_draw_svg_path(svg_path, glyph_set, layer_glyph, font_to_vbox)

elif is_transform(ot_paint.Format):
paint = Paint.from_ot(ot_paint)
transform @= paint.gettransform()
descend(parent_el, ot_paint.Paint)

elif ot_paint.Format == PaintColrLayers.format:
layerList = ttfont["COLR"].table.LayerList.Paint
assert layerList, "Paint layers without a layer list :("
Expand All @@ -265,6 +275,19 @@ def descend(parent: etree.Element, paint: otTables.Paint):
g.attrib["opacity"] = ntos(color.alpha)
descend(g, ot_paint.SourcePaint)

elif ot_paint.Format == PaintColrGlyph.format:
el = parent_el
if transform != Affine2D.identity():
el = etree.SubElement(parent_el, "g")
# Transform only occurs with reuse; we could wire up use. But for now ... not.
transform = _apply_transform(transform, font_to_vbox, el)
base_rec = only(
r
for r in ttfont["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord
if r.BaseGlyph == ot_paint.Glyph
)
descend(el, base_rec.Paint)

else:
raise NotImplementedError(ot_paint.Format)

Expand Down
145 changes: 145 additions & 0 deletions tests/colr_to_svg_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from fontTools.colorLib.builder import buildCPAL, buildColrV1, LayerListBuilder
from fontTools import ttLib
from fontTools.ttLib.tables import _g_l_y_f
from fontTools.ttLib.ttFont import newTable
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.ttLib.tables import otTables as ot
from lxml import etree
from nanoemoji.colr_to_svg import _colr_v1_paint_to_svg, _new_reuse_cache
from nanoemoji.util import only
from picosvg.svg import SVG
from picosvg.svg_transform import Affine2D
import pytest
from test_helper import svg_diff
import textwrap


def _draw_box(pen):
pen.moveTo((10, 10))
pen.lineTo((90, 10))
pen.lineTo((90, 90))
pen.lineTo((10, 90))
pen.closePath()


def _build(cls, source) -> ot.Paint:
return LayerListBuilder().tableBuilder.build(cls, source)


def _buildPaint(source) -> ot.Paint:
return LayerListBuilder().buildPaint(source)


@pytest.mark.parametrize(
"glyph_to_convert, color_glyphs, monochrome_glyphs, expected_svg",
[
# Solid filled box
(
"color_box",
{
"color_box": (
ot.PaintFormat.PaintGlyph,
(ot.PaintFormat.PaintSolid, 0),
"box",
),
},
{"box": _draw_box},
"""
<svg xmlns="http://www.w3.org/2000/svg">
<defs/>
<path d="M10,10 L90,10 L90,90 L10,90 Z"/>
</svg>
""",
),
# Paint colr glyph
(
"paint_colr_glyph",
{
"paint_colr_glyph": (ot.PaintFormat.PaintColrGlyph, "color_box"),
"color_box": (
ot.PaintFormat.PaintGlyph,
(ot.PaintFormat.PaintSolid, 0),
"box",
),
},
{"box": _draw_box},
"""
<svg xmlns="http://www.w3.org/2000/svg">
<defs/>
<path d="M10,10 L90,10 L90,90 L10,90 Z"/>
</svg>
""",
),
],
)
def test_colr_v1_paint_to_svg(
glyph_to_convert, color_glyphs, monochrome_glyphs, expected_svg
):
actual_svg = SVG.fromstring('<svg xmlns="http://www.w3.org/2000/svg"><defs/></svg>')
expected_svg = SVG.fromstring(textwrap.dedent(expected_svg))

# create a minimal font to play with
font = ttLib.TTFont()
glyf_table = font["glyf"] = newTable("glyf")
glyf_table.glyphs = {".notdef": _g_l_y_f.Glyph()}
hmtx_table = font["hmtx"] = newTable("hmtx")
hmtx_table.metrics = {}
head_table = font["head"] = newTable("head")
head_table.unitsPerEm = 100
maxp_table = font["maxp"] = newTable("maxp")
maxp_table.numGlyphs = 1
colr_table = font["COLR"] = newTable("COLR")
colr_table.table = ot.COLR()

# provide some simple shapes to play with
for glyph_name, draw_fn in monochrome_glyphs.items():
font.setGlyphOrder(font.getGlyphOrder() + [glyph_name])
pen = TTGlyphPen(None)
draw_fn(pen)
glyph = pen.glyph()
# Add to glyf
glyf_table.glyphs[glyph_name] = glyph

# setup hmtx
glyph.recalcBounds(glyf_table)
hmtx_table.metrics[glyph_name] = (head_table.unitsPerEm, glyph.xMin)

# palette 0: black, blue
palettes = [
[(0, 0, 0, 1.0), (0, 0, 1, 1.0)],
]
font["CPAL"] = buildCPAL(palettes)

layers, base_glyphs = buildColrV1(color_glyphs)
colr_table.table.BaseGlyphList = base_glyphs
paint = only(
g.Paint
for g in base_glyphs.BaseGlyphPaintRecord
if g.BaseGlyph == glyph_to_convert
)

_colr_v1_paint_to_svg(
font,
font.getGlyphSet(),
actual_svg.svg_root,
actual_svg.xpath_one("//svg:defs"),
Affine2D.identity(),
paint,
_new_reuse_cache(),
)

svg_diff(actual_svg, expected_svg)

0 comments on commit 80168f3

Please sign in to comment.