backend_svg.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368
  1. import base64
  2. import codecs
  3. import datetime
  4. import gzip
  5. import hashlib
  6. from io import BytesIO
  7. import itertools
  8. import logging
  9. import os
  10. import re
  11. import uuid
  12. import numpy as np
  13. from PIL import Image
  14. import matplotlib as mpl
  15. from matplotlib import cbook, font_manager as fm
  16. from matplotlib.backend_bases import (
  17. _Backend, FigureCanvasBase, FigureManagerBase, RendererBase)
  18. from matplotlib.backends.backend_mixed import MixedModeRenderer
  19. from matplotlib.colors import rgb2hex
  20. from matplotlib.dates import UTC
  21. from matplotlib.path import Path
  22. from matplotlib import _path
  23. from matplotlib.transforms import Affine2D, Affine2DBase
  24. _log = logging.getLogger(__name__)
  25. # ----------------------------------------------------------------------
  26. # SimpleXMLWriter class
  27. #
  28. # Based on an original by Fredrik Lundh, but modified here to:
  29. # 1. Support modern Python idioms
  30. # 2. Remove encoding support (it's handled by the file writer instead)
  31. # 3. Support proper indentation
  32. # 4. Minify things a little bit
  33. # --------------------------------------------------------------------
  34. # The SimpleXMLWriter module is
  35. #
  36. # Copyright (c) 2001-2004 by Fredrik Lundh
  37. #
  38. # By obtaining, using, and/or copying this software and/or its
  39. # associated documentation, you agree that you have read, understood,
  40. # and will comply with the following terms and conditions:
  41. #
  42. # Permission to use, copy, modify, and distribute this software and
  43. # its associated documentation for any purpose and without fee is
  44. # hereby granted, provided that the above copyright notice appears in
  45. # all copies, and that both that copyright notice and this permission
  46. # notice appear in supporting documentation, and that the name of
  47. # Secret Labs AB or the author not be used in advertising or publicity
  48. # pertaining to distribution of the software without specific, written
  49. # prior permission.
  50. #
  51. # SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD
  52. # TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT-
  53. # ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR
  54. # BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY
  55. # DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
  56. # WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS
  57. # ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE
  58. # OF THIS SOFTWARE.
  59. # --------------------------------------------------------------------
  60. def _escape_cdata(s):
  61. s = s.replace("&", "&")
  62. s = s.replace("<", "&lt;")
  63. s = s.replace(">", "&gt;")
  64. return s
  65. _escape_xml_comment = re.compile(r'-(?=-)')
  66. def _escape_comment(s):
  67. s = _escape_cdata(s)
  68. return _escape_xml_comment.sub('- ', s)
  69. def _escape_attrib(s):
  70. s = s.replace("&", "&amp;")
  71. s = s.replace("'", "&apos;")
  72. s = s.replace('"', "&quot;")
  73. s = s.replace("<", "&lt;")
  74. s = s.replace(">", "&gt;")
  75. return s
  76. def _quote_escape_attrib(s):
  77. return ('"' + _escape_cdata(s) + '"' if '"' not in s else
  78. "'" + _escape_cdata(s) + "'" if "'" not in s else
  79. '"' + _escape_attrib(s) + '"')
  80. def _short_float_fmt(x):
  81. """
  82. Create a short string representation of a float, which is %f
  83. formatting with trailing zeros and the decimal point removed.
  84. """
  85. return f'{x:f}'.rstrip('0').rstrip('.')
  86. class XMLWriter:
  87. """
  88. Parameters
  89. ----------
  90. file : writable text file-like object
  91. """
  92. def __init__(self, file):
  93. self.__write = file.write
  94. if hasattr(file, "flush"):
  95. self.flush = file.flush
  96. self.__open = 0 # true if start tag is open
  97. self.__tags = []
  98. self.__data = []
  99. self.__indentation = " " * 64
  100. def __flush(self, indent=True):
  101. # flush internal buffers
  102. if self.__open:
  103. if indent:
  104. self.__write(">\n")
  105. else:
  106. self.__write(">")
  107. self.__open = 0
  108. if self.__data:
  109. data = ''.join(self.__data)
  110. self.__write(_escape_cdata(data))
  111. self.__data = []
  112. def start(self, tag, attrib={}, **extra):
  113. """
  114. Open a new element. Attributes can be given as keyword
  115. arguments, or as a string/string dictionary. The method returns
  116. an opaque identifier that can be passed to the :meth:`close`
  117. method, to close all open elements up to and including this one.
  118. Parameters
  119. ----------
  120. tag
  121. Element tag.
  122. attrib
  123. Attribute dictionary. Alternatively, attributes can be given as
  124. keyword arguments.
  125. Returns
  126. -------
  127. An element identifier.
  128. """
  129. self.__flush()
  130. tag = _escape_cdata(tag)
  131. self.__data = []
  132. self.__tags.append(tag)
  133. self.__write(self.__indentation[:len(self.__tags) - 1])
  134. self.__write(f"<{tag}")
  135. for k, v in {**attrib, **extra}.items():
  136. if v:
  137. k = _escape_cdata(k)
  138. v = _quote_escape_attrib(v)
  139. self.__write(f' {k}={v}')
  140. self.__open = 1
  141. return len(self.__tags) - 1
  142. def comment(self, comment):
  143. """
  144. Add a comment to the output stream.
  145. Parameters
  146. ----------
  147. comment : str
  148. Comment text.
  149. """
  150. self.__flush()
  151. self.__write(self.__indentation[:len(self.__tags)])
  152. self.__write(f"<!-- {_escape_comment(comment)} -->\n")
  153. def data(self, text):
  154. """
  155. Add character data to the output stream.
  156. Parameters
  157. ----------
  158. text : str
  159. Character data.
  160. """
  161. self.__data.append(text)
  162. def end(self, tag=None, indent=True):
  163. """
  164. Close the current element (opened by the most recent call to
  165. :meth:`start`).
  166. Parameters
  167. ----------
  168. tag
  169. Element tag. If given, the tag must match the start tag. If
  170. omitted, the current element is closed.
  171. indent : bool, default: True
  172. """
  173. if tag:
  174. assert self.__tags, f"unbalanced end({tag})"
  175. assert _escape_cdata(tag) == self.__tags[-1], \
  176. f"expected end({self.__tags[-1]}), got {tag}"
  177. else:
  178. assert self.__tags, "unbalanced end()"
  179. tag = self.__tags.pop()
  180. if self.__data:
  181. self.__flush(indent)
  182. elif self.__open:
  183. self.__open = 0
  184. self.__write("/>\n")
  185. return
  186. if indent:
  187. self.__write(self.__indentation[:len(self.__tags)])
  188. self.__write(f"</{tag}>\n")
  189. def close(self, id):
  190. """
  191. Close open elements, up to (and including) the element identified
  192. by the given identifier.
  193. Parameters
  194. ----------
  195. id
  196. Element identifier, as returned by the :meth:`start` method.
  197. """
  198. while len(self.__tags) > id:
  199. self.end()
  200. def element(self, tag, text=None, attrib={}, **extra):
  201. """
  202. Add an entire element. This is the same as calling :meth:`start`,
  203. :meth:`data`, and :meth:`end` in sequence. The *text* argument can be
  204. omitted.
  205. """
  206. self.start(tag, attrib, **extra)
  207. if text:
  208. self.data(text)
  209. self.end(indent=False)
  210. def flush(self):
  211. """Flush the output stream."""
  212. pass # replaced by the constructor
  213. def _generate_transform(transform_list):
  214. parts = []
  215. for type, value in transform_list:
  216. if (type == 'scale' and (value == (1,) or value == (1, 1))
  217. or type == 'translate' and value == (0, 0)
  218. or type == 'rotate' and value == (0,)):
  219. continue
  220. if type == 'matrix' and isinstance(value, Affine2DBase):
  221. value = value.to_values()
  222. parts.append('{}({})'.format(
  223. type, ' '.join(_short_float_fmt(x) for x in value)))
  224. return ' '.join(parts)
  225. def _generate_css(attrib):
  226. return "; ".join(f"{k}: {v}" for k, v in attrib.items())
  227. _capstyle_d = {'projecting': 'square', 'butt': 'butt', 'round': 'round'}
  228. def _check_is_str(info, key):
  229. if not isinstance(info, str):
  230. raise TypeError(f'Invalid type for {key} metadata. Expected str, not '
  231. f'{type(info)}.')
  232. def _check_is_iterable_of_str(infos, key):
  233. if np.iterable(infos):
  234. for info in infos:
  235. if not isinstance(info, str):
  236. raise TypeError(f'Invalid type for {key} metadata. Expected '
  237. f'iterable of str, not {type(info)}.')
  238. else:
  239. raise TypeError(f'Invalid type for {key} metadata. Expected str or '
  240. f'iterable of str, not {type(infos)}.')
  241. class RendererSVG(RendererBase):
  242. def __init__(self, width, height, svgwriter, basename=None, image_dpi=72,
  243. *, metadata=None):
  244. self.width = width
  245. self.height = height
  246. self.writer = XMLWriter(svgwriter)
  247. self.image_dpi = image_dpi # actual dpi at which we rasterize stuff
  248. if basename is None:
  249. basename = getattr(svgwriter, "name", "")
  250. if not isinstance(basename, str):
  251. basename = ""
  252. self.basename = basename
  253. self._groupd = {}
  254. self._image_counter = itertools.count()
  255. self._clipd = {}
  256. self._markers = {}
  257. self._path_collection_id = 0
  258. self._hatchd = {}
  259. self._has_gouraud = False
  260. self._n_gradients = 0
  261. super().__init__()
  262. self._glyph_map = dict()
  263. str_height = _short_float_fmt(height)
  264. str_width = _short_float_fmt(width)
  265. svgwriter.write(svgProlog)
  266. self._start_id = self.writer.start(
  267. 'svg',
  268. width=f'{str_width}pt',
  269. height=f'{str_height}pt',
  270. viewBox=f'0 0 {str_width} {str_height}',
  271. xmlns="http://www.w3.org/2000/svg",
  272. version="1.1",
  273. attrib={'xmlns:xlink': "http://www.w3.org/1999/xlink"})
  274. self._write_metadata(metadata)
  275. self._write_default_style()
  276. def finalize(self):
  277. self._write_clips()
  278. self._write_hatches()
  279. self.writer.close(self._start_id)
  280. self.writer.flush()
  281. def _write_metadata(self, metadata):
  282. # Add metadata following the Dublin Core Metadata Initiative, and the
  283. # Creative Commons Rights Expression Language. This is mainly for
  284. # compatibility with Inkscape.
  285. if metadata is None:
  286. metadata = {}
  287. metadata = {
  288. 'Format': 'image/svg+xml',
  289. 'Type': 'http://purl.org/dc/dcmitype/StillImage',
  290. 'Creator':
  291. f'Matplotlib v{mpl.__version__}, https://matplotlib.org/',
  292. **metadata
  293. }
  294. writer = self.writer
  295. if 'Title' in metadata:
  296. title = metadata['Title']
  297. _check_is_str(title, 'Title')
  298. writer.element('title', text=title)
  299. # Special handling.
  300. date = metadata.get('Date', None)
  301. if date is not None:
  302. if isinstance(date, str):
  303. dates = [date]
  304. elif isinstance(date, (datetime.datetime, datetime.date)):
  305. dates = [date.isoformat()]
  306. elif np.iterable(date):
  307. dates = []
  308. for d in date:
  309. if isinstance(d, str):
  310. dates.append(d)
  311. elif isinstance(d, (datetime.datetime, datetime.date)):
  312. dates.append(d.isoformat())
  313. else:
  314. raise TypeError(
  315. f'Invalid type for Date metadata. '
  316. f'Expected iterable of str, date, or datetime, '
  317. f'not {type(d)}.')
  318. else:
  319. raise TypeError(f'Invalid type for Date metadata. '
  320. f'Expected str, date, datetime, or iterable '
  321. f'of the same, not {type(date)}.')
  322. metadata['Date'] = '/'.join(dates)
  323. elif 'Date' not in metadata:
  324. # Do not add `Date` if the user explicitly set `Date` to `None`
  325. # Get source date from SOURCE_DATE_EPOCH, if set.
  326. # See https://reproducible-builds.org/specs/source-date-epoch/
  327. date = os.getenv("SOURCE_DATE_EPOCH")
  328. if date:
  329. date = datetime.datetime.fromtimestamp(int(date), datetime.timezone.utc)
  330. metadata['Date'] = date.replace(tzinfo=UTC).isoformat()
  331. else:
  332. metadata['Date'] = datetime.datetime.today().isoformat()
  333. mid = None
  334. def ensure_metadata(mid):
  335. if mid is not None:
  336. return mid
  337. mid = writer.start('metadata')
  338. writer.start('rdf:RDF', attrib={
  339. 'xmlns:dc': "http://purl.org/dc/elements/1.1/",
  340. 'xmlns:cc': "http://creativecommons.org/ns#",
  341. 'xmlns:rdf': "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
  342. })
  343. writer.start('cc:Work')
  344. return mid
  345. uri = metadata.pop('Type', None)
  346. if uri is not None:
  347. mid = ensure_metadata(mid)
  348. writer.element('dc:type', attrib={'rdf:resource': uri})
  349. # Single value only.
  350. for key in ['Title', 'Coverage', 'Date', 'Description', 'Format',
  351. 'Identifier', 'Language', 'Relation', 'Source']:
  352. info = metadata.pop(key, None)
  353. if info is not None:
  354. mid = ensure_metadata(mid)
  355. _check_is_str(info, key)
  356. writer.element(f'dc:{key.lower()}', text=info)
  357. # Multiple Agent values.
  358. for key in ['Creator', 'Contributor', 'Publisher', 'Rights']:
  359. agents = metadata.pop(key, None)
  360. if agents is None:
  361. continue
  362. if isinstance(agents, str):
  363. agents = [agents]
  364. _check_is_iterable_of_str(agents, key)
  365. # Now we know that we have an iterable of str
  366. mid = ensure_metadata(mid)
  367. writer.start(f'dc:{key.lower()}')
  368. for agent in agents:
  369. writer.start('cc:Agent')
  370. writer.element('dc:title', text=agent)
  371. writer.end('cc:Agent')
  372. writer.end(f'dc:{key.lower()}')
  373. # Multiple values.
  374. keywords = metadata.pop('Keywords', None)
  375. if keywords is not None:
  376. if isinstance(keywords, str):
  377. keywords = [keywords]
  378. _check_is_iterable_of_str(keywords, 'Keywords')
  379. # Now we know that we have an iterable of str
  380. mid = ensure_metadata(mid)
  381. writer.start('dc:subject')
  382. writer.start('rdf:Bag')
  383. for keyword in keywords:
  384. writer.element('rdf:li', text=keyword)
  385. writer.end('rdf:Bag')
  386. writer.end('dc:subject')
  387. if mid is not None:
  388. writer.close(mid)
  389. if metadata:
  390. raise ValueError('Unknown metadata key(s) passed to SVG writer: ' +
  391. ','.join(metadata))
  392. def _write_default_style(self):
  393. writer = self.writer
  394. default_style = _generate_css({
  395. 'stroke-linejoin': 'round',
  396. 'stroke-linecap': 'butt'})
  397. writer.start('defs')
  398. writer.element('style', type='text/css', text='*{%s}' % default_style)
  399. writer.end('defs')
  400. def _make_id(self, type, content):
  401. salt = mpl.rcParams['svg.hashsalt']
  402. if salt is None:
  403. salt = str(uuid.uuid4())
  404. m = hashlib.sha256()
  405. m.update(salt.encode('utf8'))
  406. m.update(str(content).encode('utf8'))
  407. return f'{type}{m.hexdigest()[:10]}'
  408. def _make_flip_transform(self, transform):
  409. return transform + Affine2D().scale(1, -1).translate(0, self.height)
  410. def _get_hatch(self, gc, rgbFace):
  411. """
  412. Create a new hatch pattern
  413. """
  414. if rgbFace is not None:
  415. rgbFace = tuple(rgbFace)
  416. edge = gc.get_hatch_color()
  417. if edge is not None:
  418. edge = tuple(edge)
  419. dictkey = (gc.get_hatch(), rgbFace, edge)
  420. oid = self._hatchd.get(dictkey)
  421. if oid is None:
  422. oid = self._make_id('h', dictkey)
  423. self._hatchd[dictkey] = ((gc.get_hatch_path(), rgbFace, edge), oid)
  424. else:
  425. _, oid = oid
  426. return oid
  427. def _write_hatches(self):
  428. if not len(self._hatchd):
  429. return
  430. HATCH_SIZE = 72
  431. writer = self.writer
  432. writer.start('defs')
  433. for (path, face, stroke), oid in self._hatchd.values():
  434. writer.start(
  435. 'pattern',
  436. id=oid,
  437. patternUnits="userSpaceOnUse",
  438. x="0", y="0", width=str(HATCH_SIZE),
  439. height=str(HATCH_SIZE))
  440. path_data = self._convert_path(
  441. path,
  442. Affine2D()
  443. .scale(HATCH_SIZE).scale(1.0, -1.0).translate(0, HATCH_SIZE),
  444. simplify=False)
  445. if face is None:
  446. fill = 'none'
  447. else:
  448. fill = rgb2hex(face)
  449. writer.element(
  450. 'rect',
  451. x="0", y="0", width=str(HATCH_SIZE+1),
  452. height=str(HATCH_SIZE+1),
  453. fill=fill)
  454. hatch_style = {
  455. 'fill': rgb2hex(stroke),
  456. 'stroke': rgb2hex(stroke),
  457. 'stroke-width': str(mpl.rcParams['hatch.linewidth']),
  458. 'stroke-linecap': 'butt',
  459. 'stroke-linejoin': 'miter'
  460. }
  461. if stroke[3] < 1:
  462. hatch_style['stroke-opacity'] = str(stroke[3])
  463. writer.element(
  464. 'path',
  465. d=path_data,
  466. style=_generate_css(hatch_style)
  467. )
  468. writer.end('pattern')
  469. writer.end('defs')
  470. def _get_style_dict(self, gc, rgbFace):
  471. """Generate a style string from the GraphicsContext and rgbFace."""
  472. attrib = {}
  473. forced_alpha = gc.get_forced_alpha()
  474. if gc.get_hatch() is not None:
  475. attrib['fill'] = f"url(#{self._get_hatch(gc, rgbFace)})"
  476. if (rgbFace is not None and len(rgbFace) == 4 and rgbFace[3] != 1.0
  477. and not forced_alpha):
  478. attrib['fill-opacity'] = _short_float_fmt(rgbFace[3])
  479. else:
  480. if rgbFace is None:
  481. attrib['fill'] = 'none'
  482. else:
  483. if tuple(rgbFace[:3]) != (0, 0, 0):
  484. attrib['fill'] = rgb2hex(rgbFace)
  485. if (len(rgbFace) == 4 and rgbFace[3] != 1.0
  486. and not forced_alpha):
  487. attrib['fill-opacity'] = _short_float_fmt(rgbFace[3])
  488. if forced_alpha and gc.get_alpha() != 1.0:
  489. attrib['opacity'] = _short_float_fmt(gc.get_alpha())
  490. offset, seq = gc.get_dashes()
  491. if seq is not None:
  492. attrib['stroke-dasharray'] = ','.join(
  493. _short_float_fmt(val) for val in seq)
  494. attrib['stroke-dashoffset'] = _short_float_fmt(float(offset))
  495. linewidth = gc.get_linewidth()
  496. if linewidth:
  497. rgb = gc.get_rgb()
  498. attrib['stroke'] = rgb2hex(rgb)
  499. if not forced_alpha and rgb[3] != 1.0:
  500. attrib['stroke-opacity'] = _short_float_fmt(rgb[3])
  501. if linewidth != 1.0:
  502. attrib['stroke-width'] = _short_float_fmt(linewidth)
  503. if gc.get_joinstyle() != 'round':
  504. attrib['stroke-linejoin'] = gc.get_joinstyle()
  505. if gc.get_capstyle() != 'butt':
  506. attrib['stroke-linecap'] = _capstyle_d[gc.get_capstyle()]
  507. return attrib
  508. def _get_style(self, gc, rgbFace):
  509. return _generate_css(self._get_style_dict(gc, rgbFace))
  510. def _get_clip_attrs(self, gc):
  511. cliprect = gc.get_clip_rectangle()
  512. clippath, clippath_trans = gc.get_clip_path()
  513. if clippath is not None:
  514. clippath_trans = self._make_flip_transform(clippath_trans)
  515. dictkey = (id(clippath), str(clippath_trans))
  516. elif cliprect is not None:
  517. x, y, w, h = cliprect.bounds
  518. y = self.height-(y+h)
  519. dictkey = (x, y, w, h)
  520. else:
  521. return {}
  522. clip = self._clipd.get(dictkey)
  523. if clip is None:
  524. oid = self._make_id('p', dictkey)
  525. if clippath is not None:
  526. self._clipd[dictkey] = ((clippath, clippath_trans), oid)
  527. else:
  528. self._clipd[dictkey] = (dictkey, oid)
  529. else:
  530. clip, oid = clip
  531. return {'clip-path': f'url(#{oid})'}
  532. def _write_clips(self):
  533. if not len(self._clipd):
  534. return
  535. writer = self.writer
  536. writer.start('defs')
  537. for clip, oid in self._clipd.values():
  538. writer.start('clipPath', id=oid)
  539. if len(clip) == 2:
  540. clippath, clippath_trans = clip
  541. path_data = self._convert_path(
  542. clippath, clippath_trans, simplify=False)
  543. writer.element('path', d=path_data)
  544. else:
  545. x, y, w, h = clip
  546. writer.element(
  547. 'rect',
  548. x=_short_float_fmt(x),
  549. y=_short_float_fmt(y),
  550. width=_short_float_fmt(w),
  551. height=_short_float_fmt(h))
  552. writer.end('clipPath')
  553. writer.end('defs')
  554. def open_group(self, s, gid=None):
  555. # docstring inherited
  556. if gid:
  557. self.writer.start('g', id=gid)
  558. else:
  559. self._groupd[s] = self._groupd.get(s, 0) + 1
  560. self.writer.start('g', id=f"{s}_{self._groupd[s]:d}")
  561. def close_group(self, s):
  562. # docstring inherited
  563. self.writer.end('g')
  564. def option_image_nocomposite(self):
  565. # docstring inherited
  566. return not mpl.rcParams['image.composite_image']
  567. def _convert_path(self, path, transform=None, clip=None, simplify=None,
  568. sketch=None):
  569. if clip:
  570. clip = (0.0, 0.0, self.width, self.height)
  571. else:
  572. clip = None
  573. return _path.convert_to_string(
  574. path, transform, clip, simplify, sketch, 6,
  575. [b'M', b'L', b'Q', b'C', b'z'], False).decode('ascii')
  576. def draw_path(self, gc, path, transform, rgbFace=None):
  577. # docstring inherited
  578. trans_and_flip = self._make_flip_transform(transform)
  579. clip = (rgbFace is None and gc.get_hatch_path() is None)
  580. simplify = path.should_simplify and clip
  581. path_data = self._convert_path(
  582. path, trans_and_flip, clip=clip, simplify=simplify,
  583. sketch=gc.get_sketch_params())
  584. if gc.get_url() is not None:
  585. self.writer.start('a', {'xlink:href': gc.get_url()})
  586. self.writer.element('path', d=path_data, **self._get_clip_attrs(gc),
  587. style=self._get_style(gc, rgbFace))
  588. if gc.get_url() is not None:
  589. self.writer.end('a')
  590. def draw_markers(
  591. self, gc, marker_path, marker_trans, path, trans, rgbFace=None):
  592. # docstring inherited
  593. if not len(path.vertices):
  594. return
  595. writer = self.writer
  596. path_data = self._convert_path(
  597. marker_path,
  598. marker_trans + Affine2D().scale(1.0, -1.0),
  599. simplify=False)
  600. style = self._get_style_dict(gc, rgbFace)
  601. dictkey = (path_data, _generate_css(style))
  602. oid = self._markers.get(dictkey)
  603. style = _generate_css({k: v for k, v in style.items()
  604. if k.startswith('stroke')})
  605. if oid is None:
  606. oid = self._make_id('m', dictkey)
  607. writer.start('defs')
  608. writer.element('path', id=oid, d=path_data, style=style)
  609. writer.end('defs')
  610. self._markers[dictkey] = oid
  611. writer.start('g', **self._get_clip_attrs(gc))
  612. trans_and_flip = self._make_flip_transform(trans)
  613. attrib = {'xlink:href': f'#{oid}'}
  614. clip = (0, 0, self.width*72, self.height*72)
  615. for vertices, code in path.iter_segments(
  616. trans_and_flip, clip=clip, simplify=False):
  617. if len(vertices):
  618. x, y = vertices[-2:]
  619. attrib['x'] = _short_float_fmt(x)
  620. attrib['y'] = _short_float_fmt(y)
  621. attrib['style'] = self._get_style(gc, rgbFace)
  622. writer.element('use', attrib=attrib)
  623. writer.end('g')
  624. def draw_path_collection(self, gc, master_transform, paths, all_transforms,
  625. offsets, offset_trans, facecolors, edgecolors,
  626. linewidths, linestyles, antialiaseds, urls,
  627. offset_position):
  628. # Is the optimization worth it? Rough calculation:
  629. # cost of emitting a path in-line is
  630. # (len_path + 5) * uses_per_path
  631. # cost of definition+use is
  632. # (len_path + 3) + 9 * uses_per_path
  633. len_path = len(paths[0].vertices) if len(paths) > 0 else 0
  634. uses_per_path = self._iter_collection_uses_per_path(
  635. paths, all_transforms, offsets, facecolors, edgecolors)
  636. should_do_optimization = \
  637. len_path + 9 * uses_per_path + 3 < (len_path + 5) * uses_per_path
  638. if not should_do_optimization:
  639. return super().draw_path_collection(
  640. gc, master_transform, paths, all_transforms,
  641. offsets, offset_trans, facecolors, edgecolors,
  642. linewidths, linestyles, antialiaseds, urls,
  643. offset_position)
  644. writer = self.writer
  645. path_codes = []
  646. writer.start('defs')
  647. for i, (path, transform) in enumerate(self._iter_collection_raw_paths(
  648. master_transform, paths, all_transforms)):
  649. transform = Affine2D(transform.get_matrix()).scale(1.0, -1.0)
  650. d = self._convert_path(path, transform, simplify=False)
  651. oid = 'C{:x}_{:x}_{}'.format(
  652. self._path_collection_id, i, self._make_id('', d))
  653. writer.element('path', id=oid, d=d)
  654. path_codes.append(oid)
  655. writer.end('defs')
  656. for xo, yo, path_id, gc0, rgbFace in self._iter_collection(
  657. gc, path_codes, offsets, offset_trans,
  658. facecolors, edgecolors, linewidths, linestyles,
  659. antialiaseds, urls, offset_position):
  660. url = gc0.get_url()
  661. if url is not None:
  662. writer.start('a', attrib={'xlink:href': url})
  663. clip_attrs = self._get_clip_attrs(gc0)
  664. if clip_attrs:
  665. writer.start('g', **clip_attrs)
  666. attrib = {
  667. 'xlink:href': f'#{path_id}',
  668. 'x': _short_float_fmt(xo),
  669. 'y': _short_float_fmt(self.height - yo),
  670. 'style': self._get_style(gc0, rgbFace)
  671. }
  672. writer.element('use', attrib=attrib)
  673. if clip_attrs:
  674. writer.end('g')
  675. if url is not None:
  676. writer.end('a')
  677. self._path_collection_id += 1
  678. def draw_gouraud_triangle(self, gc, points, colors, trans):
  679. # docstring inherited
  680. self._draw_gouraud_triangle(gc, points, colors, trans)
  681. def _draw_gouraud_triangle(self, gc, points, colors, trans):
  682. # This uses a method described here:
  683. #
  684. # http://www.svgopen.org/2005/papers/Converting3DFaceToSVG/index.html
  685. #
  686. # that uses three overlapping linear gradients to simulate a
  687. # Gouraud triangle. Each gradient goes from fully opaque in
  688. # one corner to fully transparent along the opposite edge.
  689. # The line between the stop points is perpendicular to the
  690. # opposite edge. Underlying these three gradients is a solid
  691. # triangle whose color is the average of all three points.
  692. writer = self.writer
  693. if not self._has_gouraud:
  694. self._has_gouraud = True
  695. writer.start(
  696. 'filter',
  697. id='colorAdd')
  698. writer.element(
  699. 'feComposite',
  700. attrib={'in': 'SourceGraphic'},
  701. in2='BackgroundImage',
  702. operator='arithmetic',
  703. k2="1", k3="1")
  704. writer.end('filter')
  705. # feColorMatrix filter to correct opacity
  706. writer.start(
  707. 'filter',
  708. id='colorMat')
  709. writer.element(
  710. 'feColorMatrix',
  711. attrib={'type': 'matrix'},
  712. values='1 0 0 0 0 \n0 1 0 0 0 \n0 0 1 0 0' +
  713. ' \n1 1 1 1 0 \n0 0 0 0 1 ')
  714. writer.end('filter')
  715. avg_color = np.average(colors, axis=0)
  716. if avg_color[-1] == 0:
  717. # Skip fully-transparent triangles
  718. return
  719. trans_and_flip = self._make_flip_transform(trans)
  720. tpoints = trans_and_flip.transform(points)
  721. writer.start('defs')
  722. for i in range(3):
  723. x1, y1 = tpoints[i]
  724. x2, y2 = tpoints[(i + 1) % 3]
  725. x3, y3 = tpoints[(i + 2) % 3]
  726. rgba_color = colors[i]
  727. if x2 == x3:
  728. xb = x2
  729. yb = y1
  730. elif y2 == y3:
  731. xb = x1
  732. yb = y2
  733. else:
  734. m1 = (y2 - y3) / (x2 - x3)
  735. b1 = y2 - (m1 * x2)
  736. m2 = -(1.0 / m1)
  737. b2 = y1 - (m2 * x1)
  738. xb = (-b1 + b2) / (m1 - m2)
  739. yb = m2 * xb + b2
  740. writer.start(
  741. 'linearGradient',
  742. id=f"GR{self._n_gradients:x}_{i:d}",
  743. gradientUnits="userSpaceOnUse",
  744. x1=_short_float_fmt(x1), y1=_short_float_fmt(y1),
  745. x2=_short_float_fmt(xb), y2=_short_float_fmt(yb))
  746. writer.element(
  747. 'stop',
  748. offset='1',
  749. style=_generate_css({
  750. 'stop-color': rgb2hex(avg_color),
  751. 'stop-opacity': _short_float_fmt(rgba_color[-1])}))
  752. writer.element(
  753. 'stop',
  754. offset='0',
  755. style=_generate_css({'stop-color': rgb2hex(rgba_color),
  756. 'stop-opacity': "0"}))
  757. writer.end('linearGradient')
  758. writer.end('defs')
  759. # triangle formation using "path"
  760. dpath = "M " + _short_float_fmt(x1)+',' + _short_float_fmt(y1)
  761. dpath += " L " + _short_float_fmt(x2) + ',' + _short_float_fmt(y2)
  762. dpath += " " + _short_float_fmt(x3) + ',' + _short_float_fmt(y3) + " Z"
  763. writer.element(
  764. 'path',
  765. attrib={'d': dpath,
  766. 'fill': rgb2hex(avg_color),
  767. 'fill-opacity': '1',
  768. 'shape-rendering': "crispEdges"})
  769. writer.start(
  770. 'g',
  771. attrib={'stroke': "none",
  772. 'stroke-width': "0",
  773. 'shape-rendering': "crispEdges",
  774. 'filter': "url(#colorMat)"})
  775. writer.element(
  776. 'path',
  777. attrib={'d': dpath,
  778. 'fill': f'url(#GR{self._n_gradients:x}_0)',
  779. 'shape-rendering': "crispEdges"})
  780. writer.element(
  781. 'path',
  782. attrib={'d': dpath,
  783. 'fill': f'url(#GR{self._n_gradients:x}_1)',
  784. 'filter': 'url(#colorAdd)',
  785. 'shape-rendering': "crispEdges"})
  786. writer.element(
  787. 'path',
  788. attrib={'d': dpath,
  789. 'fill': f'url(#GR{self._n_gradients:x}_2)',
  790. 'filter': 'url(#colorAdd)',
  791. 'shape-rendering': "crispEdges"})
  792. writer.end('g')
  793. self._n_gradients += 1
  794. def draw_gouraud_triangles(self, gc, triangles_array, colors_array,
  795. transform):
  796. self.writer.start('g', **self._get_clip_attrs(gc))
  797. transform = transform.frozen()
  798. for tri, col in zip(triangles_array, colors_array):
  799. self._draw_gouraud_triangle(gc, tri, col, transform)
  800. self.writer.end('g')
  801. def option_scale_image(self):
  802. # docstring inherited
  803. return True
  804. def get_image_magnification(self):
  805. return self.image_dpi / 72.0
  806. def draw_image(self, gc, x, y, im, transform=None):
  807. # docstring inherited
  808. h, w = im.shape[:2]
  809. if w == 0 or h == 0:
  810. return
  811. clip_attrs = self._get_clip_attrs(gc)
  812. if clip_attrs:
  813. # Can't apply clip-path directly to the image because the image has
  814. # a transformation, which would also be applied to the clip-path.
  815. self.writer.start('g', **clip_attrs)
  816. url = gc.get_url()
  817. if url is not None:
  818. self.writer.start('a', attrib={'xlink:href': url})
  819. attrib = {}
  820. oid = gc.get_gid()
  821. if mpl.rcParams['svg.image_inline']:
  822. buf = BytesIO()
  823. Image.fromarray(im).save(buf, format="png")
  824. oid = oid or self._make_id('image', buf.getvalue())
  825. attrib['xlink:href'] = (
  826. "data:image/png;base64,\n" +
  827. base64.b64encode(buf.getvalue()).decode('ascii'))
  828. else:
  829. if self.basename is None:
  830. raise ValueError("Cannot save image data to filesystem when "
  831. "writing SVG to an in-memory buffer")
  832. filename = f'{self.basename}.image{next(self._image_counter)}.png'
  833. _log.info('Writing image file for inclusion: %s', filename)
  834. Image.fromarray(im).save(filename)
  835. oid = oid or 'Im_' + self._make_id('image', filename)
  836. attrib['xlink:href'] = filename
  837. attrib['id'] = oid
  838. if transform is None:
  839. w = 72.0 * w / self.image_dpi
  840. h = 72.0 * h / self.image_dpi
  841. self.writer.element(
  842. 'image',
  843. transform=_generate_transform([
  844. ('scale', (1, -1)), ('translate', (0, -h))]),
  845. x=_short_float_fmt(x),
  846. y=_short_float_fmt(-(self.height - y - h)),
  847. width=_short_float_fmt(w), height=_short_float_fmt(h),
  848. attrib=attrib)
  849. else:
  850. alpha = gc.get_alpha()
  851. if alpha != 1.0:
  852. attrib['opacity'] = _short_float_fmt(alpha)
  853. flipped = (
  854. Affine2D().scale(1.0 / w, 1.0 / h) +
  855. transform +
  856. Affine2D()
  857. .translate(x, y)
  858. .scale(1.0, -1.0)
  859. .translate(0.0, self.height))
  860. attrib['transform'] = _generate_transform(
  861. [('matrix', flipped.frozen())])
  862. attrib['style'] = (
  863. 'image-rendering:crisp-edges;'
  864. 'image-rendering:pixelated')
  865. self.writer.element(
  866. 'image',
  867. width=_short_float_fmt(w), height=_short_float_fmt(h),
  868. attrib=attrib)
  869. if url is not None:
  870. self.writer.end('a')
  871. if clip_attrs:
  872. self.writer.end('g')
  873. def _update_glyph_map_defs(self, glyph_map_new):
  874. """
  875. Emit definitions for not-yet-defined glyphs, and record them as having
  876. been defined.
  877. """
  878. writer = self.writer
  879. if glyph_map_new:
  880. writer.start('defs')
  881. for char_id, (vertices, codes) in glyph_map_new.items():
  882. char_id = self._adjust_char_id(char_id)
  883. # x64 to go back to FreeType's internal (integral) units.
  884. path_data = self._convert_path(
  885. Path(vertices * 64, codes), simplify=False)
  886. writer.element(
  887. 'path', id=char_id, d=path_data,
  888. transform=_generate_transform([('scale', (1 / 64,))]))
  889. writer.end('defs')
  890. self._glyph_map.update(glyph_map_new)
  891. def _adjust_char_id(self, char_id):
  892. return char_id.replace("%20", "_")
  893. def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None):
  894. # docstring inherited
  895. writer = self.writer
  896. writer.comment(s)
  897. glyph_map = self._glyph_map
  898. text2path = self._text2path
  899. color = rgb2hex(gc.get_rgb())
  900. fontsize = prop.get_size_in_points()
  901. style = {}
  902. if color != '#000000':
  903. style['fill'] = color
  904. alpha = gc.get_alpha() if gc.get_forced_alpha() else gc.get_rgb()[3]
  905. if alpha != 1:
  906. style['opacity'] = _short_float_fmt(alpha)
  907. font_scale = fontsize / text2path.FONT_SCALE
  908. attrib = {
  909. 'style': _generate_css(style),
  910. 'transform': _generate_transform([
  911. ('translate', (x, y)),
  912. ('rotate', (-angle,)),
  913. ('scale', (font_scale, -font_scale))]),
  914. }
  915. writer.start('g', attrib=attrib)
  916. if not ismath:
  917. font = text2path._get_font(prop)
  918. _glyphs = text2path.get_glyphs_with_font(
  919. font, s, glyph_map=glyph_map, return_new_glyphs_only=True)
  920. glyph_info, glyph_map_new, rects = _glyphs
  921. self._update_glyph_map_defs(glyph_map_new)
  922. for glyph_id, xposition, yposition, scale in glyph_info:
  923. attrib = {'xlink:href': f'#{glyph_id}'}
  924. if xposition != 0.0:
  925. attrib['x'] = _short_float_fmt(xposition)
  926. if yposition != 0.0:
  927. attrib['y'] = _short_float_fmt(yposition)
  928. writer.element('use', attrib=attrib)
  929. else:
  930. if ismath == "TeX":
  931. _glyphs = text2path.get_glyphs_tex(
  932. prop, s, glyph_map=glyph_map, return_new_glyphs_only=True)
  933. else:
  934. _glyphs = text2path.get_glyphs_mathtext(
  935. prop, s, glyph_map=glyph_map, return_new_glyphs_only=True)
  936. glyph_info, glyph_map_new, rects = _glyphs
  937. self._update_glyph_map_defs(glyph_map_new)
  938. for char_id, xposition, yposition, scale in glyph_info:
  939. char_id = self._adjust_char_id(char_id)
  940. writer.element(
  941. 'use',
  942. transform=_generate_transform([
  943. ('translate', (xposition, yposition)),
  944. ('scale', (scale,)),
  945. ]),
  946. attrib={'xlink:href': f'#{char_id}'})
  947. for verts, codes in rects:
  948. path = Path(verts, codes)
  949. path_data = self._convert_path(path, simplify=False)
  950. writer.element('path', d=path_data)
  951. writer.end('g')
  952. def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None):
  953. writer = self.writer
  954. color = rgb2hex(gc.get_rgb())
  955. style = {}
  956. if color != '#000000':
  957. style['fill'] = color
  958. alpha = gc.get_alpha() if gc.get_forced_alpha() else gc.get_rgb()[3]
  959. if alpha != 1:
  960. style['opacity'] = _short_float_fmt(alpha)
  961. if not ismath:
  962. attrib = {}
  963. font_parts = []
  964. if prop.get_style() != 'normal':
  965. font_parts.append(prop.get_style())
  966. if prop.get_variant() != 'normal':
  967. font_parts.append(prop.get_variant())
  968. weight = fm.weight_dict[prop.get_weight()]
  969. if weight != 400:
  970. font_parts.append(f'{weight}')
  971. def _normalize_sans(name):
  972. return 'sans-serif' if name in ['sans', 'sans serif'] else name
  973. def _expand_family_entry(fn):
  974. fn = _normalize_sans(fn)
  975. # prepend generic font families with all configured font names
  976. if fn in fm.font_family_aliases:
  977. # get all of the font names and fix spelling of sans-serif
  978. # (we accept 3 ways CSS only supports 1)
  979. for name in fm.FontManager._expand_aliases(fn):
  980. yield _normalize_sans(name)
  981. # whether a generic name or a family name, it must appear at
  982. # least once
  983. yield fn
  984. def _get_all_quoted_names(prop):
  985. # only quote specific names, not generic names
  986. return [name if name in fm.font_family_aliases else repr(name)
  987. for entry in prop.get_family()
  988. for name in _expand_family_entry(entry)]
  989. font_parts.extend([
  990. f'{_short_float_fmt(prop.get_size())}px',
  991. # ensure expansion, quoting, and dedupe of font names
  992. ", ".join(dict.fromkeys(_get_all_quoted_names(prop)))
  993. ])
  994. style['font'] = ' '.join(font_parts)
  995. if prop.get_stretch() != 'normal':
  996. style['font-stretch'] = prop.get_stretch()
  997. attrib['style'] = _generate_css(style)
  998. if mtext and (angle == 0 or mtext.get_rotation_mode() == "anchor"):
  999. # If text anchoring can be supported, get the original
  1000. # coordinates and add alignment information.
  1001. # Get anchor coordinates.
  1002. transform = mtext.get_transform()
  1003. ax, ay = transform.transform(mtext.get_unitless_position())
  1004. ay = self.height - ay
  1005. # Don't do vertical anchor alignment. Most applications do not
  1006. # support 'alignment-baseline' yet. Apply the vertical layout
  1007. # to the anchor point manually for now.
  1008. angle_rad = np.deg2rad(angle)
  1009. dir_vert = np.array([np.sin(angle_rad), np.cos(angle_rad)])
  1010. v_offset = np.dot(dir_vert, [(x - ax), (y - ay)])
  1011. ax = ax + v_offset * dir_vert[0]
  1012. ay = ay + v_offset * dir_vert[1]
  1013. ha_mpl_to_svg = {'left': 'start', 'right': 'end',
  1014. 'center': 'middle'}
  1015. style['text-anchor'] = ha_mpl_to_svg[mtext.get_ha()]
  1016. attrib['x'] = _short_float_fmt(ax)
  1017. attrib['y'] = _short_float_fmt(ay)
  1018. attrib['style'] = _generate_css(style)
  1019. attrib['transform'] = _generate_transform([
  1020. ("rotate", (-angle, ax, ay))])
  1021. else:
  1022. attrib['transform'] = _generate_transform([
  1023. ('translate', (x, y)),
  1024. ('rotate', (-angle,))])
  1025. writer.element('text', s, attrib=attrib)
  1026. else:
  1027. writer.comment(s)
  1028. width, height, descent, glyphs, rects = \
  1029. self._text2path.mathtext_parser.parse(s, 72, prop)
  1030. # Apply attributes to 'g', not 'text', because we likely have some
  1031. # rectangles as well with the same style and transformation.
  1032. writer.start('g',
  1033. style=_generate_css(style),
  1034. transform=_generate_transform([
  1035. ('translate', (x, y)),
  1036. ('rotate', (-angle,))]),
  1037. )
  1038. writer.start('text')
  1039. # Sort the characters by font, and output one tspan for each.
  1040. spans = {}
  1041. for font, fontsize, thetext, new_x, new_y in glyphs:
  1042. entry = fm.ttfFontProperty(font)
  1043. font_parts = []
  1044. if entry.style != 'normal':
  1045. font_parts.append(entry.style)
  1046. if entry.variant != 'normal':
  1047. font_parts.append(entry.variant)
  1048. if entry.weight != 400:
  1049. font_parts.append(f'{entry.weight}')
  1050. font_parts.extend([
  1051. f'{_short_float_fmt(fontsize)}px',
  1052. f'{entry.name!r}', # ensure quoting
  1053. ])
  1054. style = {'font': ' '.join(font_parts)}
  1055. if entry.stretch != 'normal':
  1056. style['font-stretch'] = entry.stretch
  1057. style = _generate_css(style)
  1058. if thetext == 32:
  1059. thetext = 0xa0 # non-breaking space
  1060. spans.setdefault(style, []).append((new_x, -new_y, thetext))
  1061. for style, chars in spans.items():
  1062. chars.sort()
  1063. if len({y for x, y, t in chars}) == 1: # Are all y's the same?
  1064. ys = str(chars[0][1])
  1065. else:
  1066. ys = ' '.join(str(c[1]) for c in chars)
  1067. attrib = {
  1068. 'style': style,
  1069. 'x': ' '.join(_short_float_fmt(c[0]) for c in chars),
  1070. 'y': ys
  1071. }
  1072. writer.element(
  1073. 'tspan',
  1074. ''.join(chr(c[2]) for c in chars),
  1075. attrib=attrib)
  1076. writer.end('text')
  1077. for x, y, width, height in rects:
  1078. writer.element(
  1079. 'rect',
  1080. x=_short_float_fmt(x),
  1081. y=_short_float_fmt(-y-1),
  1082. width=_short_float_fmt(width),
  1083. height=_short_float_fmt(height)
  1084. )
  1085. writer.end('g')
  1086. def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
  1087. # docstring inherited
  1088. clip_attrs = self._get_clip_attrs(gc)
  1089. if clip_attrs:
  1090. # Cannot apply clip-path directly to the text, because
  1091. # it has a transformation
  1092. self.writer.start('g', **clip_attrs)
  1093. if gc.get_url() is not None:
  1094. self.writer.start('a', {'xlink:href': gc.get_url()})
  1095. if mpl.rcParams['svg.fonttype'] == 'path':
  1096. self._draw_text_as_path(gc, x, y, s, prop, angle, ismath, mtext)
  1097. else:
  1098. self._draw_text_as_text(gc, x, y, s, prop, angle, ismath, mtext)
  1099. if gc.get_url() is not None:
  1100. self.writer.end('a')
  1101. if clip_attrs:
  1102. self.writer.end('g')
  1103. def flipy(self):
  1104. # docstring inherited
  1105. return True
  1106. def get_canvas_width_height(self):
  1107. # docstring inherited
  1108. return self.width, self.height
  1109. def get_text_width_height_descent(self, s, prop, ismath):
  1110. # docstring inherited
  1111. return self._text2path.get_text_width_height_descent(s, prop, ismath)
  1112. class FigureCanvasSVG(FigureCanvasBase):
  1113. filetypes = {'svg': 'Scalable Vector Graphics',
  1114. 'svgz': 'Scalable Vector Graphics'}
  1115. fixed_dpi = 72
  1116. def print_svg(self, filename, *, bbox_inches_restore=None, metadata=None):
  1117. """
  1118. Parameters
  1119. ----------
  1120. filename : str or path-like or file-like
  1121. Output target; if a string, a file will be opened for writing.
  1122. metadata : dict[str, Any], optional
  1123. Metadata in the SVG file defined as key-value pairs of strings,
  1124. datetimes, or lists of strings, e.g., ``{'Creator': 'My software',
  1125. 'Contributor': ['Me', 'My Friend'], 'Title': 'Awesome'}``.
  1126. The standard keys and their value types are:
  1127. * *str*: ``'Coverage'``, ``'Description'``, ``'Format'``,
  1128. ``'Identifier'``, ``'Language'``, ``'Relation'``, ``'Source'``,
  1129. ``'Title'``, and ``'Type'``.
  1130. * *str* or *list of str*: ``'Contributor'``, ``'Creator'``,
  1131. ``'Keywords'``, ``'Publisher'``, and ``'Rights'``.
  1132. * *str*, *date*, *datetime*, or *tuple* of same: ``'Date'``. If a
  1133. non-*str*, then it will be formatted as ISO 8601.
  1134. Values have been predefined for ``'Creator'``, ``'Date'``,
  1135. ``'Format'``, and ``'Type'``. They can be removed by setting them
  1136. to `None`.
  1137. Information is encoded as `Dublin Core Metadata`__.
  1138. .. _DC: https://www.dublincore.org/specifications/dublin-core/
  1139. __ DC_
  1140. """
  1141. with cbook.open_file_cm(filename, "w", encoding="utf-8") as fh:
  1142. if not cbook.file_requires_unicode(fh):
  1143. fh = codecs.getwriter('utf-8')(fh)
  1144. dpi = self.figure.dpi
  1145. self.figure.dpi = 72
  1146. width, height = self.figure.get_size_inches()
  1147. w, h = width * 72, height * 72
  1148. renderer = MixedModeRenderer(
  1149. self.figure, width, height, dpi,
  1150. RendererSVG(w, h, fh, image_dpi=dpi, metadata=metadata),
  1151. bbox_inches_restore=bbox_inches_restore)
  1152. self.figure.draw(renderer)
  1153. renderer.finalize()
  1154. def print_svgz(self, filename, **kwargs):
  1155. with cbook.open_file_cm(filename, "wb") as fh, \
  1156. gzip.GzipFile(mode='w', fileobj=fh) as gzipwriter:
  1157. return self.print_svg(gzipwriter, **kwargs)
  1158. def get_default_filetype(self):
  1159. return 'svg'
  1160. def draw(self):
  1161. self.figure.draw_without_rendering()
  1162. return super().draw()
  1163. FigureManagerSVG = FigureManagerBase
  1164. svgProlog = """\
  1165. <?xml version="1.0" encoding="utf-8" standalone="no"?>
  1166. <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
  1167. "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
  1168. """
  1169. @_Backend.export
  1170. class _BackendSVG(_Backend):
  1171. backend_version = mpl.__version__
  1172. FigureCanvas = FigureCanvasSVG