1
1
from collections import OrderedDict
2
2
import base64
3
+ import datetime
3
4
import gzip
4
5
import hashlib
5
6
from io import BytesIO , StringIO , TextIOWrapper
6
7
import itertools
7
8
import logging
9
+ import os
8
10
import re
9
11
import uuid
10
12
17
19
_Backend , FigureCanvasBase , FigureManagerBase , RendererBase )
18
20
from matplotlib .backends .backend_mixed import MixedModeRenderer
19
21
from matplotlib .colors import rgb2hex
22
+ from matplotlib .dates import UTC
20
23
from matplotlib .font_manager import findfont , get_font
21
24
from matplotlib .ft2font import LOAD_NO_HINTING
22
25
from matplotlib .mathtext import MathTextParser
@@ -273,7 +276,8 @@ def generate_css(attrib={}):
273
276
274
277
275
278
class 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 ):
277
281
self .width = width
278
282
self .height = height
279
283
self .writer = XMLWriter (svgwriter )
@@ -304,6 +308,7 @@ def __init__(self, width, height, svgwriter, basename=None, image_dpi=72):
304
308
xmlns = "http://www.w3.org/2000/svg" ,
305
309
version = "1.1" ,
306
310
attrib = {'xmlns:xlink' : "http://www.w3.org/1999/xlink" })
311
+ self ._write_metadata (metadata )
307
312
self ._write_default_style ()
308
313
309
314
def finalize (self ):
@@ -312,6 +317,112 @@ def finalize(self):
312
317
self .writer .close (self ._start_id )
313
318
self .writer .flush ()
314
319
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
+
315
426
def _write_default_style (self ):
316
427
writer = self .writer
317
428
default_style = generate_css ({
@@ -1163,6 +1274,36 @@ class FigureCanvasSVG(FigureCanvasBase):
1163
1274
fixed_dpi = 72
1164
1275
1165
1276
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
+ """
1166
1307
with cbook .open_file_cm (filename , "w" , encoding = "utf-8" ) as fh :
1167
1308
1168
1309
filename = getattr (fh , 'name' , '' )
@@ -1187,15 +1328,15 @@ def print_svgz(self, filename, *args, **kwargs):
1187
1328
gzip .GzipFile (mode = 'w' , fileobj = fh ) as gzipwriter :
1188
1329
return self .print_svg (gzipwriter )
1189
1330
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 ):
1192
1333
self .figure .set_dpi (72.0 )
1193
1334
width , height = self .figure .get_size_inches ()
1194
1335
w , h = width * 72 , height * 72
1195
1336
1196
1337
renderer = MixedModeRenderer (
1197
1338
self .figure , width , height , dpi ,
1198
- RendererSVG (w , h , fh , filename , dpi ),
1339
+ RendererSVG (w , h , fh , filename , dpi , metadata = metadata ),
1199
1340
bbox_inches_restore = bbox_inches_restore )
1200
1341
1201
1342
self .figure .draw (renderer )
0 commit comments