Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ This can also be enabled programmatically with `warnings.simplefilter('default',
* `libtiff` detection logic to maintain compatibility with pillow v12.
### Removed
* support for Python 3.9, that reached [end-of-life](https://devguide.python.org/versions/#supported-versions) in October 2025
* the deprecated `uni` parameter from `fpdf.add_font()`
* the deprecated `dest` parameter from `fpdf.output()`

## [2.8.4] - 2025-08-11
### Added
Expand Down
42 changes: 42 additions & 0 deletions docs/Development.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,48 @@ This page has summary information about developing the fpdf2 library.
* `.banditrc.yml` - configuration for [bandit](https://pypi.org/project/bandit/)
* `.pylintrc` - configuration for [Pylint](http://pylint.pycqa.org/en/latest/)

### Deprecation policy

We aim to keep public behaviour stable for as long as possible, so removals go through a staged process.

**Method deprecation**
- Document the deprecation directly in the docstring using a `.. deprecated::` directive.
- Emit a `DeprecationWarning`, while still executing a compatible code path when feasible.
- Example (from `fpdf/fpdf.py`):

```python
def set_doc_option(self, opt, value):
"""
Defines a document option.

Args:
opt (str): name of the option to set
value (str): option value

.. deprecated:: 2.4.0
Simply set the `FPDF.core_fonts_encoding` property as a replacement.
"""
warnings.warn(
(
"set_doc_option() is deprecated since v2.4.0 "
"and will be removed in a future release. "
"Simply set the `.core_fonts_encoding` property as a replacement."
),
DeprecationWarning,
stacklevel=get_stack_level(),
)
if opt != "core_fonts_encoding":
raise FPDFException(f'Unknown document option "{opt}"')
self.core_fonts_encoding = value
```

**Parameter deprecation**
- Step 1: Mark the parameter as deprecated in the documentation and emit a warning when it is supplied.
- Step 2: After a few releases, add the `@deprecated_parameter()` decorator so that the argument disappears from the public signature and linters/IDEs flag its usage.
- Step 3: Remove support for the parameter entirely, once it is safe with respect to backwards compatibility.

We try to leave generous time between these steps and only delete behaviour when absolutely necessary.

## Installing fpdf2 from a local git repository
```
pip install --editable $path/to/fpdf/repo
Expand Down
27 changes: 27 additions & 0 deletions fpdf/deprecation.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,33 @@ def wrapper(self, *args, **kwargs):
return wrapper


def deprecated_parameter(parameters):
"""Decorator removing deprecated keyword arguments from a function call.

Args:
parameters (Iterable[tuple[str, str]]): sequence of `(parameter, version)` pairs.
"""

deprecated_info = tuple(parameters)
_sentinel = object()

def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
for name, version in deprecated_info:
if kwargs.pop(name, _sentinel) is not _sentinel:
warnings.warn(
f'"{name}" parameter is deprecated since v{version} and will be removed in a future release',
DeprecationWarning,
stacklevel=get_stack_level(),
)
return fn(*args, **kwargs)

return wrapper

return decorator


