diff --git a/frappe/printing/doctype/print_format/print_format.json b/frappe/printing/doctype/print_format/print_format.json index 52220ed67c..42e3ba330d 100644 --- a/frappe/printing/doctype/print_format/print_format.json +++ b/frappe/printing/doctype/print_format/print_format.json @@ -29,6 +29,7 @@ "absolute_value", "column_break_11", "font", + "font_size", "css_section", "css", "custom_html_help", @@ -240,13 +241,19 @@ "fieldname": "margin_right", "fieldtype": "Float", "label": "Margin Right" + }, + { + "default": "14", + "fieldname": "font_size", + "fieldtype": "Int", + "label": "Font Size" } ], "icon": "fa fa-print", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-07-11 11:53:52.028982", + "modified": "2021-08-15 22:55:13.548182", "modified_by": "Administrator", "module": "Printing", "name": "Print Format", diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index 225c06980e..27fc4be802 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -259,7 +259,7 @@ frappe.ui.form.PrintView = class { default: print_format.name || 'Standard', }, { - label: __('Use the new Print Format Builder Beta'), + label: __('Use the new Print Format Builder'), fieldname: 'beta', fieldtype: 'Check' }, diff --git a/frappe/public/scss/print_format.bundle.scss b/frappe/public/scss/print_format.bundle.scss new file mode 100644 index 0000000000..b01e669d71 --- /dev/null +++ b/frappe/public/scss/print_format.bundle.scss @@ -0,0 +1,5 @@ +@import "./desk/variables.scss"; +@import "./common/mixins.scss"; +@import "./common/global.scss"; +@import "./common/icons.scss"; +@import "~bootstrap/scss/bootstrap"; diff --git a/frappe/templates/print_format/macros.html b/frappe/templates/print_format/macros.html new file mode 100644 index 0000000000..a801c37b29 --- /dev/null +++ b/frappe/templates/print_format/macros.html @@ -0,0 +1,67 @@ +{% macro render_field(df, doc) %} + {% if df.fieldtype == 'Table' %} + {{ render_table(df, doc) }} + {% elif df.fieldtype == 'HTML' %} + {{ render_custom_html(df, doc) }} + {% else %} + {% if doc.get(fieldname) %} +
+
+ {{ df.label }} +
+
+ {{ doc.get_formatted(df.fieldname) }} +
+
+ {% endif %} + {% endif %} +{% endmacro %} + +{% macro field_attributes(df) %} +{%- if df.fieldname -%} +data-fieldname="{{ df.fieldname }}" +{%- endif %} +{% if df.fieldtype -%} +data-fieldtype="{{ df.fieldtype }}" +{%- endif -%} +{% endmacro %} + +{% macro render_table(df, doc) %} +{% if doc.get(df.fieldname) %} +
+
+ {{ df.label }} +
+ + {% set columns = df.table_columns %} + + + {% for column in columns %} + + {% endfor %} + + + + {% for row in doc.get(df.fieldname) %} + + {% for column in columns %} + + {% endfor %} + + {% endfor %} + +
+ {{ column.label }} +
+ {{ row.get_formatted(column.fieldname) }} +
+
+{% endif %} +{% endmacro %} + +{% macro render_custom_html(df, doc) %} +
+ {{ frappe.render_template(df.html, {'doc': doc}) }} +
+{% endmacro %} + diff --git a/frappe/templates/print_format/print_format.css b/frappe/templates/print_format/print_format.css new file mode 100644 index 0000000000..a99d02d454 --- /dev/null +++ b/frappe/templates/print_format/print_format.css @@ -0,0 +1,62 @@ +@page { + size: {{ print_settings.pdf_page_size or 'A4' }} portrait; + margin-top: {{ print_format.margin_top | int }}mm; + margin-bottom: {{ print_format.margin_bottom | int }}mm; + margin-left: {{ print_format.margin_left | int }}mm; + margin-right: {{ print_format.margin_right | int }}mm; +} + +body { + font-size: {{ print_format.font_size }}px; + min-width: {{ body_width | int }}mm !important; + max-width: {{ body_width | int }}mm !important; +} + +@media screen { + html { + background-color: var(--gray-300); + } + body { + background-color: white; + box-shadow: var(--shadow-md); + margin: 2rem auto; + min-height: 297mm; + min-width: {{ body_width | int }}mm !important; + max-width: {{ body_width | int }}mm !important; + padding-top: {{ print_format.margin_top | int }}mm; + padding-right: {{ print_format.margin_right | int }}mm; + padding-left: {{ print_format.margin_left | int }}mm; + padding-bottom: {{ print_format.margin_bottom | int }}mm; + } +} + +.section:not(:first-child) { + margin-top: 1rem; +} + +.section-label { + font-size: var(--text-lg); + font-weight: 600; +} + +.field + .field { + margin-top: 0.5rem; +} + +.field .label { + font-weight: bold; +} + +.child-table [data-fieldtype="Currency"] { + text-align: right; +} + +.table-row td, .table-row th { + border-bottom-width: 1px; + border-bottom-style: solid; + border-bottom-color: var(--gray-300); +} + +.page-break { + page-break-after: always; +} diff --git a/frappe/templates/print_format/print_format.html b/frappe/templates/print_format/print_format.html new file mode 100644 index 0000000000..4c6a3bcfb7 --- /dev/null +++ b/frappe/templates/print_format/print_format.html @@ -0,0 +1,34 @@ +{% import "templates/print_format/macros.html" as macros %} + + + + + + + + {{ doc.doctype }}: {{ doc.name }} + {{ include_style('print_format.bundle.css') }} + + + + {% for section in layout.sections %} +
+ {% if section.label %} +
{{ section.label }}
+ {% endif %} + +
+ {% for column in section.columns %} +
+ {% for df in column.fields %} + {{ macros.render_field(df, doc) }} + {% endfor %} +
+ {% endfor %} +
+
+ {% endfor %} + + diff --git a/frappe/utils/weasyprint.py b/frappe/utils/weasyprint.py new file mode 100644 index 0000000000..757b237965 --- /dev/null +++ b/frappe/utils/weasyprint.py @@ -0,0 +1,217 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +import frappe +from weasyprint import HTML, CSS + +@frappe.whitelist() +def download_pdf(doctype, name, print_format, letterhead=None): + html = get_html(doctype, name, print_format, letterhead) + pdf = get_pdf(html.main, html.header, html.footer) + + frappe.local.response.filename = "{name}.pdf".format(name=name.replace(" ", "-").replace("/", "-")) + frappe.local.response.filecontent = pdf + frappe.local.response.type = "pdf" + +def get_html(doctype, name, print_format, letterhead=None): + print_format = frappe.get_doc('Print Format', print_format) + letterhead = frappe.get_doc('Letter Head', letterhead) if letterhead else None + layout = frappe.parse_json(print_format.format_data) + doc = frappe.get_doc(doctype, name) + + print_settings = frappe.get_doc('Print Settings') + + page_width_map = { + 'A4': 210, + 'Letter': 216 + } + page_width = page_width_map.get(print_settings.pdf_page_size) or 210 + body_width = page_width - print_format.margin_left - print_format.margin_right + + context = frappe._dict({ + 'doc': doc, + 'print_format': print_format, + 'print_settings': print_settings, + 'layout': layout, + 'letterhead': letterhead, + 'page_width': page_width, + 'body_width': body_width, + }) + context.css = frappe.render_template('templates/print_format/print_format.css', context) + html = frappe.render_template('templates/print_format/print_format.html', context) + + if layout.header: + header = frappe.render_template(layout.header['html'], context) + else: + header = None + + if layout.footer: + footer = frappe.render_template(layout.footer['html'], context) + else: + footer = None + + return frappe._dict({ + 'main': html, + 'header': header, + 'footer': footer, + }) + + +def get_pdf(html, header, footer): + return PdfGenerator( + base_url=frappe.utils.get_url(), + main_html=html, + header_html=header, + footer_html=footer + ).render_pdf() + + +class PdfGenerator: + """ + Generate a PDF out of a rendered template, with the possibility to integrate nicely + a header and a footer if provided. + + Notes: + ------ + - When Weasyprint renders an html into a PDF, it goes though several intermediate steps. + Here, in this class, we deal mostly with a box representation: 1 `Document` have 1 `Page` + or more, each `Page` 1 `Box` or more. Each box can contain other box. Hence the recursive + method `get_element` for example. + For more, see: + https://weasyprint.readthedocs.io/en/stable/hacking.html#dive-into-the-source + https://weasyprint.readthedocs.io/en/stable/hacking.html#formatting-structure + - Warning: the logic of this class relies heavily on the internal Weasyprint API. This + snippet was written at the time of the release 47, it might break in the future. + - This generator draws its inspiration and, also a bit of its implementation, from this + discussion in the library github issues: https://github.com/Kozea/WeasyPrint/issues/92 + """ + OVERLAY_LAYOUT = '@page {size: A4 portrait; margin: 0;}' + + def __init__(self, main_html, header_html=None, footer_html=None, + base_url=None, side_margin=2, extra_vertical_margin=30): + """ + Parameters + ---------- + main_html: str + An HTML file (most of the time a template rendered into a string) which represents + the core of the PDF to generate. + header_html: str + An optional header html. + footer_html: str + An optional footer html. + base_url: str + An absolute url to the page which serves as a reference to Weasyprint to fetch assets, + required to get our media. + side_margin: int, interpreted in cm, by default 2cm + The margin to apply on the core of the rendered PDF (i.e. main_html). + extra_vertical_margin: int, interpreted in pixel, by default 30 pixels + An extra margin to apply between the main content and header and the footer. + The goal is to avoid having the content of `main_html` touching the header or the + footer. + """ + self.main_html = main_html + self.header_html = header_html + self.footer_html = footer_html + self.base_url = base_url + self.side_margin = side_margin + self.extra_vertical_margin = extra_vertical_margin + + def _compute_overlay_element(self, element: str): + """ + Parameters + ---------- + element: str + Either 'header' or 'footer' + + Returns + ------- + element_body: BlockBox + A Weasyprint pre-rendered representation of an html element + element_height: float + The height of this element, which will be then translated in a html height + """ + html = HTML( + string=getattr(self, f'{element}_html'), + base_url=self.base_url, + ) + element_doc = html.render(stylesheets=[CSS(string=self.OVERLAY_LAYOUT)]) + element_page = element_doc.pages[0] + element_body = PdfGenerator.get_element(element_page._page_box.all_children(), 'body') + element_body = element_body.copy_with_children(element_body.all_children()) + element_html = PdfGenerator.get_element(element_page._page_box.all_children(), element) + + if element == 'header': + element_height = element_html.height + if element == 'footer': + element_height = element_page.height - element_html.position_y + + return element_body, element_height + + def _apply_overlay_on_main(self, main_doc, header_body=None, footer_body=None): + """ + Insert the header and the footer in the main document. + + Parameters + ---------- + main_doc: Document + The top level representation for a PDF page in Weasyprint. + header_body: BlockBox + A representation for an html element in Weasyprint. + footer_body: BlockBox + A representation for an html element in Weasyprint. + """ + for page in main_doc.pages: + page_body = PdfGenerator.get_element(page._page_box.all_children(), 'body') + + if header_body: + page_body.children += header_body.all_children() + if footer_body: + page_body.children += footer_body.all_children() + + def render_pdf(self): + """ + Returns + ------- + pdf: a bytes sequence + The rendered PDF. + """ + if self.header_html: + header_body, header_height = self._compute_overlay_element('header') + else: + header_body, header_height = None, 0 + if self.footer_html: + footer_body, footer_height = self._compute_overlay_element('footer') + else: + footer_body, footer_height = None, 0 + + margins = '{header_size}px {side_margin} {footer_size}px {side_margin}'.format( + header_size=header_height + self.extra_vertical_margin, + footer_size=footer_height + self.extra_vertical_margin, + side_margin=f'{self.side_margin}cm', + ) + content_print_layout = '@page {size: A4 portrait; margin: %s;}' % margins + + html = HTML( + string=self.main_html, + base_url=self.base_url, + ) + main_doc = html.render(stylesheets=[CSS(string=content_print_layout)]) + + if self.header_html or self.footer_html: + self._apply_overlay_on_main(main_doc, header_body, footer_body) + pdf = main_doc.write_pdf() + + return pdf + + @staticmethod + def get_element(boxes, element): + """ + Given a set of boxes representing the elements of a PDF page in a DOM-like way, find the + box which is named `element`. + + Look at the notes of the class for more details on Weasyprint insides. + """ + for box in boxes: + if box.element_tag == element: + return box + return PdfGenerator.get_element(box.all_children(), element) diff --git a/frappe/www/printpreview.html b/frappe/www/printpreview.html new file mode 100644 index 0000000000..c0d0669f0b --- /dev/null +++ b/frappe/www/printpreview.html @@ -0,0 +1,3 @@ + + +{{ html.main }} diff --git a/frappe/www/printpreview.py b/frappe/www/printpreview.py new file mode 100644 index 0000000000..747b119e19 --- /dev/null +++ b/frappe/www/printpreview.py @@ -0,0 +1,15 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +import frappe +from frappe.utils.weasyprint import get_html + +no_cache = 1 + +def get_context(context): + doctype = frappe.form_dict.doctype + name = frappe.form_dict.name + print_format = frappe.form_dict.print_format + letterhead = frappe.form_dict.letterhead + context.no_cache = 1 + context.html = get_html(doctype, name, print_format, letterhead) diff --git a/requirements.txt b/requirements.txt index be96520a02..27b0a9add4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -78,3 +78,4 @@ wrapt~=1.12.1 xlrd~=2.0.1 zxcvbn-python~=4.4.24 tenacity~=8.0.1 +WeasyPrint==52.5