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 %}
+
+ {{ column.label }}
+ |
+ {% endfor %}
+
+
+
+ {% for row in doc.get(df.fieldname) %}
+
+ {% for column in columns %}
+
+ {{ row.get_formatted(column.fieldname) }}
+ |
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+{% 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