xref: /aosp_15_r20/external/fonttools/Tests/subset/svg_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1from string import ascii_letters
2import textwrap
3
4from fontTools.misc.testTools import getXML
5from fontTools import subset
6from fontTools.fontBuilder import FontBuilder
7from fontTools.pens.ttGlyphPen import TTGlyphPen
8from fontTools.ttLib import TTFont, newTable
9from fontTools.subset.svg import NAMESPACES, ranges
10
11import pytest
12
13etree = pytest.importorskip("lxml.etree")
14
15
16@pytest.fixture
17def empty_svg_font():
18    glyph_order = [".notdef"] + list(ascii_letters)
19
20    pen = TTGlyphPen(glyphSet=None)
21    pen.moveTo((0, 0))
22    pen.lineTo((0, 500))
23    pen.lineTo((500, 500))
24    pen.lineTo((500, 0))
25    pen.closePath()
26    glyph = pen.glyph()
27    glyphs = {g: glyph for g in glyph_order}
28
29    fb = FontBuilder(unitsPerEm=1024, isTTF=True)
30    fb.setupGlyphOrder(glyph_order)
31    fb.setupCharacterMap({ord(c): c for c in ascii_letters})
32    fb.setupGlyf(glyphs)
33    fb.setupHorizontalMetrics({g: (500, 0) for g in glyph_order})
34    fb.setupHorizontalHeader()
35    fb.setupOS2()
36    fb.setupPost()
37    fb.setupNameTable({"familyName": "TestSVG", "styleName": "Regular"})
38
39    svg_table = newTable("SVG ")
40    svg_table.docList = []
41    fb.font["SVG "] = svg_table
42
43    return fb.font
44
45
46# 'simple' here means one svg document per glyph. The required 'id' attribute
47# containing the 'glyphXXX' indices can be either on a child of the root <svg>
48# or on the <svg> root itself, so we test with both.
49# see https://github.com/fonttools/fonttools/issues/2548
50
51
52def simple_svg_table_glyph_ids_on_children(empty_svg_font):
53    font = empty_svg_font
54    svg_docs = font["SVG "].docList
55    for i in range(1, 11):
56        svg = new_svg()
57        etree.SubElement(svg, "path", {"id": f"glyph{i}", "d": f"M{i},{i}"})
58        svg_docs.append((etree.tostring(svg).decode(), i, i))
59    return font
60
61
62def simple_svg_table_glyph_ids_on_roots(empty_svg_font):
63    font = empty_svg_font
64    svg_docs = font["SVG "].docList
65    for i in range(1, 11):
66        svg = new_svg(id=f"glyph{i}")
67        etree.SubElement(svg, "path", {"d": f"M{i},{i}"})
68        svg_docs.append((etree.tostring(svg).decode(), i, i))
69    return font
70
71
72def new_svg(**attrs):
73    return etree.Element("svg", {"xmlns": NAMESPACES["svg"], **attrs})
74
75
76def _lines(s):
77    return textwrap.dedent(s).splitlines()
78
79
80@pytest.mark.parametrize(
81    "add_svg_table, gids, retain_gids, expected_xml",
82    [
83        # keep four glyphs in total, don't retain gids, which thus get remapped
84        (
85            simple_svg_table_glyph_ids_on_children,
86            "2,4-6",
87            False,
88            _lines(
89                """\
90                <svgDoc endGlyphID="1" startGlyphID="1">
91                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg"><path id="glyph1" d="M2,2"/></svg>]]>
92                </svgDoc>
93                <svgDoc endGlyphID="2" startGlyphID="2">
94                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg"><path id="glyph2" d="M4,4"/></svg>]]>
95                </svgDoc>
96                <svgDoc endGlyphID="3" startGlyphID="3">
97                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg"><path id="glyph3" d="M5,5"/></svg>]]>
98                </svgDoc>
99                <svgDoc endGlyphID="4" startGlyphID="4">
100                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg"><path id="glyph4" d="M6,6"/></svg>]]>
101                </svgDoc>
102                """
103            ),
104        ),
105        # same as above but with glyph id attribute in the root <svg> element itself
106        # https://github.com/fonttools/fonttools/issues/2548
107        (
108            simple_svg_table_glyph_ids_on_roots,
109            "2,4-6",
110            False,
111            _lines(
112                """\
113                <svgDoc endGlyphID="1" startGlyphID="1">
114                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" id="glyph1"><path d="M2,2"/></svg>]]>
115                </svgDoc>
116                <svgDoc endGlyphID="2" startGlyphID="2">
117                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" id="glyph2"><path d="M4,4"/></svg>]]>
118                </svgDoc>
119                <svgDoc endGlyphID="3" startGlyphID="3">
120                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" id="glyph3"><path d="M5,5"/></svg>]]>
121                </svgDoc>
122                <svgDoc endGlyphID="4" startGlyphID="4">
123                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" id="glyph4"><path d="M6,6"/></svg>]]>
124                </svgDoc>
125                """
126            ),
127        ),
128        # same four glyphs, but we now retain gids
129        (
130            simple_svg_table_glyph_ids_on_children,
131            "2,4-6",
132            True,
133            _lines(
134                """\
135                <svgDoc endGlyphID="2" startGlyphID="2">
136                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg"><path id="glyph2" d="M2,2"/></svg>]]>
137                </svgDoc>
138                <svgDoc endGlyphID="4" startGlyphID="4">
139                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg"><path id="glyph4" d="M4,4"/></svg>]]>
140                </svgDoc>
141                <svgDoc endGlyphID="5" startGlyphID="5">
142                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg"><path id="glyph5" d="M5,5"/></svg>]]>
143                </svgDoc>
144                <svgDoc endGlyphID="6" startGlyphID="6">
145                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg"><path id="glyph6" d="M6,6"/></svg>]]>
146                </svgDoc>
147                """
148            ),
149        ),
150        # retain gids like above but with glyph id attribute in the root <svg> element itself
151        # https://github.com/fonttools/fonttools/issues/2548
152        (
153            simple_svg_table_glyph_ids_on_roots,
154            "2,4-6",
155            True,
156            _lines(
157                """\
158                <svgDoc endGlyphID="2" startGlyphID="2">
159                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" id="glyph2"><path d="M2,2"/></svg>]]>
160                </svgDoc>
161                <svgDoc endGlyphID="4" startGlyphID="4">
162                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" id="glyph4"><path d="M4,4"/></svg>]]>
163                </svgDoc>
164                <svgDoc endGlyphID="5" startGlyphID="5">
165                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" id="glyph5"><path d="M5,5"/></svg>]]>
166                </svgDoc>
167                <svgDoc endGlyphID="6" startGlyphID="6">
168                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" id="glyph6"><path d="M6,6"/></svg>]]>
169                </svgDoc>
170                """
171            ),
172        ),
173    ],
174)
175def test_subset_single_glyph_per_svg(
176    empty_svg_font, add_svg_table, tmp_path, gids, retain_gids, expected_xml
177):
178    font = add_svg_table(empty_svg_font)
179
180    svg_font_path = tmp_path / "TestSVG.ttf"
181    font.save(svg_font_path)
182
183    subset_path = svg_font_path.with_suffix(".subset.ttf")
184
185    subset.main(
186        [
187            str(svg_font_path),
188            f"--output-file={subset_path}",
189            f"--gids={gids}",
190            "--retain_gids" if retain_gids else "--no-retain_gids",
191        ]
192    )
193    subset_font = TTFont(subset_path)
194
195    assert getXML(subset_font["SVG "].toXML, subset_font) == expected_xml
196
197
198# This contains a bunch of cross-references between glyphs, paths, gradients, etc.
199# Note the path coordinates are completely made up and not meant to be rendered.
200# We only care about the tree structure, not it's visual content.
201COMPLEX_SVG = """\
202<svg xmlns="http://www.w3.org/2000/svg"
203     xmlns:xlink="http://www.w3.org/1999/xlink">
204  <defs>
205    <linearGradient id="lg1" x1="50" x2="50" y1="80" y2="80" gradientUnits="userSpaceOnUse">
206      <stop stop-color="#A47B62" offset="0"/>
207      <stop stop-color="#AD8264" offset="1.0"/>
208    </linearGradient>
209    <radialGradient id="rg2" cx="50" cy="50" r="10" gradientUnits="userSpaceOnUse">
210      <stop stop-color="#A47B62" offset="0"/>
211      <stop stop-color="#AD8264" offset="1.0"/>
212    </radialGradient>
213    <radialGradient id="rg3" xlink:href="#rg2" r="20"/>
214    <radialGradient id="rg4" xlink:href="#rg3" cy="100"/>
215    <path id="p1" d="M3,3"/>
216    <clipPath id="c1">
217      <circle cx="10" cy="10" r="1"/>
218    </clipPath>
219  </defs>
220  <g id="glyph1">
221    <g id="glyph2">
222      <path d="M0,0"/>
223    </g>
224    <g>
225      <path d="M1,1" fill="url(#lg1)"/>
226      <path d="M2,2"/>
227    </g>
228  </g>
229  <g id="glyph3">
230    <use xlink:href="#p1"/>
231  </g>
232  <use id="glyph4" xlink:href="#glyph1" x="10"/>
233  <use id="glyph5" xlink:href="#glyph2" y="-10"/>
234  <g id="glyph6">
235    <use xlink:href="#p1" transform="scale(2, 1)"/>
236  </g>
237  <g id="group1">
238    <g id="glyph7">
239      <path id="p2" d="M4,4"/>
240    </g>
241    <g id=".glyph7">
242      <path d="M4,4"/>
243    </g>
244    <g id="glyph8">
245      <g id=".glyph8">
246        <path id="p3" d="M5,5"/>
247        <path id="M6,6"/>
248      </g>
249      <path d="M7,7"/>
250    </g>
251    <g id="glyph9">
252      <use xlink:href="#p2"/>
253    </g>
254    <g id="glyph10">
255      <use xlink:href="#p3"/>
256    </g>
257  </g>
258  <g id="glyph11">
259    <path d="M7,7" fill="url(#rg4)"/>
260  </g>
261  <g id="glyph12">
262    <path d="M7,7" style="fill:url(#lg1);stroke:red;clip-path:url(#c1)"/>
263  </g>
264</svg>
265"""
266
267
268@pytest.mark.parametrize(
269    "subset_gids, expected_xml",
270    [
271        # we only keep gid=2, with 'glyph2' defined inside 'glyph1': 'glyph2'
272        # is renamed 'glyph1' to match the new subset indices, and the old 'glyph1'
273        # is kept (as it contains 'glyph2') but renamed '.glyph1' to avoid clash
274        (
275            "2",
276            _lines(
277                """\
278                <svgDoc endGlyphID="1" startGlyphID="1">
279                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
280                  <g id=".glyph1">
281                    <g id="glyph1">
282                      <path d="M0,0"/>
283                    </g>
284                  </g>
285                </svg>
286                ]]>
287                </svgDoc>
288                """
289            ),
290        ),
291        # we keep both gid 1 and 2: the glyph elements' ids stay as they are (only the
292        # range endGlyphID change); a gradient is kept since it's referenced by glyph1
293        (
294            "1,2",
295            _lines(
296                """\
297                <svgDoc endGlyphID="2" startGlyphID="1">
298                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
299                  <defs>
300                    <linearGradient id="lg1" x1="50" x2="50" y1="80" y2="80" gradientUnits="userSpaceOnUse">
301                      <stop stop-color="#A47B62" offset="0"/>
302                      <stop stop-color="#AD8264" offset="1.0"/>
303                    </linearGradient>
304                  </defs>
305                  <g id="glyph1">
306                    <g id="glyph2">
307                      <path d="M0,0"/>
308                    </g>
309                    <g>
310                      <path d="M1,1" fill="url(#lg1)"/>
311                      <path d="M2,2"/>
312                    </g>
313                  </g>
314                </svg>
315                ]]>
316                </svgDoc>
317                """
318            ),
319        ),
320        (
321            # both gid 3 and 6 refer (via <use xlink:href="#...") to path 'p1', which
322            # is thus kept in <defs>; the glyph ids and range start/end are renumbered.
323            "3,6",
324            _lines(
325                """\
326                <svgDoc endGlyphID="2" startGlyphID="1">
327                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
328                  <defs>
329                    <path id="p1" d="M3,3"/>
330                  </defs>
331                  <g id="glyph1">
332                    <use xlink:href="#p1"/>
333                  </g>
334                  <g id="glyph2">
335                    <use xlink:href="#p1" transform="scale(2, 1)"/>
336                  </g>
337                </svg>
338                ]]>
339                </svgDoc>
340                """
341            ),
342        ),
343        (
344            # 'glyph4' uses the whole 'glyph1' element (translated); we keep the latter
345            # renamed to avoid clashes with new gids
346            "3-4",
347            _lines(
348                """\
349                <svgDoc endGlyphID="2" startGlyphID="1">
350                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
351                  <defs>
352                    <linearGradient id="lg1" x1="50" x2="50" y1="80" y2="80" gradientUnits="userSpaceOnUse">
353                      <stop stop-color="#A47B62" offset="0"/>
354                      <stop stop-color="#AD8264" offset="1.0"/>
355                    </linearGradient>
356                    <path id="p1" d="M3,3"/>
357                  </defs>
358                  <g id=".glyph1">
359                    <g id=".glyph2">
360                      <path d="M0,0"/>
361                    </g>
362                    <g>
363                      <path d="M1,1" fill="url(#lg1)"/>
364                      <path d="M2,2"/>
365                    </g>
366                  </g>
367                  <g id="glyph1">
368                    <use xlink:href="#p1"/>
369                  </g>
370                  <use id="glyph2" xlink:href="#.glyph1" x="10"/>
371                </svg>
372                ]]>
373                </svgDoc>
374                """
375            ),
376        ),
377        (
378            # 'glyph9' uses a path 'p2' defined inside 'glyph7', the latter is excluded
379            # from our subset, thus gets renamed '.glyph7'; an unrelated element with
380            # same id=".glyph7" doesn't clash because it was dropped.
381            # Similarly 'glyph10' uses path 'p3' defined inside 'glyph8', also excluded
382            # from subset and prefixed with '.'. But since an id=".glyph8" is already
383            # used in the doc, we append a .{digit} suffix to disambiguate.
384            "9,10",
385            _lines(
386                """\
387                <svgDoc endGlyphID="2" startGlyphID="1">
388                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
389                  <g id="group1">
390                    <g id=".glyph7">
391                      <path id="p2" d="M4,4"/>
392                    </g>
393                    <g id=".glyph8.1">
394                      <g id=".glyph8">
395                        <path id="p3" d="M5,5"/>
396                      </g>
397                    </g>
398                    <g id="glyph1">
399                      <use xlink:href="#p2"/>
400                    </g>
401                    <g id="glyph2">
402                      <use xlink:href="#p3"/>
403                    </g>
404                  </g>
405                </svg>
406                ]]>
407                </svgDoc>
408                """
409            ),
410        ),
411        (
412            # 'glyph11' uses gradient 'rg4' which inherits from 'rg3', which inherits
413            # from 'rg2', etc.
414            "11",
415            _lines(
416                """\
417                <svgDoc endGlyphID="1" startGlyphID="1">
418                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
419                  <defs>
420                    <radialGradient id="rg2" cx="50" cy="50" r="10" gradientUnits="userSpaceOnUse">
421                      <stop stop-color="#A47B62" offset="0"/>
422                      <stop stop-color="#AD8264" offset="1.0"/>
423                    </radialGradient>
424                    <radialGradient id="rg3" xlink:href="#rg2" r="20"/>
425                    <radialGradient id="rg4" xlink:href="#rg3" cy="100"/>
426                  </defs>
427                  <g id="glyph1">
428                    <path d="M7,7" fill="url(#rg4)"/>
429                  </g>
430                </svg>
431                ]]>
432                </svgDoc>
433                """
434            ),
435        ),
436        (
437            # 'glyph12' contains a style attribute with inline CSS declarations that
438            # contains references to a gradient fill and a clipPath: we keep those
439            "12",
440            _lines(
441                """\
442                <svgDoc endGlyphID="1" startGlyphID="1">
443                  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
444                  <defs>
445                    <linearGradient id="lg1" x1="50" x2="50" y1="80" y2="80" gradientUnits="userSpaceOnUse">
446                      <stop stop-color="#A47B62" offset="0"/>
447                      <stop stop-color="#AD8264" offset="1.0"/>
448                    </linearGradient>
449                    <clipPath id="c1">
450                      <circle cx="10" cy="10" r="1"/>
451                    </clipPath>
452                  </defs>
453                  <g id="glyph1">
454                    <path d="M7,7" style="fill:url(#lg1);stroke:red;clip-path:url(#c1)"/>
455                  </g>
456                </svg>
457                ]]>
458                </svgDoc>
459                """
460            ),
461        ),
462    ],
463)
464def test_subset_svg_with_references(
465    empty_svg_font, tmp_path, subset_gids, expected_xml
466):
467    font = empty_svg_font
468
469    font["SVG "].docList.append((COMPLEX_SVG, 1, 12))
470    svg_font_path = tmp_path / "TestSVG.ttf"
471    font.save(svg_font_path)
472    subset_path = svg_font_path.with_suffix(".subset.ttf")
473
474    subset.main(
475        [
476            str(svg_font_path),
477            f"--output-file={subset_path}",
478            f"--gids={subset_gids}",
479            "--pretty-svg",
480        ]
481    )
482    subset_font = TTFont(subset_path)
483
484    if expected_xml is not None:
485        assert getXML(subset_font["SVG "].toXML, subset_font) == expected_xml
486    else:
487        assert "SVG " not in subset_font
488
489
490def test_subset_svg_empty_table(empty_svg_font, tmp_path):
491    font = empty_svg_font
492
493    svg = new_svg()
494    etree.SubElement(svg, "rect", {"id": "glyph1", "x": "1", "y": "2"})
495    font["SVG "].docList.append((etree.tostring(svg).decode(), 1, 1))
496
497    svg_font_path = tmp_path / "TestSVG.ttf"
498    font.save(svg_font_path)
499    subset_path = svg_font_path.with_suffix(".subset.ttf")
500
501    # there's no gid=2 in SVG table, drop the empty table
502    subset.main([str(svg_font_path), f"--output-file={subset_path}", f"--gids=2"])
503
504    assert "SVG " not in TTFont(subset_path)
505
506
507def test_subset_svg_missing_glyph(empty_svg_font, tmp_path):
508    font = empty_svg_font
509
510    svg = new_svg()
511    etree.SubElement(svg, "rect", {"id": "glyph1", "x": "1", "y": "2"})
512    font["SVG "].docList.append(
513        (
514            etree.tostring(svg).decode(),
515            1,
516            # the range endGlyphID=2 declares two glyphs however our svg contains
517            # only one glyph element with id="glyph1", the "glyph2" one is absent.
518            # Techically this would be invalid according to the OT-SVG spec.
519            2,
520        )
521    )
522    svg_font_path = tmp_path / "TestSVG.ttf"
523    font.save(svg_font_path)
524    subset_path = svg_font_path.with_suffix(".subset.ttf")
525
526    # make sure we don't crash when we don't find the expected "glyph2" element
527    subset.main([str(svg_font_path), f"--output-file={subset_path}", f"--gids=1"])
528
529    subset_font = TTFont(subset_path)
530    assert getXML(subset_font["SVG "].toXML, subset_font) == [
531        '<svgDoc endGlyphID="1" startGlyphID="1">',
532        '  <![CDATA[<svg xmlns="http://www.w3.org/2000/svg"><rect id="glyph1" x="1" y="2"/></svg>]]>',
533        "</svgDoc>",
534    ]
535
536    # ignore the missing gid even if included in the subset; in this test case we
537    # end up with an empty svg document--which is dropped, along with the empty table
538    subset.main([str(svg_font_path), f"--output-file={subset_path}", f"--gids=2"])
539
540    assert "SVG " not in TTFont(subset_path)
541
542
543@pytest.mark.parametrize(
544    "ints, expected_ranges",
545    [
546        ((), []),
547        ((0,), [(0, 0)]),
548        ((0, 1), [(0, 1)]),
549        ((1, 1, 1, 1), [(1, 1)]),
550        ((1, 3), [(1, 1), (3, 3)]),
551        ((4, 2, 1, 3), [(1, 4)]),
552        ((1, 2, 4, 5, 6, 9, 13, 14, 15), [(1, 2), (4, 6), (9, 9), (13, 15)]),
553    ],
554)
555def test_ranges(ints, expected_ranges):
556    assert list(ranges(ints)) == expected_ranges
557