From b1a54a1c524a164162ea9fff9d51679f5eaf07f8 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 10 Dec 2025 13:46:43 +0200 Subject: [PATCH 01/35] Feature: Matplotlib renderer - initial implementation based on @LandyQuack's initial code - some simple test cases --- .gitignore | 1 + fpdf/__init__.py | 2 + fpdf/fpdf_renderer.py | 385 ++++++++++++++++++ test/mpl_renderer/__init__.py | 0 test/mpl_renderer/test_matplotlib_renderer.py | 327 +++++++++++++++ 5 files changed, 715 insertions(+) create mode 100644 fpdf/fpdf_renderer.py create mode 100644 test/mpl_renderer/__init__.py create mode 100644 test/mpl_renderer/test_matplotlib_renderer.py diff --git a/.gitignore b/.gitignore index 84285e8b0..9d6249ce0 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ nosetests.xml *.*~ *.swo *.swp +test/mpl_renderer/generated_pdf/*.pdf diff --git a/fpdf/__init__.py b/fpdf/__init__.py index b1f2a092d..44bae3ba3 100644 --- a/fpdf/__init__.py +++ b/fpdf/__init__.py @@ -31,6 +31,7 @@ from .prefs import ViewerPreferences from .template import Template, FlexTemplate from .util import get_scale_factor +from .fpdf_renderer import * try: # This module only exists in PyFPDF, it has been removed in fpdf2 since v2.5.7: @@ -85,6 +86,7 @@ "FPDF_FONT_DIR", # Utility functions: "get_scale_factor", + "FPDFRenderer", ] __pdoc__ = {name: name.startswith("FPDF_") for name in __all__} diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py new file mode 100644 index 000000000..d09423297 --- /dev/null +++ b/fpdf/fpdf_renderer.py @@ -0,0 +1,385 @@ +""" + Based on https://github.com/matplotlib/matplotlib/blob/v3.7.1/lib/matplotlib/backends/backend_template.py + + Just need to tell MatPlotLib to use this renderer and then do fig.savefig. +""" + +from contextlib import nullcontext +from matplotlib import _api +from matplotlib._pylab_helpers import Gcf +from matplotlib.backend_bases import (FigureCanvasBase, FigureManagerBase, GraphicsContextBase, RendererBase) +from matplotlib.figure import Figure +from matplotlib.transforms import Affine2D, IdentityTransform +import matplotlib as mpl + +from fpdf.drawing import ClippingPath, PaintedPath +from fpdf.enums import PathPaintRule + +PT_TO_MM = 0.3527777778 # 1 point = 0.3527777778 mm +class RendererTemplate(RendererBase): + """ Removed draw_markers, draw_path_collection and draw_quad_mesh - all optional, we can add later """ + + def __init__(self, figure, dpi, fpdf, scale, transform, fig_width, fig_height): + super().__init__() + self.figure = figure + #print (f'FPDF: dpi: {dpi}') + self.dpi = dpi + + self._fpdf = fpdf + self._trans = transform + self._scale = scale + + # some safe defaults + if fpdf: + fpdf.set_draw_color(0,0,0) + fpdf.set_fill_color(255,255,255) + + # calc font scaling factor to get matplotlib font sizes to match FPDF sizes when using SVG + fig_h_mm = fig_height * scale + fig_w_mm = fig_width * scale + fig_w_inch = fig_width / dpi + fig_h_inch = fig_height / dpi + shrink_ratio_h = (fig_h_mm/25.4) / fig_h_inch + shrink_ratio_w = (fig_w_mm/25.4) / fig_w_inch + if fpdf: + self._font_scaling = shrink_ratio_w + #print(f"Font scaling factor: {self._font_scaling}") + + def draw_path(self, gc, path, transform, rgbFace=None): + #self.check_gc(gc, rgbFace) + #gc.paint() + + # Unzip the path segments into 2 arrays - commands and vertices, the transform sorts scaling and positioning + # print(f"draw_path transform: {transform.get_matrix()}, _trans: {self._trans.get_matrix()}") + #print(f"transform: {transform.get_matrix()},\n_trans: {self._trans.get_matrix()}\n\n") + #tran = transform + self._trans + tran = transform + self._trans + clip_rect = None + if gc.get_clip_rectangle(): + # print(f"clip-rect in: {gc.get_clip_rectangle().x0:.1f},{gc.get_clip_rectangle().y0:.1f} -> {gc.get_clip_rectangle().x1:.1f},{gc.get_clip_rectangle().y1:.1f}\n") + clip_rect = gc.get_clip_rectangle().extents + clip_x0,clip_y0 = self._trans.transform(clip_rect[0:2]) + clip_x1,clip_y1 = self._trans.transform(clip_rect[2:4]) + + #print(f"clip-rect xformed: {x0:.1f},{y0:.1f} -> {x1:.1f},{y1:.1f}\n") + #else: + #print(f"clip-path: {gc.get_clip_path()}\n") + c,v = zip(*[(c,v.tolist()) for v,c in path.iter_segments(transform=tran)]) + + p = self._fpdf + scaling = self._trans.get_matrix()[0,0] + #print(f"draw_path with scaling: {scaling}") + fill_opacity = None + stroke_opacity = None + if rgbFace: + p.set_fill_color(rgbFace[0]*255, rgbFace[1]*255, rgbFace[2]*255) + + if len(rgbFace) == 4: + #print(f"fill_opacity: { rgbFace[3]}") + fill_opacity = rgbFace[3] + + rgb = gc.get_rgb() + p.set_draw_color(rgb[0]*255, rgb[1]*255, rgb[2]*255) + stroke_opacity = None + if len(rgb) == 4: + #print(f"stroke_opacity: { rgb[3]}") + stroke_opacity = rgb[3] + + + line_width = gc.get_linewidth() + line_width_px = line_width * self.dpi / 72.0 # points to pixels + mm_line_width = line_width_px * self._scale + with p.rect_clip(clip_x0,clip_y0,clip_x1-clip_x0,clip_y1-clip_y0) if clip_rect is not None else nullcontext() as clip: + + with p.local_context(stroke_opacity=stroke_opacity, fill_opacity=fill_opacity, line_width=mm_line_width): + #p.set_draw_color(rgb[0]*255, rgb[1]*255, rgb[2]*255) + #print(f"draw_path: color rgb: {rgb}, line_width: {line_width} pt -> mm_line_width: {mm_line_width:.2f} mm") + #print(f"line_width: {line_width} pt -> mm_line_width: {mm_line_width:.2f} mm") + + # print(f'Path commands: {c}: {v}') + + match c: + # Simple line + case [path.MOVETO, path.LINETO]: + #print(f"simpleline: {v}") + p.polyline(v) + + # Polyline - move then a set of lines + case [path.MOVETO, *mid, path.LINETO] if all(e == path.LINETO for e in mid): + #print(f"polyline2: {v}") + p.polyline (v) + + # Path combinations: Starts with MOVETO, and can end with CLOSEPOLY + case [path.MOVETO, *rest]: + #print(f"polygon: \n{c}\n{v}\n") + + pth = None + length = len(c) + with p.drawing_context() as ctxt: + for i,vtx in enumerate(v): + #print(f" cmd: {c[i]}, vtx: {vtx}") + if pth is None: + pth = PaintedPath() + pth.style.auto_close = False + if c[i] == path.MOVETO: + pth.move_to(*vtx) + elif c[i] == path.LINETO: + pth.line_to(*vtx) + elif c[i] == path.CURVE3: + pth.quadratic_curve_to(*vtx) + elif c[i] == path.CURVE4: + pth.curve_to(*vtx) + elif c[i] == path.CLOSEPOLY: + #print(f"Closing polygon path. idx: {i}") + + if (i == length -1): + pth.close() # close the path and add to context + ctxt.add_item(pth) + pth = None + else: + pth.paint_rule = PathPaintRule.FILL_EVENODD + pth.move_to(*v[i]) # start a new sub-path + + pass + else: + print (f'Unhandled path command in polygon: {c[i]} at vertex {vtx}') + if pth is not None: + #print(f"path was not closed - adding to context") + ctxt.add_item(pth) + pth = None + + + + case _: + print (f'draw_path: Unmatched {c}') + + def draw_image(self, gc, x, y, im): + print (f'draw_image at {x},{y} size {im.get_size()}') + pass + + + def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): + + # print (f'RendererTemplate.draw_text - {s} at {x:.0f},{y:.0f} at angle {angle:.1f} with prop {prop} - {mtext}') + # print (f'RendererTemplate.draw_text - {s} at {x:.0f},{y:.0f} - {mtext}') + th = self._fpdf.font_size_pt * PT_TO_MM * self._font_scaling # Default text height in mm + + if isinstance(prop, str): + raise ValueError (f'draw_text.prop is a string ({prop}) - add code to add font') + + # We're expecting a FontProperties instance + elif isinstance(prop, mpl.font_manager.FontProperties): + #print(f"font prop size: {prop.get_size()} name: {prop.get_name()}, self._font_scaling: {self._font_scaling}") + self._fpdf.set_font(prop.get_name(), size=prop.get_size() * self._font_scaling) + + tw, th ,_ = self.get_text_width_height_descent(s, prop, ismath) + tw *= self._font_scaling * PT_TO_MM / self.dpi * 72.0 + th *= self._font_scaling * PT_TO_MM / self.dpi * 72.0 + tw_prerotate = tw + th_prerotate = th + + #print(f'Text width/height before rotation: {tw_prerotate:.1f}/{th_prerotate:.1f} mm') + # print(f'scale x: {self.figure.bbox.width}, y: {self.figure.bbox.height}') + # Calc text width and height + rotated_bbox = Affine2D().rotate_deg(angle).transform(((0,0),(tw,0),(tw,th),(0,th))) + + min_x = min(rotated_bbox[:,0]) + max_x = max(rotated_bbox[:,0]) + min_y = min(rotated_bbox[:,1]) + max_y = max(rotated_bbox[:,1]) + tw = max_x - min_x + th = max_y - min_y + else: + print (f'Unknown prop type: {type(prop)}') + tw = None + th = None + + # Transform our data point + #print(f"Before transform: x={x}, y={y}. s:'{s}'") + + trans = self._trans #+ Affine2D().translate(-tw/3.5, 0) + x,y = trans.transform((x,y)) + + #print(f'- [{x:.1f},{y:.1f}] {s}') + ha = mtext.get_ha() if mtext else 'left' + #print(f'Text \'{s}\' ha: {ha}, angle: {angle}') + color = gc.get_rgb() + self._fpdf.set_text_color(int(color[0]*255), int(color[1]*255), int(color[2]*255)) + #print("Color:", color) + # Get text width to sort positioning - MPL centers on co-ordinate + #print (f'Text width rotated: {tw:.1f}, height: {th:.1f}') + match angle: + case 0: + # if ha == "right": + # x -= tw + # if ha == "center": + # x -= tw / 2 + # x+=0.7 # FPDF text positioning seems to be a bit off to left + self._fpdf.text(x,y,s) + + case _: + #print (f'Rotate to "{angle}" {type(angle)}') + rotpt_x = 0 + rotpt_y = 0 + with self._fpdf.rotation(angle=angle, x=x - rotpt_x, y=y + rotpt_y): + self._fpdf.text(x,y,s) + + + + def flipy(self): + return False + + def get_canvas_width_height(self): + return 100, 100 + + + def new_gc(self): + return GraphicsContextTemplate() + + def points_to_pixels(self, points): + return points/72.0 * self.dpi + #return points + + +class GraphicsContextTemplate(GraphicsContextBase): + """ + The graphics context provides the color, line styles, etc. See the cairo + and postscript backends for examples of mapping the graphics context + attributes (cap styles, join styles, line widths, colors) to a particular + backend. In cairo this is done by wrapping a cairo.Context object and + forwarding the appropriate calls to it using a dictionary mapping styles + to gdk constants. In Postscript, all the work is done by the renderer, + mapping line styles to postscript calls. + + If it's more appropriate to do the mapping at the renderer level (as in + the postscript backend), you don't need to override any of the GC methods. + If it's more appropriate to wrap an instance (as in the cairo backend) and + do the mapping here, you'll need to override several of the setter + methods. + + The base GraphicsContext stores colors as an RGB tuple on the unit + interval, e.g., (0.5, 0.0, 1.0). You may need to map this to colors + appropriate for your backend. + """ + +######################################################################## +# +# The following functions and classes are for pyplot and implement +# window/figure managers, etc. +# +######################################################################## + + +class FigureManagerTemplate(FigureManagerBase): + """ + Helper class for pyplot mode, wraps everything up into a neat bundle. + + For non-interactive backends, the base class is sufficient. For + interactive backends, see the documentation of the `.FigureManagerBase` + class for the list of methods that can/should be overridden. + """ + + +class FigureCanvasTemplate(FigureCanvasBase): + """ + The canvas the figure renders into. Calls the draw and print fig + methods, creates the renderers, etc. + + Note: GUI templates will want to connect events for button presses, + mouse movements and key presses to functions that call the base + class methods button_press_event, button_release_event, + motion_notify_event, key_press_event, and key_release_event. See the + implementations of the interactive backends for examples. + + Attributes + ---------- + figure : `matplotlib.figure.Figure` + A high-level Figure instance + """ + + # The instantiated manager class. For further customization, + # ``FigureManager.create_with_canvas`` can also be overridden; see the + # wx-based backends for an example. + manager_class = FigureManagerTemplate + + def draw(self): + """ + Draw the figure using the renderer. + + It is important that this method actually walk the artist tree + even if not output is produced because this will trigger + deferred work (like computing limits auto-limits and tick + values) that users may want access to before saving to disk. + """ + #print (f'Draw: {self._fpdf}') + width = self.figure.bbox.width + height = self.figure.bbox.height + renderer = RendererTemplate(self.figure, self.figure.dpi, self._fpdf, self._scale, self._trans, width, height) + self.figure.draw(renderer) + + # You should provide a print_xxx function for every file format + # you can write. + + # If the file type is not in the base set of filetypes, + # you should add it to the class-scope filetypes dictionary as follows: + filetypes = {**FigureCanvasBase.filetypes, 'fpdf': 'My magic FPDF format'} + + def print_fpdf(self, filename, **kwargs): + self._fpdf = self._trans = origin = scale = None + self._scale = 1.0 + self._facecolor = self._edgecolor = None + # if not isinstance(self.figure, Figure): + # if self.figure is None: manager = Gcf.get_active() + # else: manager = Gcf.get_fig_manager(figure) + # if manager is None: raise ValueError(f"No figure {self.figure}") + # figure = manager.canvas.figure + + # Fpdf uses top left origin, matplotlib bottom left so... fix Y axis + # We pass scale, origin and a handle to the fpdpf instance through here + for k,v in kwargs.items(): + match (k): + case 'fpdf': self._fpdf = v + case 'origin': + origin = v + #print(f"print_fpdf: origin={origin}") + case 'scale': + scale = v + #print(f"print_fpdf: scale={scale}") + self._scale = scale + case 'facecolor': + if not v: + self._facecolor=(1,0,1) + case 'edgecolor': + if not v: + self._edgecolor=(0,1,1) + case 'orientation': + pass # ignore for now + case 'bbox_inches_restore': + pass # ignore for now + case _: + pass + print (f'Unrecognised keyword {k} -> {v}') + + fig_width = self.figure.bbox.width + fig_height = self.figure.bbox.height + + # Build our transformation do scale and offset for whole figure + if origin and scale: + fig_height_mm = fig_height*scale + #print(f"print_fpdf: fig_width={fig_width}, fig_height={fig_height}, fig_height_mm={fig_height_mm}") + self._trans = Affine2D().scale(self._scale).scale(1, -1).translate(*origin) + + self.draw() + + def get_default_filetype(self): + return 'fpdf' + + +######################################################################## +# +# Now just provide the standard names that backend.__init__ is expecting +# +######################################################################## + +FigureCanvas = FigureCanvasTemplate +FigureManager = FigureManagerTemplate \ No newline at end of file diff --git a/test/mpl_renderer/__init__.py b/test/mpl_renderer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py new file mode 100644 index 000000000..735a35514 --- /dev/null +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -0,0 +1,327 @@ +# pylint: disable=no-self-use, protected-access +from cmath import cos, sin +import io +import logging +import os +from pathlib import Path + + +import matplotlib as mpl +import matplotlib.pyplot as plt + +from fpdf import FPDF +import pytest + +default_backend = plt.get_backend() +HERE = Path(__file__).resolve().parent +GENERATED_PDF_DIR = HERE / "generated_pdf" +os.makedirs(GENERATED_PDF_DIR, exist_ok=True) +font_file = HERE / "../fonts/DejaVuSans.ttf" +logging.getLogger("fpdf.svg").setLevel(logging.ERROR) + +def create_fpdf(w_mm, h_mm): + pdf = FPDF(unit="mm", format=(w_mm, h_mm)) + pdf.add_font("dejavusans", "", str(font_file)) + pdf.set_font("dejavusans", "", 8) + pdf.add_page() + return pdf + +def test_mpl_simple_figure(): + from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" + plt.switch_backend(default_backend) + w_inch = 4 + h_inch = 3 + w_mm = w_inch * 25.4 + h_mm = h_inch * 25.4 + + fig = gen_fig(plt, w_inch, h_inch) + + svg_buffer = io.BytesIO() + fig.savefig(svg_buffer, format="svg") + svg_buffer.seek(0) + + + pdf_svg = create_fpdf(w_mm, h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.output(GENERATED_PDF_DIR / "test_simple_figure_svg.pdf") + + plt.switch_backend("module://fpdf.fpdf_renderer") + + # Re-generate the figure to use FPDFRenderer backend + fig = gen_fig(plt, w_inch, h_inch) + + pdf_fpdf = create_fpdf(w_mm, h_mm) + + scale = float(w_mm / fig.bbox.width) + origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + + fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) + + pdf_fpdf.output(GENERATED_PDF_DIR / "test_simple_figure_fpdf.pdf") + + +def gen_fig(plt, w_inch, h_inch): + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) + ax.plot([0, 1], [0, 1], 'blue', linewidth=2) + ax.set_title("Simple Figure") + ax.set_xlabel("X Axis") + ax.set_ylabel("Y Axis") + return fig + + +def test_mpl_figure_with_arrows(): + from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" + plt.switch_backend(default_backend) + + w_inch = 4 + h_inch = 3 + w_mm = w_inch * 25.4 + h_mm = h_inch * 25.4 + + fig = gen_fig_arrows(plt, w_inch, h_inch) + + svg_buffer = io.BytesIO() + fig.savefig(svg_buffer, format="svg") + svg_buffer.seek(0) + + + pdf_svg = create_fpdf(w_mm, h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.output(GENERATED_PDF_DIR / "test_arrows_figure_svg.pdf") + + plt.switch_backend("module://fpdf.fpdf_renderer") + + # Re-generate the figure to use FPDFRenderer backend + fig = gen_fig_arrows(plt, w_inch, h_inch) + + pdf_fpdf = create_fpdf(w_mm, h_mm) + + scale = float(w_mm / fig.bbox.width) + origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) + pdf_fpdf.output(GENERATED_PDF_DIR / "test_arrows_figure_fpdf.pdf") + + +def gen_fig_arrows(plt, w_inch, h_inch): + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) + ax.plot([0, 1], [1, 0], 'blue', linewidth=2) + ax.set_title("Arrows Figure") + ax.set_xlabel("X Axis") + ax.set_ylabel("Y Axis") + ax.arrow(0.2, 0.2, 0.4, 0.4, head_width=0.05, head_length=0.1, fc='orange', ec='red') + ax.arrow(0.3, 0.3, 0.4, 0.2, head_width=0.2, head_length=0.3, fc='blue', ec='green') + return fig + + + +def test_mpl_figure_with_labels(): + from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" + plt.switch_backend(default_backend) + + w_inch = 4 + h_inch = 3 + w_mm = w_inch * 25.4 + h_mm = h_inch * 25.4 + + fig = gen_fig_labels(plt, w_inch, h_inch) + + svg_buffer = io.BytesIO() + fig.savefig(svg_buffer, dpi=600, format="png") + svg_buffer.seek(0) + + + pdf_svg = create_fpdf(w_mm, h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.output(GENERATED_PDF_DIR / "test_labels_figure_svg.pdf") + + plt.switch_backend("module://fpdf.fpdf_renderer") + + # Re-generate the figure to use FPDFRenderer backend + fig = gen_fig_labels(plt, w_inch, h_inch) + + pdf_fpdf = create_fpdf(w_mm, h_mm) + + scale = float(w_mm / fig.bbox.width) + origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) + pdf_fpdf.output(GENERATED_PDF_DIR / "test_labels_figure_fpdf.pdf") + + +def gen_fig_labels(plt, w_inch, h_inch): + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) + ax.plot([0, 1], [1, 0], 'blue', linewidth=2) + ax.set_title("Labels Figure") + ax.set_xlabel("X Axis") + ax.set_ylabel("Y Axis") + ax.get_yaxis().set_ticks([]) + ax.text(0.3, 0.3, "Label 0\u00B0", fontsize=12, color='green', rotation=0) + ax.text(0.3, 0.2, "Label 0\u00B0", fontsize=12, color='green', rotation=0, ha='center') + ax.text(0.3, 0.1, "Label 0\u00B0", fontsize=12, color='green', rotation=0, ha='right') + ax.text(0.3, 0.3, "Label 30\u00B0", fontsize=12, color='red', rotation=30) + ax.text(0.3, 0.3, "Label 60\u00B0", fontsize=12, color='blue', rotation=60) + ax.text(0.3, 0.3, "Label 90\u00B0", fontsize=12, color='black', rotation=90) + + # ax.text(0.7, 0.7, "Label 0\u00B0", fontsize=4, color='green', rotation=0) + # ax.text(0.7, 0.7, "Label -30\u00B0", fontsize=4, color='red', rotation=-30) + # ax.text(0.7, 0.7, "Label -60\u00B0", fontsize=4, color='blue', rotation=-60) + # ax.text(0.7, 0.7, "Label -90\u00B0", fontsize=4, color='black', rotation=-90) + + return fig + +def test_mpl_figure_with_legend(): + from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" + plt.switch_backend(default_backend) + + w_inch = 4 + h_inch = 3 + w_mm = w_inch * 25.4 + h_mm = h_inch * 25.4 + + fig = gen_fig_legend(plt, w_inch, h_inch) + + svg_buffer = io.BytesIO() + fig.savefig(svg_buffer, format="svg") + svg_buffer.seek(0) + + + pdf_svg = create_fpdf(w_mm, h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.output(GENERATED_PDF_DIR / "test_legend_figure_svg.pdf") + + plt.switch_backend("module://fpdf.fpdf_renderer") + + # Re-generate the figure to use FPDFRenderer backend + fig = gen_fig_legend(plt, w_inch, h_inch) + + pdf_fpdf = create_fpdf(w_mm, h_mm) + + scale = float(w_mm / fig.bbox.width) + origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + + fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) + pdf_fpdf.output(GENERATED_PDF_DIR / "test_legend_figure_fpdf.pdf") + + +def gen_fig_legend(plt, w_inch, h_inch): + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) + ax.plot([0, 1], [1, 0], 'blue', linewidth=1) + ax.plot([0, 1], [0, 1], 'green', linewidth=1) + ax.legend(["Line 1", "Line 2"]) + ax.set_title("Legend Figure") + ax.set_axis_off() + + return fig + + +def test_mpl_figure_with_bezier(): + from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" + plt.switch_backend(default_backend) + + w_inch = 4 + h_inch = 3 + w_mm = w_inch * 25.4 + h_mm = h_inch * 25.4 + + fig = gen_fig_bezier(plt, w_inch, h_inch) + + svg_buffer = io.BytesIO() + fig.savefig(svg_buffer, format="svg") + svg_buffer.seek(0) + + + pdf_svg = create_fpdf(w_mm, h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.output(GENERATED_PDF_DIR / "test_bezier_figure_svg.pdf") + + plt.switch_backend("module://fpdf.fpdf_renderer") + + # Re-generate the figure to use FPDFRenderer backend + fig = gen_fig_bezier(plt, w_inch, h_inch) + + pdf_fpdf = create_fpdf(w_mm, h_mm) + + scale = float(w_mm / fig.bbox.width) + origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + + fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) + pdf_fpdf.output(GENERATED_PDF_DIR / "test_bezier_figure_fpdf.pdf") + + +def gen_fig_bezier(plt, w_inch, h_inch): + import matplotlib.patches as mpatches + + from matplotlib.path import Path as MplPath + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) + + # Vertices must be a flat list of coordinate tuples + verts = [(5, 30), (15, 55), (25, 15), (35, 40), # 4 points for first curve + (20, 10), (30, 0), (35, 10)] # 3 points for second curve + + # `codes` means: the instructions used to guide the line through the points + codes = [MplPath.MOVETO, MplPath.CURVE4, MplPath.CURVE4, MplPath.CURVE4, # Begin the curve + MplPath.MOVETO, MplPath.CURVE3, MplPath.CURVE3] # Start a new one + + bezier1 = mpatches.PathPatch( + MplPath(verts, codes), + # You can also tweak your line properties, like its color, width, etc. + fc="none", transform=ax.transData, color="green", lw=2) + + ax.add_patch(bezier1) + ax.autoscale_view() + + return fig + + +def test_mpl_figure_with_lineplot(): + from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" + plt.switch_backend(default_backend) + + w_inch = 4 + h_inch = 3 + w_mm = w_inch * 25.4 + h_mm = h_inch * 25.4 + + fig = gen_fig_lineplot(plt, w_inch, h_inch) + + svg_buffer = io.BytesIO() + fig.savefig(svg_buffer, format="svg") + svg_buffer.seek(0) + + + pdf_svg = create_fpdf(w_mm, h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.output(GENERATED_PDF_DIR / "test_lineplot_figure_svg.pdf") + + plt.switch_backend("module://fpdf.fpdf_renderer") + + # Re-generate the figure to use FPDFRenderer backend + fig = gen_fig_lineplot(plt, w_inch, h_inch) + + pdf_fpdf = create_fpdf(w_mm, h_mm) + + scale = float(w_mm / fig.bbox.width) + origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + + fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) + pdf_fpdf.output(GENERATED_PDF_DIR / "test_lineplot_figure_fpdf.pdf") + + +def gen_fig_lineplot(plt, w_inch, h_inch): + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) + + t = [i * 0.01 for i in range(3)] + s = [sin(value) + cos(value*value) for value in t] + ax.plot(t, s, 'blue', linewidth=1) + # ax.set_title("Line Plot Figure") + ax.set_title("O") + # ax.set_xlabel("t") + # ax.set_ylabel("sin(t) + cos(t^2)") + ax.autoscale_view() + ax.set_axis_off() + return fig \ No newline at end of file From 88e8fad8840b65c9ffc6c231a183f05f55607987 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 10 Dec 2025 13:57:53 +0200 Subject: [PATCH 02/35] - add dependency --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cf75943c5..27fae2a87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,8 @@ dependencies = [ "Pillow>=8.3.2,!=9.2.*", # minimum version tested in .github/workflows/continuous-integration-workflow.yml # Version 9.2.0 is excluded due to DoS vulnerability with TIFF images: https://github.com/py-pdf/fpdf2/issues/628 # Version exclusion explained here: https://devpress.csdn.net/python/630462c0c67703293080c302.html - "fonttools>=4.34.0" + "fonttools>=4.34.0", + "matplotlib>=3.60.0", # minimum version tested in .github/workflows/continuous-integration-workflow.yml ] [project.optional-dependencies] From 70ff91f6a2479b6e2d690b92ea9670eba91310f3 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 10 Dec 2025 14:01:04 +0200 Subject: [PATCH 03/35] - more dep --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 27fae2a87..1390070e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel", "fonttools"] +requires = ["setuptools>=61.0", "wheel", "fonttools","matplotlib"] build-backend = "setuptools.build_meta" [project] @@ -43,7 +43,7 @@ dependencies = [ # Version 9.2.0 is excluded due to DoS vulnerability with TIFF images: https://github.com/py-pdf/fpdf2/issues/628 # Version exclusion explained here: https://devpress.csdn.net/python/630462c0c67703293080c302.html "fonttools>=4.34.0", - "matplotlib>=3.60.0", # minimum version tested in .github/workflows/continuous-integration-workflow.yml + "matplotlib>=3.60.0", # minimum version tested ] [project.optional-dependencies] From 0dd0abf9ebdd855ae85819f62b3326d7111823d6 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 10 Dec 2025 14:02:02 +0200 Subject: [PATCH 04/35] - fix version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1390070e7..c40b360db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ # Version 9.2.0 is excluded due to DoS vulnerability with TIFF images: https://github.com/py-pdf/fpdf2/issues/628 # Version exclusion explained here: https://devpress.csdn.net/python/630462c0c67703293080c302.html "fonttools>=4.34.0", - "matplotlib>=3.60.0", # minimum version tested + "matplotlib>=3.6.0", # minimum version tested ] [project.optional-dependencies] From 5e9902fe7e2f0c1ef2513da14705fe1a952229c2 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 10 Dec 2025 17:01:03 +0200 Subject: [PATCH 05/35] - fix if condition --- fpdf/fpdf_renderer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index d09423297..94bb7f761 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -71,7 +71,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): #print(f"draw_path with scaling: {scaling}") fill_opacity = None stroke_opacity = None - if rgbFace: + if rgbFace and len(rgbFace) >=3: p.set_fill_color(rgbFace[0]*255, rgbFace[1]*255, rgbFace[2]*255) if len(rgbFace) == 4: From de2a5feeba6f6aac816c6d05ee6d7c2d39436fe5 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 10 Dec 2025 17:04:30 +0200 Subject: [PATCH 06/35] - fix --- fpdf/fpdf_renderer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index 94bb7f761..73611360e 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -71,7 +71,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): #print(f"draw_path with scaling: {scaling}") fill_opacity = None stroke_opacity = None - if rgbFace and len(rgbFace) >=3: + if rgbFace is not None and len(rgbFace) >=3: p.set_fill_color(rgbFace[0]*255, rgbFace[1]*255, rgbFace[2]*255) if len(rgbFace) == 4: From 8d06f2af8cbf8f0de536956fae26ccdb3d6969dc Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 10 Dec 2025 20:07:46 +0200 Subject: [PATCH 07/35] - set copy=false for speed --- fpdf/fpdf_renderer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index 73611360e..3154d74b7 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -133,8 +133,8 @@ def draw_path(self, gc, path, transform, rgbFace=None): #print(f"Closing polygon path. idx: {i}") if (i == length -1): - pth.close() # close the path and add to context - ctxt.add_item(pth) + pth.close() # close the path and add to context without copying + ctxt.add_item(pth, _copy=False) pth = None else: pth.paint_rule = PathPaintRule.FILL_EVENODD @@ -145,7 +145,8 @@ def draw_path(self, gc, path, transform, rgbFace=None): print (f'Unhandled path command in polygon: {c[i]} at vertex {vtx}') if pth is not None: #print(f"path was not closed - adding to context") - ctxt.add_item(pth) + # add to context without copying + ctxt.add_item(pth, _copy=False) pth = None From 30980b7c5c555753ba036d69856c2839bd5b378b Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Mon, 15 Dec 2025 15:08:16 +0200 Subject: [PATCH 08/35] - revert lineplot test case - implement "dejavu sans" -font so "-" is printed without error --- test/mpl_renderer/test_matplotlib_renderer.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index 735a35514..cd7cdf126 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -8,6 +8,7 @@ import matplotlib as mpl import matplotlib.pyplot as plt +from matplotlib import font_manager from fpdf import FPDF import pytest @@ -21,9 +22,11 @@ def create_fpdf(w_mm, h_mm): pdf = FPDF(unit="mm", format=(w_mm, h_mm)) - pdf.add_font("dejavusans", "", str(font_file)) - pdf.set_font("dejavusans", "", 8) + print(f"Adding font from file: {font_file}") + pdf.add_font("dejavu sans", "", str(font_file)) pdf.add_page() + font_manager.fontManager.addfont(str(font_file)) + mpl.rcParams["font.sans-serif"] = ["dejavu sans"] return pdf def test_mpl_simple_figure(): @@ -315,13 +318,12 @@ def test_mpl_figure_with_lineplot(): def gen_fig_lineplot(plt, w_inch, h_inch): fig, ax = plt.subplots(figsize=(w_inch, h_inch)) - t = [i * 0.01 for i in range(3)] + t = [i * 0.01 for i in range(1000)] s = [sin(value) + cos(value*value) for value in t] ax.plot(t, s, 'blue', linewidth=1) - # ax.set_title("Line Plot Figure") - ax.set_title("O") - # ax.set_xlabel("t") - # ax.set_ylabel("sin(t) + cos(t^2)") + ax.set_title("Line Plot Figure") + ax.set_xlabel("t") + ax.set_ylabel("sin(t) + cos(t^2)") ax.autoscale_view() - ax.set_axis_off() + return fig \ No newline at end of file From 2bc2c782d5d2469d5806ee3b150cdb6f838f819e Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Mon, 15 Dec 2025 15:41:21 +0200 Subject: [PATCH 09/35] - fix fonts - simple speed test --- docs/Maths.md | 68 +++++++++++++++++++ test/mpl_renderer/test_matplotlib_renderer.py | 58 +++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/docs/Maths.md b/docs/Maths.md index 398c57fab..e686ac062 100644 --- a/docs/Maths.md +++ b/docs/Maths.md @@ -220,3 +220,71 @@ If you have trouble with the SVG export, you can also render the matplotlib figu ```python {% include "../tutorial/equation_matplotlib_raster.py" %} ``` + +### Using fpdf2 renderer ### + +The new _experimental_ fpdf2 renderer for matplotlib allows direct rendering to the FPDF2 document. This provides a large performance benefit compared to using SVG (over x3 faster) or PNG intermediate rendering. There are a couple of down sides to this method of rendering the plots: + + 1. The `bbox_inches='tight'` -option cannot be used on savefig (or it can cause a lot of weird stuff) + 2. One must calculate the _origin_ and _scale_ parameters for setting the layout on pdf document + +The following code samples demonstrate the use of the new codepath: + +#### Original code using SVG #### + +```python + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) + + t = [i * 0.01 for i in range(1000)] + s = [sin(value) + cos(value*value) for value in t] + ax.plot(t, s, 'blue', linewidth=1) + ax.set_title("Line Plot Figure") + ax.set_xlabel("t") + ax.set_ylabel("sin(t) + cos(t^2)") + ax.autoscale_view() + + # Save plot to SVG + buffer = BytesIO() + fig.savefig(buffer, format='svg', dpi=300, pad_inches=0.1) + + plt.close(fig) + buffer.seek(0) + + # Draw SVG to PDF + pdf.image(buffer, x=x, y=y, w=w, h=h) +``` + + +#### New FPDF rendering #### + +```python + import matplotlib as mpl + mpl.use("module://fpdf.fpdf_renderer") +``` +... + +```python + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) + + t = [i * 0.01 for i in range(1000)] + s = [sin(value) + cos(value*value) for value in t] + ax.plot(t, s, 'blue', linewidth=1) + ax.set_title("Line Plot Figure") + ax.set_xlabel("t") + ax.set_ylabel("sin(t) + cos(t^2)") + ax.autoscale_view() + + # Calc scale and origin + if w == 0: + w = fig.bbox.width + scale = float(w / fig.bbox.width) + if h == 0: + h = fig.bbox.height * scale + origin = (float(x), float(y + h)) # FPDF uses bottom-left as origin + + # Call savefig directly with fpdf object and origin & scale + fig.savefig (fname=None, fpdf=pdf, origin=origin, scale=scale) + plt.close(fig) +``` + + diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index cd7cdf126..2df0aebff 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -282,7 +282,6 @@ def gen_fig_bezier(plt, w_inch, h_inch): def test_mpl_figure_with_lineplot(): from matplotlib import pyplot as plt - plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) w_inch = 4 @@ -326,4 +325,59 @@ def gen_fig_lineplot(plt, w_inch, h_inch): ax.set_ylabel("sin(t) + cos(t^2)") ax.autoscale_view() - return fig \ No newline at end of file + return fig + + +def test_mplrenderer_speed_test(): + import time + from matplotlib import pyplot as plt + plt.switch_backend(default_backend) + + ROUNDS = 1000 + w_inch = 4 + h_inch = 3 + w_mm = w_inch * 25.4 + h_mm = h_inch * 25.4 + + fig = gen_fig_lineplot(plt, w_inch, h_inch) + + svg_buffer = io.BytesIO() + fig.savefig(svg_buffer, format="svg") + svg_buffer.seek(0) + plt.close(fig) + + pdf_svg = create_fpdf(210, 297) + + t0 = time.time() + for i in range(ROUNDS): + x=(i/10) + y=(i%20)*10 + pdf_svg.image(svg_buffer, x=x, y=y, w=w_mm, h=h_mm) + + pdf_svg.output(GENERATED_PDF_DIR / "test_speed_figure_svg.pdf") + total_svg = time.time() - t0 + print(f"SVG backend time for {ROUNDS} rounds: {total_svg:.2f} seconds") + + + + plt.switch_backend("module://fpdf.fpdf_renderer") + + # Re-generate the figure to use FPDFRenderer backend + fig = gen_fig_lineplot(plt, w_inch, h_inch) + + pdf_fpdf = create_fpdf(210, 297) + + t0 = time.time() + for i in range(ROUNDS): + x=(i/10) + y=(i%20)*10 + # plot scale + scale = float(w_mm / fig.bbox.width) + origin = (x, 297-y) # FPDF uses bottom-left of page as origin + fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) + + plt.close(fig) + pdf_fpdf.output(GENERATED_PDF_DIR / "test_speed_figure_fpdf.pdf") + + total_fpdf = time.time() - t0 + print(f"FPDF backend time for {ROUNDS} rounds: {total_fpdf:.2f} seconds") From be248acd2712538126fa1fb94f316ff6bef049ff Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Mon, 15 Dec 2025 15:45:32 +0200 Subject: [PATCH 10/35] - added mention on CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 394e02fc2..c5267acd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ This can also be enabled programmatically with `warnings.simplefilter('default', ## [2.8.6] - Not released yet ### Added * support for SVG `` and `` elements - _cf._ [issue #1580](https://github.com/py-pdf/fpdf2/issues/1580) - thanks to @Ani07-05 +* Direct FPDF renderer for matplotlib images + ### Fixed * a bug when rendering empty tables with `INTERNAL` layout, that caused an extra border to be rendered due to an erroneous use of `list.index()` - _cf._ [issue #1669](https://github.com/py-pdf/fpdf2/issues/1669) ### Changed From 33750ddfa4e1a62836288d3d34cc55488a0d594c Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 17 Dec 2025 14:18:47 +0200 Subject: [PATCH 11/35] - added support for simple dashed lines --- fpdf/fpdf_renderer.py | 16 ++++-- test/mpl_renderer/test_matplotlib_renderer.py | 51 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index 3154d74b7..a2cc7bba7 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -61,7 +61,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): clip_x0,clip_y0 = self._trans.transform(clip_rect[0:2]) clip_x1,clip_y1 = self._trans.transform(clip_rect[2:4]) - #print(f"clip-rect xformed: {x0:.1f},{y0:.1f} -> {x1:.1f},{y1:.1f}\n") + #else: #print(f"clip-path: {gc.get_clip_path()}\n") c,v = zip(*[(c,v.tolist()) for v,c in path.iter_segments(transform=tran)]) @@ -85,7 +85,8 @@ def draw_path(self, gc, path, transform, rgbFace=None): #print(f"stroke_opacity: { rgb[3]}") stroke_opacity = rgb[3] - + _, dash_array = gc.get_dashes() + line_width = gc.get_linewidth() line_width_px = line_width * self.dpi / 72.0 # points to pixels mm_line_width = line_width_px * self._scale @@ -95,7 +96,16 @@ def draw_path(self, gc, path, transform, rgbFace=None): #p.set_draw_color(rgb[0]*255, rgb[1]*255, rgb[2]*255) #print(f"draw_path: color rgb: {rgb}, line_width: {line_width} pt -> mm_line_width: {mm_line_width:.2f} mm") #print(f"line_width: {line_width} pt -> mm_line_width: {mm_line_width:.2f} mm") - + if dash_array and len(dash_array) >= 2: + # Scale dash array from points to mm + mm_dash_array = [ (d * PT_TO_MM) for d in dash_array] + + if len(mm_dash_array) >2: + # make sure we have even number of elements + print("Warning: dash array has more than two elements - ignoring extra ones") + dash = mm_dash_array[0]-mm_line_width + gap = mm_dash_array[1]+mm_line_width + p.set_dash_pattern(dash= dash, gap=gap) # print(f'Path commands: {c}: {v}') match c: diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index 2df0aebff..180b31919 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -381,3 +381,54 @@ def test_mplrenderer_speed_test(): total_fpdf = time.time() - t0 print(f"FPDF backend time for {ROUNDS} rounds: {total_fpdf:.2f} seconds") + + +def test_mpl_figure_with_linestyles(): + from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" + plt.switch_backend(default_backend) + + w_inch = 4 + h_inch = 3 + w_mm = w_inch * 25.4 + h_mm = h_inch * 25.4 + + fig = gen_fig_linestyles(plt, w_inch, h_inch) + + svg_buffer = io.BytesIO() + fig.savefig(svg_buffer, format="svg") + svg_buffer.seek(0) + + + pdf_svg = create_fpdf(w_mm, h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.output(GENERATED_PDF_DIR / "test_linestyles_figure_svg.pdf") + + plt.switch_backend("module://fpdf.fpdf_renderer") + + # Re-generate the figure to use FPDFRenderer backend + fig = gen_fig_linestyles(plt, w_inch, h_inch) + + pdf_fpdf = create_fpdf(w_mm, h_mm) + + scale = float(w_mm / fig.bbox.width) + origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + + fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) + pdf_fpdf.output(GENERATED_PDF_DIR / "test_linestyles_figure_fpdf.pdf") + +def gen_fig_linestyles(plt, w_inch, h_inch): + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) + + t = [i * 0.1 for i in range(100)] + ax.plot(t, [0.5]*100, color='blue', linestyle='solid', linewidth=2, label='solid') + ax.plot(t, [0.4]*100, color='orange', linestyle='dashed', linewidth=2, label='dashed') + ax.plot(t, [0.3]*100, color='green', linestyle='dashdot', linewidth=2, label='dashdot') + ax.plot(t, [0.2]*100, color='red', linestyle='dotted', linewidth=2, label='dotted') + ax.set_title("Line Styles Figure") + ax.set_xlabel("t") + ax.set_ylabel("Value") + ax.autoscale_view() + ax.legend() + + return fig \ No newline at end of file From 5247d3331031eb3d34f2a79519b22d68e71bc4e4 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 17 Dec 2025 14:24:57 +0200 Subject: [PATCH 12/35] - code formatting --- fpdf/fpdf_renderer.py | 298 ++++++++++-------- test/mpl_renderer/test_matplotlib_renderer.py | 209 ++++++------ 2 files changed, 288 insertions(+), 219 deletions(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index a2cc7bba7..ade0b9583 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -1,13 +1,18 @@ """ - Based on https://github.com/matplotlib/matplotlib/blob/v3.7.1/lib/matplotlib/backends/backend_template.py - - Just need to tell MatPlotLib to use this renderer and then do fig.savefig. +Based on https://github.com/matplotlib/matplotlib/blob/v3.7.1/lib/matplotlib/backends/backend_template.py + +Just need to tell MatPlotLib to use this renderer and then do fig.savefig. """ from contextlib import nullcontext from matplotlib import _api from matplotlib._pylab_helpers import Gcf -from matplotlib.backend_bases import (FigureCanvasBase, FigureManagerBase, GraphicsContextBase, RendererBase) +from matplotlib.backend_bases import ( + FigureCanvasBase, + FigureManagerBase, + GraphicsContextBase, + RendererBase, +) from matplotlib.figure import Figure from matplotlib.transforms import Affine2D, IdentityTransform import matplotlib as mpl @@ -16,118 +21,131 @@ from fpdf.enums import PathPaintRule PT_TO_MM = 0.3527777778 # 1 point = 0.3527777778 mm + + class RendererTemplate(RendererBase): - """ Removed draw_markers, draw_path_collection and draw_quad_mesh - all optional, we can add later """ + """Removed draw_markers, draw_path_collection and draw_quad_mesh - all optional, we can add later""" def __init__(self, figure, dpi, fpdf, scale, transform, fig_width, fig_height): super().__init__() self.figure = figure - #print (f'FPDF: dpi: {dpi}') + # print (f'FPDF: dpi: {dpi}') self.dpi = dpi - + self._fpdf = fpdf self._trans = transform self._scale = scale # some safe defaults if fpdf: - fpdf.set_draw_color(0,0,0) - fpdf.set_fill_color(255,255,255) + fpdf.set_draw_color(0, 0, 0) + fpdf.set_fill_color(255, 255, 255) # calc font scaling factor to get matplotlib font sizes to match FPDF sizes when using SVG fig_h_mm = fig_height * scale fig_w_mm = fig_width * scale fig_w_inch = fig_width / dpi fig_h_inch = fig_height / dpi - shrink_ratio_h = (fig_h_mm/25.4) / fig_h_inch - shrink_ratio_w = (fig_w_mm/25.4) / fig_w_inch + shrink_ratio_h = (fig_h_mm / 25.4) / fig_h_inch + shrink_ratio_w = (fig_w_mm / 25.4) / fig_w_inch if fpdf: self._font_scaling = shrink_ratio_w - #print(f"Font scaling factor: {self._font_scaling}") + # print(f"Font scaling factor: {self._font_scaling}") def draw_path(self, gc, path, transform, rgbFace=None): - #self.check_gc(gc, rgbFace) - #gc.paint() - + # self.check_gc(gc, rgbFace) + # gc.paint() + # Unzip the path segments into 2 arrays - commands and vertices, the transform sorts scaling and positioning # print(f"draw_path transform: {transform.get_matrix()}, _trans: {self._trans.get_matrix()}") - #print(f"transform: {transform.get_matrix()},\n_trans: {self._trans.get_matrix()}\n\n") - #tran = transform + self._trans + # print(f"transform: {transform.get_matrix()},\n_trans: {self._trans.get_matrix()}\n\n") + # tran = transform + self._trans tran = transform + self._trans clip_rect = None if gc.get_clip_rectangle(): - # print(f"clip-rect in: {gc.get_clip_rectangle().x0:.1f},{gc.get_clip_rectangle().y0:.1f} -> {gc.get_clip_rectangle().x1:.1f},{gc.get_clip_rectangle().y1:.1f}\n") + # print(f"clip-rect in: {gc.get_clip_rectangle().x0:.1f},{gc.get_clip_rectangle().y0:.1f} -> {gc.get_clip_rectangle().x1:.1f},{gc.get_clip_rectangle().y1:.1f}\n") clip_rect = gc.get_clip_rectangle().extents - clip_x0,clip_y0 = self._trans.transform(clip_rect[0:2]) - clip_x1,clip_y1 = self._trans.transform(clip_rect[2:4]) + clip_x0, clip_y0 = self._trans.transform(clip_rect[0:2]) + clip_x1, clip_y1 = self._trans.transform(clip_rect[2:4]) - - #else: - #print(f"clip-path: {gc.get_clip_path()}\n") - c,v = zip(*[(c,v.tolist()) for v,c in path.iter_segments(transform=tran)]) + # else: + # print(f"clip-path: {gc.get_clip_path()}\n") + c, v = zip(*[(c, v.tolist()) for v, c in path.iter_segments(transform=tran)]) p = self._fpdf - scaling = self._trans.get_matrix()[0,0] - #print(f"draw_path with scaling: {scaling}") + scaling = self._trans.get_matrix()[0, 0] + # print(f"draw_path with scaling: {scaling}") fill_opacity = None stroke_opacity = None - if rgbFace is not None and len(rgbFace) >=3: - p.set_fill_color(rgbFace[0]*255, rgbFace[1]*255, rgbFace[2]*255) + if rgbFace is not None and len(rgbFace) >= 3: + p.set_fill_color(rgbFace[0] * 255, rgbFace[1] * 255, rgbFace[2] * 255) if len(rgbFace) == 4: - #print(f"fill_opacity: { rgbFace[3]}") + # print(f"fill_opacity: { rgbFace[3]}") fill_opacity = rgbFace[3] rgb = gc.get_rgb() - p.set_draw_color(rgb[0]*255, rgb[1]*255, rgb[2]*255) + p.set_draw_color(rgb[0] * 255, rgb[1] * 255, rgb[2] * 255) stroke_opacity = None if len(rgb) == 4: - #print(f"stroke_opacity: { rgb[3]}") + # print(f"stroke_opacity: { rgb[3]}") stroke_opacity = rgb[3] _, dash_array = gc.get_dashes() - + line_width = gc.get_linewidth() line_width_px = line_width * self.dpi / 72.0 # points to pixels mm_line_width = line_width_px * self._scale - with p.rect_clip(clip_x0,clip_y0,clip_x1-clip_x0,clip_y1-clip_y0) if clip_rect is not None else nullcontext() as clip: - - with p.local_context(stroke_opacity=stroke_opacity, fill_opacity=fill_opacity, line_width=mm_line_width): - #p.set_draw_color(rgb[0]*255, rgb[1]*255, rgb[2]*255) - #print(f"draw_path: color rgb: {rgb}, line_width: {line_width} pt -> mm_line_width: {mm_line_width:.2f} mm") - #print(f"line_width: {line_width} pt -> mm_line_width: {mm_line_width:.2f} mm") + with ( + p.rect_clip(clip_x0, clip_y0, clip_x1 - clip_x0, clip_y1 - clip_y0) + if clip_rect is not None + else nullcontext() + ) as clip: + + with p.local_context( + stroke_opacity=stroke_opacity, + fill_opacity=fill_opacity, + line_width=mm_line_width, + ): + # p.set_draw_color(rgb[0]*255, rgb[1]*255, rgb[2]*255) + # print(f"draw_path: color rgb: {rgb}, line_width: {line_width} pt -> mm_line_width: {mm_line_width:.2f} mm") + # print(f"line_width: {line_width} pt -> mm_line_width: {mm_line_width:.2f} mm") if dash_array and len(dash_array) >= 2: # Scale dash array from points to mm - mm_dash_array = [ (d * PT_TO_MM) for d in dash_array] + mm_dash_array = [(d * PT_TO_MM) for d in dash_array] - if len(mm_dash_array) >2: + if len(mm_dash_array) > 2: # make sure we have even number of elements - print("Warning: dash array has more than two elements - ignoring extra ones") - dash = mm_dash_array[0]-mm_line_width - gap = mm_dash_array[1]+mm_line_width - p.set_dash_pattern(dash= dash, gap=gap) + print( + "Warning: dash array has more than two elements - ignoring extra ones" + ) + dash = mm_dash_array[0] - mm_line_width + gap = mm_dash_array[1] + mm_line_width + p.set_dash_pattern(dash=dash, gap=gap) # print(f'Path commands: {c}: {v}') match c: # Simple line case [path.MOVETO, path.LINETO]: - #print(f"simpleline: {v}") + # print(f"simpleline: {v}") p.polyline(v) # Polyline - move then a set of lines - case [path.MOVETO, *mid, path.LINETO] if all(e == path.LINETO for e in mid): - #print(f"polyline2: {v}") - p.polyline (v) - + case [path.MOVETO, *mid, path.LINETO] if all( + e == path.LINETO for e in mid + ): + # print(f"polyline2: {v}") + p.polyline(v) + # Path combinations: Starts with MOVETO, and can end with CLOSEPOLY case [path.MOVETO, *rest]: - #print(f"polygon: \n{c}\n{v}\n") + # print(f"polygon: \n{c}\n{v}\n") pth = None length = len(c) with p.drawing_context() as ctxt: - for i,vtx in enumerate(v): - #print(f" cmd: {c[i]}, vtx: {vtx}") + for i, vtx in enumerate(v): + # print(f" cmd: {c[i]}, vtx: {vtx}") if pth is None: pth = PaintedPath() pth.style.auto_close = False @@ -140,85 +158,96 @@ def draw_path(self, gc, path, transform, rgbFace=None): elif c[i] == path.CURVE4: pth.curve_to(*vtx) elif c[i] == path.CLOSEPOLY: - #print(f"Closing polygon path. idx: {i}") - - if (i == length -1): - pth.close() # close the path and add to context without copying + # print(f"Closing polygon path. idx: {i}") + + if i == length - 1: + pth.close() # close the path and add to context without copying ctxt.add_item(pth, _copy=False) pth = None else: pth.paint_rule = PathPaintRule.FILL_EVENODD pth.move_to(*v[i]) # start a new sub-path - + pass else: - print (f'Unhandled path command in polygon: {c[i]} at vertex {vtx}') + print( + f"Unhandled path command in polygon: {c[i]} at vertex {vtx}" + ) if pth is not None: - #print(f"path was not closed - adding to context") + # print(f"path was not closed - adding to context") # add to context without copying ctxt.add_item(pth, _copy=False) pth = None - - case _: - print (f'draw_path: Unmatched {c}') - + print(f"draw_path: Unmatched {c}") + def draw_image(self, gc, x, y, im): - print (f'draw_image at {x},{y} size {im.get_size()}') + print(f"draw_image at {x},{y} size {im.get_size()}") pass - def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # print (f'RendererTemplate.draw_text - {s} at {x:.0f},{y:.0f} at angle {angle:.1f} with prop {prop} - {mtext}') # print (f'RendererTemplate.draw_text - {s} at {x:.0f},{y:.0f} - {mtext}') - th = self._fpdf.font_size_pt * PT_TO_MM * self._font_scaling # Default text height in mm - + th = ( + self._fpdf.font_size_pt * PT_TO_MM * self._font_scaling + ) # Default text height in mm + if isinstance(prop, str): - raise ValueError (f'draw_text.prop is a string ({prop}) - add code to add font') + raise ValueError( + f"draw_text.prop is a string ({prop}) - add code to add font" + ) # We're expecting a FontProperties instance elif isinstance(prop, mpl.font_manager.FontProperties): - #print(f"font prop size: {prop.get_size()} name: {prop.get_name()}, self._font_scaling: {self._font_scaling}") - self._fpdf.set_font(prop.get_name(), size=prop.get_size() * self._font_scaling) - - tw, th ,_ = self.get_text_width_height_descent(s, prop, ismath) + # print(f"font prop size: {prop.get_size()} name: {prop.get_name()}, self._font_scaling: {self._font_scaling}") + self._fpdf.set_font( + prop.get_name(), size=prop.get_size() * self._font_scaling + ) + + tw, th, _ = self.get_text_width_height_descent(s, prop, ismath) tw *= self._font_scaling * PT_TO_MM / self.dpi * 72.0 th *= self._font_scaling * PT_TO_MM / self.dpi * 72.0 tw_prerotate = tw th_prerotate = th - - #print(f'Text width/height before rotation: {tw_prerotate:.1f}/{th_prerotate:.1f} mm') + + # print(f'Text width/height before rotation: {tw_prerotate:.1f}/{th_prerotate:.1f} mm') # print(f'scale x: {self.figure.bbox.width}, y: {self.figure.bbox.height}') # Calc text width and height - rotated_bbox = Affine2D().rotate_deg(angle).transform(((0,0),(tw,0),(tw,th),(0,th))) - - min_x = min(rotated_bbox[:,0]) - max_x = max(rotated_bbox[:,0]) - min_y = min(rotated_bbox[:,1]) - max_y = max(rotated_bbox[:,1]) + rotated_bbox = ( + Affine2D() + .rotate_deg(angle) + .transform(((0, 0), (tw, 0), (tw, th), (0, th))) + ) + + min_x = min(rotated_bbox[:, 0]) + max_x = max(rotated_bbox[:, 0]) + min_y = min(rotated_bbox[:, 1]) + max_y = max(rotated_bbox[:, 1]) tw = max_x - min_x th = max_y - min_y else: - print (f'Unknown prop type: {type(prop)}') + print(f"Unknown prop type: {type(prop)}") tw = None th = None - + # Transform our data point - #print(f"Before transform: x={x}, y={y}. s:'{s}'") - - trans = self._trans #+ Affine2D().translate(-tw/3.5, 0) - x,y = trans.transform((x,y)) - - #print(f'- [{x:.1f},{y:.1f}] {s}') - ha = mtext.get_ha() if mtext else 'left' - #print(f'Text \'{s}\' ha: {ha}, angle: {angle}') + # print(f"Before transform: x={x}, y={y}. s:'{s}'") + + trans = self._trans # + Affine2D().translate(-tw/3.5, 0) + x, y = trans.transform((x, y)) + + # print(f'- [{x:.1f},{y:.1f}] {s}') + ha = mtext.get_ha() if mtext else "left" + # print(f'Text \'{s}\' ha: {ha}, angle: {angle}') color = gc.get_rgb() - self._fpdf.set_text_color(int(color[0]*255), int(color[1]*255), int(color[2]*255)) - #print("Color:", color) + self._fpdf.set_text_color( + int(color[0] * 255), int(color[1] * 255), int(color[2] * 255) + ) + # print("Color:", color) # Get text width to sort positioning - MPL centers on co-ordinate - #print (f'Text width rotated: {tw:.1f}, height: {th:.1f}') + # print (f'Text width rotated: {tw:.1f}, height: {th:.1f}') match angle: case 0: # if ha == "right": @@ -226,16 +255,14 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # if ha == "center": # x -= tw / 2 # x+=0.7 # FPDF text positioning seems to be a bit off to left - self._fpdf.text(x,y,s) + self._fpdf.text(x, y, s) case _: - #print (f'Rotate to "{angle}" {type(angle)}') + # print (f'Rotate to "{angle}" {type(angle)}') rotpt_x = 0 rotpt_y = 0 with self._fpdf.rotation(angle=angle, x=x - rotpt_x, y=y + rotpt_y): - self._fpdf.text(x,y,s) - - + self._fpdf.text(x, y, s) def flipy(self): return False @@ -243,13 +270,12 @@ def flipy(self): def get_canvas_width_height(self): return 100, 100 - def new_gc(self): return GraphicsContextTemplate() def points_to_pixels(self, points): - return points/72.0 * self.dpi - #return points + return points / 72.0 * self.dpi + # return points class GraphicsContextTemplate(GraphicsContextBase): @@ -261,18 +287,19 @@ class GraphicsContextTemplate(GraphicsContextBase): forwarding the appropriate calls to it using a dictionary mapping styles to gdk constants. In Postscript, all the work is done by the renderer, mapping line styles to postscript calls. - + If it's more appropriate to do the mapping at the renderer level (as in the postscript backend), you don't need to override any of the GC methods. If it's more appropriate to wrap an instance (as in the cairo backend) and do the mapping here, you'll need to override several of the setter methods. - + The base GraphicsContext stores colors as an RGB tuple on the unit interval, e.g., (0.5, 0.0, 1.0). You may need to map this to colors appropriate for your backend. """ + ######################################################################## # # The following functions and classes are for pyplot and implement @@ -284,7 +311,7 @@ class GraphicsContextTemplate(GraphicsContextBase): class FigureManagerTemplate(FigureManagerBase): """ Helper class for pyplot mode, wraps everything up into a neat bundle. - + For non-interactive backends, the base class is sufficient. For interactive backends, see the documentation of the `.FigureManagerBase` class for the list of methods that can/should be overridden. @@ -295,19 +322,19 @@ class FigureCanvasTemplate(FigureCanvasBase): """ The canvas the figure renders into. Calls the draw and print fig methods, creates the renderers, etc. - + Note: GUI templates will want to connect events for button presses, mouse movements and key presses to functions that call the base class methods button_press_event, button_release_event, motion_notify_event, key_press_event, and key_release_event. See the implementations of the interactive backends for examples. - + Attributes ---------- figure : `matplotlib.figure.Figure` A high-level Figure instance """ - + # The instantiated manager class. For further customization, # ``FigureManager.create_with_canvas`` can also be overridden; see the # wx-based backends for an example. @@ -316,24 +343,32 @@ class methods button_press_event, button_release_event, def draw(self): """ Draw the figure using the renderer. - + It is important that this method actually walk the artist tree even if not output is produced because this will trigger deferred work (like computing limits auto-limits and tick values) that users may want access to before saving to disk. """ - #print (f'Draw: {self._fpdf}') + # print (f'Draw: {self._fpdf}') width = self.figure.bbox.width height = self.figure.bbox.height - renderer = RendererTemplate(self.figure, self.figure.dpi, self._fpdf, self._scale, self._trans, width, height) + renderer = RendererTemplate( + self.figure, + self.figure.dpi, + self._fpdf, + self._scale, + self._trans, + width, + height, + ) self.figure.draw(renderer) # You should provide a print_xxx function for every file format # you can write. - + # If the file type is not in the base set of filetypes, # you should add it to the class-scope filetypes dictionary as follows: - filetypes = {**FigureCanvasBase.filetypes, 'fpdf': 'My magic FPDF format'} + filetypes = {**FigureCanvasBase.filetypes, "fpdf": "My magic FPDF format"} def print_fpdf(self, filename, **kwargs): self._fpdf = self._trans = origin = scale = None @@ -347,43 +382,44 @@ def print_fpdf(self, filename, **kwargs): # Fpdf uses top left origin, matplotlib bottom left so... fix Y axis # We pass scale, origin and a handle to the fpdpf instance through here - for k,v in kwargs.items(): + for k, v in kwargs.items(): match (k): - case 'fpdf': self._fpdf = v - case 'origin': + case "fpdf": + self._fpdf = v + case "origin": origin = v - #print(f"print_fpdf: origin={origin}") - case 'scale': + # print(f"print_fpdf: origin={origin}") + case "scale": scale = v - #print(f"print_fpdf: scale={scale}") + # print(f"print_fpdf: scale={scale}") self._scale = scale - case 'facecolor': + case "facecolor": if not v: - self._facecolor=(1,0,1) - case 'edgecolor': + self._facecolor = (1, 0, 1) + case "edgecolor": if not v: - self._edgecolor=(0,1,1) - case 'orientation': + self._edgecolor = (0, 1, 1) + case "orientation": pass # ignore for now - case 'bbox_inches_restore': + case "bbox_inches_restore": pass # ignore for now case _: pass - print (f'Unrecognised keyword {k} -> {v}') + print(f"Unrecognised keyword {k} -> {v}") fig_width = self.figure.bbox.width fig_height = self.figure.bbox.height - + # Build our transformation do scale and offset for whole figure if origin and scale: - fig_height_mm = fig_height*scale - #print(f"print_fpdf: fig_width={fig_width}, fig_height={fig_height}, fig_height_mm={fig_height_mm}") + fig_height_mm = fig_height * scale + # print(f"print_fpdf: fig_width={fig_width}, fig_height={fig_height}, fig_height_mm={fig_height_mm}") self._trans = Affine2D().scale(self._scale).scale(1, -1).translate(*origin) self.draw() - + def get_default_filetype(self): - return 'fpdf' + return "fpdf" ######################################################################## @@ -393,4 +429,4 @@ def get_default_filetype(self): ######################################################################## FigureCanvas = FigureCanvasTemplate -FigureManager = FigureManagerTemplate \ No newline at end of file +FigureManager = FigureManagerTemplate diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index 180b31919..80d207293 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -20,6 +20,7 @@ font_file = HERE / "../fonts/DejaVuSans.ttf" logging.getLogger("fpdf.svg").setLevel(logging.ERROR) + def create_fpdf(w_mm, h_mm): pdf = FPDF(unit="mm", format=(w_mm, h_mm)) print(f"Adding font from file: {font_file}") @@ -29,8 +30,10 @@ def create_fpdf(w_mm, h_mm): mpl.rcParams["font.sans-serif"] = ["dejavu sans"] return pdf + def test_mpl_simple_figure(): from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) w_inch = 4 @@ -39,14 +42,13 @@ def test_mpl_simple_figure(): h_mm = h_inch * 25.4 fig = gen_fig(plt, w_inch, h_inch) - + svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") svg_buffer.seek(0) - pdf_svg = create_fpdf(w_mm, h_mm) - pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) pdf_svg.output(GENERATED_PDF_DIR / "test_simple_figure_svg.pdf") plt.switch_backend("module://fpdf.fpdf_renderer") @@ -57,16 +59,16 @@ def test_mpl_simple_figure(): pdf_fpdf = create_fpdf(w_mm, h_mm) scale = float(w_mm / fig.bbox.width) - origin = (0, 0+h_mm) # FPDF uses bottom-left as origin - + origin = (0, 0 + h_mm) # FPDF uses bottom-left as origin + fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) - + pdf_fpdf.output(GENERATED_PDF_DIR / "test_simple_figure_fpdf.pdf") def gen_fig(plt, w_inch, h_inch): fig, ax = plt.subplots(figsize=(w_inch, h_inch)) - ax.plot([0, 1], [0, 1], 'blue', linewidth=2) + ax.plot([0, 1], [0, 1], "blue", linewidth=2) ax.set_title("Simple Figure") ax.set_xlabel("X Axis") ax.set_ylabel("Y Axis") @@ -75,6 +77,7 @@ def gen_fig(plt, w_inch, h_inch): def test_mpl_figure_with_arrows(): from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) @@ -84,16 +87,15 @@ def test_mpl_figure_with_arrows(): h_mm = h_inch * 25.4 fig = gen_fig_arrows(plt, w_inch, h_inch) - + svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") svg_buffer.seek(0) - pdf_svg = create_fpdf(w_mm, h_mm) - pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) pdf_svg.output(GENERATED_PDF_DIR / "test_arrows_figure_svg.pdf") - + plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend @@ -102,44 +104,45 @@ def test_mpl_figure_with_arrows(): pdf_fpdf = create_fpdf(w_mm, h_mm) scale = float(w_mm / fig.bbox.width) - origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + origin = (0, 0 + h_mm) # FPDF uses bottom-left as origin fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) pdf_fpdf.output(GENERATED_PDF_DIR / "test_arrows_figure_fpdf.pdf") def gen_fig_arrows(plt, w_inch, h_inch): fig, ax = plt.subplots(figsize=(w_inch, h_inch)) - ax.plot([0, 1], [1, 0], 'blue', linewidth=2) + ax.plot([0, 1], [1, 0], "blue", linewidth=2) ax.set_title("Arrows Figure") ax.set_xlabel("X Axis") ax.set_ylabel("Y Axis") - ax.arrow(0.2, 0.2, 0.4, 0.4, head_width=0.05, head_length=0.1, fc='orange', ec='red') - ax.arrow(0.3, 0.3, 0.4, 0.2, head_width=0.2, head_length=0.3, fc='blue', ec='green') + ax.arrow( + 0.2, 0.2, 0.4, 0.4, head_width=0.05, head_length=0.1, fc="orange", ec="red" + ) + ax.arrow(0.3, 0.3, 0.4, 0.2, head_width=0.2, head_length=0.3, fc="blue", ec="green") return fig - def test_mpl_figure_with_labels(): from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) - + w_inch = 4 h_inch = 3 w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 fig = gen_fig_labels(plt, w_inch, h_inch) - + svg_buffer = io.BytesIO() fig.savefig(svg_buffer, dpi=600, format="png") svg_buffer.seek(0) - pdf_svg = create_fpdf(w_mm, h_mm) - pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) pdf_svg.output(GENERATED_PDF_DIR / "test_labels_figure_svg.pdf") - + plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend @@ -148,53 +151,58 @@ def test_mpl_figure_with_labels(): pdf_fpdf = create_fpdf(w_mm, h_mm) scale = float(w_mm / fig.bbox.width) - origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + origin = (0, 0 + h_mm) # FPDF uses bottom-left as origin fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) pdf_fpdf.output(GENERATED_PDF_DIR / "test_labels_figure_fpdf.pdf") def gen_fig_labels(plt, w_inch, h_inch): fig, ax = plt.subplots(figsize=(w_inch, h_inch)) - ax.plot([0, 1], [1, 0], 'blue', linewidth=2) + ax.plot([0, 1], [1, 0], "blue", linewidth=2) ax.set_title("Labels Figure") ax.set_xlabel("X Axis") ax.set_ylabel("Y Axis") ax.get_yaxis().set_ticks([]) - ax.text(0.3, 0.3, "Label 0\u00B0", fontsize=12, color='green', rotation=0) - ax.text(0.3, 0.2, "Label 0\u00B0", fontsize=12, color='green', rotation=0, ha='center') - ax.text(0.3, 0.1, "Label 0\u00B0", fontsize=12, color='green', rotation=0, ha='right') - ax.text(0.3, 0.3, "Label 30\u00B0", fontsize=12, color='red', rotation=30) - ax.text(0.3, 0.3, "Label 60\u00B0", fontsize=12, color='blue', rotation=60) - ax.text(0.3, 0.3, "Label 90\u00B0", fontsize=12, color='black', rotation=90) + ax.text(0.3, 0.3, "Label 0\u00b0", fontsize=12, color="green", rotation=0) + ax.text( + 0.3, 0.2, "Label 0\u00b0", fontsize=12, color="green", rotation=0, ha="center" + ) + ax.text( + 0.3, 0.1, "Label 0\u00b0", fontsize=12, color="green", rotation=0, ha="right" + ) + ax.text(0.3, 0.3, "Label 30\u00b0", fontsize=12, color="red", rotation=30) + ax.text(0.3, 0.3, "Label 60\u00b0", fontsize=12, color="blue", rotation=60) + ax.text(0.3, 0.3, "Label 90\u00b0", fontsize=12, color="black", rotation=90) # ax.text(0.7, 0.7, "Label 0\u00B0", fontsize=4, color='green', rotation=0) # ax.text(0.7, 0.7, "Label -30\u00B0", fontsize=4, color='red', rotation=-30) # ax.text(0.7, 0.7, "Label -60\u00B0", fontsize=4, color='blue', rotation=-60) # ax.text(0.7, 0.7, "Label -90\u00B0", fontsize=4, color='black', rotation=-90) - + return fig + def test_mpl_figure_with_legend(): from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) - + w_inch = 4 h_inch = 3 w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 fig = gen_fig_legend(plt, w_inch, h_inch) - + svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") svg_buffer.seek(0) - pdf_svg = create_fpdf(w_mm, h_mm) - pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) pdf_svg.output(GENERATED_PDF_DIR / "test_legend_figure_svg.pdf") - + plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend @@ -203,7 +211,7 @@ def test_mpl_figure_with_legend(): pdf_fpdf = create_fpdf(w_mm, h_mm) scale = float(w_mm / fig.bbox.width) - origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + origin = (0, 0 + h_mm) # FPDF uses bottom-left as origin fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) pdf_fpdf.output(GENERATED_PDF_DIR / "test_legend_figure_fpdf.pdf") @@ -211,8 +219,8 @@ def test_mpl_figure_with_legend(): def gen_fig_legend(plt, w_inch, h_inch): fig, ax = plt.subplots(figsize=(w_inch, h_inch)) - ax.plot([0, 1], [1, 0], 'blue', linewidth=1) - ax.plot([0, 1], [0, 1], 'green', linewidth=1) + ax.plot([0, 1], [1, 0], "blue", linewidth=1) + ax.plot([0, 1], [0, 1], "green", linewidth=1) ax.legend(["Line 1", "Line 2"]) ax.set_title("Legend Figure") ax.set_axis_off() @@ -222,25 +230,25 @@ def gen_fig_legend(plt, w_inch, h_inch): def test_mpl_figure_with_bezier(): from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) - + w_inch = 4 h_inch = 3 w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 fig = gen_fig_bezier(plt, w_inch, h_inch) - + svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") svg_buffer.seek(0) - pdf_svg = create_fpdf(w_mm, h_mm) - pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) pdf_svg.output(GENERATED_PDF_DIR / "test_bezier_figure_svg.pdf") - + plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend @@ -249,7 +257,7 @@ def test_mpl_figure_with_bezier(): pdf_fpdf = create_fpdf(w_mm, h_mm) scale = float(w_mm / fig.bbox.width) - origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + origin = (0, 0 + h_mm) # FPDF uses bottom-left as origin fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) pdf_fpdf.output(GENERATED_PDF_DIR / "test_bezier_figure_fpdf.pdf") @@ -257,23 +265,42 @@ def test_mpl_figure_with_bezier(): def gen_fig_bezier(plt, w_inch, h_inch): import matplotlib.patches as mpatches - + from matplotlib.path import Path as MplPath + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) - + # Vertices must be a flat list of coordinate tuples - verts = [(5, 30), (15, 55), (25, 15), (35, 40), # 4 points for first curve - (20, 10), (30, 0), (35, 10)] # 3 points for second curve + verts = [ + (5, 30), + (15, 55), + (25, 15), + (35, 40), # 4 points for first curve + (20, 10), + (30, 0), + (35, 10), + ] # 3 points for second curve # `codes` means: the instructions used to guide the line through the points - codes = [MplPath.MOVETO, MplPath.CURVE4, MplPath.CURVE4, MplPath.CURVE4, # Begin the curve - MplPath.MOVETO, MplPath.CURVE3, MplPath.CURVE3] # Start a new one + codes = [ + MplPath.MOVETO, + MplPath.CURVE4, + MplPath.CURVE4, + MplPath.CURVE4, # Begin the curve + MplPath.MOVETO, + MplPath.CURVE3, + MplPath.CURVE3, + ] # Start a new one bezier1 = mpatches.PathPatch( MplPath(verts, codes), # You can also tweak your line properties, like its color, width, etc. - fc="none", transform=ax.transData, color="green", lw=2) - + fc="none", + transform=ax.transData, + color="green", + lw=2, + ) + ax.add_patch(bezier1) ax.autoscale_view() @@ -282,24 +309,24 @@ def gen_fig_bezier(plt, w_inch, h_inch): def test_mpl_figure_with_lineplot(): from matplotlib import pyplot as plt + plt.switch_backend(default_backend) - + w_inch = 4 h_inch = 3 w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 fig = gen_fig_lineplot(plt, w_inch, h_inch) - + svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") svg_buffer.seek(0) - pdf_svg = create_fpdf(w_mm, h_mm) - pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) pdf_svg.output(GENERATED_PDF_DIR / "test_lineplot_figure_svg.pdf") - + plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend @@ -308,29 +335,30 @@ def test_mpl_figure_with_lineplot(): pdf_fpdf = create_fpdf(w_mm, h_mm) scale = float(w_mm / fig.bbox.width) - origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + origin = (0, 0 + h_mm) # FPDF uses bottom-left as origin fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) pdf_fpdf.output(GENERATED_PDF_DIR / "test_lineplot_figure_fpdf.pdf") def gen_fig_lineplot(plt, w_inch, h_inch): - fig, ax = plt.subplots(figsize=(w_inch, h_inch)) - + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) + t = [i * 0.01 for i in range(1000)] - s = [sin(value) + cos(value*value) for value in t] - ax.plot(t, s, 'blue', linewidth=1) + s = [sin(value) + cos(value * value) for value in t] + ax.plot(t, s, "blue", linewidth=1) ax.set_title("Line Plot Figure") ax.set_xlabel("t") ax.set_ylabel("sin(t) + cos(t^2)") ax.autoscale_view() - + return fig def test_mplrenderer_speed_test(): import time from matplotlib import pyplot as plt + plt.switch_backend(default_backend) ROUNDS = 1000 @@ -340,7 +368,7 @@ def test_mplrenderer_speed_test(): h_mm = h_inch * 25.4 fig = gen_fig_lineplot(plt, w_inch, h_inch) - + svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") svg_buffer.seek(0) @@ -349,16 +377,14 @@ def test_mplrenderer_speed_test(): pdf_svg = create_fpdf(210, 297) t0 = time.time() - for i in range(ROUNDS): - x=(i/10) - y=(i%20)*10 + for i in range(ROUNDS): + x = i / 10 + y = (i % 20) * 10 pdf_svg.image(svg_buffer, x=x, y=y, w=w_mm, h=h_mm) - + pdf_svg.output(GENERATED_PDF_DIR / "test_speed_figure_svg.pdf") total_svg = time.time() - t0 print(f"SVG backend time for {ROUNDS} rounds: {total_svg:.2f} seconds") - - plt.switch_backend("module://fpdf.fpdf_renderer") @@ -369,13 +395,13 @@ def test_mplrenderer_speed_test(): t0 = time.time() for i in range(ROUNDS): - x=(i/10) - y=(i%20)*10 + x = i / 10 + y = (i % 20) * 10 # plot scale scale = float(w_mm / fig.bbox.width) - origin = (x, 297-y) # FPDF uses bottom-left of page as origin + origin = (x, 297 - y) # FPDF uses bottom-left of page as origin fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) - + plt.close(fig) pdf_fpdf.output(GENERATED_PDF_DIR / "test_speed_figure_fpdf.pdf") @@ -385,25 +411,25 @@ def test_mplrenderer_speed_test(): def test_mpl_figure_with_linestyles(): from matplotlib import pyplot as plt + plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) - + w_inch = 4 h_inch = 3 w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 fig = gen_fig_linestyles(plt, w_inch, h_inch) - + svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") svg_buffer.seek(0) - pdf_svg = create_fpdf(w_mm, h_mm) - pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) + pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) pdf_svg.output(GENERATED_PDF_DIR / "test_linestyles_figure_svg.pdf") - + plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend @@ -412,23 +438,30 @@ def test_mpl_figure_with_linestyles(): pdf_fpdf = create_fpdf(w_mm, h_mm) scale = float(w_mm / fig.bbox.width) - origin = (0, 0+h_mm) # FPDF uses bottom-left as origin + origin = (0, 0 + h_mm) # FPDF uses bottom-left as origin fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) pdf_fpdf.output(GENERATED_PDF_DIR / "test_linestyles_figure_fpdf.pdf") + def gen_fig_linestyles(plt, w_inch, h_inch): - fig, ax = plt.subplots(figsize=(w_inch, h_inch)) - + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) + t = [i * 0.1 for i in range(100)] - ax.plot(t, [0.5]*100, color='blue', linestyle='solid', linewidth=2, label='solid') - ax.plot(t, [0.4]*100, color='orange', linestyle='dashed', linewidth=2, label='dashed') - ax.plot(t, [0.3]*100, color='green', linestyle='dashdot', linewidth=2, label='dashdot') - ax.plot(t, [0.2]*100, color='red', linestyle='dotted', linewidth=2, label='dotted') + ax.plot(t, [0.5] * 100, color="blue", linestyle="solid", linewidth=2, label="solid") + ax.plot( + t, [0.4] * 100, color="orange", linestyle="dashed", linewidth=2, label="dashed" + ) + ax.plot( + t, [0.3] * 100, color="green", linestyle="dashdot", linewidth=2, label="dashdot" + ) + ax.plot( + t, [0.2] * 100, color="red", linestyle="dotted", linewidth=2, label="dotted" + ) ax.set_title("Line Styles Figure") ax.set_xlabel("t") ax.set_ylabel("Value") ax.autoscale_view() ax.legend() - - return fig \ No newline at end of file + + return fig From f890abca35ba75a6d485cbf09c097b5f53830942 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 17 Dec 2025 14:41:17 +0200 Subject: [PATCH 13/35] - some linter fixes --- fpdf/fpdf_renderer.py | 39 ++++++++++++++++----------------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index ade0b9583..58927be9f 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -5,7 +5,6 @@ """ from contextlib import nullcontext -from matplotlib import _api from matplotlib._pylab_helpers import Gcf from matplotlib.backend_bases import ( FigureCanvasBase, @@ -41,17 +40,20 @@ def __init__(self, figure, dpi, fpdf, scale, transform, fig_width, fig_height): fpdf.set_draw_color(0, 0, 0) fpdf.set_fill_color(255, 255, 255) - # calc font scaling factor to get matplotlib font sizes to match FPDF sizes when using SVG - fig_h_mm = fig_height * scale + # calc font scaling factor to get matplotlib font sizes to match FPDF sizes if width is scaled fig_w_mm = fig_width * scale fig_w_inch = fig_width / dpi - fig_h_inch = fig_height / dpi - shrink_ratio_h = (fig_h_mm / 25.4) / fig_h_inch + shrink_ratio_w = (fig_w_mm / 25.4) / fig_w_inch if fpdf: self._font_scaling = shrink_ratio_w # print(f"Font scaling factor: {self._font_scaling}") + + def draw_gouraud_triangles(self, gc, triangles_array, colors_array, + transform): + raise NotImplementedError("draw_gouraud_triangles not implemented yet") + def draw_path(self, gc, path, transform, rgbFace=None): # self.check_gc(gc, rgbFace) # gc.paint() @@ -62,8 +64,8 @@ def draw_path(self, gc, path, transform, rgbFace=None): # tran = transform + self._trans tran = transform + self._trans clip_rect = None + clip_x0, clip_y0, clip_x1, clip_y1 = None, None, None, None if gc.get_clip_rectangle(): - # print(f"clip-rect in: {gc.get_clip_rectangle().x0:.1f},{gc.get_clip_rectangle().y0:.1f} -> {gc.get_clip_rectangle().x1:.1f},{gc.get_clip_rectangle().y1:.1f}\n") clip_rect = gc.get_clip_rectangle().extents clip_x0, clip_y0 = self._trans.transform(clip_rect[0:2]) clip_x1, clip_y1 = self._trans.transform(clip_rect[2:4]) @@ -73,8 +75,6 @@ def draw_path(self, gc, path, transform, rgbFace=None): c, v = zip(*[(c, v.tolist()) for v, c in path.iter_segments(transform=tran)]) p = self._fpdf - scaling = self._trans.get_matrix()[0, 0] - # print(f"draw_path with scaling: {scaling}") fill_opacity = None stroke_opacity = None if rgbFace is not None and len(rgbFace) >= 3: @@ -100,7 +100,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): p.rect_clip(clip_x0, clip_y0, clip_x1 - clip_x0, clip_y1 - clip_y0) if clip_rect is not None else nullcontext() - ) as clip: + ): with p.local_context( stroke_opacity=stroke_opacity, @@ -138,9 +138,9 @@ def draw_path(self, gc, path, transform, rgbFace=None): p.polyline(v) # Path combinations: Starts with MOVETO, and can end with CLOSEPOLY - case [path.MOVETO, *rest]: + case [path.MOVETO, *_]: # print(f"polygon: \n{c}\n{v}\n") - + pth = None length = len(c) with p.drawing_context() as ctxt: @@ -168,7 +168,6 @@ def draw_path(self, gc, path, transform, rgbFace=None): pth.paint_rule = PathPaintRule.FILL_EVENODD pth.move_to(*v[i]) # start a new sub-path - pass else: print( f"Unhandled path command in polygon: {c[i]} at vertex {vtx}" @@ -182,9 +181,9 @@ def draw_path(self, gc, path, transform, rgbFace=None): case _: print(f"draw_path: Unmatched {c}") - def draw_image(self, gc, x, y, im): + def draw_image(self, gc, x, y, im, transform=None): print(f"draw_image at {x},{y} size {im.get_size()}") - pass + raise NotImplementedError("draw_image not implemented yet") def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): @@ -200,7 +199,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): ) # We're expecting a FontProperties instance - elif isinstance(prop, mpl.font_manager.FontProperties): + if isinstance(prop, mpl.font_manager.FontProperties): # print(f"font prop size: {prop.get_size()} name: {prop.get_name()}, self._font_scaling: {self._font_scaling}") self._fpdf.set_font( prop.get_name(), size=prop.get_size() * self._font_scaling @@ -209,8 +208,8 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): tw, th, _ = self.get_text_width_height_descent(s, prop, ismath) tw *= self._font_scaling * PT_TO_MM / self.dpi * 72.0 th *= self._font_scaling * PT_TO_MM / self.dpi * 72.0 - tw_prerotate = tw - th_prerotate = th + # tw_prerotate = tw + # th_prerotate = th # print(f'Text width/height before rotation: {tw_prerotate:.1f}/{th_prerotate:.1f} mm') # print(f'scale x: {self.figure.bbox.width}, y: {self.figure.bbox.height}') @@ -239,7 +238,6 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): x, y = trans.transform((x, y)) # print(f'- [{x:.1f},{y:.1f}] {s}') - ha = mtext.get_ha() if mtext else "left" # print(f'Text \'{s}\' ha: {ha}, angle: {angle}') color = gc.get_rgb() self._fpdf.set_text_color( @@ -250,11 +248,6 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # print (f'Text width rotated: {tw:.1f}, height: {th:.1f}') match angle: case 0: - # if ha == "right": - # x -= tw - # if ha == "center": - # x -= tw / 2 - # x+=0.7 # FPDF text positioning seems to be a bit off to left self._fpdf.text(x, y, s) case _: From d8d764626369768edcbd26892707f8353efb2b73 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 17 Dec 2025 14:46:00 +0200 Subject: [PATCH 14/35] - re-black --- fpdf/fpdf_renderer.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index 58927be9f..b564c4b17 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -43,15 +43,13 @@ def __init__(self, figure, dpi, fpdf, scale, transform, fig_width, fig_height): # calc font scaling factor to get matplotlib font sizes to match FPDF sizes if width is scaled fig_w_mm = fig_width * scale fig_w_inch = fig_width / dpi - + shrink_ratio_w = (fig_w_mm / 25.4) / fig_w_inch if fpdf: self._font_scaling = shrink_ratio_w # print(f"Font scaling factor: {self._font_scaling}") - - def draw_gouraud_triangles(self, gc, triangles_array, colors_array, - transform): + def draw_gouraud_triangles(self, gc, triangles_array, colors_array, transform): raise NotImplementedError("draw_gouraud_triangles not implemented yet") def draw_path(self, gc, path, transform, rgbFace=None): @@ -140,7 +138,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): # Path combinations: Starts with MOVETO, and can end with CLOSEPOLY case [path.MOVETO, *_]: # print(f"polygon: \n{c}\n{v}\n") - + pth = None length = len(c) with p.drawing_context() as ctxt: From 6399508ea4bf8f01a574fd3748094a42b614b36f Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 17 Dec 2025 15:30:18 +0200 Subject: [PATCH 15/35] - fixed linter warnings --- fpdf/__init__.py | 1 - fpdf/fpdf_renderer.py | 54 +++++++++------ test/mpl_renderer/test_matplotlib_renderer.py | 68 ++++++++----------- 3 files changed, 61 insertions(+), 62 deletions(-) diff --git a/fpdf/__init__.py b/fpdf/__init__.py index 44bae3ba3..d9679c34c 100644 --- a/fpdf/__init__.py +++ b/fpdf/__init__.py @@ -86,7 +86,6 @@ "FPDF_FONT_DIR", # Utility functions: "get_scale_factor", - "FPDFRenderer", ] __pdoc__ = {name: name.startswith("FPDF_") for name in __all__} diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index b564c4b17..aedd7f5a7 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -5,27 +5,30 @@ """ from contextlib import nullcontext -from matplotlib._pylab_helpers import Gcf +import logging from matplotlib.backend_bases import ( FigureCanvasBase, FigureManagerBase, GraphicsContextBase, RendererBase, ) -from matplotlib.figure import Figure -from matplotlib.transforms import Affine2D, IdentityTransform +from matplotlib.transforms import Affine2D import matplotlib as mpl -from fpdf.drawing import ClippingPath, PaintedPath +from fpdf.drawing import PaintedPath from fpdf.enums import PathPaintRule PT_TO_MM = 0.3527777778 # 1 point = 0.3527777778 mm +_log = logging.getLogger(__name__) + + class RendererTemplate(RendererBase): """Removed draw_markers, draw_path_collection and draw_quad_mesh - all optional, we can add later""" def __init__(self, figure, dpi, fpdf, scale, transform, fig_width, fig_height): + del fig_height # unused for now super().__init__() self.figure = figure # print (f'FPDF: dpi: {dpi}') @@ -114,7 +117,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): if len(mm_dash_array) > 2: # make sure we have even number of elements - print( + _log.warning( "Warning: dash array has more than two elements - ignoring extra ones" ) dash = mm_dash_array[0] - mm_line_width @@ -167,8 +170,10 @@ def draw_path(self, gc, path, transform, rgbFace=None): pth.move_to(*v[i]) # start a new sub-path else: - print( - f"Unhandled path command in polygon: {c[i]} at vertex {vtx}" + _log.warning( + "Unhandled path command in polygon: %d at vertex %s", + c[i], + vtx, ) if pth is not None: # print(f"path was not closed - adding to context") @@ -177,10 +182,10 @@ def draw_path(self, gc, path, transform, rgbFace=None): pth = None case _: - print(f"draw_path: Unmatched {c}") + _log.warning("draw_path: Unmatched %d", c) def draw_image(self, gc, x, y, im, transform=None): - print(f"draw_image at {x},{y} size {im.get_size()}") + _log.warning("draw_image at %d,%d size %s", x, y, im.get_size()) raise NotImplementedError("draw_image not implemented yet") def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): @@ -225,7 +230,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): tw = max_x - min_x th = max_y - min_y else: - print(f"Unknown prop type: {type(prop)}") + _log.warning("Unknown prop type: %s", type(prop)) tw = None th = None @@ -331,7 +336,7 @@ class methods button_press_event, button_release_event, # wx-based backends for an example. manager_class = FigureManagerTemplate - def draw(self): + def draw(self, *args, **kwargs): """ Draw the figure using the renderer. @@ -340,6 +345,13 @@ def draw(self): deferred work (like computing limits auto-limits and tick values) that users may want access to before saving to disk. """ + if args or kwargs: + _log.warning( + "draw() got arguments that will not be used for now: %s, %s", + args, + kwargs, + ) + # print (f'Draw: {self._fpdf}') width = self.figure.bbox.width height = self.figure.bbox.height @@ -357,11 +369,13 @@ def draw(self): # You should provide a print_xxx function for every file format # you can write. - # If the file type is not in the base set of filetypes, - # you should add it to the class-scope filetypes dictionary as follows: - filetypes = {**FigureCanvasBase.filetypes, "fpdf": "My magic FPDF format"} + # If the file type is not in the base set of filetypes, + # you should add it to the class-scope filetypes dictionary as follows: + filetypes = {**FigureCanvasBase.filetypes, "fpdf": "My magic FPDF format"} def print_fpdf(self, filename, **kwargs): + del filename # filename is not used for now + self._fpdf = self._trans = origin = scale = None self._scale = 1.0 self._facecolor = self._edgecolor = None @@ -395,21 +409,21 @@ def print_fpdf(self, filename, **kwargs): case "bbox_inches_restore": pass # ignore for now case _: - pass - print(f"Unrecognised keyword {k} -> {v}") + _log.warning("Unrecognised keyword %s -> %s", k, v) - fig_width = self.figure.bbox.width - fig_height = self.figure.bbox.height + # fig_width = self.figure.bbox.width + # fig_height = self.figure.bbox.height # Build our transformation do scale and offset for whole figure if origin and scale: - fig_height_mm = fig_height * scale + # fig_height_mm = fig_height * scale # print(f"print_fpdf: fig_width={fig_width}, fig_height={fig_height}, fig_height_mm={fig_height_mm}") self._trans = Affine2D().scale(self._scale).scale(1, -1).translate(*origin) self.draw() - def get_default_filetype(self): + @classmethod + def get_default_filetype(cls): return "fpdf" diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index 80d207293..bd10a8b74 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -1,17 +1,17 @@ -# pylint: disable=no-self-use, protected-access from cmath import cos, sin import io import logging import os +import time from pathlib import Path - import matplotlib as mpl import matplotlib.pyplot as plt +import matplotlib.patches as mpatches +from matplotlib.path import Path as MplPath from matplotlib import font_manager from fpdf import FPDF -import pytest default_backend = plt.get_backend() HERE = Path(__file__).resolve().parent @@ -32,7 +32,6 @@ def create_fpdf(w_mm, h_mm): def test_mpl_simple_figure(): - from matplotlib import pyplot as plt plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) @@ -41,7 +40,7 @@ def test_mpl_simple_figure(): w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 - fig = gen_fig(plt, w_inch, h_inch) + fig = gen_fig(w_inch, h_inch) svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") @@ -54,7 +53,7 @@ def test_mpl_simple_figure(): plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend - fig = gen_fig(plt, w_inch, h_inch) + fig = gen_fig(w_inch, h_inch) pdf_fpdf = create_fpdf(w_mm, h_mm) @@ -66,7 +65,7 @@ def test_mpl_simple_figure(): pdf_fpdf.output(GENERATED_PDF_DIR / "test_simple_figure_fpdf.pdf") -def gen_fig(plt, w_inch, h_inch): +def gen_fig(w_inch, h_inch): fig, ax = plt.subplots(figsize=(w_inch, h_inch)) ax.plot([0, 1], [0, 1], "blue", linewidth=2) ax.set_title("Simple Figure") @@ -76,7 +75,6 @@ def gen_fig(plt, w_inch, h_inch): def test_mpl_figure_with_arrows(): - from matplotlib import pyplot as plt plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) @@ -86,7 +84,7 @@ def test_mpl_figure_with_arrows(): w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 - fig = gen_fig_arrows(plt, w_inch, h_inch) + fig = gen_fig_arrows(w_inch, h_inch) svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") @@ -99,7 +97,7 @@ def test_mpl_figure_with_arrows(): plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend - fig = gen_fig_arrows(plt, w_inch, h_inch) + fig = gen_fig_arrows(w_inch, h_inch) pdf_fpdf = create_fpdf(w_mm, h_mm) @@ -109,7 +107,7 @@ def test_mpl_figure_with_arrows(): pdf_fpdf.output(GENERATED_PDF_DIR / "test_arrows_figure_fpdf.pdf") -def gen_fig_arrows(plt, w_inch, h_inch): +def gen_fig_arrows(w_inch, h_inch): fig, ax = plt.subplots(figsize=(w_inch, h_inch)) ax.plot([0, 1], [1, 0], "blue", linewidth=2) ax.set_title("Arrows Figure") @@ -123,8 +121,6 @@ def gen_fig_arrows(plt, w_inch, h_inch): def test_mpl_figure_with_labels(): - from matplotlib import pyplot as plt - plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) @@ -133,7 +129,7 @@ def test_mpl_figure_with_labels(): w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 - fig = gen_fig_labels(plt, w_inch, h_inch) + fig = gen_fig_labels(w_inch, h_inch) svg_buffer = io.BytesIO() fig.savefig(svg_buffer, dpi=600, format="png") @@ -146,7 +142,7 @@ def test_mpl_figure_with_labels(): plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend - fig = gen_fig_labels(plt, w_inch, h_inch) + fig = gen_fig_labels(w_inch, h_inch) pdf_fpdf = create_fpdf(w_mm, h_mm) @@ -156,7 +152,7 @@ def test_mpl_figure_with_labels(): pdf_fpdf.output(GENERATED_PDF_DIR / "test_labels_figure_fpdf.pdf") -def gen_fig_labels(plt, w_inch, h_inch): +def gen_fig_labels(w_inch, h_inch): fig, ax = plt.subplots(figsize=(w_inch, h_inch)) ax.plot([0, 1], [1, 0], "blue", linewidth=2) ax.set_title("Labels Figure") @@ -183,7 +179,6 @@ def gen_fig_labels(plt, w_inch, h_inch): def test_mpl_figure_with_legend(): - from matplotlib import pyplot as plt plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) @@ -193,7 +188,7 @@ def test_mpl_figure_with_legend(): w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 - fig = gen_fig_legend(plt, w_inch, h_inch) + fig = gen_fig_legend(w_inch, h_inch) svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") @@ -206,7 +201,7 @@ def test_mpl_figure_with_legend(): plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend - fig = gen_fig_legend(plt, w_inch, h_inch) + fig = gen_fig_legend(w_inch, h_inch) pdf_fpdf = create_fpdf(w_mm, h_mm) @@ -217,7 +212,8 @@ def test_mpl_figure_with_legend(): pdf_fpdf.output(GENERATED_PDF_DIR / "test_legend_figure_fpdf.pdf") -def gen_fig_legend(plt, w_inch, h_inch): +def gen_fig_legend(w_inch, h_inch): + fig, ax = plt.subplots(figsize=(w_inch, h_inch)) ax.plot([0, 1], [1, 0], "blue", linewidth=1) ax.plot([0, 1], [0, 1], "green", linewidth=1) @@ -229,8 +225,6 @@ def gen_fig_legend(plt, w_inch, h_inch): def test_mpl_figure_with_bezier(): - from matplotlib import pyplot as plt - plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) @@ -239,7 +233,7 @@ def test_mpl_figure_with_bezier(): w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 - fig = gen_fig_bezier(plt, w_inch, h_inch) + fig = gen_fig_bezier(w_inch, h_inch) svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") @@ -252,7 +246,7 @@ def test_mpl_figure_with_bezier(): plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend - fig = gen_fig_bezier(plt, w_inch, h_inch) + fig = gen_fig_bezier(w_inch, h_inch) pdf_fpdf = create_fpdf(w_mm, h_mm) @@ -263,10 +257,7 @@ def test_mpl_figure_with_bezier(): pdf_fpdf.output(GENERATED_PDF_DIR / "test_bezier_figure_fpdf.pdf") -def gen_fig_bezier(plt, w_inch, h_inch): - import matplotlib.patches as mpatches - - from matplotlib.path import Path as MplPath +def gen_fig_bezier(w_inch, h_inch): fig, ax = plt.subplots(figsize=(w_inch, h_inch)) @@ -308,7 +299,6 @@ def gen_fig_bezier(plt, w_inch, h_inch): def test_mpl_figure_with_lineplot(): - from matplotlib import pyplot as plt plt.switch_backend(default_backend) @@ -317,7 +307,7 @@ def test_mpl_figure_with_lineplot(): w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 - fig = gen_fig_lineplot(plt, w_inch, h_inch) + fig = gen_fig_lineplot(w_inch, h_inch) svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") @@ -330,7 +320,7 @@ def test_mpl_figure_with_lineplot(): plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend - fig = gen_fig_lineplot(plt, w_inch, h_inch) + fig = gen_fig_lineplot(w_inch, h_inch) pdf_fpdf = create_fpdf(w_mm, h_mm) @@ -341,7 +331,7 @@ def test_mpl_figure_with_lineplot(): pdf_fpdf.output(GENERATED_PDF_DIR / "test_lineplot_figure_fpdf.pdf") -def gen_fig_lineplot(plt, w_inch, h_inch): +def gen_fig_lineplot(w_inch, h_inch): fig, ax = plt.subplots(figsize=(w_inch, h_inch)) t = [i * 0.01 for i in range(1000)] @@ -356,8 +346,6 @@ def gen_fig_lineplot(plt, w_inch, h_inch): def test_mplrenderer_speed_test(): - import time - from matplotlib import pyplot as plt plt.switch_backend(default_backend) @@ -367,7 +355,7 @@ def test_mplrenderer_speed_test(): w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 - fig = gen_fig_lineplot(plt, w_inch, h_inch) + fig = gen_fig_lineplot(w_inch, h_inch) svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") @@ -389,7 +377,7 @@ def test_mplrenderer_speed_test(): plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend - fig = gen_fig_lineplot(plt, w_inch, h_inch) + fig = gen_fig_lineplot(w_inch, h_inch) pdf_fpdf = create_fpdf(210, 297) @@ -410,8 +398,6 @@ def test_mplrenderer_speed_test(): def test_mpl_figure_with_linestyles(): - from matplotlib import pyplot as plt - plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) @@ -420,7 +406,7 @@ def test_mpl_figure_with_linestyles(): w_mm = w_inch * 25.4 h_mm = h_inch * 25.4 - fig = gen_fig_linestyles(plt, w_inch, h_inch) + fig = gen_fig_linestyles(w_inch, h_inch) svg_buffer = io.BytesIO() fig.savefig(svg_buffer, format="svg") @@ -433,7 +419,7 @@ def test_mpl_figure_with_linestyles(): plt.switch_backend("module://fpdf.fpdf_renderer") # Re-generate the figure to use FPDFRenderer backend - fig = gen_fig_linestyles(plt, w_inch, h_inch) + fig = gen_fig_linestyles(w_inch, h_inch) pdf_fpdf = create_fpdf(w_mm, h_mm) @@ -444,7 +430,7 @@ def test_mpl_figure_with_linestyles(): pdf_fpdf.output(GENERATED_PDF_DIR / "test_linestyles_figure_fpdf.pdf") -def gen_fig_linestyles(plt, w_inch, h_inch): +def gen_fig_linestyles(w_inch, h_inch): fig, ax = plt.subplots(figsize=(w_inch, h_inch)) t = [i * 0.1 for i in range(100)] From bf2c516f35e596310c8f37c1e65f5728a8b1766d Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 17 Dec 2025 15:35:55 +0200 Subject: [PATCH 16/35] - moved matplotlib to test dependency --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c40b360db..907fef04b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel", "fonttools","matplotlib"] +requires = ["setuptools>=61.0", "wheel", "fonttools"] build-backend = "setuptools.build_meta" [project] @@ -42,8 +42,7 @@ dependencies = [ "Pillow>=8.3.2,!=9.2.*", # minimum version tested in .github/workflows/continuous-integration-workflow.yml # Version 9.2.0 is excluded due to DoS vulnerability with TIFF images: https://github.com/py-pdf/fpdf2/issues/628 # Version exclusion explained here: https://devpress.csdn.net/python/630462c0c67703293080c302.html - "fonttools>=4.34.0", - "matplotlib>=3.6.0", # minimum version tested + "fonttools>=4.34.0" ] [project.optional-dependencies] @@ -75,7 +74,8 @@ test = [ "pytest-cov", "qrcode", "tabula-py", - "uharfbuzz" + "uharfbuzz", + "matplotlib" ] [project.urls] From d3415782ed4aea832b23cb360dff46ae6e9b4f73 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 17 Dec 2025 16:00:56 +0200 Subject: [PATCH 17/35] - try using assert_pdf_equal --- test/mpl_renderer/test_matplotlib_renderer.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index bd10a8b74..509da78c5 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -13,6 +13,8 @@ from fpdf import FPDF +from test.conftest import assert_pdf_equal + default_backend = plt.get_backend() HERE = Path(__file__).resolve().parent GENERATED_PDF_DIR = HERE / "generated_pdf" @@ -31,7 +33,7 @@ def create_fpdf(w_mm, h_mm): return pdf -def test_mpl_simple_figure(): +def test_mpl_simple_figure(tmp_path): plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) @@ -48,7 +50,9 @@ def test_mpl_simple_figure(): pdf_svg = create_fpdf(w_mm, h_mm) pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) - pdf_svg.output(GENERATED_PDF_DIR / "test_simple_figure_svg.pdf") + assert_pdf_equal( + pdf_svg, GENERATED_PDF_DIR / "test_simple_figure_svg.pdf", tmp_path, generate=True + ) plt.switch_backend("module://fpdf.fpdf_renderer") @@ -63,6 +67,7 @@ def test_mpl_simple_figure(): fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) pdf_fpdf.output(GENERATED_PDF_DIR / "test_simple_figure_fpdf.pdf") + assert_pdf_equal(pdf_fpdf, GENERATED_PDF_DIR / "test_simple_figure_svg.pdf", tmp_path, ignore_original_obj_ids=True, ignore_id_changes=True) def gen_fig(w_inch, h_inch): From 54ab63af4441018c3a355c4c48c9202747a34578 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 17 Dec 2025 16:52:25 +0200 Subject: [PATCH 18/35] - hope this fixes it... --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 907fef04b..95f2e55d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "Pillow>=8.3.2,!=9.2.*", # minimum version tested in .github/workflows/continuous-integration-workflow.yml # Version 9.2.0 is excluded due to DoS vulnerability with TIFF images: https://github.com/py-pdf/fpdf2/issues/628 # Version exclusion explained here: https://devpress.csdn.net/python/630462c0c67703293080c302.html - "fonttools>=4.34.0" + "fonttools>=4.34.0", ] [project.optional-dependencies] From 549b630d1b53c7b7e7aaf0e927cbd184eb3276f6 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 17 Dec 2025 17:18:02 +0200 Subject: [PATCH 19/35] - move matplotlib as optional depedency --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 95f2e55d6..dc6923682 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,9 @@ dependencies = [ ] [project.optional-dependencies] +matplotlib = [ + "matplotlib" +] dev = [ "bandit", "black", @@ -75,7 +78,7 @@ test = [ "qrcode", "tabula-py", "uharfbuzz", - "matplotlib" + "matplotlib", ] [project.urls] From 518c2a42f52eed8cb4eab9beb18c0a91f34c1b93 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 17 Dec 2025 17:20:15 +0200 Subject: [PATCH 20/35] - removed renderer from init.py --- fpdf/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fpdf/__init__.py b/fpdf/__init__.py index d9679c34c..b1f2a092d 100644 --- a/fpdf/__init__.py +++ b/fpdf/__init__.py @@ -31,7 +31,6 @@ from .prefs import ViewerPreferences from .template import Template, FlexTemplate from .util import get_scale_factor -from .fpdf_renderer import * try: # This module only exists in PyFPDF, it has been removed in fpdf2 since v2.5.7: From 6157929ff88f9f4f0096cb480153338b8b46b5be Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Thu, 18 Dec 2025 08:06:17 +0200 Subject: [PATCH 21/35] - disabled assert_pdf_equal --- test/mpl_renderer/test_matplotlib_renderer.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index 509da78c5..8e369a268 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -51,7 +51,10 @@ def test_mpl_simple_figure(tmp_path): pdf_svg = create_fpdf(w_mm, h_mm) pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) assert_pdf_equal( - pdf_svg, GENERATED_PDF_DIR / "test_simple_figure_svg.pdf", tmp_path, generate=True + pdf_svg, + GENERATED_PDF_DIR / "test_simple_figure_svg.pdf", + tmp_path, + generate=True, ) plt.switch_backend("module://fpdf.fpdf_renderer") @@ -67,7 +70,7 @@ def test_mpl_simple_figure(tmp_path): fig.savefig(fname=None, fpdf=pdf_fpdf, origin=origin, scale=scale) pdf_fpdf.output(GENERATED_PDF_DIR / "test_simple_figure_fpdf.pdf") - assert_pdf_equal(pdf_fpdf, GENERATED_PDF_DIR / "test_simple_figure_svg.pdf", tmp_path, ignore_original_obj_ids=True, ignore_id_changes=True) + # assert_pdf_equal(pdf_fpdf, GENERATED_PDF_DIR / "test_simple_figure_svg.pdf", tmp_path, ignore_original_obj_ids=True, ignore_id_changes=True) def gen_fig(w_inch, h_inch): From bcc10f5d4f69f33974380d68bc628f131c429004 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Fri, 19 Dec 2025 10:11:05 +0200 Subject: [PATCH 22/35] - add matplotlib to docs --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index dc6923682..2f9df2873 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ docs = [ "mkdocs-with-pdf", "mknotebooks", "pdoc3", + "matplotlib", ] test = [ "camelot-py[base]", From 60476db43574b80605bd0d4c63c1b8ac513d01d9 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Fri, 19 Dec 2025 11:09:25 +0200 Subject: [PATCH 23/35] - remove generate=True --- test/mpl_renderer/test_matplotlib_renderer.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index 8e369a268..b2dbb09b7 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -50,12 +50,13 @@ def test_mpl_simple_figure(tmp_path): pdf_svg = create_fpdf(w_mm, h_mm) pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) - assert_pdf_equal( - pdf_svg, - GENERATED_PDF_DIR / "test_simple_figure_svg.pdf", - tmp_path, - generate=True, - ) + pdf_svg.output(GENERATED_PDF_DIR / "test_simple_figure_svg.pdf") + # assert_pdf_equal( + # pdf_svg, + # GENERATED_PDF_DIR / "test_simple_figure_svg.pdf", + # tmp_path, + # generate=True, + # ) plt.switch_backend("module://fpdf.fpdf_renderer") From de5ee7dbdf6ad671e152737d40d26a58474fabe3 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Fri, 19 Dec 2025 11:14:35 +0200 Subject: [PATCH 24/35] - fix lint --- test/mpl_renderer/test_matplotlib_renderer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index b2dbb09b7..6285cc197 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -13,7 +13,7 @@ from fpdf import FPDF -from test.conftest import assert_pdf_equal +#from test.conftest import assert_pdf_equal default_backend = plt.get_backend() HERE = Path(__file__).resolve().parent @@ -33,7 +33,7 @@ def create_fpdf(w_mm, h_mm): return pdf -def test_mpl_simple_figure(tmp_path): +def test_mpl_simple_figure(): plt.rcParams["font.sans-serif"][0] = "Arial" plt.switch_backend(default_backend) From 0e54f131f36ea87f9dfa897150abe4fdbf08796b Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Fri, 19 Dec 2025 12:44:27 +0200 Subject: [PATCH 25/35] - re-black --- test/mpl_renderer/test_matplotlib_renderer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index 6285cc197..511d22dfc 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -13,7 +13,7 @@ from fpdf import FPDF -#from test.conftest import assert_pdf_equal +# from test.conftest import assert_pdf_equal default_backend = plt.get_backend() HERE = Path(__file__).resolve().parent From 1ddb74ee5a0fec56f4eb0b7a6bded62e900fda69 Mon Sep 17 00:00:00 2001 From: petri-lipponen-movesense <95341815+petri-lipponen-movesense@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:53:20 +0200 Subject: [PATCH 26/35] Update fpdf/fpdf_renderer.py Seems to be leftover of the original code. committed. Co-authored-by: Lucas Cimon <925560+Lucas-C@users.noreply.github.com> --- fpdf/fpdf_renderer.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index aedd7f5a7..c6b9fff11 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -38,10 +38,6 @@ def __init__(self, figure, dpi, fpdf, scale, transform, fig_width, fig_height): self._trans = transform self._scale = scale - # some safe defaults - if fpdf: - fpdf.set_draw_color(0, 0, 0) - fpdf.set_fill_color(255, 255, 255) # calc font scaling factor to get matplotlib font sizes to match FPDF sizes if width is scaled fig_w_mm = fig_width * scale From ed1d42696a0590afb90d2510e06cbe22a35c5f85 Mon Sep 17 00:00:00 2001 From: petri-lipponen-movesense <95341815+petri-lipponen-movesense@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:54:18 +0200 Subject: [PATCH 27/35] Update docs/Maths.md Co-authored-by: Lucas Cimon <925560+Lucas-C@users.noreply.github.com> --- docs/Maths.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/Maths.md b/docs/Maths.md index e686ac062..05837dce0 100644 --- a/docs/Maths.md +++ b/docs/Maths.md @@ -223,6 +223,8 @@ If you have trouble with the SVG export, you can also render the matplotlib figu ### Using fpdf2 renderer ### +_New in [:octicons-tag-24: 2.8.6](https://github.com/py-pdf/fpdf2/blob/master/CHANGELOG.md)_ + The new _experimental_ fpdf2 renderer for matplotlib allows direct rendering to the FPDF2 document. This provides a large performance benefit compared to using SVG (over x3 faster) or PNG intermediate rendering. There are a couple of down sides to this method of rendering the plots: 1. The `bbox_inches='tight'` -option cannot be used on savefig (or it can cause a lot of weird stuff) From fc08f2410bc2f61a9799e0d99d78ed2472f0b844 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Fri, 19 Dec 2025 12:56:14 +0200 Subject: [PATCH 28/35] - use uppercase for logger --- fpdf/fpdf_renderer.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index c6b9fff11..4ad0c402f 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -21,7 +21,7 @@ PT_TO_MM = 0.3527777778 # 1 point = 0.3527777778 mm -_log = logging.getLogger(__name__) +LOGGER = logging.getLogger(__name__) class RendererTemplate(RendererBase): @@ -113,7 +113,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): if len(mm_dash_array) > 2: # make sure we have even number of elements - _log.warning( + LOGGER.warning( "Warning: dash array has more than two elements - ignoring extra ones" ) dash = mm_dash_array[0] - mm_line_width @@ -166,7 +166,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): pth.move_to(*v[i]) # start a new sub-path else: - _log.warning( + LOGGER.warning( "Unhandled path command in polygon: %d at vertex %s", c[i], vtx, @@ -178,10 +178,10 @@ def draw_path(self, gc, path, transform, rgbFace=None): pth = None case _: - _log.warning("draw_path: Unmatched %d", c) + LOGGER.warning("draw_path: Unmatched %d", c) def draw_image(self, gc, x, y, im, transform=None): - _log.warning("draw_image at %d,%d size %s", x, y, im.get_size()) + LOGGER.warning("draw_image at %d,%d size %s", x, y, im.get_size()) raise NotImplementedError("draw_image not implemented yet") def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): @@ -226,7 +226,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): tw = max_x - min_x th = max_y - min_y else: - _log.warning("Unknown prop type: %s", type(prop)) + LOGGER.warning("Unknown prop type: %s", type(prop)) tw = None th = None @@ -342,7 +342,7 @@ def draw(self, *args, **kwargs): values) that users may want access to before saving to disk. """ if args or kwargs: - _log.warning( + LOGGER.warning( "draw() got arguments that will not be used for now: %s, %s", args, kwargs, @@ -405,7 +405,7 @@ def print_fpdf(self, filename, **kwargs): case "bbox_inches_restore": pass # ignore for now case _: - _log.warning("Unrecognised keyword %s -> %s", k, v) + LOGGER.warning("Unrecognised keyword %s -> %s", k, v) # fig_width = self.figure.bbox.width # fig_height = self.figure.bbox.height From 4a665d104ce4a97036547e87a4f10d93660d8be4 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Fri, 19 Dec 2025 13:04:03 +0200 Subject: [PATCH 29/35] - style fixes --- test/mpl_renderer/test_matplotlib_renderer.py | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index 511d22dfc..1e4b1f507 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -15,20 +15,19 @@ # from test.conftest import assert_pdf_equal -default_backend = plt.get_backend() +DEFAULT_BACKEND = plt.get_backend() HERE = Path(__file__).resolve().parent GENERATED_PDF_DIR = HERE / "generated_pdf" os.makedirs(GENERATED_PDF_DIR, exist_ok=True) -font_file = HERE / "../fonts/DejaVuSans.ttf" -logging.getLogger("fpdf.svg").setLevel(logging.ERROR) +FONT_FILE = HERE / "../fonts/DejaVuSans.ttf" def create_fpdf(w_mm, h_mm): pdf = FPDF(unit="mm", format=(w_mm, h_mm)) - print(f"Adding font from file: {font_file}") - pdf.add_font("dejavu sans", "", str(font_file)) + print(f"Adding font from file: {FONT_FILE}") + pdf.add_font("dejavu sans", "", str(FONT_FILE)) pdf.add_page() - font_manager.fontManager.addfont(str(font_file)) + font_manager.fontManager.addfont(str(FONT_FILE)) mpl.rcParams["font.sans-serif"] = ["dejavu sans"] return pdf @@ -36,7 +35,7 @@ def create_fpdf(w_mm, h_mm): def test_mpl_simple_figure(): plt.rcParams["font.sans-serif"][0] = "Arial" - plt.switch_backend(default_backend) + plt.switch_backend(DEFAULT_BACKEND) w_inch = 4 h_inch = 3 w_mm = w_inch * 25.4 @@ -86,7 +85,7 @@ def gen_fig(w_inch, h_inch): def test_mpl_figure_with_arrows(): plt.rcParams["font.sans-serif"][0] = "Arial" - plt.switch_backend(default_backend) + plt.switch_backend(DEFAULT_BACKEND) w_inch = 4 h_inch = 3 @@ -131,7 +130,7 @@ def gen_fig_arrows(w_inch, h_inch): def test_mpl_figure_with_labels(): plt.rcParams["font.sans-serif"][0] = "Arial" - plt.switch_backend(default_backend) + plt.switch_backend(DEFAULT_BACKEND) w_inch = 4 h_inch = 3 @@ -190,7 +189,7 @@ def gen_fig_labels(w_inch, h_inch): def test_mpl_figure_with_legend(): plt.rcParams["font.sans-serif"][0] = "Arial" - plt.switch_backend(default_backend) + plt.switch_backend(DEFAULT_BACKEND) w_inch = 4 h_inch = 3 @@ -235,7 +234,7 @@ def gen_fig_legend(w_inch, h_inch): def test_mpl_figure_with_bezier(): plt.rcParams["font.sans-serif"][0] = "Arial" - plt.switch_backend(default_backend) + plt.switch_backend(DEFAULT_BACKEND) w_inch = 4 h_inch = 3 @@ -309,7 +308,7 @@ def gen_fig_bezier(w_inch, h_inch): def test_mpl_figure_with_lineplot(): - plt.switch_backend(default_backend) + plt.switch_backend(DEFAULT_BACKEND) w_inch = 4 h_inch = 3 @@ -356,7 +355,7 @@ def gen_fig_lineplot(w_inch, h_inch): def test_mplrenderer_speed_test(): - plt.switch_backend(default_backend) + plt.switch_backend(DEFAULT_BACKEND) ROUNDS = 1000 w_inch = 4 @@ -408,7 +407,7 @@ def test_mplrenderer_speed_test(): def test_mpl_figure_with_linestyles(): plt.rcParams["font.sans-serif"][0] = "Arial" - plt.switch_backend(default_backend) + plt.switch_backend(DEFAULT_BACKEND) w_inch = 4 h_inch = 3 From 42b18f412e2ac2bf5cc53344e62098d48c7fac2e Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Fri, 19 Dec 2025 13:05:50 +0200 Subject: [PATCH 30/35] - cleanup --- fpdf/fpdf_renderer.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index 4ad0c402f..fda578c93 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -52,13 +52,6 @@ def draw_gouraud_triangles(self, gc, triangles_array, colors_array, transform): raise NotImplementedError("draw_gouraud_triangles not implemented yet") def draw_path(self, gc, path, transform, rgbFace=None): - # self.check_gc(gc, rgbFace) - # gc.paint() - - # Unzip the path segments into 2 arrays - commands and vertices, the transform sorts scaling and positioning - # print(f"draw_path transform: {transform.get_matrix()}, _trans: {self._trans.get_matrix()}") - # print(f"transform: {transform.get_matrix()},\n_trans: {self._trans.get_matrix()}\n\n") - # tran = transform + self._trans tran = transform + self._trans clip_rect = None clip_x0, clip_y0, clip_x1, clip_y1 = None, None, None, None From 77e904b24bd6b757a86aab23bd748ad032666543 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Fri, 19 Dec 2025 13:08:47 +0200 Subject: [PATCH 31/35] - re-fix generate=true --- test/mpl_renderer/test_matplotlib_renderer.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index 1e4b1f507..092e99032 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -50,12 +50,6 @@ def test_mpl_simple_figure(): pdf_svg = create_fpdf(w_mm, h_mm) pdf_svg.image(svg_buffer, x=0, y=0, w=w_mm, h=h_mm) pdf_svg.output(GENERATED_PDF_DIR / "test_simple_figure_svg.pdf") - # assert_pdf_equal( - # pdf_svg, - # GENERATED_PDF_DIR / "test_simple_figure_svg.pdf", - # tmp_path, - # generate=True, - # ) plt.switch_backend("module://fpdf.fpdf_renderer") From fc85d0d2bc91de775d21af8be670a63a6c060dfe Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Fri, 19 Dec 2025 14:45:09 +0200 Subject: [PATCH 32/35] - re-black --- fpdf/fpdf_renderer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index fda578c93..74edab912 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -38,7 +38,6 @@ def __init__(self, figure, dpi, fpdf, scale, transform, fig_width, fig_height): self._trans = transform self._scale = scale - # calc font scaling factor to get matplotlib font sizes to match FPDF sizes if width is scaled fig_w_mm = fig_width * scale fig_w_inch = fig_width / dpi From 5634ed7f10230d3725f49e5db22804c46702cb88 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Fri, 19 Dec 2025 15:01:22 +0200 Subject: [PATCH 33/35] - lint fix --- test/mpl_renderer/test_matplotlib_renderer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/mpl_renderer/test_matplotlib_renderer.py b/test/mpl_renderer/test_matplotlib_renderer.py index 092e99032..c6ef345aa 100644 --- a/test/mpl_renderer/test_matplotlib_renderer.py +++ b/test/mpl_renderer/test_matplotlib_renderer.py @@ -1,6 +1,5 @@ from cmath import cos, sin import io -import logging import os import time from pathlib import Path From 5846c21962a8f4932f45bb2431673adf3e4865b7 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 21 Jan 2026 10:15:40 +0200 Subject: [PATCH 34/35] - handle case of empty path --- fpdf/fpdf_renderer.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index 74edab912..ca9e438c9 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -51,6 +51,11 @@ def draw_gouraud_triangles(self, gc, triangles_array, colors_array, transform): raise NotImplementedError("draw_gouraud_triangles not implemented yet") def draw_path(self, gc, path, transform, rgbFace=None): + + if len(path) == 0: + logging.debug("draw_path: empty path - skipping") + return + tran = transform + self._trans clip_rect = None clip_x0, clip_y0, clip_x1, clip_y1 = None, None, None, None From 39cf82a87d59484fc9ac703c428a53f0fa651bb3 Mon Sep 17 00:00:00 2001 From: Petri Lipponen Date: Wed, 21 Jan 2026 11:13:04 +0200 Subject: [PATCH 35/35] - skip & log non-iterable path segments --- fpdf/fpdf_renderer.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/fpdf/fpdf_renderer.py b/fpdf/fpdf_renderer.py index ca9e438c9..07793f3c6 100644 --- a/fpdf/fpdf_renderer.py +++ b/fpdf/fpdf_renderer.py @@ -64,9 +64,12 @@ def draw_path(self, gc, path, transform, rgbFace=None): clip_x0, clip_y0 = self._trans.transform(clip_rect[0:2]) clip_x1, clip_y1 = self._trans.transform(clip_rect[2:4]) - # else: - # print(f"clip-path: {gc.get_clip_path()}\n") - c, v = zip(*[(c, v.tolist()) for v, c in path.iter_segments(transform=tran)]) + try: + c, v = zip(*[(c, v.tolist()) for v, c in path.iter_segments(transform=tran)]) + except ValueError as ve: + # Sometimes path segments cannot be iterated - log and skip + logging.error(f"Error iterating path segments: {ve}") + return p = self._fpdf fill_opacity = None