Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit f6e34a6

Browse filesBrowse files
committed
Add URL support for images in PDF backend
1 parent 51ce677 commit f6e34a6
Copy full SHA for f6e34a6

File tree

3 files changed

+162
-28
lines changed
Filter options

3 files changed

+162
-28
lines changed
+10Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
URL Support for Images in PDF Backend
2+
-------------------------------------
3+
4+
The PDF backend can now generate clickable images if an URL is provided to the
5+
image. There are a few limitations worth noting though:
6+
7+
* If parts of the image are clipped, the non-visible parts are still clickable.
8+
* If there are transforms applied to the image, the whole enclosing rectangle
9+
is clickable. However, if you use ``interpolation='none'`` for the image,
10+
only the transformed image area is clickable (depending on viewer support).

‎lib/matplotlib/backends/backend_pdf.py

Copy file name to clipboardExpand all lines: lib/matplotlib/backends/backend_pdf.py
+57-28Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -230,32 +230,54 @@ def _datetime_to_pdf(d):
230230
return r
231231

232232

233-
def _calculate_quad_point_coordinates(x, y, width, height, angle=0):
233+
def _calculate_rotated_quad_point_coordinates(x, y, width, height, angle):
234234
"""
235235
Calculate the coordinates of rectangle when rotated by angle around x, y
236236
"""
237237

238-
angle = math.radians(-angle)
238+
angle = math.radians(angle)
239239
sin_angle = math.sin(angle)
240240
cos_angle = math.cos(angle)
241-
a = x + height * sin_angle
241+
width_cos = width * cos_angle
242+
width_sin = width * sin_angle
243+
a = x - height * sin_angle
242244
b = y + height * cos_angle
243-
c = x + width * cos_angle + height * sin_angle
244-
d = y - width * sin_angle + height * cos_angle
245-
e = x + width * cos_angle
246-
f = y - width * sin_angle
245+
c = a + width_cos
246+
d = b + width_sin
247+
e = x + width_cos
248+
f = y + width_sin
247249
return ((x, y), (e, f), (c, d), (a, b))
248250

249251

250-
def _get_coordinates_of_block(x, y, width, height, angle=0):
252+
def _calculate_transformed_quad_point_coordinates(x, y, trans):
251253
"""
252-
Get the coordinates of rotated rectangle and rectangle that covers the
253-
rotated rectangle.
254+
Calculate the coordinates of rectangle when rotated by angle around x, y
255+
"""
256+
tr1, tr2, tr3, tr4, tr5, tr6 = trans
257+
width = 1
258+
height = 1
259+
a = x + 0 * tr1 + height * tr3 + tr5
260+
b = y + 0 * tr2 + height * tr4 + tr6
261+
c = x + width * tr1 + height * tr3 + tr5
262+
d = y + width * tr2 + height * tr4 + tr6
263+
e = x + width * tr1 + 0 * tr3 + tr5
264+
f = y + width * tr2 + 0 * tr4 + tr6
265+
return (((x + tr5, y + tr6), (e, f), (c, d), (a, b)),
266+
(0 if math.isclose(tr2, 0) else 45))
267+
268+
269+
def _get_coordinates_of_block(x, y, width, height, angle, trans):
270+
"""
271+
Get the coordinates of rotated or transformed rectangle and rectangle
272+
that covers the rotated or transformed rectangle.
254273
"""
255274

256-
vertices = _calculate_quad_point_coordinates(x, y, width,
257-
height, angle)
258-
275+
if trans is None:
276+
vertices = _calculate_rotated_quad_point_coordinates(x, y, width,
277+
height, angle)
278+
else:
279+
vertices, angle = _calculate_transformed_quad_point_coordinates(x, y,
280+
trans)
259281
# Find min and max values for rectangle
260282
# adjust so that QuadPoints is inside Rect
261283
# PDF docs says that QuadPoints should be ignored if any point lies
@@ -268,27 +290,29 @@ def _get_coordinates_of_block(x, y, width, height, angle=0):
268290
max_x = max(v[0] for v in vertices) + pad
269291
max_y = max(v[1] for v in vertices) + pad
270292
return (tuple(itertools.chain.from_iterable(vertices)),
271-
(min_x, min_y, max_x, max_y))
293+
(min_x, min_y, max_x, max_y), angle)
272294

273295

