aboutsummaryrefslogtreecommitdiff
path: root/imagemap.py
blob: c7d462d1797278709a18f089ef90ef6533183368 (plain)
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
import gettext
import inkex
from inkex import bezier
from inkex.command import inkscape_command
from inkex.localization import inkex_gettext as _i
from lxml.builder import E
_ = gettext.translation('imagemap','locale',fallback=True).gettext

# (X)HTML stuff:
ESCAPE=str.maketrans({'&':'&amp;','<':'&lt;','>':'&gt;'})
def quotedval(val):
	quote="'" if val.count('"')>val.count("'") else '"'
	return quote+val.translate(ESCAPE).translate({ord(quote):f'&#{ord(quote)}'})+quote
def htmlval(val):
	if val=='': return ''
	elif not any(i in val for i in ' \t\r\n\f"\'=`'):
		return '='+val.translate(ESCAPE)
	else:
		return '='+quotedval(val)

AREA_ATTRS={
	'href':lambda a:a.get('href',a.get('{http://www.w3.org/1999/xlink}href')),
	'alt':lambda a:a.get('aria-label',a.get('{http://www.w3.org/1999/xlink}title',a.title)),
	'target': lambda a:a.get('target','_blank' if a.get('{http://www.w3.org/1999/xlink}show')=='new' else None),
	'download': lambda a:a.get('download'),
	'ping': lambda a:a.get('ping'),
	'rel': lambda a:a.get('rel'),
	'referrerpolicy': lambda a:a.get('referrerpolicy')
}
SHAPE_MARKUP = {
	'HTML': lambda attrs:
		f"<area shape={attrs['shape']} coords={','.join(str(c) for c in sum(attrs['coords'],start=[]))}"
		+(''.join(f' {i}{htmlval(attrs[i])}' for i in attrs if attrs[i] is not None and i not in {'shape','coords'}))
		+">\n",
	'XHTML': lambda attrs:
		f"<area shape=\"{attrs['shape']}\" coords=\"{','.join(str(c) for c in sum(attrs['coords'],start=[]))}\""
		+(''.join(f" {i}={quotedval(attrs[i])}" for i in attrs if attrs[i] is not None and i not in {'shape','coords'}))
		+"/>\n",
	'mod_imagemap': lambda attrs:
		f"{attrs['shape']} {attrs['href'] or 'nocontent'} {' '.join(f'{i[0]},{i[1]}' for i in attrs['coords'])}"
		+(' "'+alt.translate({34:"''"})+'"' if alt is not None else '')+'\n' # no way to get quotation mark in text in httpd
} # if we ever implement `circ` we gotta handle it specially
CSS_LINK_INDEX='-computer-viatrix-inx-imagemap-linkindex'

# "rectifiable" as in "can be made into a rect"
def rectifiable(coords):
	if len(coords)!=4: return False
	else: return (coords[0][0]==coords[1][0] and coords[1][1]==coords[2][1] and coords[2][0]==coords[3][0] and coords[3][1]==coords[0][1] ) \
		or (coords[0][1]==coords[1][1] and coords[1][0]==coords[2][0] and coords[2][1]==coords[3][1] and coords[0][0]==coords[3][0])
def rectify(coords):
	return [[min(coords[0][0],coords[2][0]),min(coords[0][1],coords[2][1])],[max(coords[0][0],coords[2][0]),max(coords[0][1],coords[2][1])]]

