Skip to content

Navigation Menu

Sign in
Appearance settings

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 327cfcf

Browse filesBrowse files
authored
Merge pull request #17832 from QuLogic/pdf-url
ENH: Support setting URLs on Text objects in pdf
2 parents e5404d8 + b47fa78 commit 327cfcf
Copy full SHA for 327cfcf

File tree

Expand file treeCollapse file tree

3 files changed

+110
-5
lines changed
Filter options
Expand file treeCollapse file tree

3 files changed

+110
-5
lines changed

‎doc/users/next_whats_new/pdf_urls.rst

Copy file name to clipboard
+5Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
PDF supports URLs on ``Text`` artists
2+
-------------------------------------
3+
4+
URLs on `.text.Text` artists (i.e., from `.Artist.set_url`) will now be saved
5+
in PDF files.

‎lib/matplotlib/backends/backend_pdf.py

Copy file name to clipboardExpand all lines: lib/matplotlib/backends/backend_pdf.py
+57-5Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,13 @@ def __init__(self, filename, metadata=None):
694694

695695
self.paths = []
696696

697-
self.pageAnnotations = [] # A list of annotations for the current page
697+
# A list of annotations for each page. Each entry is a tuple of the
698+
# overall Annots object reference that's inserted into the page object,
699+
# followed by a list of the actual annotations.
700+
self._annotations = []
701+
# For annotations added before a page is created; mostly for the
702+
# purpose of newTextnote.
703+
self.pageAnnotations = []
698704

699705
# The PDF spec recommends to include every procset
700706
procsets = [Name(x) for x in "PDF Text ImageB ImageC ImageI".split()]
@@ -720,6 +726,7 @@ def newPage(self, width, height):
720726

721727
self.width, self.height = width, height
722728
contentObject = self.reserveObject('page contents')
729+
annotsObject = self.reserveObject('annotations')
723730
thePage = {'Type': Name('Page'),
724731
'Parent': self.pagesObject,
725732
'Resources': self.resourceObject,
@@ -728,11 +735,12 @@ def newPage(self, width, height):
728735
'Group': {'Type': Name('Group'),
729736
'S': Name('Transparency'),
730737
'CS': Name('DeviceRGB')},
731-
'Annots': self.pageAnnotations,
738+
'Annots': annotsObject,
732739
}
733740
pageObject = self.reserveObject('page')
734741
self.writeObject(pageObject, thePage)
735742
self.pageList.append(pageObject)
743+
self._annotations.append((annotsObject, self.pageAnnotations))
736744

737745
self.beginStream(contentObject.id,
738746
self.reserveObject('length of content stream'))
@@ -750,14 +758,13 @@ def newTextnote(self, text, positionRect=[-100, -100, 0, 0]):
750758
'Contents': text,
751759
'Rect': positionRect,
752760
}
753-
annotObject = self.reserveObject('annotation')
754-
self.writeObject(annotObject, theNote)
755-
self.pageAnnotations.append(annotObject)
761+
self.pageAnnotations.append(theNote)
756762

757763
def finalize(self):
758764
"""Write out the various deferred objects and the pdf end matter."""
759765

760766
self.endStream()
767+
self._write_annotations()
761768
self.writeFonts()
762769
self.writeExtGSTates()
763770
self._write_soft_mask_groups()
@@ -816,6 +823,10 @@ def endStream(self):
816823
self.currentstream.end()
817824
self.currentstream = None
818825