274-
def _get_link_annotation(gc, x, y, width, height, angle=0):
296+
def _get_link_annotation(gc, x, y, width, height, angle=0, trans=None):
275297
"""
276298
Create a link annotation object for embedding URLs.
277299
"""
278-
quadpoints, rect = _get_coordinates_of_block(x, y, width, height, angle)
279300
link_annotation = {
280301
'Type': Name('Annot'),
281302
'Subtype': Name('Link'),
282-
'Rect': rect,
283303
'Border': [0, 0, 0],
284304
'A': {
285305
'S': Name('URI'),
286306
'URI': gc.get_url(),
287307
},
288308
}
309+
quadpoints, rect, angle = _get_coordinates_of_block(x, y, width, height,
310+
angle, trans)
311+
link_annotation['Rect'] = rect
289312
if angle % 90:
290313
# Add QuadPoints
291314
link_annotation['QuadPoints'] = quadpoints
315+
292316
return link_annotation
293317

294318

@@ -2012,21 +2036,24 @@ def draw_image(self, gc, x, y, im, transform=None):
20122036

20132037
self.check_gc(gc)
20142038

2015-
w = 72.0 * w / self.image_dpi
2016-
h = 72.0 * h / self.image_dpi
2017-
20182039
imob = self.file.imageObject(im)
20192040

20202041
if transform is None:
2042+
w = 72.0 * w / self.image_dpi
2043+
h = 72.0 * h / self.image_dpi
2044+
if gc.get_url() is not None:
2045+
self._add_link_annotation(gc, x, y, w, h)
20212046
self.file.output(Op.gsave,
20222047
w, 0, 0, h, x, y, Op.concat_matrix,
20232048
imob, Op.use_xobject, Op.grestore)
20242049
else:
2025-
tr1, tr2, tr3, tr4, tr5, tr6 = transform.frozen().to_values()
2050+
trans = transform.frozen().to_values()
2051+
if gc.get_url() is not None:
2052+
self._add_link_annotation(gc, x, y, w, h, trans=trans)
20262053

20272054
self.file.output(Op.gsave,
20282055
1, 0, 0, 1, x, y, Op.concat_matrix,
2029-
tr1, tr2, tr3, tr4, tr5, tr6, Op.concat_matrix,
2056+
*trans, Op.concat_matrix,
20302057
imob, Op.use_xobject, Op.grestore)
20312058

20322059
def draw_path(self, gc, path, transform, rgbFace=None):
@@ -2038,6 +2065,11 @@ def draw_path(self, gc, path, transform, rgbFace=None):
20382065
gc.get_sketch_params())
20392066
self.file.output(self.gc.paint())
20402067

2068+
def _add_link_annotation(self, gc, x, y, width, height, angle=0,
2069+
trans=None):
2070+
self.file._annotations[-1][1].append(
2071+
_get_link_annotation(gc, x, y, width, height, angle, trans))
2072+
20412073
def draw_path_collection(self, gc, master_transform, paths, all_transforms,
20422074
offsets, offsetTrans, facecolors, edgecolors,
20432075
linewidths, linestyles, antialiaseds, urls,
@@ -2206,8 +2238,7 @@ def draw_mathtext(self, gc, x, y, s, prop, angle):
22062238
self._text2path.mathtext_parser.parse(s, 72, prop)
22072239

22082240
if gc.get_url() is not None:
2209-
self.file._annotations[-1][1].append(_get_link_annotation(
2210-
gc, x, y, width, height, angle))
2241+
self._add_link_annotation(gc, x, y, width, height, angle)
22112242

22122243
fonttype = mpl.rcParams['pdf.fonttype']
22132244

@@ -2263,8 +2294,7 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None):
22632294
page, = dvi
22642295

22652296
if gc.get_url() is not None:
2266-
self.file._annotations[-1][1].append(_get_link_annotation(
2267-
gc, x, y, page.width, page.height, angle))
2297+
self._add_link_annotation(gc, x, y, page.width, page.height, angle)
22682298

22692299
# Gather font information and do some setup for combining
22702300
# characters into strings. The variable seq will contain a
@@ -2364,8 +2394,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23642394
if gc.get_url() is not None:
23652395
font.set_text(s)
23662396
width, height = font.get_width_height()
2367-
self.file._annotations[-1][1].append(_get_link_annotation(
2368-
gc, x, y, width / 64, height / 64, angle))
2397+
self._add_link_annotation(gc, x, y, width/64, height/64, angle)
23692398

23702399
# If fonttype is neither 3 nor 42, emit the whole string at once
23712400
# without manual kerning.

‎lib/matplotlib/tests/test_backend_pdf.py

Copy file name to clipboardExpand all lines: lib/matplotlib/tests/test_backend_pdf.py
+95Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,101 @@ def test_kerning():
360360
fig.text(0, .75, s, size=20)
361361

