11from collections import OrderedDict
22import base64
3+ import datetime
34import gzip
45import hashlib
56from io import BytesIO , StringIO , TextIOWrapper
67import itertools
78import logging
9+ import os
810import re
911import uuid
1012
1719 _Backend , FigureCanvasBase , FigureManagerBase , RendererBase )
1820from matplotlib .backends .backend_mixed import MixedModeRenderer
1921from matplotlib .colors import rgb2hex
22+ from matplotlib .dates import UTC
2023from matplotlib .font_manager import findfont , get_font
2124from matplotlib .ft2font import LOAD_NO_HINTING
2225from matplotlib .mathtext import MathTextParser
@@ -273,7 +276,8 @@ def generate_css(attrib={}):
273276
274277
275278class RendererSVG (RendererBase ):
276- def __init__ (self , width , height , svgwriter , basename = None , image_dpi = 72 ):
279+ def __init__ (self , width , height , svgwriter , basename = None , image_dpi = 72 ,
280+ * , metadata = None ):
277281 self .width = width
278282 self .height = height
279283 self .writer = XMLWriter (svgwriter )
@@ -304,6 +308,7 @@ def __init__(self, width, height, svgwriter, basename=None, image_dpi=72):
304308 xmlns = "http://www.w3.org/2000/svg" ,
305309 version = "1.1" ,
306310 attrib = {'xmlns:xlink' : "http://www.w3.org/1999/xlink" })
311+ self ._write_metadata (metadata )
307312 self ._write_default_style ()
308313
309314 def finalize (self ):
@@ -312,6 +317,112 @@ def finalize(self):
312317 self .writer .close (self ._start_id )
313318 self .writer .flush ()
314319
320+ def _write_metadata (self , metadata ):
321+ # Add metadata following the Dublin Core Metadata Initiative, and the
322+ # Creative Commons Rights Expression Language. This is mainly for
323+ # compatibility with Inkscape.
324+ if metadata is None :
325+ metadata = {}
326+ metadata = {
327+ 'Format' : 'image/svg+xml' ,
328+ 'Type' : 'http://purl.org/dc/dcmitype/StillImage' ,
329+ 'Creator' :
330+ f'Matplotlib v{ mpl .__version__ } , https://matplotlib.org/' ,
331+ ** metadata
332+ }
333+ writer = self .writer
334+
335+ if 'Title' in metadata :
336+ writer .element ('title' , text = metadata ['Title' ], indent = False )
337+
338+ # Special handling.
339+ date = metadata .get ('Date' , None )
340+ if date is not None :
341+ if isinstance (date , str ):
342+ dates = [date ]
343+ elif isinstance (date , (datetime .datetime , datetime .date )):
344+ dates = [date .isoformat ()]
345+ elif np .iterable (date ):
346+ dates = []
347+ for d in date :
348+ if isinstance (d , str ):
349+ dates .append (d )
350+ elif isinstance (d , (datetime .datetime , datetime .date )):
351+ dates .append (d .isoformat ())
352+ else :
353+ raise ValueError (
354+ 'Invalid type for Date metadata. '
355+ 'Expected iterable of str, date, or datetime, '
356+ 'not {!r}.' .format (type (d )))
357+ else :
358+ raise ValueError ('Invalid type for Date metadata. '
359+ 'Expected str, date, datetime, or iterable '
360+ 'of the same, not {!r}.' .format (type (date )))
361+ metadata ['Date' ] = '/' .join (dates )
362+ else :
363+ # Get source date from SOURCE_DATE_EPOCH, if set.
364+ # See https://reproducible-builds.org/specs/source-date-epoch/
365+ date = os .getenv ("SOURCE_DATE_EPOCH" )
366+ if date :
367+ date = datetime .datetime .utcfromtimestamp (int (date ))
368+ metadata ['Date' ] = date .replace (tzinfo = UTC ).isoformat ()
369+ else :
370+ metadata ['Date' ] = datetime .datetime .today ().isoformat ()
371+
372+ mid = writer .start ('metadata' )
373+ writer .start ('rdf:RDF' , attrib = {
374+ 'xmlns:dc' : "http://purl.org/dc/elements/1.1/" ,
375+ 'xmlns:cc' : "http://creativecommons.org/ns#" ,
376+ 'xmlns:rdf' : "http://www.w3.org/1999/02/22-rdf-syntax-ns#" ,
377+ })
378+ writer .start ('cc:Work' )
379+
380+ uri = metadata .pop ('Type' , None )
381+ if uri is not None :
382+ writer .element ('dc:type' , attrib = {'rdf:resource' : uri })
383+
384+ # Single value only.
385+ for key in ['title' , 'coverage' , 'date' , 'description' , 'format' ,
386+ 'identifier' , 'language' , 'relation' , 'source' ]:
387+ info = metadata .pop (key .title (), None )
388+ if info is not None :
389+ writer .element (f'dc:{ key } ' , text = info , indent = False )
390+
391+ # Multiple Agent values.
392+ for key in ['creator' , 'contributor' , 'publisher' , 'rights' ]:
393+ agents = metadata .pop (key .title (), None )
394+ if agents is None :
395+ continue
396+
397+ if isinstance (agents , str ):
398+ agents = [agents ]
399+
400+ writer .start (f'dc:{ key } ' )
401+ for agent in agents :
402+ writer .start ('cc:Agent' )
403+ writer .element ('dc:title' , text = agent , indent = False )
404+ writer .end ('cc:Agent' )
405+ writer .end (f'dc:{ key } ' )
406+
407+ # Multiple values.
408+ keywords = metadata .pop ('Keywords' , None )
409+ if keywords is not None :
410+ if isinstance (keywords , str ):
411+ keywords = [keywords ]
412+
413+ writer .start ('dc:subject' )
414+ writer .start ('rdf:Bag' )
415+ for keyword in keywords :
416+ writer .element ('rdf:li' , text = keyword , indent = False )
417+ writer .end ('rdf:Bag' )
418+ writer .end ('dc:subject' )
419+
420+ writer .close (mid )
421+
422+ if metadata :
423+ raise ValueError ('Unknown metadata key(s) passed to SVG writer: ' +
424+ ',' .join (metadata ))
425+
315426 def _write_default_style (self ):
316427 writer = self .writer
317428 default_style = generate_css ({
@@ -1163,6 +1274,36 @@ class FigureCanvasSVG(FigureCanvasBase):
11631274 fixed_dpi = 72
11641275
11651276 def print_svg (self , filename , * args , ** kwargs ):
1277+ """
1278+ Parameters
1279+ ----------
1280+ filename : str or path-like or file-like
1281+ Output target; if a string, a file will be opened for writing.
1282+ metadata : Dict[str, Any], optional
1283+ Metadata in the SVG file defined as key-value pairs of strings,
1284+ datetimes, or lists of strings, e.g., ``{'Creator': 'My software',
1285+ 'Contributor': ['Me', 'My Friend'], 'Title': 'Awesome'}``.
1286+
1287+ The standard keys and their value types are:
1288+
1289+ * *str*: ``'Coverage'``, ``'Description'``, ``'Format'``,
1290+ ``'Identifier'``, ``'Language'``, ``'Relation'``, ``'Source'``,
1291+ ``'Title'``, and ``'Type'``.
1292+ * *str* or *list of str*: ``'Contributor'``, ``'Creator'``,
1293+ ``'Keywords'``, ``'Publisher'``, and ``'Rights'``.
1294+ * *str*, *date*, *datetime*, or *tuple* of same: ``'Date'``. If a
1295+ non-*str*, then it will be formatted as ISO 8601.
1296+
1297+ Values have been predefined for ``'Creator'``, ``'Date'``,
1298+ ``'Format'``, and ``'Type'``. They can be removed by setting them
1299+ to `None`.
1300+
1301+ Information is encoded as `Dublin Core Metadata`__.
1302+
1303+ .. _DC: https://www.dublincore.org/specifications/dublin-core/
1304+
1305+ __ DC_
1306+ """
11661307 with cbook .open_file_cm (filename , "w" , encoding = "utf-8" ) as fh :
11671308
11681309 filename = getattr (fh , 'name' , '' )
@@ -1187,15 +1328,15 @@ def print_svgz(self, filename, *args, **kwargs):
11871328 gzip .GzipFile (mode = 'w' , fileobj = fh ) as gzipwriter :
11881329 return self .print_svg (gzipwriter )
11891330
1190- def _print_svg (
1191- self , filename , fh , * , dpi = 72 , bbox_inches_restore = None , ** kwargs ):
1331+ def _print_svg (self , filename , fh , * , dpi = 72 , bbox_inches_restore = None ,
1332+ metadata = None , ** kwargs ):
11921333 self .figure .set_dpi (72.0 )
11931334 width , height = self .figure .get_size_inches ()
11941335 w , h = width * 72 , height * 72
11951336
11961337 renderer = MixedModeRenderer (
11971338 self .figure , width , height , dpi ,
1198- RendererSVG (w , h , fh , filename , dpi ),
1339+ RendererSVG (w , h , fh , filename , dpi , metadata = metadata ),
11991340 bbox_inches_restore = bbox_inches_restore )
12001341
12011342 self .figure .draw (renderer )
0 commit comments