diff --git a/CHANGELOG.md b/CHANGELOG.md index 945b4bdf0..1c83f70a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` tags; now correctly resets emphasis style post `` tag: hyperlink styling contained within the tag authority. - [Issue #1311](https://github.com/py-pdf/fpdf2/issues/1311) diff --git a/docs/Patterns.md b/docs/Patterns.md new file mode 100644 index 000000000..e881bdbd4 --- /dev/null +++ b/docs/Patterns.md @@ -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. \ No newline at end of file diff --git a/fpdf/enums.py b/fpdf/enums.py index 65b548dbf..e28d745a1 100644 --- a/fpdf/enums.py +++ b/fpdf/enums.py @@ -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") diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index 046b0abb0..6a84a5ed1 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -85,6 +85,7 @@ class Image: PageMode, PageOrientation, PathPaintRule, + PDFResourceType, RenderStyle, TextDirection, TextEmphasis, @@ -123,6 +124,7 @@ class Image: OutputProducer, PDFPage, PDFPageLabel, + ResourceCatalog, stream_content_for_raster_image, ) from .recorder import FPDFRecorder @@ -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 @@ -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) @@ -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 @@ -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): """ @@ -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): """ @@ -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 @@ -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 != "": @@ -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") @@ -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: @@ -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): @@ -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]: diff --git a/fpdf/linearization.py b/fpdf/linearization.py index 42878f7af..016854ec9 100644 --- a/fpdf/linearization.py +++ b/fpdf/linearization.py @@ -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: diff --git a/fpdf/output.py b/fpdf/output.py index 6d2d731c8..ecd3d9613 100644 --- a/fpdf/output.py +++ b/fpdf/output.py @@ -16,7 +16,7 @@ from fontTools import subset as ftsubset from .annotations import PDFAnnotation -from .enums import PageLabelStyle, SignatureFlag +from .enums import PDFResourceType, PageLabelStyle, SignatureFlag from .errors import FPDFException from .line_break import TotalPagesSubstitutionFragment from .image_datastructures import RasterImageInfo @@ -149,12 +149,14 @@ def __init__( class PDFResources(PDFObject): - def __init__(self, proc_set, font, x_object, ext_g_state): + def __init__(self, proc_set, font, x_object, ext_g_state, shading, pattern): super().__init__() self.proc_set = proc_set self.font = font self.x_object = x_object self.ext_g_state = ext_g_state + self.shading = shading + self.pattern = pattern class PDFFontStream(PDFContentStream): @@ -430,6 +432,49 @@ def serialize(self, _security_handler=None): return "\n".join(out) +class ResourceCatalog: + "Manage the indexing of resources and association to the pages they are used" + + def __init__(self): + self.resources = defaultdict(dict) + self.resources_per_page = defaultdict(set) + + def add(self, resource_type: PDFResourceType, resource, page_number: int): + if resource_type in (PDFResourceType.PATTERN, PDFResourceType.SHADDING): + registry = self.resources[resource_type] + if resource not in registry: + registry[resource] = ( + f"{self._get_prefix(resource_type)}{len(registry) + 1}" + ) + self.resources_per_page[(page_number, resource_type)].add( + registry[resource] + ) + return registry[resource] + self.resources_per_page[(page_number, resource_type)].add(resource) + return None + + def get_items(self, resource_type: PDFResourceType): + return self.resources[resource_type].items() + + def get_resources_per_page(self, page_number: int, resource_type: PDFResourceType): + return self.resources_per_page[(page_number, resource_type)] + + def get_used_resources(self, resource_type: PDFResourceType): + unique = set() + for (_, rtype), resource in self.resources_per_page.items(): + if rtype == resource_type: + unique.update(resource) + return unique + + @classmethod + def _get_prefix(cls, resource_type: PDFResourceType): + if resource_type == PDFResourceType.PATTERN: + return "P" + if resource_type == PDFResourceType.SHADDING: + return "Sh" + raise ValueError(f"No prefix for resource type {resource_type}") + + class OutputProducer: "Generates the final bytearray representing the PDF document, based on a FPDF instance." @@ -889,14 +934,41 @@ def _add_gfxstates(self): gfxstate_objs_per_name[name] = gfxstate_obj return gfxstate_objs_per_name + def _add_shadings(self): + shading_objs_per_name = OrderedDict() + for shading, name in self.fpdf._resource_catalog.get_items( + PDFResourceType.SHADDING + ): + for function in shading.functions: + self._add_pdf_obj(function, "function") + shading_obj = shading.get_shading_object() + self._add_pdf_obj(shading_obj, "shading") + shading_objs_per_name[name] = shading_obj + return shading_objs_per_name + + def _add_patterns(self): + pattern_objs_per_name = OrderedDict() + for pattern, name in self.fpdf._resource_catalog.get_items( + PDFResourceType.PATTERN + ): + self._add_pdf_obj(pattern, "pattern") + pattern_objs_per_name[name] = pattern + return pattern_objs_per_name + def _insert_resources(self, page_objs): 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() # Insert /Resources dicts: if self.fpdf.single_resources_object: 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, ) for page_obj in page_objs: page_obj.resources = resources_dict_obj @@ -904,26 +976,51 @@ def _insert_resources(self, page_objs): for page_number, page_obj in enumerate(page_objs, start=1): page_font_objs_per_index = { font_id: font_objs_per_index[font_id] - for font_id in self.fpdf.fonts_used_per_page_number[page_number] + for font_id in self.fpdf._resource_catalog.get_resources_per_page( + page_number, PDFResourceType.FONT + ) } page_img_objs_per_index = { img_id: img_objs_per_index[img_id] - for img_id in self.fpdf.images_used_per_page_number[page_number] + for img_id in self.fpdf._resource_catalog.get_resources_per_page( + page_number, PDFResourceType.X_OBJECT + ) } page_gfxstate_objs_per_name = { gfx_name: gfx_state for (gfx_name, gfx_state) in gfxstate_objs_per_name.items() if gfx_name - in self.fpdf.graphics_style_names_per_page_number[page_number] + in self.fpdf._resource_catalog.get_resources_per_page( + page_number, PDFResourceType.EXT_G_STATE + ) + } + page_shading_objs_per_name = { + shading_name: shading_objs_per_name[shading_name] + for shading_name in self.fpdf._resource_catalog.get_resources_per_page( + page_number, PDFResourceType.SHADDING + ) + } + page_pattern_objs_per_name = { + pattern_name: pattern_objs_per_name[pattern_name] + for pattern_name in self.fpdf._resource_catalog.get_resources_per_page( + page_number, PDFResourceType.PATTERN + ) } page_obj.resources = self._add_resources_dict( page_font_objs_per_index, page_img_objs_per_index, page_gfxstate_objs_per_name, + page_shading_objs_per_name, + page_pattern_objs_per_name, ) def _add_resources_dict( - self, font_objs_per_index, img_objs_per_index, gfxstate_objs_per_name + self, + font_objs_per_index, + img_objs_per_index, + gfxstate_objs_per_name, + shading_objs_per_name, + pattern_objs_per_name, ): # From section 10.1, "Procedure Sets", of PDF 1.7 spec: # > Beginning with PDF 1.4, this feature is considered obsolete. @@ -931,7 +1028,7 @@ def _add_resources_dict( # > PDF producer applications should continue to specify procedure sets # > (preferably, all of those listed in Table 10.1). proc_set = "[/PDF /Text /ImageB /ImageC /ImageI]" - font, x_object, ext_g_state = None, None, None + font, x_object, ext_g_state, shading, pattern = None, None, None, None, None if font_objs_per_index: font = pdf_dict( @@ -956,9 +1053,29 @@ def _add_resources_dict( for name, gfxstate_obj in gfxstate_objs_per_name.items() } ) + if shading_objs_per_name: + shading = pdf_dict( + { + f"/{name}": pdf_ref(shading_obj.id) + for name, shading_obj in sorted(shading_objs_per_name.items()) + } + ) + + if pattern_objs_per_name: + pattern = pdf_dict( + { + f"/{name}": pdf_ref(pattern_obj.id) + for name, pattern_obj in sorted(pattern_objs_per_name.items()) + } + ) resources_obj = PDFResources( - proc_set=proc_set, font=font, x_object=x_object, ext_g_state=ext_g_state + proc_set=proc_set, + font=font, + x_object=x_object, + ext_g_state=ext_g_state, + shading=shading, + pattern=pattern, ) self._add_pdf_obj(resources_obj) return resources_obj diff --git a/fpdf/pattern.py b/fpdf/pattern.py new file mode 100644 index 000000000..8e9137af6 --- /dev/null +++ b/fpdf/pattern.py @@ -0,0 +1,293 @@ +""" +Handles the creation of patterns and gradients +""" + +from abc import ABC +from typing import List, Optional, TYPE_CHECKING, Tuple, Union + +from .drawing import DeviceCMYK, DeviceGray, DeviceRGB, convert_to_device_color +from .syntax import Name, PDFArray, PDFObject + +if TYPE_CHECKING: + from .fpdf import FPDF + + +class Pattern(PDFObject): + """ + Represents a PDF Pattern object. + + Currently, this class supports only "shading patterns" (pattern_type 2), + using either a linear or radial gradient. Tiling patterns (pattern_type 1) + are not yet implemented. + """ + + def __init__(self, shading: Union["LinearGradient", "RadialGradient"]): + super().__init__() + self.type = Name("Pattern") + # 1 for a tiling pattern or type 2 for a shading pattern: + self.pattern_type = 2 + self._shading = shading + + @property + def shading(self): + return f"{self._shading.get_shading_object().id} 0 R" + + +class Type2Function(PDFObject): + """Transition between 2 colors""" + + def __init__(self, color_1, color_2): + super().__init__() + # 0: Sampled function; 2: Exponential interpolation function; 3: Stitching function; 4: PostScript calculator function + self.function_type = 2 + self.domain = "[0 1]" + self.c0 = f'[{" ".join(f"{c:.2f}" for c in color_1.colors)}]' + self.c1 = f'[{" ".join(f"{c:.2f}" for c in color_2.colors)}]' + self.n = 1 + + +class Type3Function(PDFObject): + """When multiple colors are used, a type 3 function is necessary to stitch type 2 functions together + and define the bounds between each color transition""" + + def __init__(self, functions, bounds): + super().__init__() + # 0: Sampled function; 2: Exponential interpolation function; 3: Stitching function; 4: PostScript calculator function + self.function_type = 3 + self.domain = "[0 1]" + self._functions = functions + self.bounds = f"[{' '.join(f'{bound:.2f}' for bound in bounds)}]" + self.encode = f"[{' '.join('0 1' for _ in functions)}]" + self.n = 1 + + @property + def functions(self): + return f"[{' '.join(f'{f.id} 0 R' for f in self._functions)}]" + + +class Shading(PDFObject): + def __init__( + self, + shading_type: int, # 2 for axial shading, 3 for radial shading + background: Optional[Union[DeviceRGB, DeviceGray, DeviceCMYK]], + color_space: str, + coords: List[int], + function: Union[Type2Function, Type3Function], + extend_before: bool, + extend_after: bool, + ): + super().__init__() + self.shading_type = shading_type + self.background = ( + f'[{" ".join(f"{c:.2f}" for c in background.colors)}]' + if background + else None + ) + self.color_space = Name(color_space) + self.coords = coords + self.function = f"{function.id} 0 R" + self.extend = f'[{"true" if extend_before else "false"} {"true" if extend_after else "false"}]' + + +class Gradient(ABC): + def __init__(self, colors, background, extend_before, extend_after, bounds): + self.color_space, self.colors = self._convert_colors(colors) + self.background = None + if background: + self.background = ( + convert_to_device_color(background) + if isinstance(background, (str, DeviceGray, DeviceRGB, DeviceCMYK)) + else convert_to_device_color(*background) + ) + if self.background and self.background.__class__.__name__ != self.color_space: + raise ValueError( + "The background color must be of the same color space as the gradient" + ) + self.extend_before = extend_before + self.extend_after = extend_after + self.bounds = ( + bounds + if bounds + else [(i + 1) / (len(self.colors) - 1) for i in range(len(self.colors) - 2)] + ) + if len(self.bounds) != len(self.colors) - 2: + raise ValueError( + "Bounds array length must be two less than the number of colors" + ) + self.functions = self._generate_functions() + self.pattern = Pattern(self) + self._shading_object = None + self.coords = None + self.shading_type = 0 + + @classmethod + def _convert_colors(cls, colors) -> Tuple[str, List]: + color_list = [] + if len(colors) < 2: + raise ValueError("A gradient must have at least two colors") + color_spaces = set() + for color in colors: + current_color = ( + convert_to_device_color(color) + if isinstance(color, (str, DeviceGray, DeviceRGB, DeviceCMYK)) + else convert_to_device_color(*color) + ) + color_list.append(current_color) + color_spaces.add(type(current_color).__name__) + if len(color_spaces) == 1: + return color_spaces.pop(), color_list + if "DeviceCMYK" in color_spaces: + raise ValueError("Can't mix CMYK with other color spaces.") + # mix of DeviceGray and DeviceRGB + converted = [] + for color in color_list: + if isinstance(color, DeviceGray): + converted.append(DeviceRGB(color.g, color.g, color.g)) + else: + converted.append(color) + return "DeviceRGB", converted + + def _generate_functions(self): + if len(self.colors) < 2: + raise ValueError("A gradient must have at least two colors") + if len(self.colors) == 2: + return [Type2Function(self.colors[0], self.colors[1])] + number_of_colors = len(self.colors) + functions = [] + for i in range(number_of_colors - 1): + functions.append(Type2Function(self.colors[i], self.colors[i + 1])) + functions.append(Type3Function(functions[:], self.bounds)) + return functions + + def get_shading_object(self): + if not self._shading_object: + self._shading_object = Shading( + shading_type=self.shading_type, + background=self.background, + color_space=self.color_space, + coords=PDFArray(self.coords), + function=self.functions[-1], + extend_before=self.extend_before, + extend_after=self.extend_after, + ) + return self._shading_object + + def get_pattern(self): + return self.pattern + + +class LinearGradient(Gradient): + def __init__( + self, + fpdf: "FPDF", + from_x: int, + from_y: int, + to_x: int, + to_y: int, + colors: List, + background=None, + extend_before: bool = False, + extend_after: bool = False, + bounds: List[int] = None, + ): + """ + A shading pattern that creates a linear (axial) gradient in a PDF. + + The gradient is defined by two points: (from_x, from_y) and (to_x, to_y), + along which the specified colors are interpolated. Optionally, you can set + a background color, extend the gradient beyond its start or end, and + specify custom color stop positions via `bounds`. + + Args: + fpdf (FPDF): The FPDF instance used for PDF generation. + from_x (int or float): The x-coordinate of the starting point of the gradient, + in user space units. + from_y (int or float): The y-coordinate of the starting point of the gradient, + in user space units. + to_x (int or float): The x-coordinate of the ending point of the gradient, + in user space units. + to_y (int or float): The y-coordinate of the ending point of the gradient, + in user space units. + colors (List[str or Tuple[int, int, int]]): A list of colors along which the gradient + will be interpolated. Colors may be given as hex strings (e.g., "#FF0000") or + (R, G, B) tuples. + background (str or Tuple[int, int, int], optional): A background color to use + if the gradient does not fully cover the region it is applied to. + Defaults to None (no background). + extend_before (bool, optional): Whether to extend the first color beyond the + starting point (from_x, from_y). Defaults to False. + extend_after (bool, optional): Whether to extend the last color beyond the + ending point (to_x, to_y). Defaults to False. + bounds (List[float], optional): An optional list of floats in the range (0, 1) + that represent gradient stops for color transitions. The number of bounds + should be two less than the number of colors (for multi-color gradients). + Defaults to None, which evenly distributes color stops. + """ + super().__init__(colors, background, extend_before, extend_after, bounds) + coords = [from_x, fpdf.h - from_y, to_x, fpdf.h - to_y] + self.coords = [f"{fpdf.k * c:.2f}" for c in coords] + self.shading_type = 2 + + +class RadialGradient(Gradient): + def __init__( + self, + fpdf: "FPDF", + start_circle_x: int, + start_circle_y: int, + start_circle_radius: int, + end_circle_x: int, + end_circle_y: int, + end_circle_radius: int, + colors: List, + background=None, + extend_before: bool = False, + extend_after: bool = False, + bounds: List[int] = None, + ): + """ + A shading pattern that creates a radial (or circular/elliptical) gradient in a PDF. + + The gradient is defined by two circles (start and end). Colors are blended from the + start circle to the end circle, forming a radial gradient. You can optionally set a + background color, extend the gradient beyond its circles, and provide custom color + stop positions via `bounds`. + + Args: + fpdf (FPDF): The FPDF instance used for PDF generation. + start_circle_x (int or float): The x-coordinate of the inner circle's center, + in user space units. + start_circle_y (int or float): The y-coordinate of the inner circle's center, + in user space units. + start_circle_radius (int or float): The radius of the inner circle, in user space units. + end_circle_x (int or float): The x-coordinate of the outer circle's center, + in user space units. + end_circle_y (int or float): The y-coordinate of the outer circle's center, + in user space units. + end_circle_radius (int or float): The radius of the outer circle, in user space units. + colors (List[str or Tuple[int, int, int]]): A list of colors along which the gradient + will be interpolated. Colors may be given as hex strings (e.g., "#FF0000") or + (R, G, B) tuples. + background (str or Tuple[int, int, int], optional): A background color to display + if the gradient does not fully cover the region it's applied to. Defaults to None + (no background). + extend_before (bool, optional): Whether to extend the gradient beyond the start circle. + Defaults to False. + extend_after (bool, optional): Whether to extend the gradient beyond the end circle. + Defaults to False. + bounds (List[float], optional): An optional list of floats in the range (0, 1) that + represent gradient stops for color transitions. The number of bounds should be one + less than the number of colors (for multi-color gradients). Defaults to None, + which evenly distributes color stops. + """ + super().__init__(colors, background, extend_before, extend_after, bounds) + coords = [ + start_circle_x, + fpdf.h - start_circle_y, + start_circle_radius, + end_circle_x, + fpdf.h - end_circle_y, + end_circle_radius, + ] + self.coords = [f"{fpdf.k * c:.2f}" for c in coords] + self.shading_type = 3 diff --git a/fpdf/syntax.py b/fpdf/syntax.py index 449a7ebeb..ff0d5d105 100644 --- a/fpdf/syntax.py +++ b/fpdf/syntax.py @@ -331,7 +331,7 @@ class PDFArray(list): def serialize(self, _security_handler=None, _obj_id=None): if all(isinstance(elem, str) for elem in self): serialized_elems = " ".join(self) - elif all(isinstance(elem, int) for elem in self): + elif all(isinstance(elem, (int, float)) for elem in self): serialized_elems = " ".join(str(elem) for elem in self) else: serialized_elems = "\n".join( diff --git a/mkdocs.yml b/mkdocs.yml index c83d8dca5..7b0081955 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -152,6 +152,7 @@ nav: - 'Shapes': 'Shapes.md' - 'Transformations': 'Transformations.md' - 'Transparency': 'Transparency.md' + - 'Patterns': 'Patterns.md' - 'Barcodes': 'Barcodes.md' - 'Drawing': 'Drawing.md' - 'SVG': 'SVG.md' diff --git a/test/image/image_types/image_types_insert_jpg_jpxdecode.pdf b/test/image/image_types/image_types_insert_jpg_jpxdecode.pdf index 63f1cde30..0d4ac9a03 100644 Binary files a/test/image/image_types/image_types_insert_jpg_jpxdecode.pdf and b/test/image/image_types/image_types_insert_jpg_jpxdecode.pdf differ diff --git a/test/image/image_types/test_insert_images.py b/test/image/image_types/test_insert_images.py index 02f1b13e3..b053864de 100644 --- a/test/image/image_types/test_insert_images.py +++ b/test/image/image_types/test_insert_images.py @@ -25,6 +25,10 @@ def test_insert_jpg(tmp_path): sys.platform in ("cygwin", "win32"), reason="Required system libraries to generate JPEG2000 images are a PITA to install under Windows", ) +@pytest.mark.skipif( + sys.version_info < (3, 9), + reason="JPEG2000 changes were done on pillow 11.1.0 which is not available for python 3.8", +) def test_insert_jpg_jpxdecode(tmp_path): pdf = fpdf.FPDF() pdf.compress = False diff --git a/test/pattern/linear_gradient_diagonal.pdf b/test/pattern/linear_gradient_diagonal.pdf new file mode 100644 index 000000000..890419873 Binary files /dev/null and b/test/pattern/linear_gradient_diagonal.pdf differ diff --git a/test/pattern/linear_gradient_extend.pdf b/test/pattern/linear_gradient_extend.pdf new file mode 100644 index 000000000..f64a7219a Binary files /dev/null and b/test/pattern/linear_gradient_extend.pdf differ diff --git a/test/pattern/linear_gradient_multiple_colors.pdf b/test/pattern/linear_gradient_multiple_colors.pdf new file mode 100644 index 000000000..90f10e8ac Binary files /dev/null and b/test/pattern/linear_gradient_multiple_colors.pdf differ diff --git a/test/pattern/linear_gradient_vertical.pdf b/test/pattern/linear_gradient_vertical.pdf new file mode 100644 index 000000000..b0cefecfc Binary files /dev/null and b/test/pattern/linear_gradient_vertical.pdf differ diff --git a/test/pattern/radial_custom_bounds.pdf b/test/pattern/radial_custom_bounds.pdf new file mode 100644 index 000000000..2c88326ef Binary files /dev/null and b/test/pattern/radial_custom_bounds.pdf differ diff --git a/test/pattern/radial_gradient.pdf b/test/pattern/radial_gradient.pdf new file mode 100644 index 000000000..c4ea6a8cf Binary files /dev/null and b/test/pattern/radial_gradient.pdf differ diff --git a/test/pattern/radial_gradient_multiple_colors.pdf b/test/pattern/radial_gradient_multiple_colors.pdf new file mode 100644 index 000000000..ae24cb6bf Binary files /dev/null and b/test/pattern/radial_gradient_multiple_colors.pdf differ diff --git a/test/pattern/test_linear_gradient.py b/test/pattern/test_linear_gradient.py new file mode 100644 index 000000000..dc1c90e0d --- /dev/null +++ b/test/pattern/test_linear_gradient.py @@ -0,0 +1,222 @@ +from pathlib import Path +from fpdf import FPDF +from fpdf.pattern import LinearGradient +from test.conftest import assert_pdf_equal + +HERE = Path(__file__).resolve().parent + + +def test_linear_gradient_extend(tmp_path): + pdf = FPDF() + + pdf.add_page() + + pdf.set_font("helvetica", "", 20) + pdf.cell( + text="Creating a gradient slightly smaller than the rectangle", + new_x="LEFT", + new_y="NEXT", + ) + x = pdf.l_margin + y = pdf.get_y() + with pdf.use_pattern( + LinearGradient( + pdf, + pdf.l_margin + 10, + 0, + pdf.epw + pdf.l_margin - 10, + 0, + ["#C33764", "#1D2671"], + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=20, style="FD") + y += 25 + pdf.set_y(y) + pdf.set_font("helvetica", "", 40) + pdf.cell( + text="LINEAR GRADIENT", align="C", w=pdf.epw, new_x="LEFT", new_y="NEXT" + ) + + pdf.ln() + pdf.set_font("helvetica", "", 20) + pdf.cell(text="Adding a background color", new_x="LEFT", new_y="NEXT") + x = pdf.l_margin + y = pdf.get_y() + with pdf.use_pattern( + LinearGradient( + pdf, + pdf.l_margin + 10, + 0, + pdf.epw + pdf.l_margin - 10, + 0, + ["#C33764", "#1D2671"], + background="#868F96", + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=20, style="FD") + y += 25 + pdf.set_y(y) + pdf.set_font("helvetica", "", 40) + pdf.cell( + text="LINEAR GRADIENT", align="C", w=pdf.epw, new_x="LEFT", new_y="NEXT" + ) + + pdf.ln() + pdf.set_font("helvetica", "", 20) + pdf.cell(text="Adding extend before", new_x="LEFT", new_y="NEXT") + x = pdf.l_margin + y = pdf.get_y() + with pdf.use_pattern( + LinearGradient( + pdf, + pdf.l_margin + 10, + 0, + pdf.epw + pdf.l_margin - 10, + 0, + ["#C33764", "#1D2671"], + background="#868F96", + extend_before=True, + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=20, style="FD") + y += 25 + pdf.set_y(y) + pdf.set_font("helvetica", "", 40) + pdf.cell( + text="LINEAR GRADIENT", align="C", w=pdf.epw, new_x="LEFT", new_y="NEXT" + ) + + pdf.ln() + pdf.set_font("helvetica", "", 20) + pdf.cell(text="Adding extend after", new_x="LEFT", new_y="NEXT") + x = pdf.l_margin + y = pdf.get_y() + with pdf.use_pattern( + LinearGradient( + pdf, + pdf.l_margin + 10, + 0, + pdf.epw + pdf.l_margin - 10, + 0, + ["#C33764", "#1D2671"], + background="#868F96", + extend_before=True, + extend_after=True, + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=20, style="FD") + y += 25 + pdf.set_y(y) + pdf.set_font("helvetica", "", 40) + pdf.cell( + text="LINEAR GRADIENT", align="C", w=pdf.epw, new_x="LEFT", new_y="NEXT" + ) + + assert_pdf_equal(pdf, HERE / "linear_gradient_extend.pdf", tmp_path) + + +def test_linear_gradient_multiple_colors(tmp_path): + pdf = FPDF() + pdf.add_page() + x = pdf.l_margin + y = pdf.get_y() + with pdf.use_pattern( + LinearGradient( + pdf, + pdf.l_margin, + 0, + pdf.epw + pdf.l_margin, + 0, + ["#868F96", "#596164", "#537895", "#09203F"], + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=20, style="FD") + y += 25 + + with pdf.use_pattern( + LinearGradient( + pdf, + pdf.l_margin, + 0, + pdf.epw + pdf.l_margin, + 0, + ["#FFECD2", "#FCB69F", "#DD2476"], + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=20, style="FD") + + assert_pdf_equal( + pdf, + HERE / "linear_gradient_multiple_colors.pdf", + tmp_path, + ) + + +def test_linear_gradient_vertical(tmp_path): + pdf = FPDF() + pdf.add_page() + x = pdf.l_margin + y = pdf.get_y() + with pdf.use_pattern( + LinearGradient( + pdf, + 0, + y, + 0, + y + 50, + ["#92EFFD", "#4E65FF"], + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=50, style="FD") + y += 55 + + with pdf.use_pattern( + LinearGradient( + pdf, + 0, + y, + 0, + y + 50, + ["#FFECD2", "#FCB69F", "#DD2476"], + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=50, style="FD") + + assert_pdf_equal(pdf, HERE / "linear_gradient_vertical.pdf", tmp_path) + + +def test_linear_gradient_diagonal(tmp_path): + pdf = FPDF() + pdf.add_page() + x = pdf.l_margin + y = pdf.get_y() + with pdf.use_pattern( + LinearGradient( + pdf, + pdf.l_margin, + y, + pdf.epw + pdf.l_margin, + y + 100, + ["#92EFFD", "#4E65FF"], + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=100, style="FD") + y += 105 + + with pdf.use_pattern( + LinearGradient( + pdf, + pdf.l_margin, + y + 100, + pdf.epw + pdf.l_margin, + y, + ["#FFECD2", "#DD2476", "#FCB69F"], + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=100, style="FD") + + assert_pdf_equal( + pdf, + HERE / "linear_gradient_diagonal.pdf", + tmp_path, + ) diff --git a/test/pattern/test_radial_gradient.py b/test/pattern/test_radial_gradient.py new file mode 100644 index 000000000..0d9c3ad96 --- /dev/null +++ b/test/pattern/test_radial_gradient.py @@ -0,0 +1,146 @@ +from pathlib import Path +from fpdf import FPDF +from fpdf.pattern import RadialGradient +from test.conftest import assert_pdf_equal + +HERE = Path(__file__).resolve().parent + + +def test_radial_gradient(tmp_path): + pdf = FPDF() + pdf.add_page() + x = pdf.w / 2 - 25 + y = pdf.get_y() + with pdf.use_pattern( + RadialGradient( + pdf, + x + 25, + y + 25, + 0, + x + 25, + y + 25, + 25, + [(255, 255, 0), (255, 0, 0)], + ) + ): + pdf.circle(x=x + 25, y=y + 25, radius=25, style="FD") + y += 60 + with pdf.use_pattern( + RadialGradient( + pdf, + x + 5, + y + 5, + 0, + x + 25, + y + 25, + 25, + [(255, 255, 0), (255, 0, 0)], + ) + ): + pdf.circle(x=x + 25, y=y + 25, radius=25, style="FD") + + assert_pdf_equal(pdf, HERE / "radial_gradient.pdf", tmp_path) + + +def test_radial_gradient_multiple_colors(tmp_path): + pdf = FPDF() + pdf.add_page() + x = pdf.l_margin + y = pdf.get_y() + with pdf.use_pattern( + RadialGradient( + pdf, + (pdf.epw + pdf.l_margin) / 2, + y + 50, + 20, + (pdf.epw + pdf.l_margin) / 2, + y + 50, + (pdf.epw + pdf.l_margin) / 2, + ["#868F96", "#596164", "#537895", "#09203F"], + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=100, style="FD") + y += 105 + + with pdf.use_pattern( + RadialGradient( + pdf, + pdf.w / 2, + y + 50, + 0, + pdf.w / 2, + y + 50, + (y + 50) / 2, + ["#FFECD2", "#FCB69F", "#DD2476"], + ) + ): + pdf.rect(x=x, y=y, w=pdf.epw, h=100, style="FD") + + assert_pdf_equal(pdf, HERE / "radial_gradient_multiple_colors.pdf", tmp_path) + + +def test_radial_gradient(tmp_path): + pdf = FPDF() + pdf.add_page() + x = pdf.w / 2 - 25 + y = pdf.get_y() + with pdf.use_pattern( + RadialGradient( + pdf, + x + 25, + y + 25, + 0, + x + 25, + y + 25, + 25, + [(255, 255, 0), (255, 0, 0)], + ) + ): + pdf.circle(x=x + 25, y=y + 25, radius=25, style="FD") + y += 60 + with pdf.use_pattern( + RadialGradient( + pdf, + x + 5, + y + 5, + 0, + x + 25, + y + 25, + 25, + [(255, 255, 0), (255, 0, 0)], + ) + ): + pdf.circle(x=x + 25, y=y + 25, radius=25, style="FD") + + assert_pdf_equal(pdf, HERE / "radial_gradient.pdf", tmp_path) + + +def test_custom_bounds(tmp_path): + pdf = FPDF() + pdf.add_page() + with pdf.use_pattern( + RadialGradient( + pdf, + pdf.w / 2, + pdf.h / 2, + 20, + pdf.w / 2, + pdf.h / 2, + pdf.h / 2 - 20, + [ + "#FFFFFF", + "#9400D3", + "#4B0082", + "#0000FF", + "#00FF00", + "#FFFF00", + "#FF7F00", + "#FF0000", + "#FFFFFF", + ], + bounds=[0.35, 0.40, 0.45, 0.50, 0.55, 0.60, 0.65], + ) + ): + pdf.rect(x=0, y=0, w=pdf.w, h=pdf.h / 2, style="FD") + + assert_pdf_equal(pdf, HERE / "radial_custom_bounds.pdf", tmp_path) diff --git a/test/test_perfs.py b/test/test_perfs.py index 7c9bee7c9..320436247 100644 --- a/test/test_perfs.py +++ b/test/test_perfs.py @@ -13,7 +13,7 @@ ) -@ensure_exec_time_below(seconds=10) +@ensure_exec_time_below(seconds=12) @ensure_rss_memory_below(mib=30) # VERY dependent on the environement def test_intense_image_rendering(tmp_path): pdf = FPDF()