362362

363+
def test_image_url():
364+
pikepdf = pytest.importorskip('pikepdf')
365+
366+
image_url = 'https://test_image_urls.matplotlib.org/'
367+
image_url2 = 'https://test_image_urls2.matplotlib.org/'
368+
369+
X, Y = np.meshgrid(np.arange(-5, 5, 1), np.arange(-5, 5, 1))
370+
Z = np.sin(Y ** 2)
371+
fig, ax = plt.subplots()
372+
ax.imshow(Z, extent=[0, 1, 0, 1], url=image_url)
373+
with io.BytesIO() as fd:
374+
fig.savefig(fd, format="pdf")
375+
with pikepdf.Pdf.open(fd) as pdf:
376+
annots = pdf.pages[0].Annots
377+
378+
# Iteration over Annots must occur within the context manager,
379+
# otherwise it may fail depending on the pdf structure.
380+
annot = next(
381+
(a for a in annots if a.A.URI == image_url),
382+
None)
383+
assert annot is not None
384+
# Positions in points (72 per inch.)
385+
assert annot.Rect == [decimal.Decimal('122.4'),
386+
decimal.Decimal('43.2'),
387+
468,
388+
decimal.Decimal('388.8')]
389+
ax.set_xlim(0, 3)
390+
ax.imshow(Z[::-1], extent=[2, 3, 0, 1], url=image_url2)
391+
# Must save as separate images
392+
plt.rcParams['image.composite_image'] = False
393+
with io.BytesIO() as fd:
394+
fig.savefig(fd, format="pdf")
395+
with pikepdf.Pdf.open(fd) as pdf:
396+
annots = pdf.pages[0].Annots
397+
398+
# Iteration over Annots must occur within the context manager,
399+
# otherwise it may fail depending on the pdf structure.
400+
annot = next(
401+
(a for a in annots if a.A.URI == image_url2),
402+
None)
403+
assert annot is not None
404+
# Positions in points (72 per inch.)
405+
assert annot.Rect == [decimal.Decimal('369.6'),
406+
decimal.Decimal('141.6'),
407+
decimal.Decimal('518.64'),
408+
decimal.Decimal('290.64')]
409+
410+
411+
def test_transformed_image_url():
412+
pikepdf = pytest.importorskip('pikepdf')
413+
414+
image_url = 'https://test_image_urls.matplotlib.org/'
415+
416+
X, Y = np.meshgrid(np.arange(-5, 5, 1), np.arange(-5, 5, 1))
417+
Z = np.sin(Y ** 2)
418+
fig, ax = plt.subplots()
419+
im = ax.imshow(Z, interpolation='none', url=image_url)
420+
with io.BytesIO() as fd:
421+
fig.savefig(fd, format="pdf")
422+
with pikepdf.Pdf.open(fd) as pdf:
423+
annots = pdf.pages[0].Annots
424+
425+
# Iteration over Annots must occur within the context manager,
426+
# otherwise it may fail depending on the pdf structure.
427+
annot = next(
428+
(a for a in annots if a.A.URI == image_url),
429+
None)
430+
assert annot is not None
431+
# Positions in points (72 per inch.)
432+
assert annot.Rect == [decimal.Decimal('122.4'),
433+
decimal.Decimal('43.2'),
434+
decimal.Decimal('468.4'),
435+
decimal.Decimal('389.2')]
436+
assert getattr(annot, 'QuadPoints', None) is None
437+
# Transform
438+
im.set_transform(mpl.transforms.Affine2D().skew_deg(30, 15) + ax.transData)
439+
with io.BytesIO() as fd:
440+
fig.savefig(fd, format="pdf")
441+
with pikepdf.Pdf.open(fd) as pdf:
442+
annots = pdf.pages[0].Annots
443+
444+
# Iteration over Annots must occur within the context manager,
445+
# otherwise it may fail depending on the pdf structure.
446+
annot = next(
447+
(a for a in annots if a.A.URI == image_url),
448+
None)
449+
assert annot is not None
450+
# Positions in points (72 per inch.)
451+
assert annot.Rect[0] == decimal.Decimal('112.411830343')
452+
assert getattr(annot, 'QuadPoints', None) is not None
453+
# Positions in points (72 per inch)
454+
assert annot.Rect[0] == \
455+
annot.QuadPoints[0] - decimal.Decimal('0.00001')
456+
457+
363458
def test_glyphs_subset():
364459
fpath = str(_get_data_path("fonts/ttf/DejaVuSerif.ttf"))
365460
chars = "these should be subsetted! 1234567890"

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.