Skip to content

Commit ea834ec

Browse files
authored
Propertly inserting alt_text when it is provided to FPDF.link - close #90 (#109)
+ Fixing unit tests assertion mechanism
1 parent 4efca67 commit ea834ec

File tree

82 files changed

+182
-132
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

82 files changed

+182
-132
lines changed

.github/workflows/continuous-integration-workflow.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949
# Ensuring there is no `generate=True` left remaining in calls to assert_pdf_equal:
5050
grep -IRF generate=True test/ && exit 1
5151
# Executing all tests:
52-
pytest
52+
pytest -vv
5353
# Uploading coverage report to codecov.io
5454
bash <(curl -s https://codecov.io/bash)
5555
- name: Generating HTML documentation 🏗️

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and [PEP 440](https://www.python.org/dev/peps/pep-0440/).
1212
- `FPDF.set_margin` : new method to set the document right, left, top & bottom margins to the same value
1313
- `FPDF.image` now accepts new optional `title` & `alt_text` parameters defining the image title
1414
and alternative text describing it, for accessibility purposes
15+
- `FPDF.link` now honor its `alt_text` optional parameter and this alternative text describing links
16+
is now properly included in the resulting PDF document
1517
- the document language can be set using `FPDF.set_lang`
1618
### Deprecated
1719
- `fpdf.FPDF_CACHE_MODE` & `fpdf.FPDF_CACHE_DIR` in favor of a configurable new `font_cache_dir` optional argument of the `fpdf.FPDF` constructor

docs/reference/link.md

+5-32
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,6 @@
1-
## link ##
1+
_cf._ https://pypi.org/project/fpdf2/fpdf/#fpdf.FPDF.dashed_line
22

3-
```python
4-
fpdf.link(x: float, y: float, w: float, h: float, link, alt_text = '')
5-
```
6-
7-
### Description ###
8-
9-
Puts a link on a rectangular area of the page. Text or image links are generally put via [cell](cell.md), [write](write.md) or [image](image.md), but this method can be useful for instance to define a clickable area inside an image.
10-
11-
### Parameters ###
12-
13-
x:
14-
> Abscissa of the upper-left corner of the rectangle.
15-
16-
y:
17-
> Ordinate of the upper-left corner of the rectangle.
18-
19-
w:
20-
> Width of the rectangle.
21-
22-
h:
23-
> Height of the rectangle.
24-
25-
link:
26-
> URL or identifier returned by [add_link](add_link.md).
27-
28-
alt_text:
29-
> An optional string defining the link alternative text `Contents`
30-
31-
### See also ###
32-
33-
[add_link](add_link.md), [cell](cell.md), [write](write.md), [image](image.md).
3+
<script>
4+
// Migrating Markdown doc to docstrings - cf. https://github.com/PyFPDF/fpdf2/issues/31
5+
window.location = 'https://pypi.org/project/fpdf2/fpdf/#fpdf.FPDF.link'
6+
</script>

fpdf/fpdf.py

+71-30
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from enum import IntEnum
2929
from functools import wraps
3030
from pathlib import Path
31+
from typing import NamedTuple, Optional
3132

3233
from .errors import FPDFException, FPDFPageFormatException
3334
from .fonts import fpdf_charwidths
@@ -83,6 +84,15 @@ class DocumentState(IntEnum):
8384
CLOSED = 3 # EOF printed
8485

8586

87+
class PageLink(NamedTuple):
88+
x: int
89+
y: int
90+
width: int
91+
height: int
92+
link: str
93+
alt_text: Optional[str] = None
94+
95+
8696
# Disabling this check due to the "format" parameter below:
8797
# pylint: disable=redefined-builtin
8898
def get_page_format(format, k=None):
@@ -172,7 +182,7 @@ def __init__(
172182
self.font_files = {} # array of font files
173183
self.diffs = {} # array of encoding differences
174184
self.images = {} # array of used images
175-
self.page_links = {} # array of links in pages
185+
self.page_links = {} # array of PageLink
176186
self.links = {} # array of internal links
177187
self.in_footer = 0 # flag set when processing footer
178188
self.lasth = 0 # height of last cell printed
@@ -191,7 +201,7 @@ def __init__(
191201
self.angle = 0 # used by deprecated method: rotate()
192202
self.font_cache_dir = font_cache_dir
193203
self._marked_contents = [] # list of MarkedContent
194-
self._struct_parents_id_per_page = {} # {page_object_id -> StructParents ID}
204+
self._struct_parents_id_per_page = {} # {page_object_id -> StructParent(s) ID}
195205
# Only set if a Structure Tree is added to the document:
196206
self._struct_tree_root_obj_id = None
197207

@@ -927,13 +937,33 @@ def set_link(self, link, y=0, page=-1):
927937

928938
self.links[link] = [page, y]
929939

930-
def link(self, x, y, w, h, link, alt_text=""):
931-
"""Put a link on the page"""
940+
def link(self, x, y, w, h, link, alt_text=None):
941+
"""
942+
Puts a link on a rectangular area of the page.
943+
Text or image links are generally put via [cell](#fpdf.FPDF.cell),
944+
[write](#fpdf.FPDF.write) or [image](#fpdf.FPDF.image),
945+
but this method can be useful for instance to define a clickable area inside an image.
946+
947+
Args:
948+
x (int): horizontal position (from the left) to the left side of the link rectangle
949+
y (int): vertical position (from the top) to the bottom side of the link rectangle
950+
w (int): width of the link rectangle
951+
h (int): width of the link rectangle
952+
link (str): either an URL or a integer returned by `add_link`, defining an internal link to a page
953+
alt_text (str): optional textual description of the link, for accessibility purposes
954+
"""
932955
if self.page not in self.page_links:
933956
self.page_links[self.page] = []
934-
self.page_links[self.page] += [
935-
(x * self.k, self.h_pt - y * self.k, w * self.k, h * self.k, link, alt_text)
936-
]
957+
self.page_links[self.page].append(
958+
PageLink(
959+
x * self.k,
960+
self.h_pt - y * self.k,
961+
w * self.k,
962+
h * self.k,
963+
link,
964+
alt_text,
965+
)
966+
)
937967

938968
@check_page
939969
def text(self, x, y, txt=""):
@@ -1559,9 +1589,9 @@ def image(
15591589
type (str): [**DEPRECATED**] unused, will be removed in a later version.
15601590
link (str): optional link to add on the image, internal
15611591
(identifier returned by `add_link`) or external URL.
1562-
title (str): optional
1592+
title (str): optional. Currently, never seem rendered by PDF readers.
15631593
alt_text (str): optional alternative text describing the image,
1564-
for accessibility purposes
1594+
for accessibility purposes. Displayed by some PDF readers on hover.
15651595
"""
15661596
if type:
15671597
warnings.warn(
@@ -1615,20 +1645,25 @@ def image(
16151645
@contextmanager
16161646
def _marked_sequence(self, **kwargs):
16171647
page_object_id = self._current_page_object_id()
1618-
struct_parents_id = self._struct_parents_id_per_page.get(page_object_id)
1619-
if struct_parents_id is None:
1620-
struct_parents_id = len(self._struct_parents_id_per_page)
1621-
self._struct_parents_id_per_page[page_object_id] = struct_parents_id
16221648
mcid = sum(
16231649
1 for mc in self._marked_contents if mc.page_object_id == page_object_id
16241650
)
1625-
self._marked_contents.append(
1626-
MarkedContent(page_object_id, struct_parents_id, mcid, **kwargs)
1651+
self._add_marked_content(
1652+
page_object_id, struct_type="/Figure", mcid=mcid, **kwargs
16271653
)
16281654
self._out(f"/P <</MCID {mcid}>> BDC")
16291655
yield
16301656
self._out("EMC")
16311657

1658+
def _add_marked_content(self, page_object_id, **kwargs):
1659+
struct_parents_id = self._struct_parents_id_per_page.get(page_object_id)
1660+
if struct_parents_id is None:
1661+
struct_parents_id = len(self._struct_parents_id_per_page)
1662+
self._struct_parents_id_per_page[page_object_id] = struct_parents_id
1663+
marked_content = MarkedContent(page_object_id, struct_parents_id, **kwargs)
1664+
self._marked_contents.append(marked_content)
1665+
return marked_content
1666+
16321667
def _current_page_object_id(self):
16331668
# Predictable given that _putpages is invoked first in _enddoc:
16341669
return 2 * self.page + 1
@@ -1758,8 +1793,8 @@ def _putpages(self):
17581793
for pl in self.page_links[n]:
17591794
# first four things in 'link' list are coordinates?
17601795
rect = (
1761-
f"{pl[0]:.2f} {pl[1]:.2f} "
1762-
f"{pl[0] + pl[2]:.2f} {pl[1] - pl[3]:.2f}"
1796+
f"{pl.x:.2f} {pl.y:.2f} "
1797+
f"{pl.x + pl.width:.2f} {pl.y - pl.height:.2f}"
17631798
)
17641799

17651800
# start the annotation entry
@@ -1772,24 +1807,30 @@ def _putpages(self):
17721807
f"/F 4"
17731808
)
17741809

1775-
# HTML ending of annotation entry
1776-
if isinstance(pl[4], str):
1777-
annots += f"/A <</S /URI /URI {enclose_in_parens(pl[4])}>>>>"
1810+
if pl.alt_text is not None:
1811+
# Note: the spec indicates that a /StructParent could be added **inside* this /Annot,
1812+
# but tests with Adobe Acrobat Reader reveal that the page /StructParents inserted below
1813+
# is enough to link the marked content in the hierarchy tree with this annotation link.
1814+
self._add_marked_content(
1815+
self.n, struct_type="/Link", alt_text=pl.alt_text
1816+
)
17781817

1779-
# Dest type ending of annotation entry
1780-
else:
1781-
assert pl[4] in self.links, (
1818+
# HTML ending of annotation entry
1819+
if isinstance(pl.link, str):
1820+
annots += f"/A <</S /URI /URI {enclose_in_parens(pl.link)}>>"
1821+
else: # Dest type ending of annotation entry
1822+
assert pl.link in self.links, (
17821823
f"Page {n} has a link with an invalid index: "
1783-
f"{pl[4]} (doc #links={len(self.links)})"
1824+
f"{pl.link} (doc #links={len(self.links)})"
17841825
)
1785-
l = self.links[pl[4]]
1786-
# if l[0] in self.orientation_changes: h = w_pt
1787-
# else: h = h_pt
1826+
link = self.links[pl.link]
1827+
# if link[0] in self.orientation_changes: h = w_pt
1828+
# else: h = h_pt
17881829
annots += (
1789-
f"/Dest [{1 + 2 * l[0]} 0 R /XYZ 0 "
1790-
f"{h_pt - l[1] * self.k:.2f} null]>>"
1830+
f"/Dest [{1 + 2 * link[0]} 0 R /XYZ 0 "
1831+
f"{h_pt - link[1] * self.k:.2f} null]"
17911832
)
1792-
1833+
annots += ">>"
17931834
# End links list
17941835
self._out(f"{annots}]")
17951836
if self.pdf_version > "1.3":

fpdf/recorder.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class FPDFRecorder:
2222

2323
def __init__(self, pdf, accept_page_break=True):
2424
self._pdf = pdf
25-
self._initial = deepcopy(pdf.__dict__)
25+
self._initial = deepcopy(self._pdf.__dict__)
2626
self._calls = []
2727
if not accept_page_break:
2828
self.accept_page_break = False
@@ -35,6 +35,7 @@ def __getattr__(self, name):
3535

3636
def rewind(self):
3737
self._pdf.__dict__ = self._initial
38+
self._initial = deepcopy(self._pdf.__dict__)
3839

3940
def replay(self):
4041
for call in self._calls:

fpdf/structure_tree.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
class MarkedContent(NamedTuple):
1919
page_object_id: int # refers to the first page displaying this marked content
2020
struct_parents_id: int
21-
mcid: int
21+
struct_type: str
22+
mcid: Optional[int] = None
2223
title: Optional[str] = None
2324
alt_text: Optional[str] = None
2425

@@ -113,7 +114,7 @@ def serialize(self):
113114
elif all(isinstance(elem, int) for elem in self):
114115
serialized_elems = " ".join(map(str, self))
115116
else:
116-
raise NotImplementedError
117+
raise NotImplementedError(f"PDFArray.serialize with self={self}")
117118
return f"[{serialized_elems}]"
118119

119120

@@ -206,9 +207,9 @@ def __init__(self, marked_contents=()):
206207
def add_marked_content(self, marked_content):
207208
page = PDFObject(marked_content.page_object_id)
208209
struct_elem = StructElem(
209-
struct_type="/Figure",
210+
struct_type=marked_content.struct_type,
210211
parent=self.doc_struct_elem,
211-
kids=[marked_content.mcid],
212+
kids=[] if marked_content.mcid is None else [marked_content.mcid],
212213
page=page,
213214
title=marked_content.title,
214215
alt=marked_content.alt_text,

test/alias_nb_pages.pdf

-1 Bytes
Binary file not shown.

test/barcodes/test_barcodes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from pathlib import Path
22

33
from fpdf import FPDF
4-
from test.utilities import assert_pdf_equal
4+
from test.conftest import assert_pdf_equal
55

66
HERE = Path(__file__).resolve().parent
77

test/catalog-layout-continuous.pdf

-1 Bytes
Binary file not shown.

test/catalog-layout-single.pdf

-1 Bytes
Binary file not shown.

test/catalog-layout-two.pdf

-1 Bytes
Binary file not shown.

test/catalog-zoom-default.pdf

-1 Bytes
Binary file not shown.

test/catalog-zoom-fullpage.pdf

-1 Bytes
Binary file not shown.

test/catalog-zoom-fullwidth.pdf

-1 Bytes
Binary file not shown.

test/catalog-zoom-real.pdf

-1 Bytes
Binary file not shown.

test/cells/cell_table_unbreakable.pdf

-3 Bytes
Binary file not shown.
-3 Bytes
Binary file not shown.

test/cells/ln_0.pdf

-1 Bytes
Binary file not shown.

test/cells/ln_1.pdf

-1 Bytes
Binary file not shown.
Binary file not shown.
Binary file not shown.

test/cells/multi_cell_ln_0.pdf

-2 Bytes
Binary file not shown.

test/cells/multi_cell_ln_1.pdf

0 Bytes
Binary file not shown.

test/cells/multi_cell_ln_3.pdf

0 Bytes
Binary file not shown.

test/cells/multi_cell_ln_3_table.pdf

-1 Bytes
Binary file not shown.

test/cells/test_cell.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from pathlib import Path
22

33
import fpdf
4-
from test.utilities import assert_pdf_equal
4+
from test.conftest import assert_pdf_equal
55

66
TEXT_SIZE, SPACING = 36, 1.15
77
LINE_HEIGHT = TEXT_SIZE * SPACING

test/cells/test_multi_cell.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from pathlib import Path
22

33
import fpdf
4-
from test.utilities import assert_pdf_equal
4+
from test.conftest import assert_pdf_equal
55

66
TEXT_SIZE, SPACING = 36, 1.15
77
LINE_HEIGHT = TEXT_SIZE * SPACING

0 commit comments

Comments
 (0)