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