Coverage for /usr/lib/python3/dist-packages/fontTools/subset/svg.py: 19%
141 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
1from __future__ import annotations
3import re
4from functools import lru_cache
5from itertools import chain, count
6from typing import Dict, Iterable, Iterator, List, Optional, Set, Tuple
8try:
9 from lxml import etree
10except ImportError:
11 # lxml is required for subsetting SVG, but we prefer to delay the import error
12 # until subset_glyphs() is called (i.e. if font to subset has an 'SVG ' table)
13 etree = None
15from fontTools import ttLib
16from fontTools.subset.util import _add_method
17from fontTools.ttLib.tables.S_V_G_ import SVGDocument
20__all__ = ["subset_glyphs"]
23GID_RE = re.compile(r"^glyph(\d+)$")
25NAMESPACES = {
26 "svg": "http://www.w3.org/2000/svg",
27 "xlink": "http://www.w3.org/1999/xlink",
28}
29XLINK_HREF = f'{{{NAMESPACES["xlink"]}}}href'
32# TODO(antrotype): Replace with functools.cache once we are 3.9+
33@lru_cache(maxsize=None)
34def xpath(path):
35 # compile XPath upfront, caching result to reuse on multiple elements
36 return etree.XPath(path, namespaces=NAMESPACES)
39def group_elements_by_id(tree: etree.Element) -> Dict[str, etree.Element]:
40 # select all svg elements with 'id' attribute no matter where they are
41 # including the root element itself:
42 # https://github.com/fonttools/fonttools/issues/2548
43 return {el.attrib["id"]: el for el in xpath("//svg:*[@id]")(tree)}
46def parse_css_declarations(style_attr: str) -> Dict[str, str]:
47 # https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/style
48 # https://developer.mozilla.org/en-US/docs/Web/CSS/Syntax#css_declarations
49 result = {}
50 for declaration in style_attr.split(";"):
51 if declaration.count(":") == 1:
52 property_name, value = declaration.split(":")
53 property_name = property_name.strip()
54 result[property_name] = value.strip()
55 elif declaration.strip():
56 raise ValueError(f"Invalid CSS declaration syntax: {declaration}")
57 return result
60def iter_referenced_ids(tree: etree.Element) -> Iterator[str]:
61 # Yield all the ids that can be reached via references from this element tree.
62 # We currently support xlink:href (as used by <use> and gradient templates),
63 # and local url(#...) links found in fill or clip-path attributes
64 # TODO(anthrotype): Check we aren't missing other supported kinds of reference
65 find_svg_elements_with_references = xpath(
66 ".//svg:*[ "
67 "starts-with(@xlink:href, '#') "
68 "or starts-with(@fill, 'url(#') "
69 "or starts-with(@clip-path, 'url(#') "
70 "or contains(@style, ':url(#') "
71 "]",
72 )
73 for el in chain([tree], find_svg_elements_with_references(tree)):
74 ref_id = href_local_target(el)
75 if ref_id is not None:
76 yield ref_id
78 attrs = el.attrib
79 if "style" in attrs:
80 attrs = {**dict(attrs), **parse_css_declarations(el.attrib["style"])}
81 for attr in ("fill", "clip-path"):
82 if attr in attrs:
83 value = attrs[attr]
84 if value.startswith("url(#") and value.endswith(")"):
85 ref_id = value[5:-1]
86 assert ref_id
87 yield ref_id
90def closure_element_ids(
91 elements: Dict[str, etree.Element], element_ids: Set[str]
92) -> None:
93 # Expand the initial subset of element ids to include ids that can be reached
94 # via references from the initial set.
95 unvisited = element_ids
96 while unvisited:
97 referenced: Set[str] = set()
98 for el_id in unvisited:
99 if el_id not in elements:
100 # ignore dangling reference; not our job to validate svg
101 continue
102 referenced.update(iter_referenced_ids(elements[el_id]))
103 referenced -= element_ids
104 element_ids.update(referenced)
105 unvisited = referenced
108def subset_elements(el: etree.Element, retained_ids: Set[str]) -> bool:
109 # Keep elements if their id is in the subset, or any of their children's id is.
110 # Drop elements whose id is not in the subset, and either have no children,
111 # or all their children are being dropped.
112 if el.attrib.get("id") in retained_ids:
113 # if id is in the set, don't recurse; keep whole subtree
114 return True
115 # recursively subset all the children; we use a list comprehension instead
116 # of a parentheses-less generator expression because we don't want any() to
117 # short-circuit, as our function has a side effect of dropping empty elements.
118 if any([subset_elements(e, retained_ids) for e in el]):
119 return True
120 assert len(el) == 0
121 parent = el.getparent()
122 if parent is not None:
123 parent.remove(el)
124 return False
127def remap_glyph_ids(
128 svg: etree.Element, glyph_index_map: Dict[int, int]
129) -> Dict[str, str]:
130 # Given {old_gid: new_gid} map, rename all elements containing id="glyph{gid}"
131 # special attributes
132 elements = group_elements_by_id(svg)
133 id_map = {}
134 for el_id, el in elements.items():
135 m = GID_RE.match(el_id)
136 if not m:
137 continue
138 old_index = int(m.group(1))
139 new_index = glyph_index_map.get(old_index)
140 if new_index is not None:
141 if old_index == new_index:
142 continue
143 new_id = f"glyph{new_index}"
144 else:
145 # If the old index is missing, the element correspond to a glyph that was
146 # excluded from the font's subset.
147 # We rename it to avoid clashes with the new GIDs or other element ids.
148 new_id = f".{el_id}"
149 n = count(1)
150 while new_id in elements:
151 new_id = f"{new_id}.{next(n)}"
153 id_map[el_id] = new_id
154 el.attrib["id"] = new_id
156 return id_map
159def href_local_target(el: etree.Element) -> Optional[str]:
160 if XLINK_HREF in el.attrib:
161 href = el.attrib[XLINK_HREF]
162 if href.startswith("#") and len(href) > 1:
163 return href[1:] # drop the leading #
164 return None
167def update_glyph_href_links(svg: etree.Element, id_map: Dict[str, str]) -> None:
168 # update all xlink:href="#glyph..." attributes to point to the new glyph ids
169 for el in xpath(".//svg:*[starts-with(@xlink:href, '#glyph')]")(svg):
170 old_id = href_local_target(el)
171 assert old_id is not None
172 if old_id in id_map:
173 new_id = id_map[old_id]
174 el.attrib[XLINK_HREF] = f"#{new_id}"
177def ranges(ints: Iterable[int]) -> Iterator[Tuple[int, int]]:
178 # Yield sorted, non-overlapping (min, max) ranges of consecutive integers
179 sorted_ints = iter(sorted(set(ints)))
180 try:
181 start = end = next(sorted_ints)
182 except StopIteration:
183 return
184 for v in sorted_ints:
185 if v - 1 == end:
186 end = v
187 else:
188 yield (start, end)
189 start = end = v
190 yield (start, end)
193@_add_method(ttLib.getTableClass("SVG "))
194def subset_glyphs(self, s) -> bool:
195 if etree is None:
196 raise ImportError("No module named 'lxml', required to subset SVG")
198 # glyph names (before subsetting)
199 glyph_order: List[str] = s.orig_glyph_order
200 # map from glyph names to original glyph indices
201 rev_orig_glyph_map: Dict[str, int] = s.reverseOrigGlyphMap
202 # map from original to new glyph indices (after subsetting)
203 glyph_index_map: Dict[int, int] = s.glyph_index_map
205 new_docs: List[SVGDocument] = []
206 for doc in self.docList:
207 glyphs = {
208 glyph_order[i] for i in range(doc.startGlyphID, doc.endGlyphID + 1)
209 }.intersection(s.glyphs)
210 if not glyphs:
211 # no intersection: we can drop the whole record
212 continue
214 svg = etree.fromstring(
215 # encode because fromstring dislikes xml encoding decl if input is str.
216 # SVG xml encoding must be utf-8 as per OT spec.
217 doc.data.encode("utf-8"),
218 parser=etree.XMLParser(
219 # Disable libxml2 security restrictions to support very deep trees.
220 # Without this we would get an error like this:
221 # `lxml.etree.XMLSyntaxError: internal error: Huge input lookup`
222 # when parsing big fonts e.g. noto-emoji-picosvg.ttf.
223 huge_tree=True,
224 # ignore blank text as it's not meaningful in OT-SVG; it also prevents
225 # dangling tail text after removing an element when pretty_print=True
226 remove_blank_text=True,
227 # don't replace entities; we don't expect any in OT-SVG and they may
228 # be abused for XXE attacks
229 resolve_entities=False,
230 ),
231 )
233 elements = group_elements_by_id(svg)
234 gids = {rev_orig_glyph_map[g] for g in glyphs}
235 element_ids = {f"glyph{i}" for i in gids}
236 closure_element_ids(elements, element_ids)
238 if not subset_elements(svg, element_ids):
239 continue
241 if not s.options.retain_gids:
242 id_map = remap_glyph_ids(svg, glyph_index_map)
243 update_glyph_href_links(svg, id_map)
245 new_doc = etree.tostring(svg, pretty_print=s.options.pretty_svg).decode("utf-8")
247 new_gids = (glyph_index_map[i] for i in gids)
248 for start, end in ranges(new_gids):
249 new_docs.append(SVGDocument(new_doc, start, end, doc.compressed))
251 self.docList = new_docs
253 return bool(self.docList)