We'll start our refactoring by moving the existing PNG renderers into a new sub-package called renderers.png
. Create a new directory named png
within the renderers
directory, and move the title.py
, x_axis.py
, y_axis.py
, bar_series.py
and line_series.py
modules into this directory. Then, create an empty package initialization file, __init__.py
, inside the png
directory so that Python will recognize it as a package.
There is one minor change we are going to have to make to our existing PNG renderers: because each renderer module imports the constants.py
module using a relative import, we will need to update these modules so that they can still find the constants
module from their new position. To do this, edit each PNG renderer module in turn, and find the line that looks like the following:
from ..constants import *
Add an extra .
to each of these lines so that they look like this:
from ...constants import *
Our next task is to create a package to hold our PDF-format renderers. Create a sub-directory named pdf
in the renderers
directory, and create an empty package initialization file in that directory to make it a Python package.
We next want to implement the renderer.py
module we talked about earlier so that our generate_chart()
function can concentrate on drawing chart elements rather than worrying about which module each element is defined in. Create a new file named renderer.py
inside the renderers
directory, and add the following code to this file:
from .png import title as title_png from .png import x_axis as x_axis_png from .png import y_axis as y_axis_png from .png import bar_series as bar_series_png from .png import line_series as line_series_png renderers = { 'png' : { 'title' : title_png, 'x_axis' : x_axis_png, 'y_axis' : y_axis_png, 'bar_series' : bar_series_png, 'line_series' : line_series_png }, } def draw(format, element, chart, output): renderers[format][element].draw(chart, output)
This module is doing something tricky, which you may not have encountered before: after importing each PNG-format renderer module using import...as
, we then treat the imported modules as if they were Python variables, storing a reference to each module in the renderers
dictionary. Our draw()
function then selects the appropriate module from that dictionary using renderers[format][element]
, and calls the draw()
function within that module to do the actual drawing.
This Python trick saves us a lot of coding—without it, we would have had to write a whole series of if...then
statements to call the appropriate module's draw()
function based on the desired element and format. Using a dictionary in this way saves us a lot of typing and makes the code much easier to read and debug.
We could have also used the Python Standard Library's importlib
module to load a renderer module by name. This would have made our renderer
module even shorter but would have made it harder to understand the code. Using import...as
and a dictionary to select the desired module is a good trade-off between complexity and comprehensibility.
We next need to update our generate_report()
function. As discussed in the previous section, we want to choose the output format based on the file extension for the file being generated. We also need to update this function to use our new renderer.draw()
function, rather than importing and calling the renderer modules directly.
Edit the generator.py
module, and replace the contents of this module with the following code:
from PIL import Image, ImageDraw from reportlab.pdfgen.canvas import Canvas from .constants import * from .renderers import renderer def generate_chart(chart, filename): # Select the output format. if filename.lower().endswith(".pdf"): format = "pdf" elif filename.lower().endswith(".png"): format = "png" else: print("Unsupported file format: " + filename) return # Prepare the output file based on the file format. if format == "pdf": output = Canvas(filename) elif format == "png": image = Image.new("RGB", (CHART_WIDTH, CHART_HEIGHT), "#ffffff") output = ImageDraw.Draw(image) # Draw the various chart elements. renderer.draw(format, "title", chart, output) renderer.draw(format, "x_axis", chart, output) renderer.draw(format, "y_axis", chart, output) if chart['series_type'] == "bar": renderer.draw(format, "bar_series", chart, output) elif chart['series_type'] == "line": renderer.draw(format, "line_series", chart, output) # Finally, save the output to disk. if format == "pdf": output.showPage() output.save() elif format == "png": image.save(filename, format="png")
There's a lot of code in this module, but the comments should help to explain what is going on. As you can see, we use the supplied file name to set the format
variable to "pdf"
or "png"
as appropriate. We then prepare the output
variable to hold the generated image or PDF file. Next, we call renderer.draw()
to draw each chart element in turn, passing in the format
and output
variables so that the renderer can do its job. Finally, we save the output to disk so that the chart will be saved to the appropriate PDF or PNG format file.
With these changes in place, you should be able to use the updated Charter package to generate a PNG-format file. PDF files won't work yet because we haven't written the PDF renderers, but PNG format output should be working. Go ahead and test this by running the test_charter.py
script, just to make sure you haven't made any typos entering the code.
Now that we've finished refactoring our existing code, let's add our PDF renderers.
We will work through the various renderer modules one at a time. Start by creating the titles.py
module inside the pdf
directory, and enter the following code into this file:
from ...constants import * def draw(chart, canvas): text_width = canvas.stringWidth(chart['title'], "Helvetica", 24) text_height = 24 * 1.2 left = CHART_WIDTH/2 - text_width/2 bottom = CHART_HEIGHT - TITLE_HEIGHT/2 + text_height/2 canvas.setFont("Helvetica", 24) canvas.setFillColorRGB(0.25, 0.25, 0.625) canvas.drawString(left, bottom, chart['title'])
In some ways, this code is quite similar to the PNG version of this renderer: we calculate the width and height of the text and use this to calculate the position on the chart where the title should be drawn. We then draw the title in 24-point Helvetica font, in a dark blue color.
There are, however, some important differences:
stringWidth()
function, while for the height, we multiply the font size of the text by 1.2. By default, ReportLab leaves a gap of 20% of the font size between lines of text, so multiplying the font size by 1.2 is an accurate way of calculating the height of a line of text.(0.25,0.25,0.625)
is equivalent to the hex color code #4040a0
.Without further ado, let's implement the remaining PDF renderer modules. The x_axis.py
module should look like the following:
def draw(chart, canvas): label_height = 12 * 1.2 avail_width = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN bucket_width = avail_width / len(chart['x_axis']) axis_top = X_AXIS_HEIGHT canvas.setStrokeColorRGB(0.25, 0.25, 0.625) canvas.setLineWidth(2) canvas.line(Y_AXIS_WIDTH, axis_top, CHART_WIDTH - MARGIN, axis_top) left = Y_AXIS_WIDTH for bucket_num in range(len(chart['x_axis'])): canvas.setLineWidth(1) canvas.line(left, axis_top, left, axis_top - TICKMARK_HEIGHT) label_width = canvas.stringWidth( chart['x_axis'][bucket_num], "Helvetica", 12) label_left = max(left, left + bucket_width/2 - label_width/2) label_bottom = axis_top - TICKMARK_HEIGHT-4-label_height canvas.setFont("Helvetica", 12) canvas.setFillColorRGB(0.0, 0.0, 0.0) canvas.drawString(label_left, label_bottom, chart['x_axis'][bucket_num]) left = left + bucket_width canvas.setStrokeColorRGB(0.25, 0.25, 0.625) canvas.setLineWidth(1) canvas.line(left, axis_top, left, axis_top - TICKMARK_HEIGHT)
Similarly, the y_axis.py
module should be implemented as follows:
from ...constants import * def draw(chart, canvas): label_height = 12 * 1.2 axis_top = CHART_HEIGHT - TITLE_HEIGHT axis_bottom = X_AXIS_HEIGHT axis_height = axis_top - axis_bottom canvas.setStrokeColorRGB(0.25, 0.25, 0.625) canvas.setLineWidth(2) canvas.line(Y_AXIS_WIDTH, axis_top, Y_AXIS_WIDTH, axis_bottom) for y_value in chart['y_labels']: y = ((y_value - chart['y_min']) / (chart['y_max'] - chart['y_min'])) y_pos = axis_bottom + int(y * axis_height) canvas.setLineWidth(1) canvas.line(Y_AXIS_WIDTH - TICKMARK_HEIGHT, y_pos, Y_AXIS_WIDTH, y_pos) label_width = canvas.stringWidth(str(y_value), "Helvetica", 12) label_left = Y_AXIS_WIDTH - TICKMARK_HEIGHT-label_width-4 label_bottom = y_pos - label_height/4 canvas.setFont("Helvetica", 12) canvas.setFillColorRGB(0.0, 0.0, 0.0) canvas.drawString(label_left, label_bottom, str(y_value))
For the bar_series.py
module, enter the following:
from ...constants import * def draw(chart, canvas): avail_width = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN bucket_width = avail_width / len(chart['x_axis']) bottom = X_AXIS_HEIGHT max_top = CHART_HEIGHT - TITLE_HEIGHT avail_height = max_top - bottom left = Y_AXIS_WIDTH for y_value in chart['series']: bar_left = left + MARGIN / 2 bar_width = bucket_width - MARGIN y = ((y_value - chart['y_min']) / (chart['y_max'] - chart['y_min'])) bar_height = int(y * avail_height) canvas.setStrokeColorRGB(0.25, 0.25, 0.625) canvas.setFillColorRGB(0.906, 0.906, 0.953) canvas.rect(bar_left, bottom, bar_width, bar_height, stroke=True, fill=True) left = left + bucket_width
Finally, the line_series.py
module should look like the following:
from ...constants import * def draw(chart, canvas): avail_width = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN bucket_width = avail_width / len(chart['x_axis']) bottom = X_AXIS_HEIGHT max_top = CHART_HEIGHT - TITLE_HEIGHT avail_height = max_top - bottom left = Y_AXIS_WIDTH prev_y = None for y_value in chart['series']: y = ((y_value - chart['y_min']) / (chart['y_max'] - chart['y_min'])) cur_y = bottom + int(y * avail_height) if prev_y != None: canvas.setStrokeColorRGB(0.25, 0.25, 0.625) canvas.setLineWidth(1) canvas.line(left - bucket_width / 2, prev_y, left + bucket_width / 2, cur_y) prev_y = cur_y left = left + bucket_width
As you can see, these modules look very similar to their PNG versions. Anything we can do with the Python Imaging Library can also be done with ReportLab, as long as we allow for the differences in the ways these two libraries work.
This leaves us with just one more change we have to make to complete our new implementation of the Charter library: we need to update the renderer.py
module to make these new PDF renderer modules available. To do this, add the following import
statements to the top of this module:
from .pdf import title as title_pdf from .pdf import x_axis as x_axis_pdf from .pdf import y_axis as y_axis_pdf from .pdf import bar_series as bar_series_pdf from .pdf import line_series as line_series_pdf
Then, in the part of this module where we define the renderers
dictionary, create a new pdf
entry to the dictionary by adding the following highlighted lines to your code:
renderers = { ... 'pdf' : { 'title' : title_pdf, 'x_axis' : x_axis_pdf, 'y_axis' : y_axis_pdf, 'bar_series' : bar_series_pdf, 'line_series' : line_series_pdf } }
Once this is done, you've finished refactoring and reimplementing the Charter module. Assuming you haven't made any mistakes, your library should now be able to generate charts in both PNG and PDF format.
To make sure your program works, edit your test_charter.py
program and change the name of the output file from chart.png
to chart.pdf
. If you then run this program, you should end up with a PDF file that contains a high-quality version of your chart:
Notice that the chart appears at the bottom of the page, rather than the top. This is because PDF files have their y=0
position at the bottom of the page. You could easily move the chart to the top of the page by calculating the height of the page (in points) and adding an appropriate offset. Feel free to implement this if you want, but for now our task is complete.
If you zoom in, you'll see that the chart's text still looks good:
This is because we're now generating a vector-format PDF file rather than a bitmapped image. This file can be printed on a high-quality laser printer without any pixelation. Even better, existing users of your library will still be able to request PNG versions of the charts and they won't notice any changes at all.
Congratulations—you did it!
3.137.172.115