diff --git a/hypha/apply/funds/views.py b/hypha/apply/funds/views.py index 011928529e05385d6e0afb973e4b69f4e74f9d74..01fdda91baefd51258b386dac4b61dca0f2dfa46 100644 --- a/hypha/apply/funds/views.py +++ b/hypha/apply/funds/views.py @@ -39,7 +39,7 @@ from hypha.apply.projects.forms import CreateProjectForm from hypha.apply.projects.models import Project from hypha.apply.review.views import ReviewContextMixin from hypha.apply.users.decorators import staff_required -from hypha.apply.utils.pdfs import make_pdf +from hypha.apply.utils.pdfs import make_pdf, draw_submission_content from hypha.apply.utils.storage import PrivateMediaView from hypha.apply.utils.views import ( DelegateableListView, @@ -1016,15 +1016,23 @@ class SubmissionDetailPDFView(SingleObjectMixin, View): def get(self, request, *args, **kwargs): self.object = self.get_object() + content = draw_submission_content( + self.object.output_text_answers() + ) pdf = make_pdf( title=self.object.title, - meta=[ - self.object.stage, - self.object.page, - self.object.round, - f"Lead: { self.object.lead }", - ], - content=self.object.output_text_answers() + sections=[ + { + 'content': content, + 'title': 'Submission', + 'meta': [ + self.object.stage, + self.object.page, + self.object.round, + f"Lead: { self.object.lead }", + ], + }, + ] ) return FileResponse( pdf, diff --git a/hypha/apply/projects/views/project.py b/hypha/apply/projects/views/project.py index 657046a6c59ef412caa335875e687d7ef7e93ae6..d9bc8fd3ef129f9846e1a64efcdfcca8d5b7650b 100644 --- a/hypha/apply/projects/views/project.py +++ b/hypha/apply/projects/views/project.py @@ -29,7 +29,11 @@ from django_tables2 import SingleTableMixin from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.activity.views import ActivityContextMixin, CommentFormView from hypha.apply.users.decorators import approver_required, staff_required -from hypha.apply.utils.pdfs import make_pdf +from hypha.apply.utils.pdfs import ( + draw_project_content, + draw_submission_content, + make_pdf, +) from hypha.apply.utils.storage import PrivateMediaView from hypha.apply.utils.views import DelegateableView, DelegatedViewMixin, ViewDispatcher @@ -526,16 +530,39 @@ class ProjectDetailPDFView(SingleObjectMixin, View): model = Project def get(self, request, *args, **kwargs): - self.object = self.get_object().submission + self.object = self.get_object() + response = ProjectDetailSimplifiedView.as_view()( + request=self.request, + pk=self.object.pk, + ) + project = draw_project_content( + response.render().content + ) + submission = draw_submission_content( + self.object.submission.output_text_answers() + ) pdf = make_pdf( title=self.object.title, - meta=[ - self.object.stage, - self.object.page, - self.object.round, - f"Lead: { self.object.lead }", + sections=[ + { + 'content': project, + 'title': 'Project Approval Form', + 'meta': [ + self.object.submission.page, + self.object.submission.round, + f"Lead: { self.object.lead }", + ], + }, { + 'content': submission, + 'title': 'Submission', + 'meta': [ + self.object.submission.stage, + self.object.submission.page, + self.object.submission.round, + f"Lead: { self.object.submission.lead }", + ], + }, ], - content=self.object.output_text_answers() ) return FileResponse( pdf, diff --git a/hypha/apply/utils/pdfs.py b/hypha/apply/utils/pdfs.py index 3e63b38f23ce98a85c754a757af5bf83acde0cc4..e60fba6474cab2123b82b9aee08e4e0070ef2453 100644 --- a/hypha/apply/utils/pdfs.py +++ b/hypha/apply/utils/pdfs.py @@ -1,5 +1,6 @@ import io import os +from itertools import cycle from bs4 import BeautifulSoup, NavigableString from reportlab.lib import pagesizes @@ -9,18 +10,25 @@ from reportlab.lib.utils import simpleSplit from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import ( + BaseDocTemplate, KeepTogether, + Frame, ListFlowable, ListItem, + NextPageTemplate, + NotAtTopPageBreak, + PageBreak, Paragraph, - SimpleDocTemplate, + PageTemplate, Spacer, Table, TableStyle, ) -styles = { + +STYLES = { 'Question': PS(fontName='MontserratBold', fontSize=14, name='Question', spaceAfter=0, spaceBefore=18, leading=21), + 'QuestionSmall': PS(fontName='MontserratBold', fontSize=12, name='QuestionSmall', spaceAfter=0, spaceBefore=16, leading=18), 'Normal': PS(fontName='NotoSans', name='Normal'), 'Heading1': PS(fontName='NotoSansBold', fontSize=12, name='Heading1', spaceAfter=4, spaceBefore=12, leading=18), 'Heading2': PS(fontName='NotoSansBold', fontSize=10, name='Heading2', spaceAfter=4, spaceBefore=10, leading=15), @@ -77,31 +85,61 @@ PAGE_WIDTH, PAGE_HEIGHT = pagesizes.legal FRAME_PADDING = 6 -def make_pdf(title, meta, content): +def do_nothing(doc, canvas): + pass + + +class ReportDocTemplate(BaseDocTemplate): + def build(self, flowables, onFirstPage=do_nothing, onLaterPages=do_nothing): + frame = Frame(self.leftMargin, self.bottomMargin, self.width, self.height, id='normal') + self.addPageTemplates([ + PageTemplate(id='Header', autoNextPageTemplate='Main', frames=frame, onPage=onFirstPage, pagesize=self.pagesize), + PageTemplate(id='Main', frames=frame, onPage=onLaterPages, pagesize=self.pagesize), + ]) + super().build(flowables) + + +def make_pdf(title, sections): prepare_fonts() buffer = io.BytesIO() - doc = SimpleDocTemplate( + + doc = ReportDocTemplate( buffer, pagesize=(PAGE_WIDTH, PAGE_HEIGHT), title=title, ) - blocks = [] - extra_content = draw_content(content) - blocks = [*blocks, *extra_content] + story = [] + for section in sections: + story.extend(section['content']) + story.append(NextPageTemplate('Header')) + story.append(PageBreak()) - def title_page(canvas, doc): + current_section = None + sections = cycle(sections) + + def header_page(canvas, doc): + nonlocal current_section + current_section = next(sections) canvas.saveState() - title_spacer = draw_title_block(canvas, doc, title, meta) + title_spacer = draw_title_block( + canvas, + doc, + current_section['title'], + title, + current_section['meta'], + ) canvas.restoreState() - blocks.insert(0, title_spacer) + story.insert(0, title_spacer) - def later_page(canvas, doc): + def main_page(canvas, doc): + nonlocal current_section canvas.saveState() - draw_header(canvas, doc, title) + spacer = draw_header(canvas, doc, current_section['title'], title) + story.insert(0, spacer) canvas.restoreState() - doc.build(blocks, onFirstPage=title_page, onLaterPages=later_page) + doc.build(story, onFirstPage=header_page, onLaterPages=main_page) buffer.seek(0) return buffer @@ -111,32 +149,48 @@ def split_text(canvas, text, width): return simpleSplit(text, canvas._fontname, canvas._fontsize, width) -def draw_header(canvas, doc, title): +def draw_header(canvas, doc, page_title, title): title_size = 10 + # Set canvas font to correctly calculate the splitting + canvas.setFont("MontserratBold", title_size) + + text_width = PAGE_WIDTH - doc.leftMargin - doc.rightMargin - 2 * FRAME_PADDING + split_title = split_text(canvas, title, text_width) + + # only count title - assume 1 line of title in header + total_height = ( + doc.topMargin + + 1.5 * (len(split_title) - 1) * title_size + + title_size / 2 # bottom padding + ) + canvas.setFillColor(DARK_GREY) canvas.rect( 0, - PAGE_HEIGHT - doc.topMargin, + PAGE_HEIGHT - total_height, PAGE_WIDTH, - doc.topMargin, + total_height, stroke=False, fill=True, ) - # Set canvas font to correctly calculate the splitting - - canvas.setFont("MontserratBold", title_size) - canvas.setFillColor(white) - - text_width = PAGE_WIDTH - doc.leftMargin - doc.rightMargin - 2 * FRAME_PADDING - split_title = split_text(canvas, title, text_width) pos = ( (PAGE_HEIGHT - doc.topMargin) + # bottom of top margin - 1.5 * len(split_title) * title_size + # text - title_size / 2 # bottom padding + title_size / 2 + # spacing below page title + 1.5 * 1 * title_size # text + ) + + canvas.setFillColor(white) + + canvas.drawString( + doc.leftMargin + FRAME_PADDING, + pos, + page_title, ) + pos -= title_size / 2 + for line in split_title: pos -= title_size canvas.drawString( @@ -146,8 +200,11 @@ def draw_header(canvas, doc, title): ) pos -= title_size / 2 + return Spacer(1, total_height - doc.topMargin) + -def draw_title_block(canvas, doc, title, meta): +def draw_title_block(canvas, doc, page_title, title, meta): + page_title_size = 20 title_size = 30 meta_size = 10 @@ -158,10 +215,16 @@ def draw_title_block(canvas, doc, title, meta): canvas.setFillColor(white) split_title = split_text(canvas, title, text_width) + canvas.setFont("MontserratBold", meta_size) + canvas.setFillColor(white) + meta_text = ' | '.join(str(text) for text in meta) + split_meta = split_text(canvas, meta_text, text_width) + total_height = ( doc.topMargin + + page_title_size + page_title_size * 3 / 4 + # page title + spaceing len(split_title) * (title_size + title_size / 2) + # title + spacing - meta_size * 4 # 1 for text 3 for spacing + (1.5 * len(split_meta) + 3) * meta_size # 1.5 per text line + 3 for spacing ) canvas.setFillColor(DARK_GREY) @@ -174,10 +237,20 @@ def draw_title_block(canvas, doc, title, meta): fill=True, ) - canvas.setFont("MontserratBold", title_size) + canvas.setFont("MontserratBold", page_title_size) canvas.setFillColor(white) pos = PAGE_HEIGHT - doc.topMargin + pos -= page_title_size + canvas.drawString( + doc.leftMargin + FRAME_PADDING, + pos, + page_title, + ) + pos -= page_title_size * 3 / 4 + + canvas.setFont("MontserratBold", title_size) + canvas.setFillColor(white) for line in split_title: pos -= title_size canvas.drawString( @@ -189,20 +262,28 @@ def draw_title_block(canvas, doc, title, meta): canvas.setFont("MontserratBold", meta_size) canvas.setFillColor(white) - meta_text = ' | '.join(str(text) for text in meta) pos -= meta_size * 2 - canvas.drawString( - doc.leftMargin + FRAME_PADDING, - pos, - meta_text, - ) + + for line in split_meta: + canvas.drawString( + doc.leftMargin + FRAME_PADDING, + pos, + line, + ) + pos -= meta_size / 2 return Spacer(1, total_height - doc.topMargin) -def handle_block(block): +def handle_block(block, custom_style=None): paragraphs = [] + if not custom_style: + custom_style = {} + + styles = {**STYLES} + for style, overwrite in custom_style.items(): + styles[style] = STYLES[overwrite] for tag in block: if isinstance(tag, NavigableString): @@ -243,6 +324,7 @@ def handle_block(block): ) ) else: + style = None if tag.name in {'p'}: style = styles['Normal'] elif tag.name == 'h2': @@ -254,18 +336,21 @@ def handle_block(block): elif tag.name == 'h5': style = styles['Heading5'] - text = tag.get_text() - if text: - paragraphs.append(Paragraph(text, style)) + if style: + text = tag.get_text() + if text: + paragraphs.append(Paragraph(text, style)) + else: + paragraphs.extend(handle_block(tag)) return paragraphs -def draw_content(content): +def draw_submission_content(content): paragraphs = [] for section in BeautifulSoup(content, "html5lib").find_all('section'): question_text = section.select_one('.question').get_text() - question = Paragraph(question_text, styles['Question']) + question = Paragraph(question_text, STYLES['Question']) # Keep the question and the first block of the answer together # this keeps 1 line answers tidy and ensures that bigger responses break @@ -279,3 +364,12 @@ def draw_content(content): *rest ]) return paragraphs + + +def draw_project_content(content): + paragraphs = [] + for section in BeautifulSoup(content, "html5lib").find_all(class_='simplified__wrapper'): + flowables = handle_block(section, custom_style={"Heading3": "Question", "Heading5": "QuestionSmall"}) + paragraphs.extend(flowables) + + return paragraphs