class WarnOnDeprecatedModuleAttributes(ModuleType):
def __call__(self):
raise TypeError(
Expand Down
98 changes: 29 additions & 69 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class Image:
from .bidi import BidiParagraph, auto_detect_base_direction
from .deprecation import (
WarnOnDeprecatedModuleAttributes,
deprecated_parameter,
get_stack_level,
support_deprecated_txt_arg,
)
Expand Down Expand Up @@ -2272,12 +2273,13 @@ def bezier(self, point_list, closed=False, style=None):

ctxt.add_item(path)

@deprecated_parameter([("uni", "2.5.1")])
def add_font(
self,
family=None,
style="",
fname=None,
uni="DEPRECATED",
*,
unicode_range=None,
variations=None,
palette=None,
Expand All @@ -2300,7 +2302,6 @@ def add_font(
variations (dict[style, dict]): maps style to limits of axes for the variable font.
palette (int): optional palette index for color fonts (COLR/CPAL). Defaults to 0 (first palette).
Only applicable to fonts with CPAL table (color fonts).
uni (bool): [**DEPRECATED since 2.5.1**] unused
"""
if not fname:
raise ValueError('"fname" parameter is required')
Expand All @@ -2313,16 +2314,6 @@ def add_font(
" this feature is deprecated since v2.5.1 and has been removed in v2.5.3."
)

if uni != "DEPRECATED":
warnings.warn(
(
'"uni" parameter is deprecated since v2.5.1, '
Copy link
Member

@Lucas-C Lucas-C Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By introducing deprecated_parameter(), we lose the information of the deprecation version.

Could we maybe preserve it by passing it to deprecated_parameter()?

"unused and will soon be removed"
),
DeprecationWarning,
stacklevel=get_stack_level(),
)

for parent in (".", FPDF_FONT_DIR):
if not parent:
continue
Expand All @@ -2340,16 +2331,6 @@ def add_font(
if unicode_range is not None:
parsed_unicode_range = get_parsed_unicode_range(unicode_range)

def already_exists(fontkey):
# Check if font already added or one of the core fonts
if fontkey in self.fonts or fontkey in CORE_FONTS:
warnings.warn(
f"Core font or font already added '{fontkey}': doing nothing",
stacklevel=get_stack_level(),
)
return True
return False

style = "".join(sorted(style.upper()))
if any(letter not in "BI" for letter in style):
raise ValueError(
Expand All @@ -2367,43 +2348,33 @@ def already_exists(fontkey):
for key, value in variations.items()
):
for var_style, axes_dict in variations.items():
fontkey = f"{family.lower()}{var_style}"
if already_exists(fontkey):
continue
self.fonts[fontkey] = TTFFont(
self,
font_file_path,
fontkey,
var_style,
parsed_unicode_range,
axes_dict,
palette,
self.add_font(
family=family,
style=var_style,
fname=font_file_path,
unicode_range=unicode_range,
variations=axes_dict,
palette=palette,
)
else:
fontkey = f"{family.lower()}{style}"
self.fonts[fontkey] = TTFFont(
self,
font_file_path,
fontkey,
style,
parsed_unicode_range,
variations,
palette,
)
else:
# Handle static fonts.
fontkey = f"{family.lower()}{style}"
if already_exists(fontkey):
return
self.fonts[fontkey] = TTFFont(
self,
font_file_path,
fontkey,
style,
parsed_unicode_range,
None,
palette,
fontkey = f"{family.lower()}{style}"

if fontkey in self.fonts or fontkey in CORE_FONTS:
warnings.warn(
f"Core font or font already added '{fontkey}': doing nothing",
stacklevel=get_stack_level(),
)
return

self.fonts[fontkey] = TTFFont(
self,
font_file_path,
fontkey,
style,
parsed_unicode_range,
variations,
palette,
)

def set_font(self, family=None, style: Union[str, TextEmphasis] = "", size=0):
"""
Expand Down Expand Up @@ -6019,9 +5990,8 @@ def table(self, *args: Any, **kwargs: Any) -> ContextManager[Table]:
yield table
table.render()

def output(
self, name="", dest="", linearize=False, output_producer_class=OutputProducer
):
@deprecated_parameter([("dest", "2.2.0")])
def output(self, name="", *, linearize=False, output_producer_class=OutputProducer):
"""
Output PDF to some destination.
The method first calls [close](close.md) if necessary to terminate the document.
Expand All @@ -6032,18 +6002,8 @@ def output(

Args:
name (str): optional File object or file path where to save the PDF under
dest (str): [**DEPRECATED since 2.3.0**] unused, will be removed in a later version
output_producer_class (class): use a custom class for PDF file generation
"""
if dest:
warnings.warn(
(
'"dest" parameter is deprecated since v2.2.0, '
"unused and will soon be removed"
),
DeprecationWarning,
stacklevel=get_stack_level(),
)
# Clear cache of cached functions to free up memory after output
get_unicode_script.cache_clear()
# Finish document if necessary:
Expand Down
35 changes: 35 additions & 0 deletions test/errors/test_deprecation_warnings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import pytest

from pathlib import Path

from fpdf import FPDF

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


def test_TitleStyle_deprecation():
# pylint: disable=import-outside-toplevel
Expand All @@ -12,3 +18,32 @@ def test_TitleStyle_deprecation():
from fpdf.fonts import TitleStyle

TitleStyle()


def test_add_font_uni_deprecation():
pdf = FPDF()

with pytest.warns(DeprecationWarning) as record:
# pylint: disable=unexpected-keyword-arg
pdf.add_font(
"DejaVu",
"",
HERE.parent / "fonts" / "DejaVuSans.ttf",
uni=True,
)
assert (
str(record[0].message)
== '"uni" parameter is deprecated since v2.5.1 and will be removed in a future release'
)


def test_output_dest_deprecation():
pdf = FPDF()

with pytest.warns(DeprecationWarning) as record:
# pylint: disable=unexpected-keyword-arg
pdf.output(dest="S")
assert (
str(record[0].message)
== '"dest" parameter is deprecated since v2.2.0 and will be removed in a future release'
)
3 changes: 2 additions & 1 deletion test/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ def test_repeated_calls_to_output(tmp_path):
def test_deprecation_warning(tmp_path):
pdf = fpdf.FPDF()
with pytest.warns(DeprecationWarning) as record:
pdf.output(tmp_path / "empty.pdf", "F")
# pylint: disable=unexpected-keyword-arg
pdf.output(name=tmp_path / "empty.pdf", dest="F")
assert len(record) == 1
assert_same_file(record[0].filename, __file__)

Expand Down