seitime-frappe/frappe/utils/weasyprint.py

249 lines
7.7 KiB
Python

# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See LICENSE
import click
import frappe
from frappe import _
@frappe.whitelist()
def download_pdf(doctype: str, name: str | int, print_format: str, letterhead: str | None = None):
doc = frappe.get_doc(doctype, name)
doc.check_permission("print")
generator = PrintFormatGenerator(print_format, doc, letterhead)
pdf = generator.render_pdf()
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):
doc = frappe.get_doc(doctype, name)
doc.check_permission("print")
generator = PrintFormatGenerator(print_format, doc, letterhead)
return generator.get_html_preview()
class PrintFormatGenerator:
"""
Generate a PDF of a Document, with repeatable header and footer if letterhead is provided.
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
"""
def __init__(self, print_format, doc, letterhead=None):
"""
Parameters
----------
print_format: str
Name of the Print Format
doc: str
Document to print
letterhead: str
Letter Head to apply (optional)
"""
self.base_url = frappe.utils.get_url()
self.print_format = frappe.get_doc("Print Format", print_format)
self.doc = doc
if letterhead == _("No Letterhead"):
letterhead = None
self.letterhead = frappe.get_doc("Letter Head", letterhead) if letterhead else None
self.build_context()
self.layout = self.get_layout(self.print_format)
self.context.layout = self.layout
def build_context(self):
self.print_settings = frappe.get_doc("Print Settings")
page_width_map = {"A4": 210, "Letter": 216}
page_width = page_width_map.get(self.print_settings.pdf_page_size) or 210
body_width = page_width - self.print_format.margin_left - self.print_format.margin_right
print_style = (
frappe.get_doc("Print Style", self.print_settings.print_style)
if self.print_settings.print_style
else None
)
context = frappe._dict(
{
"doc": self.doc,
"print_format": self.print_format,
"print_settings": self.print_settings,
"print_style": print_style,
"letterhead": self.letterhead,
"page_width": page_width,
"body_width": body_width,
}
)
self.context = context
def get_html_preview(self):
header_html, footer_html = self.get_header_footer_html()
self.context.header = header_html
self.context.footer = footer_html
return self.get_main_html()
def get_main_html(self):
self.context.css = frappe.render_template("templates/print_format/print_format.css", self.context)
return frappe.render_template("templates/print_format/print_format.html", self.context)
def get_header_footer_html(self):
header_html = footer_html = None
if self.letterhead or self.layout.get("header"):
header_html = frappe.render_template("templates/print_format/print_header.html", self.context)
if self.letterhead or self.layout.get("footer"):
footer_html = frappe.render_template("templates/print_format/print_footer.html", self.context)
return header_html, footer_html
def render_pdf(self):
"""Return a bytes sequence of the rendered PDF."""
HTML, _CSS = import_weasyprint()
self._make_header_footer()
self.context.update({"header_height": self.header_height, "footer_height": self.footer_height})
main_html = self.get_main_html()
html = HTML(string=main_html, base_url=self.base_url)
main_doc = html.render()
if self.header_html or self.footer_html:
self._apply_overlay_on_main(main_doc, self.header_body, self.footer_body)
return main_doc.write_pdf()
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, CSS = import_weasyprint()
html = HTML(
string=getattr(self, f"{element}_html"),
base_url=self.base_url,
)
element_doc = html.render(stylesheets=[CSS(string="@page {size: A4 portrait; margin: 0;}")])
element_page = element_doc.pages[0]
element_body = PrintFormatGenerator.get_element(element_page._page_box.all_children(), "body")
element_body = element_body.copy_with_children(element_body.all_children())
element_html = PrintFormatGenerator.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 = PrintFormatGenerator.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 _make_header_footer(self):
self.header_html, self.footer_html = self.get_header_footer_html()
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
self.header_body = header_body
self.header_height = header_height
self.footer_body = footer_body
self.footer_height = footer_height
def get_layout(self, print_format):
layout = frappe.parse_json(print_format.format_data)
layout = self.set_field_renderers(layout)
layout = self.process_margin_texts(layout)
return layout
def set_field_renderers(self, layout):
renderers = {"HTML Editor": "HTML", "Markdown Editor": "Markdown"}
for section in layout["sections"]:
for column in section["columns"]:
for df in column["fields"]:
fieldtype = df["fieldtype"]
renderer_name = fieldtype.replace(" ", "")
df["renderer"] = renderers.get(fieldtype) or renderer_name
df["section"] = section
return layout
def process_margin_texts(self, layout):
margin_texts = [
"top_left",
"top_center",
"top_right",
"bottom_left",
"bottom_center",
"bottom_right",
]
for key in margin_texts:
text = layout.get("text_" + key)
if text and "{{" in text:
layout["text_" + key] = frappe.render_template(text, self.context)
return layout
@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 PrintFormatGenerator.get_element(box.all_children(), element)
def import_weasyprint():
try:
from weasyprint import CSS, HTML
return HTML, CSS
except OSError:
message = "\n".join(
[
"WeasyPrint depdends on additional system dependencies.",
"Follow instructions specific to your operating system:",
"https://doc.courtbouillon.org/weasyprint/stable/first_steps.html",
]
)
click.secho(message, fg="yellow")
frappe.throw(message)