Search code examples
pythonpdfframereportlabspacing

Reportlab PDF: cannot space paragraphs proportionally across a frame


I am trying to make a script that pulls event data from Google Calendar and for each day displays the days' events in two columns with breaks between them that are proportional to time (the more time between events, the more space between paragraphs). Every event is a Reportlab Platypus paragraph and there are spacers between them. Full code here (will be commented soon, I promise).

My problem is that I can't calculate the exact spacing needed in order to fit every paragraph into a frame exactly: I always over- or underestimate, no matter the formula, the last paragraph never lands exactly on the bottom of the frame.

The main calculation starts in add_events_to_frame(). The paragraphs are made for every event and the available space is stored, spacer height values are requested and the story written to canvas:

def add_events_to_frame(frame, events, canvas):

    story = []
    free_space = frame.height

    for event in events:
        paragraph = get_event_paragraph(event)
        story.append(paragraph)
        free_space -= paragraph.wrap(frame_width, frame_height)[1] - 1

    real_story = []

    if free_space > 0:
        spaces = Collector.get_space_after(events, free_space)
    for i, paragraph in enumerate(story):
        real_story.append(paragraph)
        real_story.append(Spacer(1, spaces[i] + 1))
    frame.addFromList(real_story, canvas)
    return frame

The get_event_paragraph() looks like this:

def get_event_paragraph(event, spaceAfter=0, borderPadding=2):

    event_style = ParagraphStyle(
        "event",
        fontName="Times-Roman",
        fontSize=5,
        backColor=event['color'],
        textColor='white',
        leading=6,
        borderPadding=borderPadding,
        spaceAfter=spaceAfter + 2 * borderPadding
    )

    beginTime = event['start'].strftime("%H:%M")
    endTime = event['end'].strftime("%H:%M")

    eventStringList = [f"<b>{beginTime}-{endTime}</b>"]

    eventStringList.append(event['summary'])

    return Paragraph(' '.join(eventStringList), event_style)

And the spacer height calculator:

def get_space_after(events, free_space):
    """
    Calculate spaces between events in percentages based on sum of free time and constraints.

    :param events:
    :return:
    """
    timesum = timedelta(seconds=1)
    time_list = []

    for i in range(len(events) - 1):
        time_diff = events[i + 1]['start'] - events[i]['end']
        timesum += time_diff
        time_list.append(time_diff)

    time_list.append(timedelta(hours=0))

    spaces = []

    for diff in time_list:
        spaces.append(round(diff/timesum * free_space, 4))
    return spaces

I have tried KeepInFrame(), but when it does exert itself, it leaves the design uneven and ugly. Also I have tried to exclude the frame padding, the paragraph padding and the 1px space between paragraphs from the free_space variable in various combinations, but again, no exact match. What do?


Solution

  • I have figured out the exact formula for the free space thing. What follows is the formula to calculate exactly the total free space between paragraphs, it arises from the fact that paragraph borderPadding is just a preface, all the calculations are done in the context of frame paddings and paragraph heights. All variables are in accordance with the code in the question.

    First, we need to account for the frame padding at the top and bottom of the frame:

    free_space = frame.height - frame.topPadding - frame.bottomPadding
    

    Second, for every paragraph in our frame, we need to subtract the wrapped height in context of the frame paddings and add to them the height wasted on the paragraph's border padding (NOTE: this applies, when the borderPadding values are the same for all paragraphs) and the space desired between paragraphs (in this case the 1 at the end):

    free_space -= paragraph.wrap(
                    frame_width - frame.lefPadding - frame.rightPadding + 2 * paragraph.style.borderPadding, \
                    frame_height - frame.topPadding - frame.bottomPadding)[1] \
            + paragraph.style.borderPadding * 2 + 1
    

    Finally, you have to add twice the paragraph borderPadding value to free_space, because otherwise you'd fall short: the borderPadding is not taken into account, only the paragraph edges are, so we need to fill the void made by first and last paragraph with free space to align the paragraph's edge to the frame padding's:

        free_space += paragraph.style.borderPadding * 2
    

    This will make the clear space between paragraph and frame:

    frame.{any padding} - paragraph.style.borderPadding