backend_svg.py 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380
  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._clip_path_ids = {}
  256. self._clipd = {}
  257. self._markers = {}
  258. self._path_collection_id = 0
  259. self._hatchd = {}
  260. self._has_gouraud = False
  261. self._n_gradients = 0
  262. super().__init__()
  263. self._glyph_map = dict()
  264. str_height = _short_float_fmt(height)
  265. str_width = _short_float_fmt(width)
  266. svgwriter.write(svgProlog)
  267. self._start_id = self.writer.start(
  268. 'svg',
  269. width=f'{str_width}pt',
  270. height=f'{str_height}pt',
  271. viewBox=f'0 0 {str_width} {str_height}',
  272. xmlns="http://www.w3.org/2000/svg",
  273. version="1.1",
  274. id=mpl.rcParams['svg.id'],
  275. attrib={'xmlns:xlink': "http://www.w3.org/1999/xlink"})
  276. self._write_metadata(metadata)
  277. self._write_default_style()
  278. def _get_clippath_id(self, clippath):
  279. """
  280. Returns a stable and unique identifier for the *clippath* argument
  281. object within the current rendering context.
  282. This allows plots that include custom clip paths to produce identical
  283. SVG output on each render, provided that the :rc:`svg.hashsalt` config
  284. setting and the ``SOURCE_DATE_EPOCH`` build-time environment variable
  285. are set to fixed values.
  286. """
  287. if clippath not in self._clip_path_ids:
  288. self._clip_path_ids[clippath] = len(self._clip_path_ids)
  289. return self._clip_path_ids[clippath]
  290. def finalize(self):
  291. self._write_clips()
  292. self._write_hatches()
  293. self.writer.close(self._start_id)
  294. self.writer.flush()
  295. def _write_metadata(self, metadata):
  296. # Add metadata following the Dublin Core Metadata Initiative, and the
  297. # Creative Commons Rights Expression Language. This is mainly for
  298. # compatibility with Inkscape.
  299. if metadata is None:
  300. metadata = {}
  301. metadata = {
  302. 'Format': 'image/svg+xml',
  303. 'Type': 'http://purl.org/dc/dcmitype/StillImage',
  304. 'Creator':
  305. f'Matplotlib v{mpl.__version__}, https://matplotlib.org/',
  306. **metadata
  307. }
  308. writer = self.writer
  309. if 'Title' in metadata:
  310. title = metadata['Title']
  311. _check_is_str(title, 'Title')
  312. writer.element('title', text=title)
  313. # Special handling.
  314. date = metadata.get('Date', None)
  315. if date is not None:
  316. if isinstance(date, str):
  317. dates = [date]
  318. elif isinstance(date, (datetime.datetime, datetime.date)):
  319. dates = [date.isoformat()]
  320. elif np.iterable(date):
  321. dates = []
  322. for d in date:
  323. if isinstance(d, str):
  324. dates.append(d)
  325. elif isinstance(d, (datetime.datetime, datetime.date)):
  326. dates.append(d.isoformat())
  327. else:
  328. raise TypeError(
  329. f'Invalid type for Date metadata. '
  330. f'Expected iterable of str, date, or datetime, '
  331. f'not {type(d)}.')
  332. else:
  333. raise TypeError(f'Invalid type for Date metadata. '
  334. f'Expected str, date, datetime, or iterable '
  335. f'of the same, not {type(date)}.')
  336. metadata['Date'] = '/'.join(dates)
  337. elif 'Date' not in metadata:
  338. # Do not add `Date` if the user explicitly set `Date` to `None`
  339. # Get source date from SOURCE_DATE_EPOCH, if set.
  340. # See https://reproducible-builds.org/specs/source-date-epoch/
  341. date = os.getenv("SOURCE_DATE_EPOCH")
  342. if date:
  343. date = datetime.datetime.fromtimestamp(int(date), datetime.timezone.utc)
  344. metadata['Date'] = date.replace(tzinfo=UTC).isoformat()
  345. else:
  346. metadata['Date'] = datetime.datetime.today().isoformat()
  347. mid = None
  348. def ensure_metadata(mid):
  349. if mid is not None:
  350. return mid
  351. mid = writer.start('metadata')
  352. writer.start('rdf:RDF', attrib={
  353. 'xmlns:dc': "http://purl.org/dc/elements/1.1/",
  354. 'xmlns:cc': "http://creativecommons.org/ns#",
  355. 'xmlns:rdf': "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
  356. })
  357. writer.start('cc:Work')
  358. return mid
  359. uri = metadata.pop('Type', None)
  360. if uri is not None:
  361. mid = ensure_metadata(mid)
  362. writer.element('dc:type', attrib={'rdf:resource': uri})
  363. # Single value only.
  364. for key in ['Title', 'Coverage', 'Date', 'Description', 'Format',
  365. 'Identifier', 'Language', 'Relation', 'Source']:
  366. info = metadata.pop(key, None)
  367. if info is not None:
  368. mid = ensure_metadata(mid)
  369. _check_is_str(info, key)
  370. writer.element(f'dc:{key.lower()}', text=info)
  371. # Multiple Agent values.
  372. for key in ['Creator', 'Contributor', 'Publisher', 'Rights']:
  373. agents = metadata.pop(key, None)
  374. if agents is None:
  375. continue
  376. if isinstance(agents, str):
  377. agents = [agents]
  378. _check_is_iterable_of_str(agents, key)
  379. # Now we know that we have an iterable of str
  380. mid = ensure_metadata(mid)
  381. writer.start(f'dc:{key.lower()}')
  382. for agent in agents:
  383. writer.start('cc:Agent')
  384. writer.element('dc:title', text=agent)
  385. writer.end('cc:Agent')
  386. writer.end(f'dc:{key.lower()}')
  387. # Multiple values.
  388. keywords = metadata.pop('Keywords', None)
  389. if keywords is not None:
  390. if isinstance(keywords, str):
  391. keywords = [keywords]
  392. _check_is_iterable_of_str(keywords, 'Keywords')
  393. # Now we know that we have an iterable of str
  394. mid = ensure_metadata(mid)
  395. writer.start('dc:subject')
  396. writer.start('rdf:Bag')
  397. for keyword in keywords:
  398. writer.element('rdf:li', text=keyword)
  399. writer.end('rdf:Bag')
  400. writer.end('dc:subject')
  401. if mid is not None:
  402. writer.close(mid)
  403. if metadata:
  404. raise ValueError('Unknown metadata key(s) passed to SVG writer: ' +
  405. ','.join(metadata))
  406. def _write_default_style(self):
  407. writer = self.writer
  408. default_style = _generate_css({
  409. 'stroke-linejoin': 'round',
  410. 'stroke-linecap': 'butt'})
  411. writer.start('defs')
  412. writer.element('style', type='text/css', text='*{%s}' % default_style)
  413. writer.end('defs')
  414. def _make_id(self, type, content):
  415. salt = mpl.rcParams['svg.hashsalt']
  416. if salt is None:
  417. salt = str(uuid.uuid4())
  418. m = hashlib.sha256()
  419. m.update(salt.encode('utf8'))
  420. m.update(str(content).encode('utf8'))
  421. return f'{type}{m.hexdigest()[:10]}'
  422. def _make_flip_transform(self, transform):
  423. return transform + Affine2D().scale(1, -1).translate(0, self.height)
  424. def _get_hatch(self, gc, rgbFace):
  425. """
  426. Create a new hatch pattern
  427. """
  428. if rgbFace is not None:
  429. rgbFace = tuple(rgbFace)
  430. edge = gc.get_hatch_color()
  431. if edge is not None:
  432. edge = tuple(edge)
  433. lw = gc.get_hatch_linewidth()
  434. dictkey = (gc.get_hatch(), rgbFace, edge, lw)
  435. oid = self._hatchd.get(dictkey)
  436. if oid is None:
  437. oid = self._make_id('h', dictkey)
  438. self._hatchd[dictkey] = ((gc.get_hatch_path(), rgbFace, edge, lw), oid)
  439. else:
  440. _, oid = oid
  441. return oid
  442. def _write_hatches(self):
  443. if not len(self._hatchd):
  444. return
  445. HATCH_SIZE = 72
  446. writer = self.writer
  447. writer.start('defs')
  448. for (path, face, stroke, lw), oid in self._hatchd.values():
  449. writer.start(
  450. 'pattern',
  451. id=oid,
  452. patternUnits="userSpaceOnUse",
  453. x="0", y="0", width=str(HATCH_SIZE),
  454. height=str(HATCH_SIZE))
  455. path_data = self._convert_path(
  456. path,
  457. Affine2D()
  458. .scale(HATCH_SIZE).scale(1.0, -1.0).translate(0, HATCH_SIZE),
  459. simplify=False)
  460. if face is None:
  461. fill = 'none'
  462. else:
  463. fill = rgb2hex(face)
  464. writer.element(
  465. 'rect',
  466. x="0", y="0", width=str(HATCH_SIZE+1),
  467. height=str(HATCH_SIZE+1),
  468. fill=fill)
  469. hatch_style = {
  470. 'fill': rgb2hex(stroke),
  471. 'stroke': rgb2hex(stroke),
  472. 'stroke-width': str(lw),
  473. 'stroke-linecap': 'butt',
  474. 'stroke-linejoin': 'miter'
  475. }
  476. if stroke[3] < 1:
  477. hatch_style['stroke-opacity'] = str(stroke[3])
  478. writer.element(
  479. 'path',
  480. d=path_data,
  481. style=_generate_css(hatch_style)
  482. )
  483. writer.end('pattern')
  484. writer.end('defs')
  485. def _get_style_dict(self, gc, rgbFace):
  486. """Generate a style string from the GraphicsContext and rgbFace."""
  487. attrib = {}
  488. forced_alpha = gc.get_forced_alpha()
  489. if gc.get_hatch() is not None:
  490. attrib['fill'] = f"url(#{self._get_hatch(gc, rgbFace)})"
  491. if (rgbFace is not None and len(rgbFace) == 4 and rgbFace[3] != 1.0
  492. and not forced_alpha):
  493. attrib['fill-opacity'] = _short_float_fmt(rgbFace[3])
  494. else:
  495. if rgbFace is None:
  496. attrib['fill'] = 'none'
  497. else:
  498. if tuple(rgbFace[:3]) != (0, 0, 0):
  499. attrib['fill'] = rgb2hex(rgbFace)
  500. if (len(rgbFace) == 4 and rgbFace[3] != 1.0
  501. and not forced_alpha):
  502. attrib['fill-opacity'] = _short_float_fmt(rgbFace[3])
  503. if forced_alpha and gc.get_alpha() != 1.0:
  504. attrib['opacity'] = _short_float_fmt(gc.get_alpha())
  505. offset, seq = gc.get_dashes()
  506. if seq is not None:
  507. attrib['stroke-dasharray'] = ','.join(
  508. _short_float_fmt(val) for val in seq)
  509. attrib['stroke-dashoffset'] = _short_float_fmt(float(offset))
  510. linewidth = gc.get_linewidth()
  511. if linewidth:
  512. rgb = gc.get_rgb()
  513. attrib['stroke'] = rgb2hex(rgb)
  514. if not forced_alpha and rgb[3] != 1.0:
  515. attrib['stroke-opacity'] = _short_float_fmt(rgb[3])
  516. if linewidth != 1.0:
  517. attrib['stroke-width'] = _short_float_fmt(linewidth)
  518. if gc.get_joinstyle() != 'round':
  519. attrib['stroke-linejoin'] = gc.get_joinstyle()
  520. if gc.get_capstyle() != 'butt':
  521. attrib['stroke-linecap'] = _capstyle_d[gc.get_capstyle()]
  522. return attrib
  523. def _get_style(self, gc, rgbFace):
  524. return _generate_css(self._get_style_dict(gc, rgbFace))
  525. def _get_clip_attrs(self, gc):
  526. cliprect = gc.get_clip_rectangle()
  527. clippath, clippath_trans = gc.get_clip_path()
  528. if clippath is not None:
  529. clippath_trans = self._make_flip_transform(clippath_trans)
  530. dictkey = (self._get_clippath_id(clippath), str(clippath_trans))
  531. elif cliprect is not None:
  532. x, y, w, h = cliprect.bounds
  533. y = self.height-(y+h)
  534. dictkey = (x, y, w, h)
  535. else:
  536. return {}
  537. clip = self._clipd.get(dictkey)
  538. if clip is None:
  539. oid = self._make_id('p', dictkey)
  540. if clippath is not None:
  541. self._clipd[dictkey] = ((clippath, clippath_trans), oid)
  542. else:
  543. self._clipd[dictkey] = (dictkey, oid)
  544. else:
  545. _, oid = clip
  546. return {'clip-path': f'url(#{oid})'}
  547. def _write_clips(self):
  548. if not len(self._clipd):
  549. return
  550. writer = self.writer
  551. writer.start('defs')
  552. for clip, oid in self._clipd.values():
  553. writer.start('clipPath', id=oid)
  554. if len(clip) == 2:
  555. clippath, clippath_trans = clip
  556. path_data = self._convert_path(
  557. clippath, clippath_trans, simplify=False)
  558. writer.element('path', d=path_data)
  559. else:
  560. x, y, w, h = clip
  561. writer.element(
  562. 'rect',
  563. x=_short_float_fmt(x),
  564. y=_short_float_fmt(y),
  565. width=_short_float_fmt(w),
  566. height=_short_float_fmt(h))
  567. writer.end('clipPath')
  568. writer.end('defs')
  569. def open_group(self, s, gid=None):
  570. # docstring inherited
  571. if gid:
  572. self.writer.start('g', id=gid)
  573. else:
  574. self._groupd[s] = self._groupd.get(s, 0) + 1
  575. self.writer.start('g', id=f"{s}_{self._groupd[s]:d}")
  576. def close_group(self, s):
  577. # docstring inherited
  578. self.writer.end('g')
  579. def option_image_nocomposite(self):
  580. # docstring inherited
  581. return not mpl.rcParams['image.composite_image']
  582. def _convert_path(self, path, transform=None, clip=None, simplify=None,
  583. sketch=None):
  584. if clip:
  585. clip = (0.0, 0.0, self.width, self.height)
  586. else:
  587. clip = None
  588. return _path.convert_to_string(
  589. path, transform, clip, simplify, sketch, 6,
  590. [b'M', b'L', b'Q', b'C', b'z'], False).decode('ascii')
  591. def draw_path(self, gc, path, transform, rgbFace=None):
  592. # docstring inherited
  593. trans_and_flip = self._make_flip_transform(transform)
  594. clip = (rgbFace is None and gc.get_hatch_path() is None)
  595. simplify = path.should_simplify and clip
  596. path_data = self._convert_path(
  597. path, trans_and_flip, clip=clip, simplify=simplify,
  598. sketch=gc.get_sketch_params())
  599. if gc.get_url() is not None:
  600. self.writer.start('a', {'xlink:href': gc.get_url()})
  601. self.writer.element('path', d=path_data, **self._get_clip_attrs(gc),
  602. style=self._get_style(gc, rgbFace))
  603. if gc.get_url() is not None:
  604. self.writer.end('a')
  605. def draw_markers(
  606. self, gc, marker_path, marker_trans, path, trans, rgbFace=None):
  607. # docstring inherited
  608. if not len(path.vertices):
  609. return
  610. writer = self.writer
  611. path_data = self._convert_path(
  612. marker_path,
  613. marker_trans + Affine2D().scale(1.0, -1.0),
  614. simplify=False)
  615. style = self._get_style_dict(gc, rgbFace)
  616. dictkey = (path_data, _generate_css(style))
  617. oid = self._markers.get(dictkey)
  618. style = _generate_css({k: v for k, v in style.items()
  619. if k.startswith('stroke')})
  620. if oid is None:
  621. oid = self._make_id('m', dictkey)
  622. writer.start('defs')
  623. writer.element('path', id=oid, d=path_data, style=style)
  624. writer.end('defs')
  625. self._markers[dictkey] = oid
  626. writer.start('g', **self._get_clip_attrs(gc))
  627. if gc.get_url() is not None:
  628. self.writer.start('a', {'xlink:href': gc.get_url()})
  629. trans_and_flip = self._make_flip_transform(trans)
  630. attrib = {'xlink:href': f'#{oid}'}
  631. clip = (0, 0, self.width*72, self.height*72)
  632. for vertices, code in path.iter_segments(
  633. trans_and_flip, clip=clip, simplify=False):
  634. if len(vertices):
  635. x, y = vertices[-2:]
  636. attrib['x'] = _short_float_fmt(x)
  637. attrib['y'] = _short_float_fmt(y)
  638. attrib['style'] = self._get_style(gc, rgbFace)
  639. writer.element('use', attrib=attrib)
  640. if gc.get_url() is not None:
  641. self.writer.end('a')
  642. writer.end('g')
  643. def draw_path_collection(self, gc, master_transform, paths, all_transforms,
  644. offsets, offset_trans, facecolors, edgecolors,
  645. linewidths, linestyles, antialiaseds, urls,
  646. offset_position):
  647. # Is the optimization worth it? Rough calculation:
  648. # cost of emitting a path in-line is
  649. # (len_path + 5) * uses_per_path
  650. # cost of definition+use is
  651. # (len_path + 3) + 9 * uses_per_path
  652. len_path = len(paths[0].vertices) if len(paths) > 0 else 0
  653. uses_per_path = self._iter_collection_uses_per_path(
  654. paths, all_transforms, offsets, facecolors, edgecolors)
  655. should_do_optimization = \
  656. len_path + 9 * uses_per_path + 3 < (len_path + 5) * uses_per_path
  657. if not should_do_optimization:
  658. return super().draw_path_collection(
  659. gc, master_transform, paths, all_transforms,
  660. offsets, offset_trans, facecolors, edgecolors,
  661. linewidths, linestyles, antialiaseds, urls,
  662. offset_position)
  663. writer = self.writer
  664. path_codes = []
  665. writer.start('defs')
  666. for i, (path, transform) in enumerate(self._iter_collection_raw_paths(
  667. master_transform, paths, all_transforms)):
  668. transform = Affine2D(transform.get_matrix()).scale(1.0, -1.0)
  669. d = self._convert_path(path, transform, simplify=False)
  670. oid = 'C{:x}_{:x}_{}'.format(
  671. self._path_collection_id, i, self._make_id('', d))
  672. writer.element('path', id=oid, d=d)
  673. path_codes.append(oid)
  674. writer.end('defs')
  675. for xo, yo, path_id, gc0, rgbFace in self._iter_collection(
  676. gc, path_codes, offsets, offset_trans,
  677. facecolors, edgecolors, linewidths, linestyles,
  678. antialiaseds, urls, offset_position):
  679. url = gc0.get_url()
  680. if url is not None:
  681. writer.start('a', attrib={'xlink:href': url})
  682. clip_attrs = self._get_clip_attrs(gc0)
  683. if clip_attrs:
  684. writer.start('g', **clip_attrs)
  685. attrib = {
  686. 'xlink:href': f'#{path_id}',
  687. 'x': _short_float_fmt(xo),
  688. 'y': _short_float_fmt(self.height - yo),
  689. 'style': self._get_style(gc0, rgbFace)
  690. }
  691. writer.element('use', attrib=attrib)
  692. if clip_attrs:
  693. writer.end('g')
  694. if url is not None:
  695. writer.end('a')
  696. self._path_collection_id += 1
  697. def _draw_gouraud_triangle(self, transformed_points, colors):
  698. # This uses a method described here:
  699. #
  700. # http://www.svgopen.org/2005/papers/Converting3DFaceToSVG/index.html
  701. #
  702. # that uses three overlapping linear gradients to simulate a
  703. # Gouraud triangle. Each gradient goes from fully opaque in
  704. # one corner to fully transparent along the opposite edge.
  705. # The line between the stop points is perpendicular to the
  706. # opposite edge. Underlying these three gradients is a solid
  707. # triangle whose color is the average of all three points.
  708. avg_color = np.average(colors, axis=0)
  709. if avg_color[-1] == 0:
  710. # Skip fully-transparent triangles
  711. return
  712. writer = self.writer
  713. writer.start('defs')
  714. for i in range(3):
  715. x1, y1 = transformed_points[i]
  716. x2, y2 = transformed_points[(i + 1) % 3]
  717. x3, y3 = transformed_points[(i + 2) % 3]
  718. rgba_color = colors[i]
  719. if x2 == x3:
  720. xb = x2
  721. yb = y1
  722. elif y2 == y3:
  723. xb = x1
  724. yb = y2
  725. else:
  726. m1 = (y2 - y3) / (x2 - x3)
  727. b1 = y2 - (m1 * x2)
  728. m2 = -(1.0 / m1)
  729. b2 = y1 - (m2 * x1)
  730. xb = (-b1 + b2) / (m1 - m2)
  731. yb = m2 * xb + b2
  732. writer.start(
  733. 'linearGradient',
  734. id=f"GR{self._n_gradients:x}_{i:d}",
  735. gradientUnits="userSpaceOnUse",
  736. x1=_short_float_fmt(x1), y1=_short_float_fmt(y1),
  737. x2=_short_float_fmt(xb), y2=_short_float_fmt(yb))
  738. writer.element(
  739. 'stop',
  740. offset='1',
  741. style=_generate_css({
  742. 'stop-color': rgb2hex(avg_color),
  743. 'stop-opacity': _short_float_fmt(rgba_color[-1])}))
  744. writer.element(
  745. 'stop',
  746. offset='0',
  747. style=_generate_css({'stop-color': rgb2hex(rgba_color),
  748. 'stop-opacity': "0"}))
  749. writer.end('linearGradient')
  750. writer.end('defs')
  751. # triangle formation using "path"
  752. dpath = (f"M {_short_float_fmt(x1)},{_short_float_fmt(y1)}"
  753. f" L {_short_float_fmt(x2)},{_short_float_fmt(y2)}"
  754. f" {_short_float_fmt(x3)},{_short_float_fmt(y3)} Z")
  755. writer.element(
  756. 'path',
  757. attrib={'d': dpath,
  758. 'fill': rgb2hex(avg_color),
  759. 'fill-opacity': '1',
  760. 'shape-rendering': "crispEdges"})
  761. writer.start(
  762. 'g',
  763. attrib={'stroke': "none",
  764. 'stroke-width': "0",
  765. 'shape-rendering': "crispEdges",
  766. 'filter': "url(#colorMat)"})
  767. writer.element(
  768. 'path',
  769. attrib={'d': dpath,
  770. 'fill': f'url(#GR{self._n_gradients:x}_0)',
  771. 'shape-rendering': "crispEdges"})
  772. writer.element(
  773. 'path',
  774. attrib={'d': dpath,
  775. 'fill': f'url(#GR{self._n_gradients:x}_1)',
  776. 'filter': 'url(#colorAdd)',
  777. 'shape-rendering': "crispEdges"})
  778. writer.element(
  779. 'path',
  780. attrib={'d': dpath,
  781. 'fill': f'url(#GR{self._n_gradients:x}_2)',
  782. 'filter': 'url(#colorAdd)',
  783. 'shape-rendering': "crispEdges"})
  784. writer.end('g')
  785. self._n_gradients += 1
  786. def draw_gouraud_triangles(self, gc, triangles_array, colors_array,
  787. transform):
  788. writer = self.writer
  789. writer.start('g', **self._get_clip_attrs(gc))
  790. transform = transform.frozen()
  791. trans_and_flip = self._make_flip_transform(transform)
  792. if not self._has_gouraud:
  793. self._has_gouraud = True
  794. writer.start(
  795. 'filter',
  796. id='colorAdd')
  797. writer.element(
  798. 'feComposite',
  799. attrib={'in': 'SourceGraphic'},
  800. in2='BackgroundImage',
  801. operator='arithmetic',
  802. k2="1", k3="1")
  803. writer.end('filter')
  804. # feColorMatrix filter to correct opacity
  805. writer.start(
  806. 'filter',
  807. id='colorMat')
  808. writer.element(
  809. 'feColorMatrix',
  810. attrib={'type': 'matrix'},
  811. values='1 0 0 0 0 \n0 1 0 0 0 \n0 0 1 0 0 \n1 1 1 1 0 \n0 0 0 0 1 ')
  812. writer.end('filter')
  813. for points, colors in zip(triangles_array, colors_array):
  814. self._draw_gouraud_triangle(trans_and_flip.transform(points), colors)
  815. writer.end('g')
  816. def option_scale_image(self):
  817. # docstring inherited
  818. return True
  819. def get_image_magnification(self):
  820. return self.image_dpi / 72.0
  821. def draw_image(self, gc, x, y, im, transform=None):
  822. # docstring inherited
  823. h, w = im.shape[:2]
  824. if w == 0 or h == 0:
  825. return
  826. clip_attrs = self._get_clip_attrs(gc)
  827. if clip_attrs:
  828. # Can't apply clip-path directly to the image because the image has
  829. # a transformation, which would also be applied to the clip-path.
  830. self.writer.start('g', **clip_attrs)
  831. url = gc.get_url()
  832. if url is not None:
  833. self.writer.start('a', attrib={'xlink:href': url})
  834. attrib = {}
  835. oid = gc.get_gid()
  836. if mpl.rcParams['svg.image_inline']:
  837. buf = BytesIO()
  838. Image.fromarray(im).save(buf, format="png")
  839. oid = oid or self._make_id('image', buf.getvalue())
  840. attrib['xlink:href'] = (
  841. "data:image/png;base64,\n" +
  842. base64.b64encode(buf.getvalue()).decode('ascii'))
  843. else:
  844. if self.basename is None:
  845. raise ValueError("Cannot save image data to filesystem when "
  846. "writing SVG to an in-memory buffer")
  847. filename = f'{self.basename}.image{next(self._image_counter)}.png'
  848. _log.info('Writing image file for inclusion: %s', filename)
  849. Image.fromarray(im).save(filename)
  850. oid = oid or 'Im_' + self._make_id('image', filename)
  851. attrib['xlink:href'] = filename
  852. attrib['id'] = oid
  853. if transform is None:
  854. w = 72.0 * w / self.image_dpi
  855. h = 72.0 * h / self.image_dpi
  856. self.writer.element(
  857. 'image',
  858. transform=_generate_transform([
  859. ('scale', (1, -1)), ('translate', (0, -h))]),
  860. x=_short_float_fmt(x),
  861. y=_short_float_fmt(-(self.height - y - h)),
  862. width=_short_float_fmt(w), height=_short_float_fmt(h),
  863. attrib=attrib)
  864. else:
  865. alpha = gc.get_alpha()
  866. if alpha != 1.0:
  867. attrib['opacity'] = _short_float_fmt(alpha)
  868. flipped = (
  869. Affine2D().scale(1.0 / w, 1.0 / h) +
  870. transform +
  871. Affine2D()
  872. .translate(x, y)
  873. .scale(1.0, -1.0)
  874. .translate(0.0, self.height))
  875. attrib['transform'] = _generate_transform(
  876. [('matrix', flipped.frozen())])
  877. attrib['style'] = (
  878. 'image-rendering:crisp-edges;'
  879. 'image-rendering:pixelated')
  880. self.writer.element(
  881. 'image',
  882. width=_short_float_fmt(w), height=_short_float_fmt(h),
  883. attrib=attrib)
  884. if url is not None:
  885. self.writer.end('a')
  886. if clip_attrs:
  887. self.writer.end('g')
  888. def _update_glyph_map_defs(self, glyph_map_new):
  889. """
  890. Emit definitions for not-yet-defined glyphs, and record them as having
  891. been defined.
  892. """
  893. writer = self.writer
  894. if glyph_map_new:
  895. writer.start('defs')
  896. for char_id, (vertices, codes) in glyph_map_new.items():
  897. char_id = self._adjust_char_id(char_id)
  898. # x64 to go back to FreeType's internal (integral) units.
  899. path_data = self._convert_path(
  900. Path(vertices * 64, codes), simplify=False)
  901. writer.element(
  902. 'path', id=char_id, d=path_data,
  903. transform=_generate_transform([('scale', (1 / 64,))]))
  904. writer.end('defs')
  905. self._glyph_map.update(glyph_map_new)
  906. def _adjust_char_id(self, char_id):
  907. return char_id.replace("%20", "_")
  908. def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None):
  909. # docstring inherited
  910. writer = self.writer
  911. writer.comment(s)
  912. glyph_map = self._glyph_map
  913. text2path = self._text2path
  914. color = rgb2hex(gc.get_rgb())
  915. fontsize = prop.get_size_in_points()
  916. style = {}
  917. if color != '#000000':
  918. style['fill'] = color
  919. alpha = gc.get_alpha() if gc.get_forced_alpha() else gc.get_rgb()[3]
  920. if alpha != 1:
  921. style['opacity'] = _short_float_fmt(alpha)
  922. font_scale = fontsize / text2path.FONT_SCALE
  923. attrib = {
  924. 'style': _generate_css(style),
  925. 'transform': _generate_transform([
  926. ('translate', (x, y)),
  927. ('rotate', (-angle,)),
  928. ('scale', (font_scale, -font_scale))]),
  929. }
  930. writer.start('g', attrib=attrib)
  931. if not ismath:
  932. font = text2path._get_font(prop)
  933. _glyphs = text2path.get_glyphs_with_font(
  934. font, s, glyph_map=glyph_map, return_new_glyphs_only=True)
  935. glyph_info, glyph_map_new, rects = _glyphs
  936. self._update_glyph_map_defs(glyph_map_new)
  937. for glyph_id, xposition, yposition, scale in glyph_info:
  938. writer.element(
  939. 'use',
  940. transform=_generate_transform([
  941. ('translate', (xposition, yposition)),
  942. ('scale', (scale,)),
  943. ]),
  944. attrib={'xlink:href': f'#{glyph_id}'})
  945. else:
  946. if ismath == "TeX":
  947. _glyphs = text2path.get_glyphs_tex(
  948. prop, s, glyph_map=glyph_map, return_new_glyphs_only=True)
  949. else:
  950. _glyphs = text2path.get_glyphs_mathtext(
  951. prop, s, glyph_map=glyph_map, return_new_glyphs_only=True)
  952. glyph_info, glyph_map_new, rects = _glyphs
  953. self._update_glyph_map_defs(glyph_map_new)
  954. for char_id, xposition, yposition, scale in glyph_info:
  955. char_id = self._adjust_char_id(char_id)
  956. writer.element(
  957. 'use',
  958. transform=_generate_transform([
  959. ('translate', (xposition, yposition)),
  960. ('scale', (scale,)),
  961. ]),
  962. attrib={'xlink:href': f'#{char_id}'})
  963. for verts, codes in rects:
  964. path = Path(verts, codes)
  965. path_data = self._convert_path(path, simplify=False)
  966. writer.element('path', d=path_data)
  967. writer.end('g')
  968. def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None):
  969. # NOTE: If you change the font styling CSS, then be sure the check for
  970. # svg.fonttype = none in `lib/matplotlib/testing/compare.py::convert` remains in
  971. # sync. Also be sure to re-generate any SVG using this mode, or else such tests
  972. # will fail to use the right converter for the expected images, and they will
  973. # fail strangely.
  974. writer = self.writer
  975. color = rgb2hex(gc.get_rgb())
  976. font_style = {}
  977. color_style = {}
  978. if color != '#000000':
  979. color_style['fill'] = color
  980. alpha = gc.get_alpha() if gc.get_forced_alpha() else gc.get_rgb()[3]
  981. if alpha != 1:
  982. color_style['opacity'] = _short_float_fmt(alpha)
  983. if not ismath:
  984. attrib = {}
  985. # Separate font style in their separate attributes
  986. if prop.get_style() != 'normal':
  987. font_style['font-style'] = prop.get_style()
  988. if prop.get_variant() != 'normal':
  989. font_style['font-variant'] = prop.get_variant()
  990. weight = fm.weight_dict[prop.get_weight()]
  991. if weight != 400:
  992. font_style['font-weight'] = f'{weight}'
  993. def _normalize_sans(name):
  994. return 'sans-serif' if name in ['sans', 'sans serif'] else name
  995. def _expand_family_entry(fn):
  996. fn = _normalize_sans(fn)
  997. # prepend generic font families with all configured font names
  998. if fn in fm.font_family_aliases:
  999. # get all of the font names and fix spelling of sans-serif
  1000. # (we accept 3 ways CSS only supports 1)
  1001. for name in fm.FontManager._expand_aliases(fn):
  1002. yield _normalize_sans(name)
  1003. # whether a generic name or a family name, it must appear at
  1004. # least once
  1005. yield fn
  1006. def _get_all_quoted_names(prop):
  1007. # only quote specific names, not generic names
  1008. return [name if name in fm.font_family_aliases else repr(name)
  1009. for entry in prop.get_family()
  1010. for name in _expand_family_entry(entry)]
  1011. font_style['font-size'] = f'{_short_float_fmt(prop.get_size())}px'
  1012. # ensure expansion, quoting, and dedupe of font names
  1013. font_style['font-family'] = ", ".join(
  1014. dict.fromkeys(_get_all_quoted_names(prop))
  1015. )
  1016. if prop.get_stretch() != 'normal':
  1017. font_style['font-stretch'] = prop.get_stretch()
  1018. attrib['style'] = _generate_css({**font_style, **color_style})
  1019. if mtext and (angle == 0 or mtext.get_rotation_mode() == "anchor"):
  1020. # If text anchoring can be supported, get the original
  1021. # coordinates and add alignment information.
  1022. # Get anchor coordinates.
  1023. transform = mtext.get_transform()
  1024. ax, ay = transform.transform(mtext.get_unitless_position())
  1025. ay = self.height - ay
  1026. # Don't do vertical anchor alignment. Most applications do not
  1027. # support 'alignment-baseline' yet. Apply the vertical layout
  1028. # to the anchor point manually for now.
  1029. angle_rad = np.deg2rad(angle)
  1030. dir_vert = np.array([np.sin(angle_rad), np.cos(angle_rad)])
  1031. v_offset = np.dot(dir_vert, [(x - ax), (y - ay)])
  1032. ax = ax + v_offset * dir_vert[0]
  1033. ay = ay + v_offset * dir_vert[1]
  1034. ha_mpl_to_svg = {'left': 'start', 'right': 'end',
  1035. 'center': 'middle'}
  1036. font_style['text-anchor'] = ha_mpl_to_svg[mtext.get_ha()]
  1037. attrib['x'] = _short_float_fmt(ax)
  1038. attrib['y'] = _short_float_fmt(ay)
  1039. attrib['style'] = _generate_css({**font_style, **color_style})
  1040. attrib['transform'] = _generate_transform([
  1041. ("rotate", (-angle, ax, ay))])
  1042. else:
  1043. attrib['transform'] = _generate_transform([
  1044. ('translate', (x, y)),
  1045. ('rotate', (-angle,))])
  1046. writer.element('text', s, attrib=attrib)
  1047. else:
  1048. writer.comment(s)
  1049. width, height, descent, glyphs, rects = \
  1050. self._text2path.mathtext_parser.parse(s, 72, prop)
  1051. # Apply attributes to 'g', not 'text', because we likely have some
  1052. # rectangles as well with the same style and transformation.
  1053. writer.start('g',
  1054. style=_generate_css({**font_style, **color_style}),
  1055. transform=_generate_transform([
  1056. ('translate', (x, y)),
  1057. ('rotate', (-angle,))]),
  1058. )
  1059. writer.start('text')
  1060. # Sort the characters by font, and output one tspan for each.
  1061. spans = {}
  1062. for font, fontsize, thetext, new_x, new_y in glyphs:
  1063. entry = fm.ttfFontProperty(font)
  1064. font_style = {}
  1065. # Separate font style in its separate attributes
  1066. if entry.style != 'normal':
  1067. font_style['font-style'] = entry.style
  1068. if entry.variant != 'normal':
  1069. font_style['font-variant'] = entry.variant
  1070. if entry.weight != 400:
  1071. font_style['font-weight'] = f'{entry.weight}'
  1072. font_style['font-size'] = f'{_short_float_fmt(fontsize)}px'
  1073. font_style['font-family'] = f'{entry.name!r}' # ensure quoting
  1074. if entry.stretch != 'normal':
  1075. font_style['font-stretch'] = entry.stretch
  1076. style = _generate_css({**font_style, **color_style})
  1077. if thetext == 32:
  1078. thetext = 0xa0 # non-breaking space
  1079. spans.setdefault(style, []).append((new_x, -new_y, thetext))
  1080. for style, chars in spans.items():
  1081. chars.sort() # Sort by increasing x position
  1082. for x, y, t in chars: # Output one tspan for each character
  1083. writer.element(
  1084. 'tspan',
  1085. chr(t),
  1086. x=_short_float_fmt(x),
  1087. y=_short_float_fmt(y),
  1088. style=style)
  1089. writer.end('text')
  1090. for x, y, width, height in rects:
  1091. writer.element(
  1092. 'rect',
  1093. x=_short_float_fmt(x),
  1094. y=_short_float_fmt(-y-1),
  1095. width=_short_float_fmt(width),
  1096. height=_short_float_fmt(height)
  1097. )
  1098. writer.end('g')
  1099. def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
  1100. # docstring inherited
  1101. clip_attrs = self._get_clip_attrs(gc)
  1102. if clip_attrs:
  1103. # Cannot apply clip-path directly to the text, because
  1104. # it has a transformation
  1105. self.writer.start('g', **clip_attrs)
  1106. if gc.get_url() is not None:
  1107. self.writer.start('a', {'xlink:href': gc.get_url()})
  1108. if mpl.rcParams['svg.fonttype'] == 'path':
  1109. self._draw_text_as_path(gc, x, y, s, prop, angle, ismath, mtext)
  1110. else:
  1111. self._draw_text_as_text(gc, x, y, s, prop, angle, ismath, mtext)
  1112. if gc.get_url() is not None:
  1113. self.writer.end('a')
  1114. if clip_attrs:
  1115. self.writer.end('g')
  1116. def flipy(self):
  1117. # docstring inherited
  1118. return True
  1119. def get_canvas_width_height(self):
  1120. # docstring inherited
  1121. return self.width, self.height
  1122. def get_text_width_height_descent(self, s, prop, ismath):
  1123. # docstring inherited
  1124. return self._text2path.get_text_width_height_descent(s, prop, ismath)
  1125. class FigureCanvasSVG(FigureCanvasBase):
  1126. filetypes = {'svg': 'Scalable Vector Graphics',
  1127. 'svgz': 'Scalable Vector Graphics'}
  1128. fixed_dpi = 72
  1129. def print_svg(self, filename, *, bbox_inches_restore=None, metadata=None):
  1130. """
  1131. Parameters
  1132. ----------
  1133. filename : str or path-like or file-like
  1134. Output target; if a string, a file will be opened for writing.
  1135. metadata : dict[str, Any], optional
  1136. Metadata in the SVG file defined as key-value pairs of strings,
  1137. datetimes, or lists of strings, e.g., ``{'Creator': 'My software',
  1138. 'Contributor': ['Me', 'My Friend'], 'Title': 'Awesome'}``.
  1139. The standard keys and their value types are:
  1140. * *str*: ``'Coverage'``, ``'Description'``, ``'Format'``,
  1141. ``'Identifier'``, ``'Language'``, ``'Relation'``, ``'Source'``,
  1142. ``'Title'``, and ``'Type'``.
  1143. * *str* or *list of str*: ``'Contributor'``, ``'Creator'``,
  1144. ``'Keywords'``, ``'Publisher'``, and ``'Rights'``.
  1145. * *str*, *date*, *datetime*, or *tuple* of same: ``'Date'``. If a
  1146. non-*str*, then it will be formatted as ISO 8601.
  1147. Values have been predefined for ``'Creator'``, ``'Date'``,
  1148. ``'Format'``, and ``'Type'``. They can be removed by setting them
  1149. to `None`.
  1150. Information is encoded as `Dublin Core Metadata`__.
  1151. .. _DC: https://www.dublincore.org/specifications/dublin-core/
  1152. __ DC_
  1153. """
  1154. with cbook.open_file_cm(filename, "w", encoding="utf-8") as fh:
  1155. if not cbook.file_requires_unicode(fh):
  1156. fh = codecs.getwriter('utf-8')(fh)
  1157. dpi = self.figure.dpi
  1158. self.figure.dpi = 72
  1159. width, height = self.figure.get_size_inches()
  1160. w, h = width * 72, height * 72
  1161. renderer = MixedModeRenderer(
  1162. self.figure, width, height, dpi,
  1163. RendererSVG(w, h, fh, image_dpi=dpi, metadata=metadata),
  1164. bbox_inches_restore=bbox_inches_restore)
  1165. self.figure.draw(renderer)
  1166. renderer.finalize()
  1167. def print_svgz(self, filename, **kwargs):
  1168. with (cbook.open_file_cm(filename, "wb") as fh,
  1169. gzip.GzipFile(mode='w', fileobj=fh) as gzipwriter):
  1170. return self.print_svg(gzipwriter, **kwargs)
  1171. def get_default_filetype(self):
  1172. return 'svg'
  1173. def draw(self):
  1174. self.figure.draw_without_rendering()
  1175. return super().draw()
  1176. FigureManagerSVG = FigureManagerBase
  1177. svgProlog = """\
  1178. <?xml version="1.0" encoding="utf-8" standalone="no"?>
  1179. <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
  1180. "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
  1181. """
  1182. @_Backend.export
  1183. class _BackendSVG(_Backend):
  1184. backend_version = mpl.__version__
  1185. FigureCanvas = FigureCanvasSVG