465 lines
15 KiB
Python
465 lines
15 KiB
Python
from typing import ClassVar
|
|
|
|
from bs4 import BeautifulSoup
|
|
|
|
import frappe
|
|
from frappe.utils.print_utils import convert_uom, parse_float_and_unit
|
|
|
|
|
|
class Browser:
|
|
def __init__(self, generator, print_format, html, options):
|
|
self.is_print_designer = frappe.get_cached_value("Print Format", print_format, "print_designer")
|
|
self.browserID = frappe.utils.random_string(10)
|
|
generator.add_browser(self.browserID)
|
|
# sets soup from html
|
|
self.set_html(html)
|
|
# sets wkhtmltopdf options
|
|
self.set_options(options)
|
|
# start cdp connection and create browser context ( kind of like new window / incognito mode)
|
|
self.open(generator)
|
|
# opens header and footer pages and sets content ( not waiting for it to load)
|
|
self.prepare_header_footer()
|
|
# opens body page and sets content and waits for it to finshing load
|
|
self.setup_body_page()
|
|
# prepare options as per chrome for pdf
|
|
self.prepare_options_for_pdf()
|
|
# generate header and footer pages if they are not dynamic ( first, odd, even, last)
|
|
self.update_header_footer_page_pd()
|
|
# if header and footer are not dynamic start generating pdf for them (non-blocking)
|
|
self.try_async_header_footer_pdf()
|
|
# now wait for page to load as we need DOM to generate pdf
|
|
self.body_page.wait_for_set_content()
|
|
self.body_pdf = self.body_page.generate_pdf(raw=not self.header_page and not self.footer_page)
|
|
self.body_page.close()
|
|
self.update_header_footer_page()
|
|
|
|
if self.header_page:
|
|
if not self.is_header_dynamic:
|
|
self.header_pdf = self.header_page.get_pdf_from_stream(self.header_page.get_pdf_stream_id())
|
|
else:
|
|
self.header_pdf = self.header_page.generate_pdf()
|
|
self.header_page.close()
|
|
|
|
if self.footer_page:
|
|
if not self.is_footer_dynamic:
|
|
self.footer_pdf = self.footer_page.get_pdf_from_stream(self.footer_page.get_pdf_stream_id())
|
|
else:
|
|
self.footer_pdf = self.footer_page.generate_pdf()
|
|
self.footer_page.close()
|
|
|
|
self.close()
|
|
|
|
generator.remove_browser(self.browserID)
|
|
|
|
def open(self, generator):
|
|
from frappe.utils.pdf_generator.cdp_connection import CDPSocketClient
|
|
|
|
# checking because if we share browser accross request _devtools_url will already be set for subsequent requests.
|
|
if not generator._devtools_url:
|
|
generator._set_devtools_url()
|
|
# start the CDP websocket connection to browser
|
|
self.session = CDPSocketClient(generator._devtools_url)
|
|
|
|
self.session.connect()
|
|
self.create_browser_context()
|
|
|
|
def create_browser_context(self):
|
|
# create browser context
|
|
result, error = self.session.send("Target.createBrowserContext", {"disposeOnDetach": True})
|
|
if error:
|
|
frappe.log_error(title="Error creating browser context:", message=f"{error}")
|
|
self.browser_context_id = result["browserContextId"]
|
|
|
|
def set_html(self, html):
|
|
self.soup = BeautifulSoup(html, "html5lib")
|
|
|
|
def set_options(self, options):
|
|
self.options = options
|
|
|
|
def new_page(self, page_type):
|
|
"""
|
|
# create a new page in the browser inside browser context
|
|
----
|
|
TODO: Implement Deterministic rendering for headless-chrome via DevTools Protocol ( waiting for macos support )
|
|
https://docs.google.com/document/d/1PppegrpXhOzKKAuNlP6XOEnviXFGUiX2hop00Cxcv4o/edit?tab=t.0#bookmark=id.dukbomwxpb3j
|
|
|
|
NOTE: In theory this will make it faster but more importantly use less cpu, ram etc.
|
|
"""
|
|
|
|
from frappe.utils.pdf_generator.page import Page
|
|
|
|
page = Page(self.session, self.browser_context_id, page_type)
|
|
page.is_print_designer = self.is_print_designer
|
|
|
|
return page
|
|
|
|
def setup_body_page(self):
|
|
self.body_page = self.new_page("body")
|
|
self.body_page.set_tab_url(frappe.request.host_url)
|
|
self.body_page.wait_for_navigate()
|
|
self.body_page.set_content(str(self.soup))
|
|
|
|
def close_page(self, type):
|
|
page = getattr(self, f"{type}_page")
|
|
page.close()
|
|
|
|
def is_page_no_used(self, soup):
|
|
# Check if any of the classes exist
|
|
classes_to_check = [
|
|
"page",
|
|
"frompage",
|
|
"topage",
|
|
"page_info_page",
|
|
"page_info_frompage",
|
|
"page_info_topage",
|
|
]
|
|
|
|
# Loop through the classes to check
|
|
for class_name in classes_to_check:
|
|
if soup.find(class_=class_name): # Check if any element with the class is found
|
|
return True # Return True if class is found
|
|
|
|
return False
|
|
|
|
def prepare_header_footer(self):
|
|
# code is structured like this to improve performance by running commands in chrome as soon as possible.
|
|
soup = self.soup
|
|
options = self.options
|
|
# open header and footer pages
|
|
self._open_header_footer_pages()
|
|
|
|
# get tags to pass to header template.
|
|
head = soup.find("head").contents
|
|
styles = soup.find_all("style")
|
|
|
|
# set header and footer content ( not waiting for it to load yet).
|
|
if self.header_page:
|
|
self.header_page.wait_for_navigate()
|
|
self.header_page.set_content(
|
|
self.get_rendered_header_footer(self.header_content, "header", head, styles, css=[])
|
|
)
|
|
|
|
if self.footer_page:
|
|
self.footer_page.wait_for_navigate()
|
|
self.footer_page.set_content(
|
|
self.get_rendered_header_footer(self.footer_content, "footer", head, styles, css=[])
|
|
)
|
|
if self.header_page:
|
|
self.header_page.wait_for_set_content()
|
|
self.header_height = self.header_page.get_element_height()
|
|
self.is_header_dynamic = self.is_page_no_used(self.header_content)
|
|
del self.header_content
|
|
else:
|
|
# bad implicit setting of margin #backwards-compatibility
|
|
options["margin-top"] = "15mm"
|
|
|
|
if self.footer_page:
|
|
self.footer_page.wait_for_set_content()
|
|
self.footer_height = self.footer_page.get_element_height()
|
|
self.is_footer_dynamic = self.is_page_no_used(self.footer_content)
|
|
del self.footer_content
|
|
else:
|
|
# bad implicit setting of margin #backwards-compatibility
|
|
options["margin-bottom"] = "15mm"
|
|
|
|
# Remove instances of them from main content for render_template
|
|
for html_id in ["header-html", "footer-html"]:
|
|
for tag in soup.find_all(id=html_id):
|
|
tag.extract()
|
|
|
|
def try_async_header_footer_pdf(self):
|
|
if self.header_page and not self.is_header_dynamic:
|
|
self.header_page.generate_pdf(wait_for_pdf=False)
|
|
if self.footer_page and not self.is_footer_dynamic:
|
|
self.footer_page.generate_pdf(wait_for_pdf=False)
|
|
|
|
def _get_converted_num(self, num_str, unit="px"):
|
|
parsed = parse_float_and_unit(num_str)
|
|
if parsed:
|
|
return convert_uom(parsed["value"], parsed["unit"], unit, only_number=True)
|
|
|
|
def _parse_pdf_options_from_html(self):
|
|
from frappe.utils.pdf import get_print_format_styles
|
|
|
|
soup: BeautifulSoup = self.soup
|
|
options = {}
|
|
print_format_css = get_print_format_styles(soup)
|
|
attrs = (
|
|
"margin-top",
|
|
"margin-bottom",
|
|
"margin-left",
|
|
"margin-right",
|
|
"page-size",
|
|
"header-spacing",
|
|
"orientation",
|
|
"page-width",
|
|
"page-height",
|
|
)
|
|
options |= {style.name: style.value for style in print_format_css if style.name in attrs}
|
|
self.options.update(options)
|
|
|
|
def _set_default_page_size(self):
|
|
options = self.options
|
|
pdf_page_size = (
|
|
options.get("page-size") or frappe.db.get_single_value("Print Settings", "pdf_page_size") or "A4"
|
|
)
|
|
|
|
if pdf_page_size == "Custom":
|
|
options["page-height"] = options.get("page-height") or frappe.db.get_single_value(
|
|
"Print Settings", "pdf_page_height"
|
|
)
|
|
options["page-width"] = options.get("page-width") or frappe.db.get_single_value(
|
|
"Print Settings", "pdf_page_width"
|
|
)
|
|
else:
|
|
options["page-size"] = pdf_page_size
|
|
|
|
def prepare_options_for_pdf(self):
|
|
self._parse_pdf_options_from_html()
|
|
self._set_default_page_size()
|
|
|
|
options = self.options
|
|
|
|
updated_options = {
|
|
"scale": 1,
|
|
"printBackground": True,
|
|
"transferMode": "ReturnAsStream",
|
|
"marginTop": 0,
|
|
"marginBottom": 0,
|
|
"marginLeft": 0,
|
|
"marginRight": 0,
|
|
"landscape": options.get("orientation", "Portrait") == "Landscape",
|
|
"preferCSSPageSize": False,
|
|
"pageRanges": options.get("page-ranges", ""),
|
|
# Experimental
|
|
"generateTaggedPDF": options.get("generate-tagged-pdf", False),
|
|
"generateOutline": options.get("generate-outline", False),
|
|
}
|
|
|
|
# bad implicit setting of margin #backwards-compatibility
|
|
if not self.is_print_designer:
|
|
if not options.get("margin-right"):
|
|
options["margin-right"] = "15mm"
|
|
|
|
if not options.get("margin-left"):
|
|
options["margin-left"] = "15mm"
|
|
|
|
if not options.get("page-height") or not options.get("page-width"):
|
|
if not (page_size := self.options.get("page-size")):
|
|
raise frappe.ValidationError("Page size is required")
|
|
if page_size == "CUSTOM":
|
|
raise frappe.ValidationError("Custom page size requires page-height and page-width")
|
|
size = PageSize.get(page_size)
|
|
if not size:
|
|
raise frappe.ValidationError("Invalid page size")
|
|
|
|
options["page-height"] = convert_uom(size["height"], "mm", "px", only_number=True)
|
|
options["page-width"] = convert_uom(size["width"], "mm", "px", only_number=True)
|
|
|
|
if isinstance(options["page-height"], str):
|
|
options["page-height"] = self._get_converted_num(options["page-height"])
|
|
|
|
if isinstance(options["page-width"], str):
|
|
options["page-width"] = self._get_converted_num(options["page-width"])
|
|
|
|
updated_options["paperWidth"] = convert_uom(options["page-width"], "px", "in", only_number=True)
|
|
|
|
if options.get("margin-left"):
|
|
updated_options["marginLeft"] = convert_uom(
|
|
self._get_converted_num(options["margin-left"]), "px", "in", only_number=True
|
|
)
|
|
|
|
if options.get("margin-right"):
|
|
updated_options["marginRight"] = convert_uom(
|
|
self._get_converted_num(options["margin-right"]), "px", "in", only_number=True
|
|
)
|
|
|
|
# make copy of options to update them in header, body, footer.
|
|
self.body_page.options = updated_options.copy()
|
|
if self.header_page:
|
|
self.header_page.options = updated_options.copy()
|
|
if self.footer_page:
|
|
self.footer_page.options = updated_options.copy()
|
|
|
|
margin_top = self._get_converted_num(options.get("margin-top", 0))
|
|
margin_bottom = self._get_converted_num(options.get("margin-bottom", 0))
|
|
|
|
header_with_top_margin = 0
|
|
header_with_spacing_top_margin = 0
|
|
footer_with_bottom_margin = 0
|
|
footer_height = 0
|
|
|
|
if self.header_page:
|
|
header_with_top_margin = self.header_height + margin_top
|
|
header_spacing = options.get("header-spacing", 0)
|
|
header_with_spacing_top_margin = header_with_top_margin + header_spacing
|
|
self.header_page.options["paperHeight"] = (
|
|
convert_uom(header_with_spacing_top_margin, "px", "in", only_number=True)
|
|
if header_with_spacing_top_margin
|
|
else 0
|
|
)
|
|
|
|
margin_top = convert_uom(margin_top, "px", "in", only_number=True)
|
|
|
|
if self.header_page:
|
|
self.header_page.options["marginTop"] = margin_top
|
|
else:
|
|
self.body_page.options["marginTop"] = margin_top
|
|
|
|
if self.footer_page:
|
|
footer_height = self.footer_height
|
|
self.footer_page.options["paperHeight"] = (
|
|
convert_uom(footer_height, "px", "in", only_number=True) if footer_height else 0
|
|
)
|
|
footer_with_bottom_margin = self.footer_height + margin_bottom
|
|
|
|
margin_bottom = convert_uom(margin_bottom, "px", "in", only_number=True)
|
|
|
|
if self.footer_page:
|
|
self.footer_page.options["marginBottom"] = margin_bottom
|
|
else:
|
|
self.body_page.options["marginBottom"] = margin_bottom
|
|
|
|
body_height = options.get("page-height") - (
|
|
header_with_spacing_top_margin + footer_with_bottom_margin
|
|
)
|
|
|
|
"""
|
|
matching scale for some old formats is 1.46 #backwards-compatibility ( scale 1 is better in my opinion)
|
|
If we face issues in custom formats then only we should enable this.
|
|
"""
|
|
|
|
self.body_page.options["paperHeight"] = convert_uom(body_height, "px", "in", only_number=True)
|
|
|
|
def get_rendered_header_footer(self, content, type, head, styles, css):
|
|
from frappe.utils.pdf import toggle_visible_pdf
|
|
|
|
html_id = f"{type}-html"
|
|
content = content.extract()
|
|
toggle_visible_pdf(content)
|
|
id_map = {"header": "pdf_header_html", "footer": "pdf_footer_html"}
|
|
hook_func = frappe.get_hooks(id_map.get(type))
|
|
return frappe.call(
|
|
hook_func[-1],
|
|
soup=self.soup,
|
|
head=head,
|
|
content=content,
|
|
styles=styles,
|
|
html_id=html_id,
|
|
css=css,
|
|
path="templates/print_formats/chrome_pdf_header_footer.html",
|
|
)
|
|
|
|
def update_header_footer_page(self):
|
|
if not self.header_page and not self.footer_page:
|
|
return
|
|
total_pages = len(self.body_pdf.pages)
|
|
# function is added to html from update_page_no.js
|
|
if self.header_page:
|
|
if self.is_header_dynamic:
|
|
self.header_page.evaluate(
|
|
f"clone_and_update('{'#header-render-container' if self.is_print_designer else '.wrapper'}', {total_pages}, {1 if self.is_print_designer else 0}, 'Header', 1);",
|
|
await_promise=True,
|
|
)
|
|
|
|
if self.footer_page:
|
|
if self.is_footer_dynamic:
|
|
self.footer_page.evaluate(
|
|
f"clone_and_update('{'#footer-render-container' if self.is_print_designer else '.wrapper'}', {total_pages}, {1 if self.is_print_designer else 0}, 'Footer', 1);",
|
|
await_promise=True,
|
|
)
|
|
|
|
def update_header_footer_page_pd(self):
|
|
if not self.is_print_designer:
|
|
return
|
|
if not self.header_page and not self.footer_page:
|
|
return
|
|
# function is added to html from update_page_no.js
|
|
if self.header_page and not self.is_header_dynamic:
|
|
self.header_page.evaluate(
|
|
"clone_and_update('#header-render-container', 0, 1, 'Header', 0);",
|
|
await_promise=True,
|
|
)
|
|
|
|
if self.footer_page and not self.is_footer_dynamic:
|
|
self.footer_page.evaluate(
|
|
"clone_and_update('#footer-render-container', 0, 1, 'Footer', 0);",
|
|
await_promise=True,
|
|
)
|
|
|
|
def _open_header_footer_pages(self):
|
|
self.header_page = None
|
|
self.footer_page = None
|
|
# open new page for header/footer if they exist.
|
|
# It sends CDP command to the browser to open a new tab.
|
|
if header_content := self.soup.find(id="header-html"):
|
|
self.header_page = self.new_page("header")
|
|
self.header_page.set_tab_url(frappe.request.host_url)
|
|
|
|
if footer_content := self.soup.find(id="footer-html"):
|
|
self.footer_page = self.new_page("footer")
|
|
self.footer_page.set_tab_url(frappe.request.host_url)
|
|
|
|
self.header_content = header_content
|
|
self.footer_content = footer_content
|
|
|
|
def close(self):
|
|
self.session.disconnect()
|
|
|
|
|
|
class PageSize:
|
|
page_sizes: ClassVar[dict[str, tuple[int, int]]] = {
|
|
"A10": (26, 37),
|
|
"A1": (594, 841),
|
|
"A0": (841, 1189),
|
|
"A3": (297, 420),
|
|
"A2": (420, 594),
|
|
"A5": (148, 210),
|
|
"A4": (210, 297),
|
|
"A7": (74, 105),
|
|
"A6": (105, 148),
|
|
"A9": (37, 52),
|
|
"A8": (52, 74),
|
|
"B10": (44, 31),
|
|
"B1+": (1020, 720),
|
|
"B4": (353, 250),
|
|
"B5": (250, 176),
|
|
"B6": (176, 125),
|
|
"B7": (125, 88),
|
|
"B0": (1414, 1000),
|
|
"B1": (1000, 707),
|
|
"B2": (707, 500),
|
|
"B3": (500, 353),
|
|
"B2+": (720, 520),
|
|
"B8": (88, 62),
|
|
"B9": (62, 44),
|
|
"C10": (40, 28),
|
|
"C9": (57, 40),
|
|
"C8": (81, 57),
|
|
"C3": (458, 324),
|
|
"C2": (648, 458),
|
|
"C1": (917, 648),
|
|
"C0": (1297, 917),
|
|
"C7": (114, 81),
|
|
"C6": (162, 114),
|
|
"C5": (229, 162),
|
|
"C4": (324, 229),
|
|
"Legal": (216, 356),
|
|
"Junior Legal": (127, 203),
|
|
"Letter": (216, 279),
|
|
"Tabloid": (279, 432),
|
|
"Ledger": (432, 279),
|
|
"ANSI C": (432, 559),
|
|
"ANSI A (letter)": (216, 279),
|
|
"ANSI B (ledger & tabloid)": (279, 432),
|
|
"ANSI E": (864, 1118),
|
|
"ANSI D": (559, 864),
|
|
}
|
|
|
|
@classmethod
|
|
def get(cls, name):
|
|
if name in cls.page_sizes:
|
|
width, height = cls.page_sizes[name]
|
|
return {"width": width, "height": height}
|
|
else:
|
|
return None # Return None if the page size is not found
|