class ImageMap(inkex.OutputExtension):
	def add_arguments(self,pars):
		pars.add_argument("--maptype")
	def save(self,stream):
		assert self.options.maptype in {"HTML","XHTML","mod_imagemap"}
		shapemarkup=SHAPE_MARKUP[self.options.maptype]
		
		viewBox=self.svg.get_viewbox()
		wscale=self.svg.viewport_width/viewBox[2] if viewBox[2]!=0 else 1
		hscale=self.svg.viewport_height/viewBox[3] if viewBox[3]!=0 else 1
		
		# preprocess shapes for our purposes.
		# after this, the shapes within the image must: look the same as before (barring colour/alpha), not be clones, have no stroke, not intersect, be visually unaffected by `fill-rule`, not be clipped, and not go out of bounds.
		links=[]
		rects=[]
		svgIDs=[i.get_id() for i in self.svg.iterdescendants('{http://www.w3.org/2000/svg}svg')]
		for a in self.svg.iterdescendants('{http://www.w3.org/2000/svg}a'):
			# save link attributes because they get removed when flattening
			link={attr:AREA_ATTRS[attr](a) for attr in AREA_ATTRS.keys()}
			for el in a.iterdescendants(): # CSS is preserved when flattening (for paths)
				if not isinstance(el,inkex.ShapeElement) or isinstance(el,inkex.Group): continue
				style=el.effective_style()
				style[CSS_LINK_INDEX]=f'" {CSS_LINK_INDEX}-{len(links)} "'
				if el.tag=='{http://www.w3.org/2000/svg}image':
					el.tag='{http://www.w3.org/2000/svg}rect' # because flattening an image creates a clip-path
					style['stroke']='none'
					style['fill']='#000'
			links += [link]
			
			# for clipping out-of-bounds elements
			newid=self.svg.get_unique_id('intersect')
			rect=E('{http://www.w3.org/2000/svg}rect',
				x=str(viewBox[0]),
				y=str(viewBox[1]),
				width='100%',
				height='100%',
				style='fill:#000;stroke:none',
				id=newid)
			self.svg.append(rect)
			rects+=[newid]
		
		#clip-paths
		clipped=[]
		clippedpaths=set()
		for clippedEl in self.svg.iterdescendants():
			if not isinstance(clippedEl,inkex.BaseElement): continue
			if clippedEl.cascaded_style().get('clip-path','none')=='none': continue
			clipped.append([clippedEl.get_id(),0])
			for el in clippedEl.descendants():
				if not isinstance(el,inkex.ShapeElement) or isinstance(el,inkex.elements._groups.GroupBase):
					clipped[-1][1]+=1 # can overshoot number of groups but works
					continue
				clippedpaths.add((el.get_id(),el.cascaded_style().get(CSS_LINK_INDEX)))
		
		if len(links)==0:
			raise inkex.AbortExtension(_("Image has no hyperlinks.\nAdd a hyperlink to an object with right-click → \"{}\".").format(_i("Create Anchor (Hyperlink)")))
		command=\
			''.join(f'select-clear;select-by-id:{i};selection-ungroup;' for i in reversed(svgIDs)) \
			+''.join(f'select-clear;select-by-id:{i[0]};{"selection-ungroup;"*i[1]}' for i in reversed(clipped) if i[1]>0) \
			+''.join(f'select-clear;select-by-id:{i[0]};object-release-clip;unselect-by-id:{i[0]};selection-set-backup;select-clear;select-by-id:{i[0]};object-stroke-to-path;selection-ungroup;path-union;object-set-attribute:id,{i[0]};selection-restore-backup;select-by-id:{i[0]};path-intersection;object-set-attribute:style,{CSS_LINK_INDEX}:{i[1]};' for i in clippedpaths) \
			+''.join(f'select-clear;select-by-selector:[style~="{CSS_LINK_INDEX}-{i}"];object-stroke-to-path;selection-ungroup;path-union;select-by-id:{rects[i]};path-intersection;object-set-attribute:style,{CSS_LINK_INDEX}:" {CSS_LINK_INDEX}-{i} ";' for i in range(len(links))) \
			+'select-all;path-flatten;path-split'
			# (we re-set the existing style attribute in case it got unset on non-paths)
		from lxml import etree
		newbytes=inkscape_command(self.svg,actions=command)
		self.svg=self.load(newbytes).getroot()
		# preprocessing done, now for map generation
		
		shapes=[[] for i in range(len(links))]
		for el in self.svg.iterdescendants():
			if not isinstance(el,inkex.ShapeElement): continue
			linkindex=el.cascaded_style().get(CSS_LINK_INDEX)
			if linkindex is None: continue
			linkindex=int(linkindex[len(CSS_LINK_INDEX)+3:-2])
			link=links[linkindex]
			href=link['href']
			path=el.get_path().transform(el.composed_transform()).to_superpath()
			bezier.cspsubdiv(path,0.5)
			for subpath in path:
				coords=[[round((c[0][0]-viewBox[0])*wscale),round((c[0][1]-viewBox[1])*hscale)] for c in subpath]
				i=0
				while i<len(coords):
					if coords[i]==coords[(i+1)%len(coords)]: coords.pop(i)
					else: i+=1
				if rectifiable(coords): shapes[linkindex].insert(0,{'shape':'rect','coords':rectify(coords),'href':href})
				elif len(coords)>=3: shapes[linkindex].insert(0,{'shape':'poly','coords':coords,'href':href})
				href=None # because subsequent subpaths must be enclaves
		for i in range(len(shapes)):
			alt=links[i]['alt']
			if len(shapes[i])==0: inkex.errormsg(_("The hyperlink \"{}\" could not be added to the output.").format(links[i]['href']))
			for j in shapes[i]:
				attrs=links[i].copy()
				attrs['alt']=alt
				attrs.update(j)
				stream.write(bytes(shapemarkup(attrs),'utf-8'))
				alt=None

if __name__ == "__main__":
	ImageMap().run()