826+
def _write_annotations(self):
827+
for annotsObject, annotations in self._annotations:
828+
self.writeObject(annotsObject, annotations)
829+
819830
def fontName(self, fontprop):
820831
"""
821832
Select a font based on fontprop and return a name suitable for
@@ -2095,6 +2106,19 @@ def draw_mathtext(self, gc, x, y, s, prop, angle):
20952106
width, height, descent, glyphs, rects = \
20962107
self._text2path.mathtext_parser.parse(s, 72, prop)
20972108

2109+
if gc.get_url() is not None:
2110+
link_annotation = {
2111+
'Type': Name('Annot'),
2112+
'Subtype': Name('Link'),
2113+
'Rect': (x, y, x + width, y + height),
2114+
'Border': [0, 0, 0],
2115+
'A': {
2116+
'S': Name('URI'),
2117+
'URI': gc.get_url(),
2118+
},
2119+
}
2120+
self.file._annotations[-1][1].append(link_annotation)
2121+
20982122
global_fonttype = mpl.rcParams['pdf.fonttype']
20992123

21002124
# Set up a global transformation matrix for the whole math expression
@@ -2151,6 +2175,19 @@ def draw_tex(self, gc, x, y, s, prop, angle, ismath='TeX!', mtext=None):
21512175
with dviread.Dvi(dvifile, 72) as dvi:
21522176
page, = dvi
21532177

2178+
if gc.get_url() is not None:
2179+
link_annotation = {
2180+
'Type': Name('Annot'),
2181+
'Subtype': Name('Link'),
2182+
'Rect': (x, y, x + page.width, y + page.height),
2183+
'Border': [0, 0, 0],
2184+
'A': {
2185+
'S': Name('URI'),
2186+
'URI': gc.get_url(),
2187+
},
2188+
}
2189+
self.file._annotations[-1][1].append(link_annotation)
2190+
21542191
# Gather font information and do some setup for combining
21552192
# characters into strings. The variable seq will contain a
21562193
# sequence of font and text entries. A font entry is a list
@@ -2250,6 +2287,21 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
22502287
if is_opentype_cff_font(font.fname):
22512288
fonttype = 42
22522289

2290+
if gc.get_url() is not None:
2291+
font.set_text(s)
2292+
width, height = font.get_width_height()
2293+
link_annotation = {
2294+
'Type': Name('Annot'),
2295+
'Subtype': Name('Link'),
2296+
'Rect': (x, y, x + width / 64, y + height / 64),
2297+
'Border': [0, 0, 0],
2298+
'A': {
2299+
'S': Name('URI'),
2300+
'URI': gc.get_url(),
2301+
},
2302+
}
2303+
self.file._annotations[-1][1].append(link_annotation)
2304+
22532305
# If fonttype != 3 or there are no multibyte characters, emit the whole
22542306
# string at once.
22552307
if fonttype != 3 or all(ord(char) <= 255 for char in s):

‎lib/matplotlib/tests/test_backend_pdf.py

Copy file name to clipboardExpand all lines: lib/matplotlib/tests/test_backend_pdf.py
+48Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import decimal
23
import io
34
import os
45
from pathlib import Path
@@ -212,6 +213,53 @@ def test_multipage_metadata(monkeypatch):
212213
}
213214

214215

216+
def test_text_urls():
217+
pikepdf = pytest.importorskip('pikepdf')
218+
219+
test_url = 'https://test_text_urls.matplotlib.org/'
220+
221+
fig = plt.figure(figsize=(2, 1))
222+
fig.text(0.1, 0.1, 'test plain 123', url=f'{test_url}plain')
223+
fig.text(0.1, 0.4, 'test mathtext $123$', url=f'{test_url}mathtext')
224+
225+
with io.BytesIO() as fd:
226+
fig.savefig(fd, format='pdf')
227+
228+
with pikepdf.Pdf.open(fd) as pdf:
229+
annots = pdf.pages[0].Annots
230+
231+
for y, fragment in [('0.1', 'plain'), ('0.4', 'mathtext')]:
232+
annot = next(
233+
(a for a in annots if a.A.URI == f'{test_url}{fragment}'),
234+
None)
235+
assert annot is not None
236+
# Positions in points (72 per inch.)
237+
assert annot.Rect[1] == decimal.Decimal(y) * 72
238+
239+
240+
@needs_usetex
241+
def test_text_urls_tex():
242+
pikepdf = pytest.importorskip('pikepdf')
243+
244+
test_url = 'https://test_text_urls.matplotlib.org/'
245+
246+
fig = plt.figure(figsize=(2, 1))
247+
fig.text(0.1, 0.7, 'test tex $123$', usetex=True, url=f'{test_url}tex')
248+
249+
with io.BytesIO() as fd:
250+
fig.savefig(fd, format='pdf')
251+
252+
with pikepdf.Pdf.open(fd) as pdf:
253+
annots = pdf.pages[0].Annots
254+
255+
annot = next(
256+
(a for a in annots if a.A.URI == f'{test_url}tex'),
257+
None)
258+
assert annot is not None
259+
# Positions in points (72 per inch.)
260+
assert annot.Rect[1] == decimal.Decimal('0.7') * 72
261+
262+
215263
def test_pdfpages_fspath():
216264
with PdfPages(Path(os.devnull)) as pdf:
217265
pdf.savefig(plt.figure())

0 commit comments

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