Skip to content

Commit e54b066

Browse files
authored
New feature: Gradient patterns (#1334)
* implement linear and radial gradient * add tests * start adding typing and comments * black * fix f strings * fix tests; remove code duplication on bad git clone * initial implementation of ResourceCatalog * replace match (python 3.10+ only) * pylint * proceed with resource catalog implementation * more tests and create documentation * formatting * add docstrings and changelog entry * increase performance image rendering time limit * replace jpxdecode test reference after changes in pillow 11.1.0 * skip jpxdecode test on python 3.8 * fix version check
1 parent bb3881b commit e54b066

21 files changed

+985
-34
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ in order to get warned about deprecated features used in your code.
1717
This can also be enabled programmatically with `warnings.simplefilter('default', DeprecationWarning)`.
1818

1919
## [2.8.3] - Not released yet
20+
### Added
21+
* support for [shading patterns (gradients)](https://py-pdf.github.io/fpdf2/Patterns.html)
2022
### Fixed
2123
* [`FPDF.write_html()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write_html): Fixed rendering of content following `<a>` tags; now correctly resets emphasis style post `</a>` tag: hyperlink styling contained within the tag authority. - [Issue #1311](https://github.com/py-pdf/fpdf2/issues/1311)
2224

docs/Patterns.md

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Patterns and Gradients
2+
3+
## Overview
4+
5+
In PDF (Portable Document Format), a **pattern** is a graphical object that can be used to fill (or stroke) shapes. Patterns can include simple color fills, images, or more advanced textures and gradients.
6+
7+
The **patterns** on PDF documents are grouped on 2 types:
8+
- **Tiling patterns** for any repeating patters.
9+
- **Shading patterns** for gradients.
10+
11+
*fpdf2* provides a context manager `pdf.use_pattern(...)`. Within this context, all drawn shapes or text will use the specified pattern. Once the context ends, drawing reverts to the previously defined color.
12+
13+
**At this moment, tiling patterns are not yet supported by `fpdf2`**.
14+
15+
## 2. Gradients
16+
17+
### 2.1 What is a Gradient?
18+
19+
A **gradient** is a progressive blend between two or more colors. In PDF terms, gradients are implemented as *shading patterns*—they allow a smooth color transition based on geometry.
20+
21+
### 2.2 Linear Gradients (axial shading)
22+
23+
A **linear gradient** blends colors along a straight line between two points. For instance, you can define a gradient that goes:
24+
25+
- Left to right
26+
- Top to bottom
27+
- Diagonally
28+
29+
or in any arbitrary orientation by specifying coordinates.
30+
31+
**Example: Creating a Linear Gradient**
32+
33+
```python
34+
from fpdf import FPDF
35+
from fpdf.pattern import LinearGradient
36+
37+
pdf = FPDF()
38+
pdf.add_page()
39+
40+
# Define a linear gradient
41+
linear_grad = LinearGradient(
42+
pdf,
43+
from_x=10, # Starting x-coordinate
44+
from_y=0, # Starting y-coordinate
45+
to_x=100, # Ending x-coordinate
46+
to_y=0, # Ending y-coordinate
47+
colors=["#C33764", "#1D2671"] # Start -> End color
48+
)
49+
50+
with pdf.use_pattern(linear_grad):
51+
# Draw a rectangle that will be filled with the gradient
52+
pdf.rect(x=10, y=10, w=100, h=20, style="FD")
53+
54+
pdf.output("linear_gradient_example.pdf")
55+
```
56+
57+
**Key Parameters**:
58+
59+
- **from_x, from_y, to_x, to_y**: The coordinates defining the line along which colors will blend.
60+
- **colors**: A list of colors (hex strings or (R,G,B) tuples). The pattern will interpolate between these colors.
61+
62+
63+
### 2.3 Radial Gradients
64+
65+
A **radial gradient** blends colors in a circular or elliptical manner from an inner circle to an outer circle. This is perfect for spotlight-like effects or circular color transitions.
66+
67+
**Example: Creating a Radial Gradient**
68+
69+
```python
70+
from fpdf import FPDF
71+
from fpdf.pattern import RadialGradient
72+
73+
pdf = FPDF()
74+
pdf.add_page()
75+
76+
# Define a radial gradient
77+
radial_grad = RadialGradient(
78+
pdf,
79+
start_circle_x=50, # Center X of inner circle
80+
start_circle_y=50, # Center Y of inner circle
81+
start_circle_radius=0, # Radius of inner circle
82+
end_circle_x=50, # Center X of outer circle
83+
end_circle_y=50, # Center Y of outer circle
84+
end_circle_radius=25, # Radius of outer circle
85+
colors=["#FFFF00", "#FF0000"], # Inner -> Outer color
86+
)
87+
88+
with pdf.use_pattern(radial_grad):
89+
# Draw a circle filled with the radial gradient
90+
pdf.circle(x=50, y=50, radius=25, style="FD")
91+
92+
pdf.output("radial_gradient_example.pdf")
93+
```
94+
95+
**Key Parameters**:
96+
97+
- **start_circle_x, start_circle_y, start_circle_radius**: Center and radius of the inner circle.
98+
- **end_circle_x, end_circle_y, end_circle_radius**: Center and radius of the outer circle.
99+
- **colors**: A list of colors to be interpolated from inner circle to outer circle.
100+
101+
## 4. Advanced Usage
102+
103+
### 4.1 Multiple Colors
104+
105+
Both linear and radial gradients support **multiple colors**. If you pass, for example, `colors=["#C33764", "#1D2671", "#FFA500"]`, the resulting pattern will interpolate color transitions through each color in that order.
106+
107+
### 4.2 Extending & Background for Linear Gradients
108+
109+
- **extend_before**: Extends the first color before the starting point (i.e., `x1,y1`).
110+
- **extend_after**: Extends the last color beyond the end point (i.e., `x2,y2`).
111+
- **background**: Ensures that if any area is uncovered by the gradient (e.g., a rectangle that is bigger than the gradient line), it’ll show the given background color.
112+
113+
### 4.3 Custom Bounds
114+
115+
For **linear gradients** or **radial gradients**, passing `bounds=[0.2, 0.4, 0.7, ...]` (values between 0 and 1) fine-tunes where each color transition occurs. For instance, if you have 5 colors, you can specify 3 boundary values that partition the color progression among them.
116+
117+
For example, taking a gradient with 5 colors and `bounds=[0.1, 0.8, 0.9]`:
118+
- The transition from color 1 to color 2 start at the beggining (0%) and ends at 10%
119+
- The transition from color 2 to color 3 start at 10% and ends at 80%
120+
- The transition from color 3 to color 4 start at 80% and ends at 90%
121+
- The transition from color 4 to color 5 start at 90% and goes to the end (100%)
122+
123+
In other words, each boundary value dictates where the color transitions will occur along the total gradient length.

fpdf/enums.py

+11
Original file line numberDiff line numberDiff line change
@@ -1068,3 +1068,14 @@ def coerce(cls, value):
10681068
if isinstance(value, str):
10691069
value = value.upper()
10701070
return super(cls, cls).coerce(value)
1071+
1072+
1073+
class PDFResourceType(Enum):
1074+
EXT_G_STATE = intern("ExtGState")
1075+
COLOR_SPACE = intern("ColorSpece")
1076+
PATTERN = intern("Pattern")
1077+
SHADDING = intern("Shading")
1078+
X_OBJECT = intern("XObject")
1079+
FONT = intern("Font")
1080+
PROC_SET = intern("ProcSet")
1081+
PROPERTIES = intern("Properties")

fpdf/fpdf.py

+48-22
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class Image:
8585
PageMode,
8686
PageOrientation,
8787
PathPaintRule,
88+
PDFResourceType,
8889
RenderStyle,
8990
TextDirection,
9091
TextEmphasis,
@@ -123,6 +124,7 @@ class Image:
123124
OutputProducer,
124125
PDFPage,
125126
PDFPageLabel,
127+
ResourceCatalog,
126128
stream_content_for_raster_image,
127129
)
128130
from .recorder import FPDFRecorder
@@ -277,12 +279,9 @@ def __init__(
277279
self.pages: Dict[int, PDFPage] = {}
278280
self.fonts = {} # map font string keys to an instance of CoreFont or TTFFont
279281
# map page numbers to a set of font indices:
280-
self.fonts_used_per_page_number = defaultdict(set)
281282
self.links = {} # array of Destination objects starting at index 1
282283
self.embedded_files = [] # array of PDFEmbeddedFile
283284
self.image_cache = ImageCache()
284-
# map page numbers to a set of image indices
285-
self.images_used_per_page_number = defaultdict(set)
286285
self.in_footer = False # flag set while rendering footer
287286
# indicates that we are inside an .unbreakable() code block:
288287
self._in_unbreakable = False
@@ -365,9 +364,8 @@ def __init__(
365364
self._current_draw_context = None
366365
self._drawing_graphics_state_registry = GraphicsStateDictRegistry()
367366
# map page numbers to a set of GraphicsState names:
368-
self.graphics_style_names_per_page_number = defaultdict(set)
369-
370367
self._record_text_quad_points = False
368+
self._resource_catalog = ResourceCatalog()
371369

372370
# page number -> array of 8 × n numbers:
373371
self._text_quad_points = defaultdict(list)
@@ -1251,20 +1249,39 @@ def drawing_context(self, debug_stream=None):
12511249
else:
12521250
rendered = context.render(*render_args)
12531251

1254-
self.graphics_style_names_per_page_number[self.page].update(
1255-
match.group(1) for match in self._GS_REGEX.finditer(rendered)
1256-
)
1252+
for match in self._GS_REGEX.finditer(rendered):
1253+
self._resource_catalog.add(
1254+
PDFResourceType.EXT_G_STATE, match.group(1), self.page
1255+
)
12571256
# Registering raster images embedded in the vector graphics:
1258-
self.images_used_per_page_number[self.page].update(
1259-
int(match.group(1)) for match in self._IMG_REGEX.finditer(rendered)
1260-
)
1257+
for match in self._IMG_REGEX.finditer(rendered):
1258+
self._resource_catalog.add(
1259+
PDFResourceType.X_OBJECT, int(match.group(1)), self.page
1260+
)
12611261
# Once we handle text-rendering SVG tags (cf. PR #1029),
1262-
# we should also detect fonts used and add them to self.fonts_used_per_page_number
1262+
# we should also detect fonts used and add them to the resource catalog
12631263

12641264
self._out(rendered)
12651265
# The drawing API makes use of features (notably transparency and blending modes) that were introduced in PDF 1.4:
12661266
self._set_min_pdf_version("1.4")
12671267

1268+
@contextmanager
1269+
@check_page
1270+
def use_pattern(self, shading):
1271+
"""
1272+
Create a context for using a shading pattern on the current page.
1273+
"""
1274+
self._resource_catalog.add(PDFResourceType.SHADDING, shading, self.page)
1275+
pattern = shading.get_pattern()
1276+
pattern_name = self._resource_catalog.add(
1277+
PDFResourceType.PATTERN, pattern, self.page
1278+
)
1279+
self._out(f"/Pattern cs /{pattern_name} scn")
1280+
try:
1281+
yield
1282+
finally:
1283+
self._out(self.draw_color.serialize().lower())
1284+
12681285
def _current_graphic_style(self):
12691286
gs = GraphicsStyle()
12701287
gs.allow_transparency = self.allow_images_transparency
@@ -2127,7 +2144,9 @@ def set_font(self, family=None, style="", size=0):
21272144
self.current_font = self.fonts[fontkey]
21282145
if self.page > 0:
21292146
self._out(f"BT /F{self.current_font.i} {self.font_size_pt:.2f} Tf ET")
2130-
self.fonts_used_per_page_number[self.page].add(self.current_font.i)
2147+
self._resource_catalog.add(
2148+
PDFResourceType.FONT, self.current_font.i, self.page
2149+
)
21312150

21322151
def set_font_size(self, size):
21332152
"""
@@ -2145,7 +2164,9 @@ def set_font_size(self, size):
21452164
"Cannot set font size: a font must be selected first"
21462165
)
21472166
self._out(f"BT /F{self.current_font.i} {self.font_size_pt:.2f} Tf ET")
2148-
self.fonts_used_per_page_number[self.page].add(self.current_font.i)
2167+
self._resource_catalog.add(
2168+
PDFResourceType.FONT, self.current_font.i, self.page
2169+
)
21492170

21502171
def set_char_spacing(self, spacing):
21512172
"""
@@ -2477,7 +2498,7 @@ def free_text_annotation(
24772498
default_appearance=f"({self.draw_color.serialize()} /F{self.current_font.i} {self.font_size_pt:.2f} Tf)",
24782499
**kwargs,
24792500
)
2480-
self.fonts_used_per_page_number[self.page].add(self.current_font.i)
2501+
self._resource_catalog.add(PDFResourceType.FONT, self.current_font.i, self.page)
24812502
self.pages[self.page].annots.append(annotation)
24822503
return annotation
24832504

@@ -2665,7 +2686,7 @@ def text(self, x, y, text=""):
26652686
if self.text_mode != TextMode.FILL:
26662687
sl.append(f" {self.text_mode} Tr {self.line_width:.2f} w")
26672688
sl.append(f"{self.current_font.encode_text(text)} ET")
2668-
self.fonts_used_per_page_number[self.page].add(self.current_font.i)
2689+
self._resource_catalog.add(PDFResourceType.FONT, self.current_font.i, self.page)
26692690
if (self.underline and text != "") or self._record_text_quad_points:
26702691
w = self.get_string_width(text, normalized=True, markdown=False)
26712692
if self.underline and text != "":
@@ -2952,7 +2973,7 @@ def _start_local_context(
29522973
raise ValueError(f"Unsupported setting: {key}")
29532974
if gs:
29542975
gs_name = self._drawing_graphics_state_registry.register_style(gs)
2955-
self.graphics_style_names_per_page_number[self.page].add(gs_name)
2976+
self._resource_catalog.add(PDFResourceType.EXT_G_STATE, gs_name, self.page)
29562977
self._out(f"q /{gs_name} gs")
29572978
else:
29582979
self._out("q")
@@ -3303,7 +3324,9 @@ def _render_styled_text_line(
33033324
current_font = frag.font
33043325
sl.append(f"/F{frag.font.i} {frag.font_size_pt:.2f} Tf")
33053326
if self.page > 0:
3306-
self.fonts_used_per_page_number[self.page].add(current_font.i)
3327+
self._resource_catalog.add(
3328+
PDFResourceType.FONT, current_font.i, self.page
3329+
)
33073330
lift = frag.lift
33083331
if lift != current_lift:
33093332
# Use text rise operator:
@@ -4380,7 +4403,7 @@ def _raster_image(
43804403
if link:
43814404
self.link(x, y, w, h, link)
43824405

4383-
self.images_used_per_page_number[self.page].add(info["i"])
4406+
self._resource_catalog.add(PDFResourceType.X_OBJECT, info["i"], self.page)
43844407
return RasterImageInfo(**info, rendered_width=w, rendered_height=h)
43854408

43864409
def x_by_align(self, x, w, h, img_info, keep_aspect_ratio):
@@ -4516,9 +4539,12 @@ def _downscale_image(self, name, img, info, w, h, scale):
45164539
)
45174540
info["usages"] -= 1 # no need to embed the high-resolution image
45184541
if info["usages"] == 0:
4519-
for images_used in self.images_used_per_page_number.values():
4520-
if info["i"] in images_used:
4521-
images_used.remove(info["i"])
4542+
for (
4543+
_,
4544+
rtype,
4545+
), resource in self._resource_catalog.resources_per_page.items():
4546+
if rtype == PDFResourceType.X_OBJECT and info["i"] in resource:
4547+
resource.remove(info["i"])
45224548
if lowres_info: # Great, we've already done the job!
45234549
info = lowres_info
45244550
if info["w"] * info["h"] < dims[0] * dims[1]:

fpdf/linearization.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,14 @@ def bufferize(self):
166166
font_objs_per_index = self._add_fonts()
167167
img_objs_per_index = self._add_images()
168168
gfxstate_objs_per_name = self._add_gfxstates()
169+
shading_objs_per_name = self._add_shadings()
170+
pattern_objs_per_name = self._add_patterns()
169171
resources_dict_obj = self._add_resources_dict(
170-
font_objs_per_index, img_objs_per_index, gfxstate_objs_per_name
172+
font_objs_per_index,
173+
img_objs_per_index,
174+
gfxstate_objs_per_name,
175+
shading_objs_per_name,
176+
pattern_objs_per_name,
171177
)
172178
# Part 9: Objects not associated with pages, if any
173179
for embedded_file in fpdf.embedded_files:

0 commit comments

Comments
 (0)