Skip to content

Commit

Permalink
New feature: Gradient patterns (#1334)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
andersonhc authored Jan 5, 2025
1 parent bb3881b commit e54b066
Show file tree
Hide file tree
Showing 21 changed files with 985 additions and 34 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ in order to get warned about deprecated features used in your code.
This can also be enabled programmatically with `warnings.simplefilter('default', DeprecationWarning)`.

## [2.8.3] - Not released yet
### Added
* support for [shading patterns (gradients)](https://py-pdf.github.io/fpdf2/Patterns.html)
### Fixed
* [`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)

Expand Down
123 changes: 123 additions & 0 deletions docs/Patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Patterns and Gradients

## Overview

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.

The **patterns** on PDF documents are grouped on 2 types:
- **Tiling patterns** for any repeating patters.
- **Shading patterns** for gradients.

*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.

**At this moment, tiling patterns are not yet supported by `fpdf2`**.

## 2. Gradients

### 2.1 What is a Gradient?

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.

### 2.2 Linear Gradients (axial shading)

A **linear gradient** blends colors along a straight line between two points. For instance, you can define a gradient that goes:

- Left to right
- Top to bottom
- Diagonally

or in any arbitrary orientation by specifying coordinates.

**Example: Creating a Linear Gradient**

```python
from fpdf import FPDF
from fpdf.pattern import LinearGradient

pdf = FPDF()
pdf.add_page()

# Define a linear gradient
linear_grad = LinearGradient(
pdf,
from_x=10, # Starting x-coordinate
from_y=0, # Starting y-coordinate
to_x=100, # Ending x-coordinate
to_y=0, # Ending y-coordinate
colors=["#C33764", "#1D2671"] # Start -> End color
)

with pdf.use_pattern(linear_grad):
# Draw a rectangle that will be filled with the gradient
pdf.rect(x=10, y=10, w=100, h=20, style="FD")

pdf.output("linear_gradient_example.pdf")
```

**Key Parameters**:

- **from_x, from_y, to_x, to_y**: The coordinates defining the line along which colors will blend.
- **colors**: A list of colors (hex strings or (R,G,B) tuples). The pattern will interpolate between these colors.


### 2.3 Radial Gradients

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.

**Example: Creating a Radial Gradient**

```python
from fpdf import FPDF
from fpdf.pattern import RadialGradient

pdf = FPDF()
pdf.add_page()

# Define a radial gradient
radial_grad = RadialGradient(
pdf,
start_circle_x=50, # Center X of inner circle
start_circle_y=50, # Center Y of inner circle
start_circle_radius=0, # Radius of inner circle
end_circle_x=50, # Center X of outer circle
end_circle_y=50, # Center Y of outer circle
end_circle_radius=25, # Radius of outer circle
colors=["#FFFF00", "#FF0000"], # Inner -> Outer color
)

with pdf.use_pattern(radial_grad):
# Draw a circle filled with the radial gradient
pdf.circle(x=50, y=50, radius=25, style="FD")

pdf.output("radial_gradient_example.pdf")
```

**Key Parameters**:

- **start_circle_x, start_circle_y, start_circle_radius**: Center and radius of the inner circle.
- **end_circle_x, end_circle_y, end_circle_radius**: Center and radius of the outer circle.
- **colors**: A list of colors to be interpolated from inner circle to outer circle.

## 4. Advanced Usage

### 4.1 Multiple Colors

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.

### 4.2 Extending & Background for Linear Gradients

- **extend_before**: Extends the first color before the starting point (i.e., `x1,y1`).
- **extend_after**: Extends the last color beyond the end point (i.e., `x2,y2`).
- **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.

### 4.3 Custom Bounds

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.

For example, taking a gradient with 5 colors and `bounds=[0.1, 0.8, 0.9]`:
- The transition from color 1 to color 2 start at the beggining (0%) and ends at 10%
- The transition from color 2 to color 3 start at 10% and ends at 80%
- The transition from color 3 to color 4 start at 80% and ends at 90%
- The transition from color 4 to color 5 start at 90% and goes to the end (100%)

In other words, each boundary value dictates where the color transitions will occur along the total gradient length.
11 changes: 11 additions & 0 deletions fpdf/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -1068,3 +1068,14 @@ def coerce(cls, value):
if isinstance(value, str):
value = value.upper()
return super(cls, cls).coerce(value)


class PDFResourceType(Enum):
EXT_G_STATE = intern("ExtGState")
COLOR_SPACE = intern("ColorSpece")
PATTERN = intern("Pattern")
SHADDING = intern("Shading")
X_OBJECT = intern("XObject")
FONT = intern("Font")
PROC_SET = intern("ProcSet")
PROPERTIES = intern("Properties")
70 changes: 48 additions & 22 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class Image:
PageMode,
PageOrientation,
PathPaintRule,
PDFResourceType,
RenderStyle,
TextDirection,
TextEmphasis,
Expand Down Expand Up @@ -123,6 +124,7 @@ class Image:
OutputProducer,
PDFPage,
PDFPageLabel,
ResourceCatalog,
stream_content_for_raster_image,
)
from .recorder import FPDFRecorder
Expand Down Expand Up @@ -277,12 +279,9 @@ def __init__(
self.pages: Dict[int, PDFPage] = {}
self.fonts = {} # map font string keys to an instance of CoreFont or TTFFont
# map page numbers to a set of font indices:
self.fonts_used_per_page_number = defaultdict(set)
self.links = {} # array of Destination objects starting at index 1
self.embedded_files = [] # array of PDFEmbeddedFile
self.image_cache = ImageCache()
# map page numbers to a set of image indices
self.images_used_per_page_number = defaultdict(set)
self.in_footer = False # flag set while rendering footer
# indicates that we are inside an .unbreakable() code block:
self._in_unbreakable = False
Expand Down Expand Up @@ -365,9 +364,8 @@ def __init__(
self._current_draw_context = None
self._drawing_graphics_state_registry = GraphicsStateDictRegistry()
# map page numbers to a set of GraphicsState names:
self.graphics_style_names_per_page_number = defaultdict(set)

self._record_text_quad_points = False
self._resource_catalog = ResourceCatalog()

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

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

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

@contextmanager
@check_page
def use_pattern(self, shading):
"""
Create a context for using a shading pattern on the current page.
"""
self._resource_catalog.add(PDFResourceType.SHADDING, shading, self.page)
pattern = shading.get_pattern()
pattern_name = self._resource_catalog.add(
PDFResourceType.PATTERN, pattern, self.page
)
self._out(f"/Pattern cs /{pattern_name} scn")
try:
yield
finally:
self._out(self.draw_color.serialize().lower())

def _current_graphic_style(self):
gs = GraphicsStyle()
gs.allow_transparency = self.allow_images_transparency
Expand Down Expand Up @@ -2127,7 +2144,9 @@ def set_font(self, family=None, style="", size=0):
self.current_font = self.fonts[fontkey]
if self.page > 0:
self._out(f"BT /F{self.current_font.i} {self.font_size_pt:.2f} Tf ET")
self.fonts_used_per_page_number[self.page].add(self.current_font.i)
self._resource_catalog.add(
PDFResourceType.FONT, self.current_font.i, self.page
)

def set_font_size(self, size):
"""
Expand All @@ -2145,7 +2164,9 @@ def set_font_size(self, size):
"Cannot set font size: a font must be selected first"
)
self._out(f"BT /F{self.current_font.i} {self.font_size_pt:.2f} Tf ET")
self.fonts_used_per_page_number[self.page].add(self.current_font.i)
self._resource_catalog.add(
PDFResourceType.FONT, self.current_font.i, self.page
)

def set_char_spacing(self, spacing):
"""
Expand Down Expand Up @@ -2477,7 +2498,7 @@ def free_text_annotation(
default_appearance=f"({self.draw_color.serialize()} /F{self.current_font.i} {self.font_size_pt:.2f} Tf)",
**kwargs,
)
self.fonts_used_per_page_number[self.page].add(self.current_font.i)
self._resource_catalog.add(PDFResourceType.FONT, self.current_font.i, self.page)
self.pages[self.page].annots.append(annotation)
return annotation

Expand Down Expand Up @@ -2665,7 +2686,7 @@ def text(self, x, y, text=""):
if self.text_mode != TextMode.FILL:
sl.append(f" {self.text_mode} Tr {self.line_width:.2f} w")
sl.append(f"{self.current_font.encode_text(text)} ET")
self.fonts_used_per_page_number[self.page].add(self.current_font.i)
self._resource_catalog.add(PDFResourceType.FONT, self.current_font.i, self.page)
if (self.underline and text != "") or self._record_text_quad_points:
w = self.get_string_width(text, normalized=True, markdown=False)
if self.underline and text != "":
Expand Down Expand Up @@ -2952,7 +2973,7 @@ def _start_local_context(
raise ValueError(f"Unsupported setting: {key}")
if gs:
gs_name = self._drawing_graphics_state_registry.register_style(gs)
self.graphics_style_names_per_page_number[self.page].add(gs_name)
self._resource_catalog.add(PDFResourceType.EXT_G_STATE, gs_name, self.page)
self._out(f"q /{gs_name} gs")
else:
self._out("q")
Expand Down Expand Up @@ -3303,7 +3324,9 @@ def _render_styled_text_line(
current_font = frag.font
sl.append(f"/F{frag.font.i} {frag.font_size_pt:.2f} Tf")
if self.page > 0:
self.fonts_used_per_page_number[self.page].add(current_font.i)
self._resource_catalog.add(
PDFResourceType.FONT, current_font.i, self.page
)
lift = frag.lift
if lift != current_lift:
# Use text rise operator:
Expand Down Expand Up @@ -4380,7 +4403,7 @@ def _raster_image(
if link:
self.link(x, y, w, h, link)

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

def x_by_align(self, x, w, h, img_info, keep_aspect_ratio):
Expand Down Expand Up @@ -4516,9 +4539,12 @@ def _downscale_image(self, name, img, info, w, h, scale):
)
info["usages"] -= 1 # no need to embed the high-resolution image
if info["usages"] == 0:
for images_used in self.images_used_per_page_number.values():
if info["i"] in images_used:
images_used.remove(info["i"])
for (
_,
rtype,
), resource in self._resource_catalog.resources_per_page.items():
if rtype == PDFResourceType.X_OBJECT and info["i"] in resource:
resource.remove(info["i"])
if lowres_info: # Great, we've already done the job!
info = lowres_info
if info["w"] * info["h"] < dims[0] * dims[1]:
Expand Down
8 changes: 7 additions & 1 deletion fpdf/linearization.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,14 @@ def bufferize(self):
font_objs_per_index = self._add_fonts()
img_objs_per_index = self._add_images()
gfxstate_objs_per_name = self._add_gfxstates()
shading_objs_per_name = self._add_shadings()
pattern_objs_per_name = self._add_patterns()
resources_dict_obj = self._add_resources_dict(
font_objs_per_index, img_objs_per_index, gfxstate_objs_per_name
font_objs_per_index,
img_objs_per_index,
gfxstate_objs_per_name,
shading_objs_per_name,
pattern_objs_per_name,
)
# Part 9: Objects not associated with pages, if any
for embedded_file in fpdf.embedded_files:
Expand Down
Loading

0 comments on commit e54b066

Please sign in to comment.