Skip to content

Commit

Permalink
Supporting bytes as input to FPDF.image() + minor docs refactoring (#764
Browse files Browse the repository at this point in the history
)
  • Loading branch information
Lucas-C committed Apr 12, 2023
1 parent 393580e commit e6e08b6
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 106 deletions.
105 changes: 7 additions & 98 deletions docs/Maths.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Charts & graphs #


## Charts ##

### Using Matplotlib ###
Expand Down Expand Up @@ -226,106 +225,16 @@ Result:
![](plotly_svg.png)


## Using Pygal ##
[Pygal](https://www.pygal.org/en/stable/) is a graph plotting library using Python. You can install Pygal using `pip install pygal` command.

`fpdf2` is able to embed the graph and charts that are generated using `Pygal` library. The following ways explain how to embed `Pygal` charts into `fpdf2` library. However, we can not embed graphs as SVG directly. Since, `Pygal` introduces `<style>` & `<script>` elements to the `SVG` images it produces ([Ref](https://github.com/Kozea/pygal/blob/3.0.0/pygal/svg.py#L449)) which is currently not supported by `fpdf2`. The full list of unsupported SVG features of `fpdf2` is [there](https://pyfpdf.github.io/fpdf2/SVG.html#currently-unsupported-notable-svg-features).

### Using cairosvg (*A faster and efficient implementation*) ###

A faster and expected approach of embedding a `Pygal` svg graph into a PDF file is to use the `cairosvg` library to convert the `svg` string generated by `pygal` into byte string using `BytesIO` library so that we can keep these data in an in-memory buffer.
As the `fpdf` library can understand byte string of a `svg` content, it can easily embed a graph inside a `pdf`.

```python
import pygal
from fpdf import FPDF
from io import BytesIO
import cairosvg

# Create a Pygal bar chart
bar_chart = pygal.Bar()
bar_chart.title = 'Browser usage evolution (in %)'
bar_chart.x_labels = map(str, range(2002, 2013))
bar_chart.add('Firefox', [None, None, 0, 16.6, 25, 31, 36.4, 45.5, 46.3, 42.8, 37.1])
bar_chart.add('Chrome', [None, None, None, None, None, None, 0, 3.9, 10.8, 23.8, 35.3])
bar_chart.add('IE', [85.8, 84.6, 84.7, 74.5, 66, 58.6, 54.7, 44.8, 36.2, 26.6, 20.1])
bar_chart.add('Others', [14.2, 15.4, 15.3, 8.9, 9, 10.4, 8.9, 5.8, 6.7, 6.8, 7.5])

# Use CairoSVG to convert PNG from SVG of barchart
svg_img_bytesio = BytesIO()
cairosvg.svg2png(bar_chart.render(), write_to=svg_img_byte)

# Set the position and size of the image in the PDF
x = 50
y = 50
w = 100
h = 70

# Make the PDF
pdf = FPDF()
pdf.add_page()
pdf.image(svg_img_byte, x=x, y=y, w=w, h=h)
pdf.output('bar_chart.pdf')
```
The above code generates a pdf with title `bar_chart.pdf` file with following graph -
![](pygal_chart_cairo.PNG)

**!! Troubleshoot: !!**

You may encounter `GTK` (Gnome Toolkit) errors while executing the above example in windows. Error could be like following -
```
OSError: no library called "cairo-2" was found
no library called "cairo" was found
no library called "libcairo-2" was found
cannot load library 'libcairo.so.2': error 0x7e
cannot load library 'libcairo.2.dylib': error 0x7e
cannot load library 'libcairo-2.dll': error 0x7e
```
In this case install install `GTK` from [GTK-for-Windows-Runtime-Environment-Installer](https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/releases). Restart your editor. And you are all done.

### Using svglib and reportlab (*A slower and purely pythonic implementation*) ###
```python
import io
import pygal
from reportlab.graphics import renderPM
from svglib.svglib import SvgRenderer
from fpdf import FPDF
from lxml import etree

# Create a Pygal bar chart
bar_chart = pygal.Bar()
bar_chart.title = 'Sales by Year'
bar_chart.x_labels = ['2016', '2017', '2018', '2019', '2020']
bar_chart.add('Product A', [500, 750, 1000, 1250, 1500])
bar_chart.add('Product B', [750, 1000, 1250, 1500, 1750])

# Render the chart and convert it to a bytestring object
svg_img = bar_chart.render()
svg_root = etree.fromstring(svg_img)
drawing = SvgRenderer(svg_img).render(svg_root)
drawing_img_byte = renderPM.drawToString(drawing)
img_bytes = io.BytesIO(drawing_img_byte)

# Set the position and size of the image in the PDF
x = 50
y = 50
w = 100
h = 70

# Make the PDF
pdf = FPDF()
pdf.add_page()
pdf.image(img_bytes, x=x, y=y, w=w, h=h)
pdf.output('bar_chart_pdf.pdf')
```
User who are using `reportlab` and `svglib` to work with `svg` images and are intended to render `svg` images `reportlab` and `svglib` can use this library. However, it consumes a little bit more time than the previous example.
### Using Pygal ###
[Pygal](https://www.pygal.org/en/stable/) is a Python graph plotting library.
You can install it using: `pip install pygal`

The above code shows following output -
![](pygal_chart.png)
`fpdf2` can embed graphs and charts generated using `Pygal` library. However, they cannot be embedded as SVG directly, because `Pygal` inserts `<style>` & `<script>` tags in the images it produces (_cf._ [`pygal/svg.py`](https://github.com/Kozea/pygal/blob/3.0.0/pygal/svg.py#L449)), which is currently not supported by `fpdf2`.
The full list of supported & unsupported SVG features can be found there: [SVG page](SVG.md#supported-svg-features).

**Why there is a performance issue between `cairosvg` and `svglib`?**
You can find documentation on how to convert vector images (SVG) to raster images (PNG, JPG), with a practical example of embedding PyGal charts, there:
[SVG page](SVG.md#converting-vector-graphics-to-raster-graphics).

*Regarding performance, cairosvg is generally faster than svglib when it comes to rendering SVG files to other formats. This is because cairosvg is built on top of a fast C-based rendering engine, while svglib is written entirely in Python. Additionally, cairosvg offers various options for optimizing the rendering performance, such as disabling certain features, like fonts or filters.*

## Mathematical formulas ##
`fpdf2` can only insert mathematical formula in the form of **images**.
Expand Down
106 changes: 106 additions & 0 deletions docs/SVG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,112 @@ pdf.draw_path(paths)
pdf.output("my_file.pdf")
```

## Converting vector graphics to raster graphics ##
Usually, embedding SVG as vector graphics in PDF documents is the best approach,
as it is both lightweight and will allow for better details / precision of the images inserted.

But sometimes, SVG images cannot be directly embedded as vector graphics (SVG),
and a conversion to raster graphics (PNG, JPG) must be performed.

The following sections demonstrate how to perform such conversion, using [Pygal charts](Maths.md#using-pygal) as examples:

### Using cairosvg ###
A faster and efficient approach for embedding `Pygal` SVG charts into a PDF file is to use the `cairosvg` library to convert the vector graphics generated into a `BytesIO` instance, so that we can keep these data in an in-memory buffer:

```python
import pygal
from fpdf import FPDF
from io import BytesIO
import cairosvg

# Create a Pygal bar chart
bar_chart = pygal.Bar()
bar_chart.title = 'Browser usage evolution (in %)'
bar_chart.x_labels = map(str, range(2002, 2013))
bar_chart.add('Firefox', [None, None, 0, 16.6, 25, 31, 36.4, 45.5, 46.3, 42.8, 37.1])
bar_chart.add('Chrome', [None, None, None, None, None, None, 0, 3.9, 10.8, 23.8, 35.3])
bar_chart.add('IE', [85.8, 84.6, 84.7, 74.5, 66, 58.6, 54.7, 44.8, 36.2, 26.6, 20.1])
bar_chart.add('Others', [14.2, 15.4, 15.3, 8.9, 9, 10.4, 8.9, 5.8, 6.7, 6.8, 7.5])

# Use CairoSVG to convert PNG from SVG of barchart
svg_img_bytesio = BytesIO()
cairosvg.svg2png(bar_chart.render(), write_to=svg_img_byte)

# Set the position and size of the image in the PDF
x = 50
y = 50
w = 100
h = 70

# Make the PDF
pdf = FPDF()
pdf.add_page()
pdf.image(svg_img_byte, x=x, y=y, w=w, h=h)
pdf.output('bar_chart.pdf')
```
The above code generates a PDF with the following graph:
![](pygal_chart_cairo.PNG)

**!! Troubleshooting advice !!**

You may encounter `GTK` (Gnome Toolkit) errors while executing the above example in windows. Error could be like following -
```
OSError: no library called "cairo-2" was found
no library called "cairo" was found
no library called "libcairo-2" was found
cannot load library 'libcairo.so.2': error 0x7e
cannot load library 'libcairo.2.dylib': error 0x7e
cannot load library 'libcairo-2.dll': error 0x7e
```
In this case install install `GTK` from [GTK-for-Windows-Runtime-Environment-Installer](https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/releases). Restart your editor. And you are all done.

### Using svglib and reportlab ###
An alternative, purely pythonic but slightly slower solution is to use `reportlab` and `svglib`:

```python
import io
import pygal
from reportlab.graphics import renderPM
from svglib.svglib import SvgRenderer
from fpdf import FPDF
from lxml import etree

# Create a Pygal bar chart
bar_chart = pygal.Bar()
bar_chart.title = 'Sales by Year'
bar_chart.x_labels = ['2016', '2017', '2018', '2019', '2020']
bar_chart.add('Product A', [500, 750, 1000, 1250, 1500])
bar_chart.add('Product B', [750, 1000, 1250, 1500, 1750])

# Render the chart and convert it to a bytestring object
svg_img = bar_chart.render()
svg_root = etree.fromstring(svg_img)
drawing = SvgRenderer(svg_img).render(svg_root)
drawing_img_byte = renderPM.drawToString(drawing)
img_bytes = io.BytesIO(drawing_img_byte)

# Set the position and size of the image in the PDF
x = 50
y = 50
w = 100
h = 70

# Make the PDF
pdf = FPDF()
pdf.add_page()
pdf.image(img_bytes, x=x, y=y, w=w, h=h)
pdf.output('bar_chart_pdf.pdf')
```

The above code generates the following output:
![](pygal_chart.png)

**Performance considerations**

Regarding performance, `cairosvg` is generally faster than `svglib` when it comes to rendering SVG files to other formats. This is because `cairosvg` is built on top of a fast C-based rendering engine, while `svglib` is written entirely in Python, and hence a bit slower.
Additionally, `cairosvg` offers various options for optimizing the rendering performance, such as disabling certain features, like fonts or filters.


## Supported SVG Features ##

- groups
Expand Down
17 changes: 11 additions & 6 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -3702,7 +3702,7 @@ def image(
Args:
name: either a string representing a file path to an image, an URL to an image,
an io.BytesIO, or a instance of `PIL.Image.Image`
bytes, an io.BytesIO, or a instance of `PIL.Image.Image`
x (float, fpdf.enums.Align): optional horizontal position where to put the image on the page.
If not specified or equal to None, the current abscissa is used.
`Align.C` can also be passed to center the image horizontally;
Expand Down Expand Up @@ -3743,6 +3743,10 @@ def image(
# Insert it as a PDF path:
img = load_image(str(name))
return self._vector_image(img, x, y, w, h, link, title, alt_text)
if isinstance(name, bytes) and _is_svg(name.strip()):
return self._vector_image(
io.BytesIO(name), x, y, w, h, link, title, alt_text
)
if isinstance(name, io.BytesIO) and _is_svg(name.getvalue().strip()):
return self._vector_image(name, x, y, w, h, link, title, alt_text)
name, img, info = self.preload_image(name, dims)
Expand Down Expand Up @@ -3825,14 +3829,15 @@ def preload_image(self, name, dims=None):
if isinstance(name, str):
img = None
elif isinstance(name, Image):
bytes = name.tobytes()
bytes_ = name.tobytes()
img_hash = hashlib.new("md5", usedforsecurity=False) # nosec B324
img_hash.update(bytes)
img_hash.update(bytes_)
name, img = img_hash.hexdigest(), name
elif isinstance(name, io.BytesIO):
bytes = name.getvalue().strip()
elif isinstance(name, (bytes, io.BytesIO)):
bytes_ = name.getvalue() if isinstance(name, io.BytesIO) else name
bytes_ = bytes_.strip()
img_hash = hashlib.new("md5", usedforsecurity=False) # nosec B324
img_hash.update(bytes)
img_hash.update(bytes_)
name, img = img_hash.hexdigest(), name
else:
name, img = str(name), name
Expand Down
4 changes: 3 additions & 1 deletion fpdf/image_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def get_img_info(filename, img=None, image_filter="AUTO", dims=None):
"""
Args:
filename: in a format that can be passed to load_image
img: optional `BytesIO` or `PIL.Image.Image` instance
img: optional `bytes`, `BytesIO` or `PIL.Image.Image` instance
image_filter (str): one of the SUPPORTED_IMAGE_FILTERS
"""
if Image is None:
Expand All @@ -121,6 +121,8 @@ def get_img_info(filename, img=None, image_filter="AUTO", dims=None):
img = Image.open(img_raw_data)
is_pil_img = False
elif not isinstance(img, Image.Image):
if isinstance(img, bytes):
img = BytesIO(img)
img_raw_data = img
img = Image.open(img_raw_data)
is_pil_img = False
Expand Down
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ nav:
- 'Combine with borb': 'CombineWithBorb.md'
- 'Combine with pdfrw': 'CombineWithPdfrw.md'
- 'Combine with PyPDF2': 'CombineWithPyPDF2.md'
- 'Matplotlib, Pandas, Plotly': 'CombineWithChartingLibs.md'
- 'Matplotlib, Pandas, Plotly, Pygal': 'CombineWithChartingLibs.md'
- 'Templating with Jinja': 'TemplatingWithJinja.md'
- 'Usage in web APIs': 'UsageInWebAPI.md'
- 'Database storage': 'DatabaseStorage.md'
Expand Down
10 changes: 10 additions & 0 deletions test/image/image_types/test_insert_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,13 @@ def test_insert_bytesio(tmp_path):
img.save(img_bytes, "PNG")
pdf.image(img_bytes, x=15, y=15, h=140)
assert_pdf_equal(pdf, HERE / "image_types_insert_png.pdf", tmp_path)


def test_insert_bytes(tmp_path):
pdf = fpdf.FPDF()
pdf.add_page()
img = Image.open(HERE / "insert_images_insert_png.png")
img_bytes = io.BytesIO()
img.save(img_bytes, "PNG")
pdf.image(img_bytes.getvalue(), x=15, y=15, h=140)
assert_pdf_equal(pdf, HERE / "image_types_insert_png.pdf", tmp_path)
11 changes: 11 additions & 0 deletions test/image/test_vector_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,17 @@ def test_svg_image_from_bytesio(tmp_path):
assert_pdf_equal(pdf, HERE / "svg_image_from_bytesio.pdf", tmp_path)


def test_svg_image_from_bytes(tmp_path):
pdf = fpdf.FPDF()
pdf.add_page()
pdf.image(
b'<svg width="180" height="180" xmlns="http://www.w3.org/2000/svg">'
b' <rect x="60" y="60" width="60" height="60"/>'
b"</svg>"
)
assert_pdf_equal(pdf, HERE / "svg_image_from_bytesio.pdf", tmp_path)


def test_svg_image_billion_laughs():
"cf. https://pypi.org/project/defusedxml/#attack-vectors"
pdf = fpdf.FPDF()
Expand Down

0 comments on commit e6e08b6

Please sign in to comment.