Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New feature: Gradient patterns #1334

Merged
merged 18 commits into from
Jan 5, 2025
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 @@ -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
Loading