Refactoring the code

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.

Note

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.

Implementing the PDF renderer modules

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:

  • The way we calculate the width and the height of the text is different. For the width, we call the canvas's 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.
  • The units used to calculate the position of elements on the page are different. ReportLab measures all positions and sizes using points rather than pixels. A point is roughly 1/72nd of an inch. Fortunately, one point is fairly close to the size of a pixel on a typical computer screen; this allows us to ignore the different measurement systems and have the PDF output still look good.
  • PDF files use a different coordinate system to PNG files. In a PNG-format file, the top of the image has a y value of zero, while for PDF files y=0 is at the bottom of the image. This means that all our positions on the page have to be calculated relative to the bottom of the page, rather than the top of the image as was done with the PNG renderers.
  • The colors are specified using RGB color values, where each component of the color is given as a number between zero and one. For example, a color value of (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.

Testing the code

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:

Testing the code

Note

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:

Testing the code

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!

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.137.172.115