backend_svg.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241
  1. from collections import OrderedDict
  2. import base64
  3. import gzip
  4. import hashlib
  5. import io
  6. import itertools
  7. import logging
  8. import re
  9. import uuid
  10. import numpy as np
  11. from matplotlib import cbook, __version__, rcParams
  12. from matplotlib.backend_bases import (
  13. _Backend, FigureCanvasBase, FigureManagerBase, RendererBase)
  14. from matplotlib.backends.backend_mixed import MixedModeRenderer
  15. from matplotlib.colors import rgb2hex
  16. from matplotlib.font_manager import findfont, get_font
  17. from matplotlib.ft2font import LOAD_NO_HINTING
  18. from matplotlib.mathtext import MathTextParser
  19. from matplotlib.path import Path
  20. from matplotlib import _path
  21. from matplotlib.transforms import Affine2D, Affine2DBase
  22. from matplotlib import _png
  23. _log = logging.getLogger(__name__)
  24. backend_version = __version__
  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 short_float_fmt(x):
  77. """
  78. Create a short string representation of a float, which is %f
  79. formatting with trailing zeros and the decimal point removed.
  80. """
  81. return '{0:f}'.format(x).rstrip('0').rstrip('.')
  82. class XMLWriter:
  83. """
  84. Parameters
  85. ----------
  86. file : writable text file-like object
  87. """
  88. def __init__(self, file):
  89. self.__write = file.write
  90. if hasattr(file, "flush"):
  91. self.flush = file.flush
  92. self.__open = 0 # true if start tag is open
  93. self.__tags = []
  94. self.__data = []
  95. self.__indentation = " " * 64
  96. def __flush(self, indent=True):
  97. # flush internal buffers
  98. if self.__open:
  99. if indent:
  100. self.__write(">\n")
  101. else:
  102. self.__write(">")
  103. self.__open = 0
  104. if self.__data:
  105. data = ''.join(self.__data)
  106. self.__write(escape_cdata(data))
  107. self.__data = []
  108. def start(self, tag, attrib={}, **extra):
  109. """
  110. Opens a new element. Attributes can be given as keyword
  111. arguments, or as a string/string dictionary. The method returns
  112. an opaque identifier that can be passed to the :meth:`close`
  113. method, to close all open elements up to and including this one.
  114. Parameters
  115. ----------
  116. tag
  117. Element tag.
  118. attrib
  119. Attribute dictionary. Alternatively, attributes can be given as
  120. keyword arguments.
  121. Returns
  122. -------
  123. An element identifier.
  124. """
  125. self.__flush()
  126. tag = escape_cdata(tag)
  127. self.__data = []
  128. self.__tags.append(tag)
  129. self.__write(self.__indentation[:len(self.__tags) - 1])
  130. self.__write("<%s" % tag)
  131. for k, v in sorted({**attrib, **extra}.items()):
  132. if v:
  133. k = escape_cdata(k)
  134. v = escape_attrib(v)
  135. self.__write(' %s="%s"' % (k, v))
  136. self.__open = 1
  137. return len(self.__tags) - 1
  138. def comment(self, comment):
  139. """
  140. Adds a comment to the output stream.
  141. Parameters
  142. ----------
  143. comment : str
  144. Comment text.
  145. """
  146. self.__flush()
  147. self.__write(self.__indentation[:len(self.__tags)])
  148. self.__write("<!-- %s -->\n" % escape_comment(comment))
  149. def data(self, text):
  150. """
  151. Adds character data to the output stream.
  152. Parameters
  153. ----------
  154. text : str
  155. Character data.
  156. """
  157. self.__data.append(text)
  158. def end(self, tag=None, indent=True):
  159. """
  160. Closes the current element (opened by the most recent call to
  161. :meth:`start`).
  162. Parameters
  163. ----------
  164. tag
  165. Element tag. If given, the tag must match the start tag. If
  166. omitted, the current element is closed.
  167. """
  168. if tag:
  169. assert self.__tags, "unbalanced end(%s)" % tag
  170. assert escape_cdata(tag) == self.__tags[-1], \
  171. "expected end(%s), got %s" % (self.__tags[-1], tag)
  172. else:
  173. assert self.__tags, "unbalanced end()"
  174. tag = self.__tags.pop()
  175. if self.__data:
  176. self.__flush(indent)
  177. elif self.__open:
  178. self.__open = 0
  179. self.__write("/>\n")
  180. return
  181. if indent:
  182. self.__write(self.__indentation[:len(self.__tags)])
  183. self.__write("</%s>\n" % tag)
  184. def close(self, id):
  185. """
  186. Closes open elements, up to (and including) the element identified
  187. by the given identifier.
  188. Parameters
  189. ----------
  190. id
  191. Element identifier, as returned by the :meth:`start` method.
  192. """
  193. while len(self.__tags) > id:
  194. self.end()
  195. def element(self, tag, text=None, attrib={}, **extra):
  196. """
  197. Adds an entire element. This is the same as calling :meth:`start`,
  198. :meth:`data`, and :meth:`end` in sequence. The *text* argument can be
  199. omitted.
  200. """
  201. self.start(tag, attrib, **extra)
  202. if text:
  203. self.data(text)
  204. self.end(indent=False)
  205. def flush(self):
  206. """Flushes the output stream."""
  207. pass # replaced by the constructor
  208. def generate_transform(transform_list=[]):
  209. if len(transform_list):
  210. output = io.StringIO()
  211. for type, value in transform_list:
  212. if (type == 'scale' and (value == (1,) or value == (1, 1))
  213. or type == 'translate' and value == (0, 0)
  214. or type == 'rotate' and value == (0,)):
  215. continue
  216. if type == 'matrix' and isinstance(value, Affine2DBase):
  217. value = value.to_values()
  218. output.write('%s(%s)' % (
  219. type, ' '.join(short_float_fmt(x) for x in value)))
  220. return output.getvalue()
  221. return ''
  222. def generate_css(attrib={}):
  223. if attrib:
  224. output = io.StringIO()
  225. attrib = sorted(attrib.items())
  226. for k, v in attrib:
  227. k = escape_attrib(k)
  228. v = escape_attrib(v)
  229. output.write("%s:%s;" % (k, v))
  230. return output.getvalue()
  231. return ''
  232. _capstyle_d = {'projecting': 'square', 'butt': 'butt', 'round': 'round'}
  233. class RendererSVG(RendererBase):
  234. def __init__(self, width, height, svgwriter, basename=None, image_dpi=72):
  235. self.width = width
  236. self.height = height
  237. self.writer = XMLWriter(svgwriter)
  238. self.image_dpi = image_dpi # actual dpi at which we rasterize stuff
  239. self._groupd = {}
  240. self.basename = basename
  241. self._image_counter = itertools.count()
  242. self._clipd = OrderedDict()
  243. self._markers = {}
  244. self._path_collection_id = 0
  245. self._hatchd = OrderedDict()
  246. self._has_gouraud = False
  247. self._n_gradients = 0
  248. self._fonts = OrderedDict()
  249. self.mathtext_parser = MathTextParser('SVG')
  250. RendererBase.__init__(self)
  251. self._glyph_map = dict()
  252. str_height = short_float_fmt(height)
  253. str_width = short_float_fmt(width)
  254. svgwriter.write(svgProlog)
  255. self._start_id = self.writer.start(
  256. 'svg',
  257. width='%spt' % str_width,
  258. height='%spt' % str_height,
  259. viewBox='0 0 %s %s' % (str_width, str_height),
  260. xmlns="http://www.w3.org/2000/svg",
  261. version="1.1",
  262. attrib={'xmlns:xlink': "http://www.w3.org/1999/xlink"})
  263. self._write_default_style()
  264. def finalize(self):
  265. self._write_clips()
  266. self._write_hatches()
  267. self.writer.close(self._start_id)
  268. self.writer.flush()
  269. def _write_default_style(self):
  270. writer = self.writer
  271. default_style = generate_css({
  272. 'stroke-linejoin': 'round',
  273. 'stroke-linecap': 'butt'})
  274. writer.start('defs')
  275. writer.start('style', type='text/css')
  276. writer.data('*{%s}\n' % default_style)
  277. writer.end('style')
  278. writer.end('defs')
  279. def _make_id(self, type, content):
  280. salt = rcParams['svg.hashsalt']
  281. if salt is None:
  282. salt = str(uuid.uuid4())
  283. m = hashlib.md5()
  284. m.update(salt.encode('utf8'))
  285. m.update(str(content).encode('utf8'))
  286. return '%s%s' % (type, m.hexdigest()[:10])
  287. def _make_flip_transform(self, transform):
  288. return (transform +
  289. Affine2D()
  290. .scale(1.0, -1.0)
  291. .translate(0.0, self.height))
  292. def _get_font(self, prop):
  293. fname = findfont(prop)
  294. font = get_font(fname)
  295. font.clear()
  296. size = prop.get_size_in_points()
  297. font.set_size(size, 72.0)
  298. return font
  299. def _get_hatch(self, gc, rgbFace):
  300. """
  301. Create a new hatch pattern
  302. """
  303. if rgbFace is not None:
  304. rgbFace = tuple(rgbFace)
  305. edge = gc.get_hatch_color()
  306. if edge is not None:
  307. edge = tuple(edge)
  308. dictkey = (gc.get_hatch(), rgbFace, edge)
  309. oid = self._hatchd.get(dictkey)
  310. if oid is None:
  311. oid = self._make_id('h', dictkey)
  312. self._hatchd[dictkey] = ((gc.get_hatch_path(), rgbFace, edge), oid)
  313. else:
  314. _, oid = oid
  315. return oid
  316. def _write_hatches(self):
  317. if not len(self._hatchd):
  318. return
  319. HATCH_SIZE = 72
  320. writer = self.writer
  321. writer.start('defs')
  322. for (path, face, stroke), oid in self._hatchd.values():
  323. writer.start(
  324. 'pattern',
  325. id=oid,
  326. patternUnits="userSpaceOnUse",
  327. x="0", y="0", width=str(HATCH_SIZE),
  328. height=str(HATCH_SIZE))
  329. path_data = self._convert_path(
  330. path,
  331. Affine2D()
  332. .scale(HATCH_SIZE).scale(1.0, -1.0).translate(0, HATCH_SIZE),
  333. simplify=False)
  334. if face is None:
  335. fill = 'none'
  336. else:
  337. fill = rgb2hex(face)
  338. writer.element(
  339. 'rect',
  340. x="0", y="0", width=str(HATCH_SIZE+1),
  341. height=str(HATCH_SIZE+1),
  342. fill=fill)
  343. writer.element(
  344. 'path',
  345. d=path_data,
  346. style=generate_css({
  347. 'fill': rgb2hex(stroke),
  348. 'stroke': rgb2hex(stroke),
  349. 'stroke-width': str(rcParams['hatch.linewidth']),
  350. 'stroke-linecap': 'butt',
  351. 'stroke-linejoin': 'miter'
  352. })
  353. )
  354. writer.end('pattern')
  355. writer.end('defs')
  356. def _get_style_dict(self, gc, rgbFace):
  357. """Generate a style string from the GraphicsContext and rgbFace."""
  358. attrib = {}
  359. forced_alpha = gc.get_forced_alpha()
  360. if gc.get_hatch() is not None:
  361. attrib['fill'] = "url(#%s)" % self._get_hatch(gc, rgbFace)
  362. if (rgbFace is not None and len(rgbFace) == 4 and rgbFace[3] != 1.0
  363. and not forced_alpha):
  364. attrib['fill-opacity'] = short_float_fmt(rgbFace[3])
  365. else:
  366. if rgbFace is None:
  367. attrib['fill'] = 'none'
  368. else:
  369. if tuple(rgbFace[:3]) != (0, 0, 0):
  370. attrib['fill'] = rgb2hex(rgbFace)
  371. if (len(rgbFace) == 4 and rgbFace[3] != 1.0
  372. and not forced_alpha):
  373. attrib['fill-opacity'] = short_float_fmt(rgbFace[3])
  374. if forced_alpha and gc.get_alpha() != 1.0:
  375. attrib['opacity'] = short_float_fmt(gc.get_alpha())
  376. offset, seq = gc.get_dashes()
  377. if seq is not None:
  378. attrib['stroke-dasharray'] = ','.join(
  379. short_float_fmt(val) for val in seq)
  380. attrib['stroke-dashoffset'] = short_float_fmt(float(offset))
  381. linewidth = gc.get_linewidth()
  382. if linewidth:
  383. rgb = gc.get_rgb()
  384. attrib['stroke'] = rgb2hex(rgb)
  385. if not forced_alpha and rgb[3] != 1.0:
  386. attrib['stroke-opacity'] = short_float_fmt(rgb[3])
  387. if linewidth != 1.0:
  388. attrib['stroke-width'] = short_float_fmt(linewidth)
  389. if gc.get_joinstyle() != 'round':
  390. attrib['stroke-linejoin'] = gc.get_joinstyle()
  391. if gc.get_capstyle() != 'butt':
  392. attrib['stroke-linecap'] = _capstyle_d[gc.get_capstyle()]
  393. return attrib
  394. def _get_style(self, gc, rgbFace):
  395. return generate_css(self._get_style_dict(gc, rgbFace))
  396. def _get_clip(self, gc):
  397. cliprect = gc.get_clip_rectangle()
  398. clippath, clippath_trans = gc.get_clip_path()
  399. if clippath is not None:
  400. clippath_trans = self._make_flip_transform(clippath_trans)
  401. dictkey = (id(clippath), str(clippath_trans))
  402. elif cliprect is not None:
  403. x, y, w, h = cliprect.bounds
  404. y = self.height-(y+h)
  405. dictkey = (x, y, w, h)
  406. else:
  407. return None
  408. clip = self._clipd.get(dictkey)
  409. if clip is None:
  410. oid = self._make_id('p', dictkey)
  411. if clippath is not None:
  412. self._clipd[dictkey] = ((clippath, clippath_trans), oid)
  413. else:
  414. self._clipd[dictkey] = (dictkey, oid)
  415. else:
  416. clip, oid = clip
  417. return oid
  418. def _write_clips(self):
  419. if not len(self._clipd):
  420. return
  421. writer = self.writer
  422. writer.start('defs')
  423. for clip, oid in self._clipd.values():
  424. writer.start('clipPath', id=oid)
  425. if len(clip) == 2:
  426. clippath, clippath_trans = clip
  427. path_data = self._convert_path(
  428. clippath, clippath_trans, simplify=False)
  429. writer.element('path', d=path_data)
  430. else:
  431. x, y, w, h = clip
  432. writer.element(
  433. 'rect',
  434. x=short_float_fmt(x),
  435. y=short_float_fmt(y),
  436. width=short_float_fmt(w),
  437. height=short_float_fmt(h))
  438. writer.end('clipPath')
  439. writer.end('defs')
  440. def open_group(self, s, gid=None):
  441. # docstring inherited
  442. if gid:
  443. self.writer.start('g', id=gid)
  444. else:
  445. self._groupd[s] = self._groupd.get(s, 0) + 1
  446. self.writer.start('g', id="%s_%d" % (s, self._groupd[s]))
  447. def close_group(self, s):
  448. # docstring inherited
  449. self.writer.end('g')
  450. def option_image_nocomposite(self):
  451. # docstring inherited
  452. return not rcParams['image.composite_image']
  453. def _convert_path(self, path, transform=None, clip=None, simplify=None,
  454. sketch=None):
  455. if clip:
  456. clip = (0.0, 0.0, self.width, self.height)
  457. else:
  458. clip = None
  459. return _path.convert_to_string(
  460. path, transform, clip, simplify, sketch, 6,
  461. [b'M', b'L', b'Q', b'C', b'z'], False).decode('ascii')
  462. def draw_path(self, gc, path, transform, rgbFace=None):
  463. # docstring inherited
  464. trans_and_flip = self._make_flip_transform(transform)
  465. clip = (rgbFace is None and gc.get_hatch_path() is None)
  466. simplify = path.should_simplify and clip
  467. path_data = self._convert_path(
  468. path, trans_and_flip, clip=clip, simplify=simplify,
  469. sketch=gc.get_sketch_params())
  470. attrib = {}
  471. attrib['style'] = self._get_style(gc, rgbFace)
  472. clipid = self._get_clip(gc)
  473. if clipid is not None:
  474. attrib['clip-path'] = 'url(#%s)' % clipid
  475. if gc.get_url() is not None:
  476. self.writer.start('a', {'xlink:href': gc.get_url()})
  477. self.writer.element('path', d=path_data, attrib=attrib)
  478. if gc.get_url() is not None:
  479. self.writer.end('a')
  480. def draw_markers(
  481. self, gc, marker_path, marker_trans, path, trans, rgbFace=None):
  482. # docstring inherited
  483. if not len(path.vertices):
  484. return
  485. writer = self.writer
  486. path_data = self._convert_path(
  487. marker_path,
  488. marker_trans + Affine2D().scale(1.0, -1.0),
  489. simplify=False)
  490. style = self._get_style_dict(gc, rgbFace)
  491. dictkey = (path_data, generate_css(style))
  492. oid = self._markers.get(dictkey)
  493. style = generate_css({k: v for k, v in style.items()
  494. if k.startswith('stroke')})
  495. if oid is None:
  496. oid = self._make_id('m', dictkey)
  497. writer.start('defs')
  498. writer.element('path', id=oid, d=path_data, style=style)
  499. writer.end('defs')
  500. self._markers[dictkey] = oid
  501. attrib = {}
  502. clipid = self._get_clip(gc)
  503. if clipid is not None:
  504. attrib['clip-path'] = 'url(#%s)' % clipid
  505. writer.start('g', attrib=attrib)
  506. trans_and_flip = self._make_flip_transform(trans)
  507. attrib = {'xlink:href': '#%s' % oid}
  508. clip = (0, 0, self.width*72, self.height*72)
  509. for vertices, code in path.iter_segments(
  510. trans_and_flip, clip=clip, simplify=False):
  511. if len(vertices):
  512. x, y = vertices[-2:]
  513. attrib['x'] = short_float_fmt(x)
  514. attrib['y'] = short_float_fmt(y)
  515. attrib['style'] = self._get_style(gc, rgbFace)
  516. writer.element('use', attrib=attrib)
  517. writer.end('g')
  518. def draw_path_collection(self, gc, master_transform, paths, all_transforms,
  519. offsets, offsetTrans, facecolors, edgecolors,
  520. linewidths, linestyles, antialiaseds, urls,
  521. offset_position):
  522. # Is the optimization worth it? Rough calculation:
  523. # cost of emitting a path in-line is
  524. # (len_path + 5) * uses_per_path
  525. # cost of definition+use is
  526. # (len_path + 3) + 9 * uses_per_path
  527. len_path = len(paths[0].vertices) if len(paths) > 0 else 0
  528. uses_per_path = self._iter_collection_uses_per_path(
  529. paths, all_transforms, offsets, facecolors, edgecolors)
  530. should_do_optimization = \
  531. len_path + 9 * uses_per_path + 3 < (len_path + 5) * uses_per_path
  532. if not should_do_optimization:
  533. return RendererBase.draw_path_collection(
  534. self, gc, master_transform, paths, all_transforms,
  535. offsets, offsetTrans, facecolors, edgecolors,
  536. linewidths, linestyles, antialiaseds, urls,
  537. offset_position)
  538. writer = self.writer
  539. path_codes = []
  540. writer.start('defs')
  541. for i, (path, transform) in enumerate(self._iter_collection_raw_paths(
  542. master_transform, paths, all_transforms)):
  543. transform = Affine2D(transform.get_matrix()).scale(1.0, -1.0)
  544. d = self._convert_path(path, transform, simplify=False)
  545. oid = 'C%x_%x_%s' % (
  546. self._path_collection_id, i, self._make_id('', d))
  547. writer.element('path', id=oid, d=d)
  548. path_codes.append(oid)
  549. writer.end('defs')
  550. for xo, yo, path_id, gc0, rgbFace in self._iter_collection(
  551. gc, master_transform, all_transforms, path_codes, offsets,
  552. offsetTrans, facecolors, edgecolors, linewidths, linestyles,
  553. antialiaseds, urls, offset_position):
  554. clipid = self._get_clip(gc0)
  555. url = gc0.get_url()
  556. if url is not None:
  557. writer.start('a', attrib={'xlink:href': url})
  558. if clipid is not None:
  559. writer.start('g', attrib={'clip-path': 'url(#%s)' % clipid})
  560. attrib = {
  561. 'xlink:href': '#%s' % path_id,
  562. 'x': short_float_fmt(xo),
  563. 'y': short_float_fmt(self.height - yo),
  564. 'style': self._get_style(gc0, rgbFace)
  565. }
  566. writer.element('use', attrib=attrib)
  567. if clipid is not None:
  568. writer.end('g')
  569. if url is not None:
  570. writer.end('a')
  571. self._path_collection_id += 1
  572. def draw_gouraud_triangle(self, gc, points, colors, trans):
  573. # This uses a method described here:
  574. #
  575. # http://www.svgopen.org/2005/papers/Converting3DFaceToSVG/index.html
  576. #
  577. # that uses three overlapping linear gradients to simulate a
  578. # Gouraud triangle. Each gradient goes from fully opaque in
  579. # one corner to fully transparent along the opposite edge.
  580. # The line between the stop points is perpendicular to the
  581. # opposite edge. Underlying these three gradients is a solid
  582. # triangle whose color is the average of all three points.
  583. writer = self.writer
  584. if not self._has_gouraud:
  585. self._has_gouraud = True
  586. writer.start(
  587. 'filter',
  588. id='colorAdd')
  589. writer.element(
  590. 'feComposite',
  591. attrib={'in': 'SourceGraphic'},
  592. in2='BackgroundImage',
  593. operator='arithmetic',
  594. k2="1", k3="1")
  595. writer.end('filter')
  596. # feColorMatrix filter to correct opacity
  597. writer.start(
  598. 'filter',
  599. id='colorMat')
  600. writer.element(
  601. 'feColorMatrix',
  602. attrib={'type': 'matrix'},
  603. values='1 0 0 0 0 \n0 1 0 0 0 \n0 0 1 0 0' +
  604. ' \n1 1 1 1 0 \n0 0 0 0 1 ')
  605. writer.end('filter')
  606. avg_color = np.sum(colors[:, :], axis=0) / 3.0
  607. # Just skip fully-transparent triangles
  608. if avg_color[-1] == 0.0:
  609. return
  610. trans_and_flip = self._make_flip_transform(trans)
  611. tpoints = trans_and_flip.transform(points)
  612. writer.start('defs')
  613. for i in range(3):
  614. x1, y1 = tpoints[i]
  615. x2, y2 = tpoints[(i + 1) % 3]
  616. x3, y3 = tpoints[(i + 2) % 3]
  617. c = colors[i][:]
  618. if x2 == x3:
  619. xb = x2
  620. yb = y1
  621. elif y2 == y3:
  622. xb = x1
  623. yb = y2
  624. else:
  625. m1 = (y2 - y3) / (x2 - x3)
  626. b1 = y2 - (m1 * x2)
  627. m2 = -(1.0 / m1)
  628. b2 = y1 - (m2 * x1)
  629. xb = (-b1 + b2) / (m1 - m2)
  630. yb = m2 * xb + b2
  631. writer.start(
  632. 'linearGradient',
  633. id="GR%x_%d" % (self._n_gradients, i),
  634. gradientUnits="userSpaceOnUse",
  635. x1=short_float_fmt(x1), y1=short_float_fmt(y1),
  636. x2=short_float_fmt(xb), y2=short_float_fmt(yb))
  637. writer.element(
  638. 'stop',
  639. offset='1',
  640. style=generate_css({'stop-color': rgb2hex(avg_color),
  641. 'stop-opacity': short_float_fmt(c[-1])}))
  642. writer.element(
  643. 'stop',
  644. offset='0',
  645. style=generate_css({'stop-color': rgb2hex(c),
  646. 'stop-opacity': "0"}))
  647. writer.end('linearGradient')
  648. writer.end('defs')
  649. # triangle formation using "path"
  650. dpath = "M " + short_float_fmt(x1)+',' + short_float_fmt(y1)
  651. dpath += " L " + short_float_fmt(x2) + ',' + short_float_fmt(y2)
  652. dpath += " " + short_float_fmt(x3) + ',' + short_float_fmt(y3) + " Z"
  653. writer.element(
  654. 'path',
  655. attrib={'d': dpath,
  656. 'fill': rgb2hex(avg_color),
  657. 'fill-opacity': '1',
  658. 'shape-rendering': "crispEdges"})
  659. writer.start(
  660. 'g',
  661. attrib={'stroke': "none",
  662. 'stroke-width': "0",
  663. 'shape-rendering': "crispEdges",
  664. 'filter': "url(#colorMat)"})
  665. writer.element(
  666. 'path',
  667. attrib={'d': dpath,
  668. 'fill': 'url(#GR%x_0)' % self._n_gradients,
  669. 'shape-rendering': "crispEdges"})
  670. writer.element(
  671. 'path',
  672. attrib={'d': dpath,
  673. 'fill': 'url(#GR%x_1)' % self._n_gradients,
  674. 'filter': 'url(#colorAdd)',
  675. 'shape-rendering': "crispEdges"})
  676. writer.element(
  677. 'path',
  678. attrib={'d': dpath,
  679. 'fill': 'url(#GR%x_2)' % self._n_gradients,
  680. 'filter': 'url(#colorAdd)',
  681. 'shape-rendering': "crispEdges"})
  682. writer.end('g')
  683. self._n_gradients += 1
  684. def draw_gouraud_triangles(self, gc, triangles_array, colors_array,
  685. transform):
  686. attrib = {}
  687. clipid = self._get_clip(gc)
  688. if clipid is not None:
  689. attrib['clip-path'] = 'url(#%s)' % clipid
  690. self.writer.start('g', attrib=attrib)
  691. transform = transform.frozen()
  692. for tri, col in zip(triangles_array, colors_array):
  693. self.draw_gouraud_triangle(gc, tri, col, transform)
  694. self.writer.end('g')
  695. def option_scale_image(self):
  696. # docstring inherited
  697. return True
  698. def get_image_magnification(self):
  699. return self.image_dpi / 72.0
  700. def draw_image(self, gc, x, y, im, transform=None):
  701. # docstring inherited
  702. h, w = im.shape[:2]
  703. if w == 0 or h == 0:
  704. return
  705. attrib = {}
  706. clipid = self._get_clip(gc)
  707. if clipid is not None:
  708. # Can't apply clip-path directly to the image because the
  709. # image has a transformation, which would also be applied
  710. # to the clip-path
  711. self.writer.start('g', attrib={'clip-path': 'url(#%s)' % clipid})
  712. oid = gc.get_gid()
  713. url = gc.get_url()
  714. if url is not None:
  715. self.writer.start('a', attrib={'xlink:href': url})
  716. if rcParams['svg.image_inline']:
  717. buf = _png.write_png(im, None)
  718. oid = oid or self._make_id('image', buf)
  719. attrib['xlink:href'] = (
  720. "data:image/png;base64,\n" +
  721. base64.b64encode(buf).decode('ascii'))
  722. else:
  723. if self.basename is None:
  724. raise ValueError("Cannot save image data to filesystem when "
  725. "writing SVG to an in-memory buffer")
  726. filename = '{}.image{}.png'.format(
  727. self.basename, next(self._image_counter))
  728. _log.info('Writing image file for inclusion: %s', filename)
  729. with open(filename, 'wb') as file:
  730. _png.write_png(im, file)
  731. oid = oid or 'Im_' + self._make_id('image', filename)
  732. attrib['xlink:href'] = filename
  733. attrib['id'] = oid
  734. if transform is None:
  735. w = 72.0 * w / self.image_dpi
  736. h = 72.0 * h / self.image_dpi
  737. self.writer.element(
  738. 'image',
  739. transform=generate_transform([
  740. ('scale', (1, -1)), ('translate', (0, -h))]),
  741. x=short_float_fmt(x),
  742. y=short_float_fmt(-(self.height - y - h)),
  743. width=short_float_fmt(w), height=short_float_fmt(h),
  744. attrib=attrib)
  745. else:
  746. alpha = gc.get_alpha()
  747. if alpha != 1.0:
  748. attrib['opacity'] = short_float_fmt(alpha)
  749. flipped = (
  750. Affine2D().scale(1.0 / w, 1.0 / h) +
  751. transform +
  752. Affine2D()
  753. .translate(x, y)
  754. .scale(1.0, -1.0)
  755. .translate(0.0, self.height))
  756. attrib['transform'] = generate_transform(
  757. [('matrix', flipped.frozen())])
  758. self.writer.element(
  759. 'image',
  760. width=short_float_fmt(w), height=short_float_fmt(h),
  761. attrib=attrib)
  762. if url is not None:
  763. self.writer.end('a')
  764. if clipid is not None:
  765. self.writer.end('g')
  766. def _adjust_char_id(self, char_id):
  767. return char_id.replace("%20", "_")
  768. def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None):
  769. """
  770. draw the text by converting them to paths using textpath module.
  771. Parameters
  772. ----------
  773. prop : `matplotlib.font_manager.FontProperties`
  774. font property
  775. s : str
  776. text to be converted
  777. usetex : bool
  778. If True, use matplotlib usetex mode.
  779. ismath : bool
  780. If True, use mathtext parser. If "TeX", use *usetex* mode.
  781. """
  782. writer = self.writer
  783. writer.comment(s)
  784. glyph_map = self._glyph_map
  785. text2path = self._text2path
  786. color = rgb2hex(gc.get_rgb())
  787. fontsize = prop.get_size_in_points()
  788. style = {}
  789. if color != '#000000':
  790. style['fill'] = color
  791. alpha = gc.get_alpha() if gc.get_forced_alpha() else gc.get_rgb()[3]
  792. if alpha != 1:
  793. style['opacity'] = short_float_fmt(alpha)
  794. if not ismath:
  795. font = text2path._get_font(prop)
  796. _glyphs = text2path.get_glyphs_with_font(
  797. font, s, glyph_map=glyph_map, return_new_glyphs_only=True)
  798. glyph_info, glyph_map_new, rects = _glyphs
  799. if glyph_map_new:
  800. writer.start('defs')
  801. for char_id, glyph_path in glyph_map_new.items():
  802. path = Path(*glyph_path)
  803. path_data = self._convert_path(path, simplify=False)
  804. writer.element('path', id=char_id, d=path_data)
  805. writer.end('defs')
  806. glyph_map.update(glyph_map_new)
  807. attrib = {}
  808. attrib['style'] = generate_css(style)
  809. font_scale = fontsize / text2path.FONT_SCALE
  810. attrib['transform'] = generate_transform([
  811. ('translate', (x, y)),
  812. ('rotate', (-angle,)),
  813. ('scale', (font_scale, -font_scale))])
  814. writer.start('g', attrib=attrib)
  815. for glyph_id, xposition, yposition, scale in glyph_info:
  816. attrib = {'xlink:href': '#%s' % glyph_id}
  817. if xposition != 0.0:
  818. attrib['x'] = short_float_fmt(xposition)
  819. if yposition != 0.0:
  820. attrib['y'] = short_float_fmt(yposition)
  821. writer.element(
  822. 'use',
  823. attrib=attrib)
  824. writer.end('g')
  825. else:
  826. if ismath == "TeX":
  827. _glyphs = text2path.get_glyphs_tex(
  828. prop, s, glyph_map=glyph_map, return_new_glyphs_only=True)
  829. else:
  830. _glyphs = text2path.get_glyphs_mathtext(
  831. prop, s, glyph_map=glyph_map, return_new_glyphs_only=True)
  832. glyph_info, glyph_map_new, rects = _glyphs
  833. # We store the character glyphs w/o flipping. Instead, the
  834. # coordinate will be flipped when these characters are used.
  835. if glyph_map_new:
  836. writer.start('defs')
  837. for char_id, glyph_path in glyph_map_new.items():
  838. char_id = self._adjust_char_id(char_id)
  839. # Some characters are blank
  840. if not len(glyph_path[0]):
  841. path_data = ""
  842. else:
  843. path = Path(*glyph_path)
  844. path_data = self._convert_path(path, simplify=False)
  845. writer.element('path', id=char_id, d=path_data)
  846. writer.end('defs')
  847. glyph_map.update(glyph_map_new)
  848. attrib = {}
  849. font_scale = fontsize / text2path.FONT_SCALE
  850. attrib['style'] = generate_css(style)
  851. attrib['transform'] = generate_transform([
  852. ('translate', (x, y)),
  853. ('rotate', (-angle,)),
  854. ('scale', (font_scale, -font_scale))])
  855. writer.start('g', attrib=attrib)
  856. for char_id, xposition, yposition, scale in glyph_info:
  857. char_id = self._adjust_char_id(char_id)
  858. writer.element(
  859. 'use',
  860. transform=generate_transform([
  861. ('translate', (xposition, yposition)),
  862. ('scale', (scale,)),
  863. ]),
  864. attrib={'xlink:href': '#%s' % char_id})
  865. for verts, codes in rects:
  866. path = Path(verts, codes)
  867. path_data = self._convert_path(path, simplify=False)
  868. writer.element('path', d=path_data)
  869. writer.end('g')
  870. def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None):
  871. writer = self.writer
  872. color = rgb2hex(gc.get_rgb())
  873. style = {}
  874. if color != '#000000':
  875. style['fill'] = color
  876. alpha = gc.get_alpha() if gc.get_forced_alpha() else gc.get_rgb()[3]
  877. if alpha != 1:
  878. style['opacity'] = short_float_fmt(alpha)
  879. if not ismath:
  880. font = self._get_font(prop)
  881. font.set_text(s, 0.0, flags=LOAD_NO_HINTING)
  882. attrib = {}
  883. # Must add "px" to workaround a Firefox bug
  884. style['font-size'] = short_float_fmt(prop.get_size()) + 'px'
  885. style['font-family'] = str(font.family_name)
  886. style['font-style'] = prop.get_style().lower()
  887. style['font-weight'] = str(prop.get_weight()).lower()
  888. attrib['style'] = generate_css(style)
  889. if mtext and (angle == 0 or mtext.get_rotation_mode() == "anchor"):
  890. # If text anchoring can be supported, get the original
  891. # coordinates and add alignment information.
  892. # Get anchor coordinates.
  893. transform = mtext.get_transform()
  894. ax, ay = transform.transform(mtext.get_unitless_position())
  895. ay = self.height - ay
  896. # Don't do vertical anchor alignment. Most applications do not
  897. # support 'alignment-baseline' yet. Apply the vertical layout
  898. # to the anchor point manually for now.
  899. angle_rad = np.deg2rad(angle)
  900. dir_vert = np.array([np.sin(angle_rad), np.cos(angle_rad)])
  901. v_offset = np.dot(dir_vert, [(x - ax), (y - ay)])
  902. ax = ax + v_offset * dir_vert[0]
  903. ay = ay + v_offset * dir_vert[1]
  904. ha_mpl_to_svg = {'left': 'start', 'right': 'end',
  905. 'center': 'middle'}
  906. style['text-anchor'] = ha_mpl_to_svg[mtext.get_ha()]
  907. attrib['x'] = short_float_fmt(ax)
  908. attrib['y'] = short_float_fmt(ay)
  909. attrib['style'] = generate_css(style)
  910. attrib['transform'] = "rotate(%s, %s, %s)" % (
  911. short_float_fmt(-angle),
  912. short_float_fmt(ax),
  913. short_float_fmt(ay))
  914. writer.element('text', s, attrib=attrib)
  915. else:
  916. attrib['transform'] = generate_transform([
  917. ('translate', (x, y)),
  918. ('rotate', (-angle,))])
  919. writer.element('text', s, attrib=attrib)
  920. else:
  921. writer.comment(s)
  922. width, height, descent, svg_elements, used_characters = \
  923. self.mathtext_parser.parse(s, 72, prop)
  924. svg_glyphs = svg_elements.svg_glyphs
  925. svg_rects = svg_elements.svg_rects
  926. attrib = {}
  927. attrib['style'] = generate_css(style)
  928. attrib['transform'] = generate_transform([
  929. ('translate', (x, y)),
  930. ('rotate', (-angle,))])
  931. # Apply attributes to 'g', not 'text', because we likely have some
  932. # rectangles as well with the same style and transformation.
  933. writer.start('g', attrib=attrib)
  934. writer.start('text')
  935. # Sort the characters by font, and output one tspan for each.
  936. spans = OrderedDict()
  937. for font, fontsize, thetext, new_x, new_y, metrics in svg_glyphs:
  938. style = generate_css({
  939. 'font-size': short_float_fmt(fontsize) + 'px',
  940. 'font-family': font.family_name,
  941. 'font-style': font.style_name.lower(),
  942. 'font-weight': font.style_name.lower()})
  943. if thetext == 32:
  944. thetext = 0xa0 # non-breaking space
  945. spans.setdefault(style, []).append((new_x, -new_y, thetext))
  946. for style, chars in spans.items():
  947. chars.sort()
  948. if len({y for x, y, t in chars}) == 1: # Are all y's the same?
  949. ys = str(chars[0][1])
  950. else:
  951. ys = ' '.join(str(c[1]) for c in chars)
  952. attrib = {
  953. 'style': style,
  954. 'x': ' '.join(short_float_fmt(c[0]) for c in chars),
  955. 'y': ys
  956. }
  957. writer.element(
  958. 'tspan',
  959. ''.join(chr(c[2]) for c in chars),
  960. attrib=attrib)
  961. writer.end('text')
  962. if len(svg_rects):
  963. for x, y, width, height in svg_rects:
  964. writer.element(
  965. 'rect',
  966. x=short_float_fmt(x),
  967. y=short_float_fmt(-y + height),
  968. width=short_float_fmt(width),
  969. height=short_float_fmt(height)
  970. )
  971. writer.end('g')
  972. def draw_tex(self, gc, x, y, s, prop, angle, ismath='TeX!', mtext=None):
  973. # docstring inherited
  974. self._draw_text_as_path(gc, x, y, s, prop, angle, ismath="TeX")
  975. def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
  976. # docstring inherited
  977. clipid = self._get_clip(gc)
  978. if clipid is not None:
  979. # Cannot apply clip-path directly to the text, because
  980. # is has a transformation
  981. self.writer.start(
  982. 'g', attrib={'clip-path': 'url(#%s)' % clipid})
  983. if gc.get_url() is not None:
  984. self.writer.start('a', {'xlink:href': gc.get_url()})
  985. if rcParams['svg.fonttype'] == 'path':
  986. self._draw_text_as_path(gc, x, y, s, prop, angle, ismath, mtext)
  987. else:
  988. self._draw_text_as_text(gc, x, y, s, prop, angle, ismath, mtext)
  989. if gc.get_url() is not None:
  990. self.writer.end('a')
  991. if clipid is not None:
  992. self.writer.end('g')
  993. def flipy(self):
  994. # docstring inherited
  995. return True
  996. def get_canvas_width_height(self):
  997. # docstring inherited
  998. return self.width, self.height
  999. def get_text_width_height_descent(self, s, prop, ismath):
  1000. # docstring inherited
  1001. return self._text2path.get_text_width_height_descent(s, prop, ismath)
  1002. class FigureCanvasSVG(FigureCanvasBase):
  1003. filetypes = {'svg': 'Scalable Vector Graphics',
  1004. 'svgz': 'Scalable Vector Graphics'}
  1005. fixed_dpi = 72
  1006. def print_svg(self, filename, *args, **kwargs):
  1007. with cbook.open_file_cm(filename, "w", encoding="utf-8") as fh:
  1008. filename = getattr(fh, 'name', '')
  1009. if not isinstance(filename, str):
  1010. filename = ''
  1011. if cbook.file_requires_unicode(fh):
  1012. detach = False
  1013. else:
  1014. fh = io.TextIOWrapper(fh, 'utf-8')
  1015. detach = True
  1016. result = self._print_svg(filename, fh, **kwargs)
  1017. # Detach underlying stream from wrapper so that it remains open in
  1018. # the caller.
  1019. if detach:
  1020. fh.detach()
  1021. return result
  1022. def print_svgz(self, filename, *args, **kwargs):
  1023. with cbook.open_file_cm(filename, "wb") as fh, \
  1024. gzip.GzipFile(mode='w', fileobj=fh) as gzipwriter:
  1025. return self.print_svg(gzipwriter)
  1026. def _print_svg(
  1027. self, filename, fh, *, dpi=72, bbox_inches_restore=None, **kwargs):
  1028. self.figure.set_dpi(72.0)
  1029. width, height = self.figure.get_size_inches()
  1030. w, h = width * 72, height * 72
  1031. renderer = MixedModeRenderer(
  1032. self.figure, width, height, dpi,
  1033. RendererSVG(w, h, fh, filename, dpi),
  1034. bbox_inches_restore=bbox_inches_restore)
  1035. self.figure.draw(renderer)
  1036. renderer.finalize()
  1037. def get_default_filetype(self):
  1038. return 'svg'
  1039. FigureManagerSVG = FigureManagerBase
  1040. svgProlog = """\
  1041. <?xml version="1.0" encoding="utf-8" standalone="no"?>
  1042. <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
  1043. "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
  1044. <!-- Created with matplotlib (https://matplotlib.org/) -->
  1045. """
  1046. @_Backend.export
  1047. class _BackendSVG(_Backend):
  1048. FigureCanvas = FigureCanvasSVG