-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathsvg2pdfx.py
executable file
·2043 lines (1706 loc) · 93.5 KB
/
svg2pdfx.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# todo:
# 1) allow all elements to set font-size (and other styles: text-anchor etc.)
# 2) patterns in a separate Object, patternUnits/matrix
# 3) linear/radial gradients -- linear gradients done!
# 4) import external svg
# 5) masking-path-07-b.svg
# 6) BBoxes!!!!! Especially for the root svg -- improved!
# 7) Polyline
# 8) <marker>
# 9) the preserveAspectRatio attribute
'''
svg2pdfx -- convert SVG(s) to PDF efficiently using XObjects
usage: svg2pdfx [-o output.pdf] input1.svg [input2.svg ...]
'''
# globalCount = 0
# These are Page/XObject-s bounding box margins.
# We're talking PDF BBoxes, which have not effect unless they're too small in which case cropping occurs.
# So it's a good idea to keep them on the safe side.
# With this parameter, all BBOxes will be inflated by a factor = 1+margins;
# set margin = 0 for no inflation. A good idea is to keep these margins around 1 to avoid unneeded cropping
# The 'overflow' SVG attribute controls whether cropping should occur at all; we should probably set margins = 1000
# if overflow == 'visible'; it's not implemented yet, though, so we just set margins to a const
margins = 1
# ================================================== Imports
import sys
import string
import re
import math
from math import sin, cos, tan, acos, sqrt, ceil
import base64
from PIL import Image
import io
import zlib
import random
import hashlib
# import codecs
# Try using: github.com/sarnold/pdfrw as it contains many fixes compared to pmaupin's version
from pdfrw import PdfWriter, PdfName, PdfArray, PdfDict, IndirectPdfDict, PdfObject, py23_diffs
from pdfrwx.common import err, warn
from pdfrwx.pdffont import PdfFontUtils
import xml.etree.ElementTree as ET
# ================================================== svgUnits
# These are the SVG units assuming 72 dpi which is appropriate for PDF
# The 'em' unit is relative to the font size: 2em == 2*font_size
# We just assume it's 12 for now, but this needs to be properly coded.
# svgUnits = {'pt':1, 'px':0.75, 'pc':12, 'mm':2.83464, 'cm':28.3464, 'in':72}
svgUnits = {'pt':1, 'px':1, 'pc':12, 'mm':2.83464, 'cm':28.3464, 'in':72, 'em':12}
# svgUnits = {'pt':1.25, 'px':1, 'pc':12, 'mm':2.83464, 'cm':28.3464, 'in':72, 'em':12}
# ================================================== svgIndirectTags
# These are elements that are not to be painted when encountered, only when referenced
# We add 'defs' to it (which cannot be referenced) for ease of processing since it
# is not to be painted by itself either. <title> is excluded since it contains metadata (no drawing)
svgIndirectTags = ['defs', 'altGlyphDef', 'clipPath', 'cursor', 'filter', 'linearGradient',
'marker', 'mask', 'pattern', 'radialGradient', 'symbol',
'SVGTestCase','title']
# ================================================== font config
options = {}
import os
exec_path = os.path.dirname(os.path.abspath(__file__))
ini_path = os.path.join(exec_path,'svg2pdfx.ini')
try:
with open(ini_path, 'r') as f:
for line in f:
l = re.split('=', line.strip(), maxsplit=1)
if len(l) != 2: print(f'skipping: {line}') ; continue
options.update({l[0]:l[1]})
print(f'Read options from {ini_path}:\n{options}')
except:
print(f'Failed to read options from {ini_path}')
defaultFontDir = options.get('defaultFontDir','fonts')
defaultFontDir = os.path.join(exec_path, defaultFontDir)
defaultUnicodeFont = options.get('defaultUnicodeFont', 'NimbusSans-Regular')
# ================================================== the utilities
utils = PdfFontUtils()
# ================================================== fullProcSet
# This is the full list of PDF processing instruction sets
fullProcSet = [PdfName.PDF, PdfName.Text, PdfName.ImageB, PdfName.ImageC, PdfName.ImageI]
# ================================================== Auxiliary functions
def shift():
sys.argv.pop(0)
if len(sys.argv) == 0:
print(f'Type svg2pdfx -h for usage info')
sys.exit()
def idGenerator(size=6, chars=string.ascii_uppercase + string.digits):
return ''.join(random.choice(chars) for _ in range(size))
# ================================================== class attrDict(dict)
class attrDict(dict):
def __getattr__(self, key): return self.__getitem__(key)
def __setattr__(self, key, value): self.__setitem__(key,value)
def __getitem__(self, key): return dict.__getitem__(self, key) if key in self else None
def __setitem__(self, key, value):
if value is not None: dict.__setitem__(self, key, value)
elif key in self: del self[key]
# ================================================== units()
def units(coordinates: string):
'''Transform a string containing (comma/space-separated) coordinates or dimensions
to a float representation in user units (px)
Returns a list of floats or a single float for a single coordinate.
ViewPort deminsions arguments are needed to process units given as % of the viewPort'''
coordList = re.split(r'[\s,]+',coordinates.strip('%')) # Just remove % till proper processing is coded
for i in range(len(coordList)):
try:
c = coordList[i]
if len(c) > 2 and c[-2:] in svgUnits:
coordList[i] = float(c[:-2])*svgUnits[c[-2:]]
else:
coordList[i] = float(c)
except:
warn(f'invalid list of coordinates or dimensions: \'{coordinates}\'; returning None')
return None
return coordList if len(coordList) > 1 else coordList[0]
# ================================================== rgb_color()
def rgb_color(v:string):
'''
Parses SVG color strings and returns color in the form of a triple: [R,G,B]
'''
# Full list of SVG colors (https://www.w3.org/TR/SVG11/types.html#ColorKeywords)
RGB = {'aliceblue':[240,248,255],'antiquewhite':[250,235,215],'aqua':[0,255,255],'aquamarine':[127,255,212],
'azure':[240,255,255],'beige':[245,245,220],'bisque':[255,228,196],'black':[0,0,0],
'blanchedalmond':[255,235,205],'blue':[0,0,255],'blueviolet':[138,43,226],'brown':[165,42,42],
'burlywood':[222,184,135],'cadetblue':[95,158,160],'chartreuse':[127,255,0],'chocolate':[210,105,30],
'coral':[255,127,80],'cornflowerblue':[100,149,237],'cornsilk':[255,248,220],'crimson':[220,20,60],
'cyan':[0,255,255],'darkblue':[0,0,139],'darkcyan':[0,139,139],'darkgoldenrod':[184,134,11],
'darkgray':[169,169,169],'darkgreen':[0,100,0],'darkgrey':[169,169,169],'darkkhaki':[189,183,107],
'darkmagenta':[139,0,139],'darkolivegreen':[85,107,47],'darkorange':[255,140,0],'darkorchid':[153,50,204],
'darkred':[139,0,0],'darksalmon':[233,150,122],'darkseagreen':[143,188,143],'darkslateblue':[72,61,139],
'darkslategray':[47,79,79],'darkslategrey':[47,79,79],'darkturquoise':[0,206,209],'darkviolet':[148,0,211],
'deeppink':[255,20,147],'deepskyblue':[0,191,255],'dimgray':[105,105,105],'dimgrey':[105,105,105],
'dodgerblue':[30,144,255],'firebrick':[178,34,34],'floralwhite':[255,250,240],'forestgreen':[34,139,34],
'fuchsia':[255,0,255],'gainsboro':[220,220,220],'ghostwhite':[248,248,255],'gold':[255,215,0],
'goldenrod':[218,165,32],'gray':[128,128,128],'grey':[128,128,128],'green':[0,128,0],
'greenyellow':[173,255,47],'honeydew':[240,255,240],'hotpink':[255,105,180],'indianred':[205,92,92],
'indigo':[75,0,130],'ivory':[255,255,240],'khaki':[240,230,140],'lavender':[230,230,250],
'lavenderblush':[255,240,245],'lawngreen':[124,252,0],'lemonchiffon':[255,250,205],'lightblue':[173,216,230],
'lightcoral':[240,128,128],'lightcyan':[224,255,255],'lightgoldenrodyellow':[250,250,210],'lightgray':[211,211,211],
'lightgreen':[144,238,144],'lightgrey':[211,211,211],'lightpink':[255,182,193],'lightsalmon':[255,160,122],
'lightseagreen':[32,178,170],'lightskyblue':[135,206,250],'lightslategray':[119,136,153],'lightslategrey':[119,136,153],
'lightsteelblue':[176,196,222],'lightyellow':[255,255,224],'lime':[0,255,0],'limegreen':[50,205,50],
'linen':[250,240,230],'magenta':[255,0,255],'maroon':[128,0,0],'mediumaquamarine':[102,205,170],
'mediumblue':[0,0,205],'mediumorchid':[186,85,211],'mediumpurple':[147,112,219],'mediumseagreen':[60,179,113],
'mediumslateblue':[123,104,238],'mediumspringgreen':[0,250,154],'mediumturquoise':[72,209,204],'mediumvioletred':[199,21,133],
'midnightblue':[25,25,112],'mintcream':[245,255,250],'mistyrose':[255,228,225],'moccasin':[255,228,181],
'navajowhite':[255,222,173],'navy':[0,0,128],'oldlace':[253,245,230],'olive':[128,128,0],
'olivedrab':[107,142,35],'orange':[255,165,0],'orangered':[255,69,0],'orchid':[218,112,214],
'palegoldenrod':[238,232,170],'palegreen':[152,251,152],'paleturquoise':[175,238,238],'palevioletred':[219,112,147],
'papayawhip':[255,239,213],'peachpuff':[255,218,185],'peru':[205,133,63],'pink':[255,192,203],
'plum':[221,160,221],'powderblue':[176,224,230],'purple':[128,0,128],'red':[255,0,0],
'rosybrown':[188,143,143],'royalblue':[65,105,225],'saddlebrown':[139,69,19],'salmon':[250,128,114],
'sandybrown':[244,164,96],'seagreen':[46,139,87],'seashell':[255,245,238],'sienna':[160,82,45],
'silver':[192,192,192],'skyblue':[135,206,235],'slateblue':[106,90,205],'slategray':[112,128,144],
'slategrey':[112,128,144],'snow':[255,250,250],'springgreen':[0,255,127],'steelblue':[70,130,180],
'tan':[210,180,140],'teal':[0,128,128],'thistle':[216,191,216],'tomato':[255,99,71],
'turquoise':[64,224,208],'violet':[238,130,238],'wheat':[245,222,179],'white':[255,255,255],
'whitesmoke':[245,245,245],'yellow':[255,255,0],'yellowgreen':[154,205,50]}
try:
if v in RGB: # colors referred to by name
rgb = RGB[v]
elif v[0] == '#':
v = v.strip('#') # colors in the hex format #AABBCC
if len(v)==3: v = ''.join([c+c for c in v])
if len(v)!=6: err(f'invalid color: {v}')
rgb = [int(h,16) for h in re.findall('..',v)]
elif v[:4] == 'rgb(': # colors in the 'rgb(r,g,b)' format
rgbStrLst = [d.strip(' )') for d in re.split(r'[\s,]+',v[4:])]
rgb = [int(c) if c[-1] != '%' else int(float(c[:-1])*255/100) for c in rgbStrLst ]
else: err(f'invalid color: {v}')
except:
err(f'invalid color: {v}')
if len(rgb) != 3 or any(d>255 or d<0 for d in rgb): err(f'invalid color: {v}')
return rgb
# ================================================== class PATH
class PATH(list):
def __init__(self, svgPath: str):
'''Create a list representation (list of str/float lists) of the SVG path (<path d="..">)
by parsing the d-string. Path commands' numerical arguments are converted to floats'''
# Path commands names together with the numbers of their arguments
svgPathCommands = {'M':2,'m':2,'Z':0,'z':0,'L':2,'l':2,'H':1,'h':1,'V':1,'v':1,
'C':6,'c':6,'S':4,'s':4,'Q':4,'q':4,'T':2,'t':2,'A':7,'a':7}
# This is needed for converting M/m commands with > 2 args to a string of M+L(n) commands
cmdConvert = {'M':'L','m':'l'}
self.bbox = None
commands = re.split(r'([a-zA-Z][^a-zA-Z]*)', svgPath.strip())
commands = [cmd.strip() for cmd in commands]
commands = [cmd for cmd in commands if len(cmd)>0]
for cmd in commands:
try:
cmdName = cmd[0]
if cmdName not in svgPathCommands: err(f'invalid path command: {cmd}')
argStr = cmd[1:].strip()
argStr = re.sub(r'[.]([0-9]+)',r'.\1 ',argStr)
argStr = re.sub(r'-',' -',argStr).strip()
args = re.split(r'[\s,]+',argStr) if len(argStr)>0 else []
# The args now is a list of strings. Special treatment of the elliptic arc commands is needed:
# their arguments may include flags ('0' & '1') which may not be comma/space-separated
if cmdName in ['A','a']:
i = 0
while (i+4 < len(args)):
flag = args[i+3]
if len(flag)>1: args.insert(i+4,flag[1:]); args[i+3] = flag[0]
flag = args[i+4]
if len(flag)>1: args.insert(i+5,flag[1:]); args[i+4] = flag[0]
i += 7
# Convert args from list of strings list of floats
args = [float(arg) for arg in args]
# Check that there's a whole number of argument chunks
m = len(args) # actual number of arguments
n = svgPathCommands[cmdName] # expected number of arguments in a single arguments chunk
if n==0 and m>0 or n>0 and (m==0 or m % n != 0): err(f'bad number of arguments: {cmd}')
if n==0: self.append([cmdName]); continue
# De-concatenate same-command sequences
for i in range(0,m,n):
self.append([cmdName if cmdName not in cmdConvert or i==0 else cmdConvert[cmdName]] + args[i:i+n])
except:
err(f'invalid path command: {cmd}')
self.normalize()
def __repr__(self):
'''Implicit conversion to str'''
return self.toPdfStream()
def normalize(self):
'''Converts all commands and coordinates to absolute (upper-case) versions and sets self.bbox'''
x=0; y=0 # current point
xmin = None; xmax = None; ymin = None; ymax = None
xStart = 0; yStart = 0
for tokens in self:
cmd = tokens[0]
if cmd=='Z' or cmd=='z': x=xStart; y=yStart; pass
elif cmd=='H': x=tokens[1]
elif cmd=='h': tokens[1]+=x; x=tokens[1]
elif cmd=='V': y=tokens[1]
elif cmd=='v': tokens[1]+=y; y=tokens[1]
else:
if len(tokens) < 3: err(f'path command has few arguments: {tokens}')
if cmd in ['m','l','c','s','q','t']:
for i in range(1,len(tokens),2): tokens[i] += x; tokens[i+1] += y
if cmd == 'a':
tokens[6] += x; tokens[7] += y # only the last two arguments are relative
x=tokens[-2]; y=tokens[-1]
if cmd in ['M','m']: xStart = x; yStart = y
tokens[0] = tokens[0].upper()
xmin = min(xmin,x) if xmin != None else x
xmax = max(xmax,x) if xmax != None else x
ymin = min(ymin,y) if ymin != None else y
ymax = max(ymax,y) if ymax != None else y
b = [xmin, ymin, xmax, ymax]
if any(a == None for a in b): self.bbox = None
else: self.bbox = BOX(b)
def toPdfStream(self):
'''Convert internal representation (list of str/float lists) to PDF path commands
All SVG commands except the ones for quadratic Bezier curves have their exact countarparts in PDF.
Quadratic Bezier curve commands (Q,q,T,t), are expressed (with fidelity) in terms of the cubic ones:
https://fontforge.org/docs/techref/bezier.html#converting-truetype-to-postscript.
Conversion of ellipse-drawing commands (A,a) is not implemented and exits with error'''
r = ''
u=0.33333
v=0.66667
cx=0; cy=0 # current point
nx=0; ny=0 # new current point after the current command
dx=0; dy=0 # diff between (cx,cy) and the last control point of the previous Bezier curve
cmdPrev=''
p = lambda x: f'{round(x*1000)/1000:f}'.rstrip('0').rstrip('.')
for tokens in self:
cmd = tokens[0]
a = tokens[1:] # Arguments; slicing copies element's values; we don't modify a[] though
s=''
# Determine new current point
if cmd=='Z': pass
elif cmd=='H': nx=a[0]
elif cmd=='V': ny=a[0]
else: nx=a[-2]; ny=a[-1]
# Set (dx,dy)=0 if command class changes
if cmd == 'S' and cmdPrev not in ['C','S'] \
or cmd == 'T' and cmdPrev not in ['Q','T']:
dx=0; dy=0
# Move commands:
if cmd == 'M': s = f'{p(nx)} {p(ny)} m\n'
# Path closing commands
elif cmd == 'Z': s = 'h\n'
# Line commands
elif cmd in ['L','H','V']: s = f'{p(nx)} {p(ny)} l\n'
# Bezier curve commands:
elif cmd == 'C': s = f'{p(a[0])} {p(a[1])} {p(a[2])} {p(a[3])} {p(nx)} {p(ny)} c\n'
elif cmd == 'S': s = f'{p(cx+dx)} {p(cy+dy)} {p(a[0])} {p(a[1])} {p(nx)} {p(ny)} c '
elif cmd == 'Q': s = f'{p(u*cx+v*a[0])} {p(u*cy+v*a[1])} {p(u*nx+v*a[0])} {p(u*ny+v*a[1])} {p(nx)} {p(ny)} c\n'
elif cmd == 'T': s = f'{p(cx+v*dx)} {p(cy+v*dy)} {p(u*nx+v*(cx+dx))} {p(u*ny+v*(cy+dy))} {p(nx)} {p(ny)} c\n'
elif cmd == 'A': s = A2C.a2c(cx,cy,a)
else:
err(f'path command invalid or its conversion not implemented: {tokens}')
# Determine (dx,dy)
if cmd == 'C': dx=nx-a[2]; dy=ny-a[3]
elif cmd in ['S','Q']: dx=nx-a[0]; dy=ny-a[1]
elif cmd == 'T': dx=nx-cx-dx; dy=ny-cy-dy
else: dx=0; dy=0
cmdPrev = cmd
cx=nx; cy=ny
r+=s
return r
# def parseBack(self):
# r = ''
# for commandLine in self:
# r += commandLine[0] + ' ' + ' '.join([f'{a:f}' for a in commandLine[1:]]) + ' '
# return r.strip()
class A2C:
'''A utility class for converting elliptic arcs to Bezier curves.
The code is a (corrected) Python version of the a2c.js from the svgpath package:
https://github.com/fontello/svgpath
All credits & thanks go to the original authors: Sergey Batishchev, Vitaly Puzrin & Alex Kocharin'''
def unit_vector_angle(ux, uy, vx, vy):
'''Calculate an angle between two unit vectors.
Since we measure angle between radii of circular arcs,
we can use simplified math (without length normalization)
'''
sign = -1 if ux * vy - uy * vx < 0 else 1
dot = ux * vx + uy * vy
# Add this to work with arbitrary vectors:
# dot /= Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy);
# rounding errors, e.g. -1.0000000000000002 can screw up this
if (dot > 1.0): dot = 1.0
if (dot < -1.0): dot = -1.0
return sign * acos(dot)
def get_arc_center(x1, y1, x2, y2, fa, fs, rx, ry, sin_phi, cos_phi):
'''Convert from endpoint to center parameterization,
see http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes
Returns: cx, cy, theta1, delta_theta
'''
# Step 1.
# Moving an ellipse so origin will be the middlepoint between our two
# points. After that, rotate it to line up ellipse axes with coordinate axes
x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2
y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2
rx_sq = rx * rx
ry_sq = ry * ry
x1p_sq = x1p * x1p
y1p_sq = y1p * y1p
# Step 2.
# Compute coordinates of the centre of this ellipse (cx', cy') in the new coordinate system.
radicant = (rx_sq * ry_sq) - (rx_sq * y1p_sq) - (ry_sq * x1p_sq)
if radicant < 0: radicant = 0 # due to rounding errors it might be e.g. -1.38e-17
radicant /= (rx_sq * y1p_sq) + (ry_sq * x1p_sq)
radicant = sqrt(radicant) * (-1 if fa == fs else 1)
cxp = radicant * rx/ry * y1p
cyp = radicant * -ry/rx * x1p
# Step 3.
# Transform back to get centre coordinates (cx, cy) in the original coordinate system.
cx = cos_phi*cxp - sin_phi*cyp + (x1+x2)/2
cy = sin_phi*cxp + cos_phi*cyp + (y1+y2)/2
# Step 4.
# Compute angles (theta1, delta_theta).
v1x = (x1p - cxp) / rx
v1y = (y1p - cyp) / ry
v2x = (-x1p - cxp) / rx
v2y = (-y1p - cyp) / ry
theta1 = A2C.unit_vector_angle(1, 0, v1x, v1y)
delta_theta = A2C.unit_vector_angle(v1x, v1y, v2x, v2y)
if (fs == 0 and delta_theta > 0): delta_theta -= 2 * math.pi
if (fs == 1 and delta_theta < 0): delta_theta += 2 * math.pi
return cx, cy, theta1, delta_theta
def approximate_unit_arc(theta1, delta_theta):
'''Approximate one unit arc segment with bézier curves,
see http://math.stackexchange.com/questions/873224.
Returns: [p0x,p0y,p1x,p1y,p2x,p2y,p3x,p3y] - the list of 4 points of the Bezier curve.'''
alpha = 4/3 * tan(delta_theta/4)
x1,y1 = cos(theta1),sin(theta1)
x2,y2 = cos(theta1 + delta_theta),sin(theta1 + delta_theta)
return [x1, y1, x1 - y1*alpha, y1 + x1*alpha, x2 + y2*alpha, y2 - x2*alpha, x2, y2]
def a2c(x1:float, y1:float, arguments:list):
'''Converts an elliptic arc command arguments to one ore more PDF 'c'-commands (Bezier curves).
(x1,y1) is the starting point of the arc (current point),
arguments == [rx, ry, phi, fa, fs, x2, y2] (see SVG spec).
Returns: string with PDF c-commands'''
p = lambda x: f'{round(x*1000)/1000:f}'.rstrip('0').rstrip('.')
rx, ry, phi, fa, fs, x2, y2 = arguments
if (x1 == x2 and y1 == y2): return '' # draw nothing if (x1,y1) == (x2,y2), as the spec says
if (rx == 0 or ry == 0): return f'{p(x2)} {p(y2)} l\n' # return a straight line, as the spec says
rx,ry = abs(rx),abs(ry)
sin_phi = sin(phi * math.pi / 180)
cos_phi = cos(phi * math.pi / 180)
x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2
y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2
# Compensate out-of-range radii
scale = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
if (scale > 1): rx *= sqrt(scale); ry *= sqrt(scale)
# Get center parameters: cx, cy, theta1, delta_theta
cx, cy, theta1, delta_theta = A2C.get_arc_center(x1, y1, x2, y2, fa, fs, rx, ry, sin_phi, cos_phi)
# Split an arc to multiple segments, so each segment will be less than pi/2 == 90°
segments = max(ceil(abs(delta_theta) / (math.pi / 2)), 1)
delta_theta /= segments
result = ''
for i in range(segments):
curve = A2C.approximate_unit_arc(theta1, delta_theta)
# We have a bezier approximation of a unit arc,
# now need to transform back to the original ellipse
for i in range(0,len(curve),2):
x,y = curve[i:i+2]
# scale
x *= rx; y *= ry
# rotate
xp = cos_phi*x - sin_phi*y
yp = sin_phi*x + cos_phi*y
# translate
curve[i:i+2] = xp + cx, yp + cy
result += ' '.join(p(c) for c in curve[2:]) + ' c\n'
theta1 += delta_theta
return result
# def parseArcCmd(self, x1:float, y1:float, arguments:list):
# '''Implements: https://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes'''
# rx,ry,phi,fa,fs,x2,y2 = arguments
# p = lambda x: f'{round(x*1000)/1000:f}'.rstrip('0').rstrip('.')
# if x1==y2 and y1==y2: return ''
# if rx==0 or ry==0: return f'{p(x2)} {p(y2)} l\n'
# rx = abs(rx); ry = abs(ry)
# phi = divmod(phi,360)[1]
# fa = 1 if fa != 0 else 0; fs = 1 if fs != 0 else 0
# ================================================== class CTM
class CTM(list):
'''A CTM matrix'''
# -------------------------------------------------- __init__()
def __init__(self, *arguments):
'''Creates CTM by setting it to a list or by parsing a string containing SVG transformations.
See: https://www.w3.org/TR/2001/REC-SVG-20010904/coords.html#TransformAttribute'''
if len(arguments) == 0: self += [1,0,0,1,0,0] ; return
if len(arguments) > 1: err('more than one argument: {arguments}')
if isinstance(arguments[0],list):
if len(arguments[0]) == 6: self += arguments[0]; return
else: err(f'invalid argument: {arguments[0]}')
elif not isinstance(arguments[0],str):
err(f'invalid argument type: {arguments[0]}')
elif arguments[0] == "": self += [1,0,0,1,0,0] ; return
transformString = arguments[0]
transforms = transformString.strip(" )").split(")")
ctm = CTM()
for transform in transforms:
tokens = re.split(r'[\s,()]+', transform.strip(' ,\t\r\n'))
cmd = tokens[0]
args = [units(tokens[i]) for i in range(1,len(tokens))]
n = len(args)
tr = None
z=math.pi/180
if cmd == 'matrix' and n==6: tr=args
elif cmd == 'translate' and n==1: tr=[1,0,0,1,args[0],0]
elif cmd == 'translate' and n==2: tr=[1,0,0,1,args[0],args[1]]
elif cmd == 'scale' and n==1: tr=[args[0],0,0,args[0],0,0]
elif cmd == 'scale' and n==2: tr=[args[0],0,0,args[1],0,0]
elif cmd == 'rotate' and n==1: a=args[0]; tr=[cos(a*z),sin(a*z),-sin(a*z),cos(a*z),0,0]
elif cmd == 'rotate' and n==3:
a=args[0]; cx=args[1]; cy=args[2]
tr=CTM([1,0,0,1,cx,cy])
tr=tr.multiply([cos(a*z),sin(a*z),-sin(a*z),cos(a*z),0,0])
tr=tr.multiply([1,0,0,1,-cx,-cy])
elif cmd == 'skewX' and n==1: tr = [1,0,tan(args[0]*z),1,0,0]
elif cmd == 'skewY' and n==1: tr = [1,tan(args[0]*z),0,1,0,0]
if tr==None: err(f'invalid transform string: {transformString}')
ctm = ctm.multiply(tr)
self.clear()
self += ctm
# -------------------------------------------------- multiply ()
def multiply(self, multiplier: 'CTM'):
'''Returns CTM obtained by multiplying self (CTM) by the multiplier (CTM) from the right.
See: https://www.w3.org/TR/2001/REC-SVG-20010904/coords.html#TransformMatrixDefined'''
try:
ctm = CTM()
ctm[0] = self[0]*multiplier[0] + self[2]*multiplier[1]
ctm[2] = self[0]*multiplier[2] + self[2]*multiplier[3]
ctm[4] = self[0]*multiplier[4] + self[2]*multiplier[5] + self[4]
ctm[1] = self[1]*multiplier[0] + self[3]*multiplier[1]
ctm[3] = self[1]*multiplier[2] + self[3]*multiplier[3]
ctm[5] = self[1]*multiplier[4] + self[3]*multiplier[5] + self[5]
except:
err(f'failed to multiply {self} x {multiplier}')
return ctm
def inverse(self):
'''Return inverse ctm'''
a,b,c,d,e,f = self
det = a*d-b*c
if det == 0: err(f'a degenerate ctm matrix has no inverse: {self}')
return CTM([d/det, -b/det, -c/det, a/det, (c*f-d*e)/det, (b*e-a*f)/det])
def equal(self, ctm: 'CTM'):
'''Returns True if self is equal to ctm'''
return all(self[i]==ctm[i] for i in range(6))
def toPdfStream(self):
'''Return a string representation of the transformation
that can be inserted into a PDF dictionary stream (the 'cm' operator)'''
p = lambda x: f'{round(x*1000)/1000:f}'.rstrip('0').rstrip('.')
listStr = [p(a) for a in self]
return ' '.join(listStr)+' cm\n'
def toPdfStreamRough(self):
'''Return a string representation of the transformation
that can be inserted into a PDF dictionary stream (the 'cm' operator);
this 'rough' version rounds off the dx/dy portion of the cm operator arguments to 1 decimal point accuracy'''
p = lambda x: f'{round(x*1000)/1000:f}'.rstrip('0').rstrip('.')
q = lambda x: f'{round(x*10)/10:f}'.rstrip('0').rstrip('.')
listStr = [p(a) for a in self[0:4]] + [q(self[4]), q(self[5])]
return ' '.join(listStr)+' cm\n'
# ================================================== class VEC
class VEC(list):
'''A 2D vector'''
def __init__(self, vec = [0,0]):
if len(vec) != 2: err(f'invalid vector: {vec}')
super().__init__(vec)
def transform(self, ctm: CTM):
return VEC([self[0]*ctm[0] + self[1]*ctm[2] + ctm[4], self[0]*ctm[1] + self[1]*ctm[3] + ctm[5]])
# ================================================== class DEST
class DEST:
'''Destinations: refs, idMaps, md5map, tags'''
def __init__(self, debug):
self.debug = debug
self.refs = [] # list of lists of all refIds, accessed by page number --- !!!!! REPLACE WITH A SET !!!!!
self.idMaps = [] # maps from an id to a node with node.id == id (destination), accessed by page number
self.md5Map = {} # an all-pages (doc-wide) map from md5 to a node with node.md4 == md5
self.fonts = {} # an all-pages (doc-wide) map from font names to fonts; used for caching font search results
self.tags = {} # an all-pages (doc-wide) map of all node.tag's; used for info purposes
def getNode(self, id: str, page: int):
'''Return the node (destination) with node.id == id or, if another (identical) node
with the same node._md5 is found in self.md5Map (i.e., it had been saved earlier),
then return that other node.'''
if id == None: err('id is None')
if page <= 0: err('page numbering starts with 1')
if id not in self.idMaps[page-1]:
warn(f'id={id} on page={page} not found in idMaps')
return None
node = self.idMaps[page-1][id]
if node.id != id: err('node.id != id')
if node._md5 in self.md5Map:
node_new = self.md5Map[node._md5]
if node_new._md5 != node._md5: err('node._md5 != md5')
if node_new != node:
if self.debug: print(f'mapping {node.id}[{node._page}] --> {node_new.id}[{node_new._page}]')
node = node_new
return node
def gatherReferences(self, node: 'XNODE'):
'''
Collects all refIds encountered in the attributes of the node and all of its kids recursively
and store these refIds in a map: self.refs[page] = refList.
The self.refs lists are used in self.hashDestinations() to only hash nodes
that are actually referenced. Note: no checking of the validity of the
references is done here (see self.validateReferences() for this).
'''
p = node._page
while p > len(self.refs): self.refs.append([])
for attr in ['clip-path', 'mask', 'href', 'strokePattern', 'fillPattern']:
if attr in node:
refId = node[attr]
if refId not in self.refs[p-1]: self.refs[p-1].append(refId)
for kid in node._kids: self.gatherReferences(kid)
def hashDestinations(self, node: 'XNODE'):
'''Hashes all destinations (nodes with node.id != None) recursively for the node
and all of its kids. Destinations are hashed in two maps:
self.idMaps[page][id] and self.md5Map[md5] (the latter is a single map for all pages)
'''
p = node._page
if p <= 0: err('page numbering starts with 1')
while p > len(self.idMaps): self.idMaps.append({})
if node.id != None and node.id in self.refs[p-1]: # only hash nodes that are actually referenced
id = node.id
if node._md5 != None: err(f'md5 already set: {node}')
nodeHash = node.toHashableString()
node._md5 = hashlib.md5(nodeHash.encode("utf-8")).hexdigest() if nodeHash != None else None
if id in self.idMaps[p-1]:
node_existing = self.idMaps[p-1][id]
if node._md5 != node_existing._md5:
err(f'a node with this id and a different md5 is already in idMaps[{p}]: {node_existing}')
else:
self.idMaps[p-1][id] = node
if node._md5 != None and node._md5 not in self.md5Map: self.md5Map[node._md5] = node
for kid in node._kids: self.hashDestinations(kid)
def validateReferences(self, node: 'XNODE'):
'''
Important: the call to this function should be preceded by calls to
node.parseAttributes() and self.hashDestinations(node).
Validates references contained in the attributes of the node and all of its kids recursively:
makes sure that reference destinations exist for all of the refIds encountered,
otherwise generates an error. In a special case of refIds in patternRef attributes
(node.strokePattern/fillPattern), if the refDest is not found then the patternRef attribute
is deleted and node.stroke/fill is set to the node.strokeFallBackColor/fillFallBackColor.
If the latter is None then an error is generated.
'''
paintAttr = {'strokePattern':'stroke','fillPattern':'fill'}
fbColorAttr = {'strokePattern':'strokeFallBackColor','fillPattern':'fillFallBackColor'}
for attr in ['clip-path', 'mask', 'href','strokePattern','fillPattern']:
if attr in node and self.getNode(node[attr],node._page) == None:
if attr in paintAttr: # the special case of patternRef attributes
if node[fbColorAttr[attr]] != None: # if the fallBackColor has been specified
node[paintAttr[attr]] = node[fbColorAttr[attr]]
del node[attr]; del node[fbColorAttr[attr]]
else: err('invalid refDest in: {self.tag}.{attr}={self[attr]} and no fallback color')
else:
warn(f'invalid refDest in {node.tag}.{attr}={node[attr]}')
for kid in node._kids: self.validateReferences(kid)
def gatherAttributes(self, node: 'XNODE'):
'''
Gathers all tags and attributes from the node and all of its kids and stores them
in a map: self.tags[tag] = attributesList. Use it to learn of all the tags/attributes contained
in the node and all of its kids.
'''
if node.tag not in self.tags: self.tags[node.tag] = []
for attr in node:
if attr[0]!='_' and attr!='tag' and attr not in self.tags[node.tag]:
self.tags[node.tag].append(attr)
for kid in node._kids: self.gatherAttributes(kid)
# ================================================== class BOX
class BOX(list):
def __init__(self, box = [0,0,595,842], convert=False):
'''Creates a BOX: [xmin, ymin, xmax, ymax].
Set convert = True for the box argument of the form: [xmin, ymin, width, height]'''
c = [box[0],box[1],box[0]+box[2],box[1]+box[3]] if convert else box
super().__init__(c)
def __repr__(self):
p = lambda x: f'{round(x*1000)/1000:f}'.rstrip('0').rstrip('.')
return '[' + ', '.join([f'{p(b)}' for b in self]) + ']'
def round(self):
return [round(b*1000)/1000 for b in self]
def dimensions(self):
'''Returns self in the [xmin, ymin, width, height] format'''
return [self[0],self[1],self[2]-self[0],self[3]-self[1]]
def minimax(self,a,b): return (a,b) if a<b else (b,a)
def equal(self, box: 'BOX'):
'''Return True if self is equal to box'''
return all(self[i] == box[i] for i in range(4))
def embed(self, box: 'BOX'):
'''Returns a BOX obtained from self by minimal enlargment so that box fully fits in it'''
r = BOX(self)
r[0] = min(r[0],box[0])
r[1] = min(r[1],box[1])
r[2] = max(r[2],box[2])
r[3] = max(r[3],box[3])
return r
def transformFrom(self, box: 'BOX'):
'''Returns a ctm such that box.transform(ctm) == self'''
ctm1 = CTM([1,0,0,1,self[0],self[1]])
ctm2 = CTM([(self[2]-self[0])/(box[2]-box[0]),0,0,(self[3]-self[1])/(box[3]-box[1]),0,0])
ctm3 = CTM([1,0,0,1,-box[0],-box[1]])
return ctm1.multiply(ctm2).multiply(ctm3)
def transform(self, ctm: CTM):
'''Return a box which is equal to self transformed by the ctm, i.e. ctm x self'''
xmin,ymin,xmax,ymax = self
v1 = VEC([xmin,ymin])
v2 = VEC([xmin,ymax])
v3 = VEC([xmax,ymin])
v4 = VEC([xmax,ymax])
v1 = v1.transform(ctm)
v2 = v2.transform(ctm)
v3 = v3.transform(ctm)
v4 = v4.transform(ctm)
xmin = min(min(v1[0],v2[0]),min(v3[0],v4[0]))
xmax = max(max(v1[0],v2[0]),max(v3[0],v4[0]))
ymin = min(min(v1[1],v2[1]),min(v3[1],v4[1]))
ymax = max(max(v1[1],v2[1]),max(v3[1],v4[1]))
return BOX([xmin,ymin,xmax,ymax])
def scale(self, scale = 1):
'''Returns a box which is equal to self scaled with self's center as a fixed point'''
x = (self[0]+self[2])/2
y = (self[1]+self[3])/2
ctm1 = CTM([1,0,0,1,-x,-y])
ctm2 = CTM([scale,0,0,scale,0,0])
ctm3 = CTM([1,0,0,1,x,y])
return self.transform(ctm1).transform(ctm2).transform(ctm3)
# ================================================== class STATE
class STATE:
'''A state is everything that is inherited by kids from parents '''
contents: PdfDict
resources: PdfDict
viewPort: BOX
paintCmd: str
paintColor: str
isClipPath: bool
fontName: str
fontSize: float
debug: bool
def __init__(self, contents: PdfDict, resources: PdfDict,
viewPort: BOX, paintCmd: str, paintColor: str, isClipPath: bool, fontName: str, fontSize: float,
debug: bool):
self.contents = contents # pdf page/dictionary contents (where the .stream is)
self.resources = resources # pdf page/dictionary resources (where .ExtGState & .XObject are)
self.viewPort = viewPort # The initial viewport; this is same as the PDF MediaBox
self.paintCmd = paintCmd # path-painting command
self.paintColor = paintColor # this is changed by the color="" attributes, e.g. in <g>
self.isClipPath = isClipPath
self.fontName = fontName
self.fontSize = fontSize
self.debug = debug # debug flag
def copy(self):
return STATE(self.contents, self.resources,
self.viewPort, self.paintCmd, self.paintColor, self.isClipPath, self.fontName, self.fontSize,
self.debug)
# ================================================== class XIMAGE
class XIMAGE:
def __init__(self,imageStream: str):
'''Creates a byte-array gzip/deflate-encoded representation of an SVG image ASCII stream'''
self.width, self.height, self.BitsPerComponent, self.ColorSpace, self.stream, self.filter \
= self.parseImageStream(imageStream)
def parseImageStream(self, imageStream):
if imageStream[:5] == 'data:':
header, data = re.split(r'base64[\s+,]*', imageStream,1)
# print(f'data: {data[-10:]}')
image = Image.open(io.BytesIO(base64.b64decode(data)))
else:
image = Image.open(imageStream)
with open(imageStream,'rb') as f:
data = f.read()
w,h = image.size
# image.save("test.jpg")
mode_to_bpc = {'1':1, 'L':8, 'LA':8, 'P':8, 'RGB':8, 'RGBA':8, 'CMYK':8, 'YCbCr':8, 'I':8, 'F':8}
mode_to_cs = {'1':'Gray', 'L':'Gray', 'LA':'Gray', 'P':'RGB', 'RGB':'RGB', 'RGBA':'RGB', 'CMYK': 'CMYK'}
bpc = mode_to_bpc[image.mode]
if image.mode not in mode_to_cs: err(f'No colorspace for mode: {image.mode}')
cs = mode_to_cs[image.mode]
if image.format == 'PNG':
if image.mode == 'RGBA': image = image.convert('RGB')
if image.mode == 'LA': image = image.convert('L')
stream = zlib.compress(image.tobytes())
filter = 'FlateDecode'
elif image.format == 'JPEG':
stream = base64.b64decode(data)
if stream[-2:] != b'\xff\xd9': stream += b'\xff\xd9'
# print(f'stream: {stream[-16:]}')
filter = 'DCTDecode'
else:
err(f'unsupported image format: {image.format}')
return w,h,bpc,cs,stream,filter
def toStr(self):
return f'{self.width} x {self.height} {self.ColorSpace} {self.BitsPerComponent}bpc {len(self.stream)}bytes'
# ================================================== class XNODE
class XNODE(attrDict):
def __init__(self,node: ET.ElementTree, destinations: DEST, page: int):
'''Puts ET.ElementTree keys (kids) in self._kids and ET.ElementTree.attrib in self,
making attributes directly accessible.
This works as long as there are no node attributes like tag=, kids=, xobj= etc.
Finally, set self._dest to the destinations argument; use this to look up destinations by refId'''
super().__init__(node.attrib) # node attributes are in self
self.tag = node.tag # treat tag as an attribute (which kind of makes sense)
self.text = node.text
# all the other variables a prefixed with '_'
self._kids = [XNODE(elem,destinations,page) for elem in node]
self._xobj = None
self._md5 = None
self._state = None
self._parsed = False
self._dest = destinations # destinations, see the DEST class
self._page = page # page number in PDF, starting from 1
def __repr__(self):
return self.toString(recurse = False)
def toString(self, recurse = False, indent = 0):
'''String representation of the node and all of its kids; only tags, ids & references are printed'''
r = ' '*indent + f'<{self.tag}'
for attr in ['id', 'x', 'y', 'width', 'height', 'cx', 'cy', 'r', 'rx', 'ry',
'transform', 'patternTransform', 'href', 'mask', 'fill', 'stroke',
'opacity','fill-opacity','stroke-opacity']:
if self[attr] == None: continue
if attr == 'href':
href = self.href[:32] + '..' if self.href != None and len(self.href) > 32 else self.href
r += f' href="{href}"'
else: r += f' {attr}="{self[attr]}"'
r += ' ...>'
# r += f' BBOX: {self.getBBox()}'
r += f' port = {self._vpw} x {self._vph}'
if recurse: r += '\n'.join([kid.toString(recurse, indent+4) for kid in self._kids]) + '\n'
return r
def toHashableString(self, indent = 0):
'''Hashable string representation of the node (with all of its public attributes except .id)
and all of its kids. Two nodes that have same hash appear identical when drawn, and thus
subsequent instances of any node with same hash can be substituted by a reference to the first instance.
'''
# Some SVG elements use links which, in turn, can contain refIds;
# now, identical refIds can actually refer to different elements if links are coming from
# different pages (refIds are page-specific!); this means that just inlcuding refIds as a string
# in the element's (node's) hash is not enough: we either have to include in the node's hash the hashes
# of all the refDests for all the refIds in this node's links, or hash no nodes with refIds
# in the first place (this means they will not be turned into x-objects and will appear in the PDF's
# content stream as graphics commands). We choose the second option
# !!! THIS CAN BE IMPROVED: JUST ADD PAGE NUMBER TO THE REFID STRING AND HASH ANYTHING YOU WANT !!!
if self.href != None or self.mask != None or self['clip-path'] != None: return None
r = ' '*indent + f'<{self.tag}'
for attr in self:
if attr[0] == '_' or attr == 'id': continue # Do not inlcude private attributes or ids in the hash
r += f' {attr}="{self[attr]}",'
r += ' ...>\n'
for kid in self._kids:
kidHash = kid.toHashableString(indent+4)
if kidHash == None: return None
r += kidHash
return r
def find(self, value: str):
'''Returns the first node such that node.tag == value by searhing among self and kids recursively'''
if self.tag == value: return self
else:
for kid in self._kids:
r = kid.find(value)
if r != None: return r
return None
def parseAttributes(self):
'''
Parses node attributes. Coordinates are parsed into floats and the viewBox -- into the BOX class,
all respecting dimension suffixes. path.d attributes are parsed into PATH classes,
.transform & .patternTransform -- into CTM classes.
These non-standard SVG attributes may be set:
.fillPattern, .fillFallBackColor, .strokePattern, .strokeFallBackColor, image.imageData.
These attributes may be deleted: .fill, .stroke, image.href
'''
if self._parsed: return
self._parsed = True
# scaleWidth,scaleHeight = False,False
# if self.width != None and self.width[-1] == '%': scaleWidth = True; self.width = self.width.strip('%')
# if self.height != None and self.height[-1] == '%': scaleHeight = True; self.height = self.height.strip('%')
# Parse lengths
for attr in ['x','y','width','height','viewBox','cx','cy','r','rx','ry',
'x1','y1','x2','y2','points']:
if attr in self:
try:
self[attr] = units(self[attr])
except: err(f'failed to parse {self.tag}.{attr}: {self}')
# Image's href attribute is not really an href: it never contains a refId, so just rename the attribute!
if self.tag == 'image' and self.href != None: self.imageStream = self.href; del self['href']
# Parse references in href, mask & clip-path attributes
# Paint attributes (fill & stroke) can optionally contain refs as well, they are parse at the next step
for attr in ['href','mask','clip-path']:
if attr in self:
refId = self.parseRefId(self[attr])
if refId == None: err(f'invalid refId {self.tag}.{attr}={self[attr]} in: {self}')
self[attr] = refId
# Parses cases of self.fill/stroke == 'url(#patternId) [fallBackColor]'.
# As a result of parsing, self.fill/stroke is deleted and instead
# self.fillPattern/strokePattern is set to patternId and
# self.fillFallBackColor/strokeFallBackColor is set to fallBackColor'''
# transform self.style='attr:value;..' into the self.attr=value,.. format
if self.style != None:
styleList = [s.strip() for s in re.split(r';',self.style) if s != '']
for s in styleList:
attr,value=re.split(r':',s,1)
attr = attr.strip(); value = value.strip()
if attr == '' or value == '': continue
self[attr] = value
del self['style']
patternAttr = {'fill':'fillPattern','stroke':'strokePattern'}
fallBackColorAttr = {'fill':'fillFallBackColor','stroke':'strokeFallBackColor'}
for attr in ['fill','stroke']:
if attr in self and self[attr][:3] == 'url':
refList = re.split(r'[\s,]+',self[attr],1)