Sfoglia il codice sorgente

feat: Use weasyprint to generate PDF

- /printpreview route to preview HTML template
version-14
Faris Ansari 3 anni fa
parent
commit
0928c4c172
10 ha cambiato i file con 413 aggiunte e 2 eliminazioni
  1. +8
    -1
      frappe/printing/doctype/print_format/print_format.json
  2. +1
    -1
      frappe/printing/page/print/print.js
  3. +5
    -0
      frappe/public/scss/print_format.bundle.scss
  4. +67
    -0
      frappe/templates/print_format/macros.html
  5. +62
    -0
      frappe/templates/print_format/print_format.css
  6. +34
    -0
      frappe/templates/print_format/print_format.html
  7. +217
    -0
      frappe/utils/weasyprint.py
  8. +3
    -0
      frappe/www/printpreview.html
  9. +15
    -0
      frappe/www/printpreview.py
  10. +1
    -0
      requirements.txt

+ 8
- 1
frappe/printing/doctype/print_format/print_format.json Vedi File

@@ -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",


+ 1
- 1
frappe/printing/page/print/print.js Vedi File

@@ -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'
},


+ 5
- 0
frappe/public/scss/print_format.bundle.scss Vedi File

@@ -0,0 +1,5 @@
@import "./desk/variables.scss";
@import "./common/mixins.scss";
@import "./common/global.scss";
@import "./common/icons.scss";
@import "~bootstrap/scss/bootstrap";

+ 67
- 0
frappe/templates/print_format/macros.html Vedi File

@@ -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) %}
<div class="field">
<div class="label" {{ field_attributes(df) }}>
{{ df.label }}
</div>
<div class="value" {{ field_attributes(df) }}>
{{ doc.get_formatted(df.fieldname) }}
</div>
</div>
{% 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) %}
<div class="child-table" {{ field_attributes(df) }}>
<div class="label">
{{ df.label }}
</div>
<table class="table">
{% set columns = df.table_columns %}
<thead>
<tr class="table-row">
{% for column in columns %}
<th class="column-header" width="{{ column.width }}%" {{ field_attributes(column) }}>
{{ column.label }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in doc.get(df.fieldname) %}
<tr class="table-row {{ loop.cycle('odd', 'even') }}" data-idx="{{ row.idx }}">
{% for column in columns %}
<td class="column-value" width="{{ column.width }}%" {{ field_attributes(column) }}>
{{ row.get_formatted(column.fieldname) }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endmacro %}

{% macro render_custom_html(df, doc) %}
<div class="custom-html">
{{ frappe.render_template(df.html, {'doc': doc}) }}
</div>
{% endmacro %}


+ 62
- 0
frappe/templates/print_format/print_format.css Vedi File

@@ -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;
}

+ 34
- 0
frappe/templates/print_format/print_format.html Vedi File

@@ -0,0 +1,34 @@
{% import "templates/print_format/macros.html" as macros %}

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ doc.doctype }}: {{ doc.name }}</title>
{{ include_style('print_format.bundle.css') }}
<style>
{{ css }}
</style>
</head>
<body>
{% for section in layout.sections %}
<div class="section {{ resolve_class({'page-break': section.page_break}) }}">
{% if section.label %}
<div class="section-label">{{ section.label }}</div>
{% endif %}

<div class="section-columns row">
{% for column in section.columns %}
<div class="column col">
{% for df in column.fields %}
{{ macros.render_field(df, doc) }}
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</body>
</html>

+ 217
- 0
frappe/utils/weasyprint.py Vedi File

@@ -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)

+ 3
- 0
frappe/www/printpreview.html Vedi File

@@ -0,0 +1,3 @@
<!-- </body> -->

{{ html.main }}

+ 15
- 0
frappe/www/printpreview.py Vedi File

@@ -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)

+ 1
- 0
requirements.txt Vedi File

@@ -78,3 +78,4 @@ wrapt~=1.12.1
xlrd~=2.0.1
zxcvbn-python~=4.4.24
tenacity~=8.0.1
WeasyPrint==52.5

Caricamento…
Annulla
Salva