# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import copy import json import os import re from typing import TYPE_CHECKING, Optional, TypedDict import frappe from frappe import _, get_module_path from frappe.core.doctype.access_log.access_log import make_access_log from frappe.core.doctype.document_share_key.document_share_key import is_expired from frappe.utils import cint, escape_html, strip_html from frappe.utils.jinja_globals import is_rtl if TYPE_CHECKING: from frappe.core.doctype.docfield.docfield import DocField from frappe.model.document import Document from frappe.model.meta import Meta from frappe.printing.doctype.print_format.print_format import PrintFormat from frappe.printing.doctype.print_settings.print_settings import PrintSettings no_cache = 1 standard_format = "templates/print_formats/standard.html" class PrintContext(TypedDict): body: str print_style: str comment: str title: str lang: str layout_direction: str doctype: str name: str key: str def get_context(context) -> PrintContext: """Build context for print""" if not ((frappe.form_dict.doctype and frappe.form_dict.name) or frappe.form_dict.doc): return { "body": f"""

Error

Parameters doctype and name required

{escape_html(frappe.as_json(frappe.form_dict, indent=2))}
""" } if frappe.form_dict.doc: doc = frappe.form_dict.doc else: doc = frappe.get_doc(frappe.form_dict.doctype, frappe.form_dict.name) set_link_titles(doc) settings = frappe.parse_json(frappe.form_dict.settings) letterhead = frappe.form_dict.letterhead or None meta = frappe.get_meta(doc.doctype) print_format = get_print_format_doc(None, meta=meta) make_access_log( doctype=frappe.form_dict.doctype, document=frappe.form_dict.name, file_type="PDF", method="Print" ) print_style = None body = get_rendered_template( doc, print_format=print_format, meta=meta, trigger_print=frappe.form_dict.trigger_print, no_letterhead=frappe.form_dict.no_letterhead, letterhead=letterhead, settings=settings, ) print_style = get_print_style(frappe.form_dict.style, print_format) return { "body": body, "print_style": print_style, "comment": frappe.session.user, "title": frappe.utils.strip_html(doc.get_title() or doc.name), "lang": frappe.local.lang, "layout_direction": "rtl" if is_rtl() else "ltr", "doctype": frappe.form_dict.doctype, "name": frappe.form_dict.name, "key": frappe.form_dict.get("key"), } def get_print_format_doc(print_format_name: str, meta: "Meta") -> Optional["PrintFormat"]: """Return print format document.""" if not print_format_name: print_format_name = frappe.form_dict.format or meta.default_print_format or "Standard" if print_format_name == "Standard": return None else: try: return frappe.get_doc("Print Format", print_format_name) except frappe.DoesNotExistError: # if old name, return standard! return None def get_rendered_template( doc: "Document", print_format: Optional["PrintFormat"] = None, meta: "Meta" = None, no_letterhead: bool | None = None, letterhead: str | None = None, trigger_print: bool = False, settings: dict | None = None, ) -> str: print_settings = frappe.get_single("Print Settings").as_dict() print_settings.update(settings or {}) if isinstance(no_letterhead, str): no_letterhead = cint(no_letterhead) elif no_letterhead is None: no_letterhead = not cint(print_settings.with_letterhead) doc.flags.in_print = True doc.flags.print_settings = print_settings if not frappe.flags.ignore_print_permissions: validate_print_permission(doc) if doc.meta.is_submittable: if doc.docstatus.is_draft() and not cint(print_settings.allow_print_for_draft): frappe.throw(_("Not allowed to print draft documents"), frappe.PermissionError) if doc.docstatus.is_cancelled() and not cint(print_settings.allow_print_for_cancelled): frappe.throw(_("Not allowed to print cancelled documents"), frappe.PermissionError) doc.run_method("before_print", print_settings) if not hasattr(doc, "print_heading"): doc.print_heading = None if not hasattr(doc, "sub_heading"): doc.sub_heading = None if not meta: meta = frappe.get_meta(doc.doctype) jenv = frappe.get_jenv() format_data, format_data_map = [], {} # determine template if print_format: doc.print_section_headings = print_format.show_section_headings doc.print_line_breaks = print_format.line_breaks doc.align_labels_right = print_format.align_labels_right doc.absolute_value = print_format.absolute_value def get_template_from_string(): return jenv.from_string(get_print_format(doc.doctype, print_format)) template = None if hook_func := frappe.get_hooks("get_print_format_template"): template = frappe.get_attr(hook_func[-1])(jenv=jenv, print_format=print_format) if template: pass elif print_format.custom_format: template = get_template_from_string() elif print_format.format_data: # set format data format_data = json.loads(print_format.format_data) for df in format_data: format_data_map[df.get("fieldname")] = df if "visible_columns" in df: for _df in df.get("visible_columns"): format_data_map[_df.get("fieldname")] = _df doc.format_data_map = format_data_map template = "standard" elif print_format.standard == "Yes": template = get_template_from_string() else: # fallback template = "standard" else: template = "standard" if template == "standard": template = jenv.get_template(standard_format) letter_head = frappe._dict(get_letter_head(doc, no_letterhead, letterhead) or {}) if letter_head.content: letter_head.content = frappe.utils.jinja.render_template(letter_head.content, {"doc": doc.as_dict()}) if letter_head.header_script: letter_head.content += f""" """ if letter_head.footer: letter_head.footer = frappe.utils.jinja.render_template(letter_head.footer, {"doc": doc.as_dict()}) if letter_head.footer_script: letter_head.footer += f""" """ convert_markdown(doc) args = {} # extract `print_heading_template` from the first field and remove it if format_data and format_data[0].get("fieldname") == "print_heading_template": args["print_heading_template"] = format_data.pop(0).get("options") args.update( { "doc": doc, "meta": frappe.get_meta(doc.doctype), "layout": make_layout(doc, meta, format_data), "no_letterhead": no_letterhead, "trigger_print": cint(trigger_print), "letter_head": letter_head.content, "footer": letter_head.footer, "print_settings": print_settings, } ) hook_func = frappe.get_hooks("pdf_body_html") html = frappe.get_attr(hook_func[-1])(jenv=jenv, template=template, print_format=print_format, args=args) if cint(trigger_print): html += trigger_print_script return html def set_link_titles(doc: "Document") -> None: # Adds name with title of link field doctype to __link_titles if not doc.get("__link_titles"): setattr(doc, "__link_titles", {}) meta = frappe.get_meta(doc.doctype) set_title_values_for_link_and_dynamic_link_fields(meta, doc) set_title_values_for_table_and_multiselect_fields(meta, doc) def set_title_values_for_link_and_dynamic_link_fields( meta: "Meta", doc: "Document", parent_doc: Optional["Document"] = None ) -> None: if parent_doc and not parent_doc.get("__link_titles"): setattr(parent_doc, "__link_titles", {}) elif doc and not doc.get("__link_titles"): setattr(doc, "__link_titles", {}) for field in meta.get_link_fields() + meta.get_dynamic_link_fields(): if not doc.get(field.fieldname): continue # If link field, then get doctype from options # If dynamic link field, then get doctype from dependent field doctype = field.options if field.fieldtype == "Link" else doc.get(field.options) meta = frappe.get_meta(doctype) if not meta or not meta.title_field or not meta.show_title_field_in_link: continue link_title = frappe.get_cached_value(doctype, doc.get(field.fieldname), meta.title_field) if parent_doc: parent_doc.__link_titles[f"{doctype}::{doc.get(field.fieldname)}"] = link_title elif doc: doc.__link_titles[f"{doctype}::{doc.get(field.fieldname)}"] = link_title def set_title_values_for_table_and_multiselect_fields(meta: "Meta", doc: "Document") -> None: for field in meta.get_table_fields(): if not doc.get(field.fieldname): continue _meta = frappe.get_meta(field.options) for value in doc.get(field.fieldname): set_title_values_for_link_and_dynamic_link_fields(_meta, value, doc) def convert_markdown(doc: "Document") -> None: """Convert text field values to markdown if necessary.""" for field in doc.meta.fields: if field.fieldtype == "Text Editor": value = doc.get(field.fieldname) if value and "" in value: doc.set(field.fieldname, frappe.utils.md_to_html(value)) @frappe.whitelist() def get_html_and_style( doc: str, name: str | None = None, print_format: str | None = None, no_letterhead: bool | None = None, letterhead: str | None = None, trigger_print: bool = False, style: str | None = None, settings: str | None = None, ) -> dict[str, str]: """Return `html` and `style` of print format, used in PDF etc.""" if isinstance(name, str): document = frappe.get_doc(doc, name) else: document = frappe.get_doc(json.loads(doc)) document.check_permission() print_format = get_print_format_doc(print_format, meta=document.meta) set_link_titles(document) try: html = get_rendered_template( doc=document, print_format=print_format, meta=document.meta, no_letterhead=no_letterhead, letterhead=letterhead, trigger_print=trigger_print, settings=frappe.parse_json(settings), ) except frappe.TemplateNotFoundError: frappe.clear_last_message() html = None return {"html": html, "style": get_print_style(style=style, print_format=print_format)} @frappe.whitelist() def get_rendered_raw_commands(doc: str, name: str | None = None, print_format: str | None = None) -> dict: """Return Rendered Raw Commands of print format, used to send directly to printer.""" if isinstance(name, str): document = frappe.get_doc(doc, name) else: document = frappe.get_doc(json.loads(doc)) document.check_permission() print_format = get_print_format_doc(print_format, meta=document.meta) if not print_format or (print_format and not print_format.raw_printing): frappe.throw( _("{0} is not a raw printing format.").format(print_format), frappe.TemplateNotFoundError ) return { "raw_commands": get_rendered_template(doc=document, print_format=print_format, meta=document.meta) } def validate_print_permission(doc: "Document") -> None: for ptype in ("read", "print"): if frappe.has_permission(doc.doctype, ptype, doc) or frappe.has_website_permission(doc): return key = frappe.form_dict.key if key and isinstance(key, str): validate_key(key, doc) else: raise frappe.PermissionError(_("You do not have permission to view this document")) def validate_key(key: str, doc: "Document") -> None: document_key_expiry = frappe.get_cached_value( "Document Share Key", {"reference_doctype": doc.doctype, "reference_docname": doc.name, "key": key}, ["expires_on"], ) if document_key_expiry is not None: if is_expired(document_key_expiry[0]): raise frappe.exceptions.LinkExpired else: return # TODO: Deprecate this! kept it for backward compatibility if frappe.get_system_settings("allow_older_web_view_links") and key == doc.get_signature(): return raise frappe.exceptions.InvalidKeyError def get_letter_head(doc: "Document", no_letterhead: bool, letterhead: str | None = None) -> dict: if no_letterhead: return {} letterhead_name = letterhead or doc.get("letter_head") if letterhead_name: return frappe.db.get_value( "Letter Head", letterhead_name, ["content", "footer", "header_script", "footer_script"], as_dict=True, ) else: return ( frappe.db.get_value( "Letter Head", {"is_default": 1}, ["content", "footer", "header_script", "footer_script"], as_dict=True, ) or {} ) def get_print_format(doctype: str, print_format: "PrintFormat") -> str: if print_format.disabled: frappe.throw(_("Print Format {0} is disabled").format(print_format.name), frappe.DoesNotExistError) # server, find template module = print_format.module or frappe.db.get_value("DocType", doctype, "module") path = os.path.join( get_module_path(module, "Print Format", print_format.name), frappe.scrub(print_format.name) + ".html", ) if os.path.exists(path): with open(path) as pffile: return pffile.read() else: if print_format.raw_printing: return print_format.raw_commands if print_format.html: return print_format.html frappe.throw(_("No template found at path: {0}").format(path), frappe.TemplateNotFoundError) def make_layout(doc: "Document", meta: "Meta", format_data=None) -> list: """Builds a hierarchical layout object from the fields list to be rendered by `standard.html` :param doc: Document to be rendered. :param meta: Document meta object (doctype). :param format_data: Fields sequence and properties defined by Print Format Builder.""" layout, page = [], [] layout.append(page) def get_new_section(): return {"columns": [], "has_data": False} def append_empty_field_dict_to_page_column(page): """append empty columns dict to page layout""" if not page[-1]["columns"]: page[-1]["columns"].append({"fields": []}) for df in format_data or meta.fields: if format_data: # embellish df with original properties df = frappe._dict(df) if df.fieldname: original = meta.get_field(df.fieldname) if original: newdf = original.as_dict() newdf.hide_in_print_layout = original.get("hide_in_print_layout") newdf.update(df) df = newdf df.print_hide = 0 if df.fieldtype == "Section Break" or page == []: if len(page) > 1: if not page[-1]["has_data"]: # truncate last section if empty del page[-1] section = get_new_section() if df.fieldtype == "Section Break" and df.label: section["label"] = df.label page.append(section) elif df.fieldtype == "Column Break": # if last column break and last column is not empty page[-1]["columns"].append({"fields": []}) else: # add a column if not yet added append_empty_field_dict_to_page_column(page) if df.fieldtype == "HTML" and df.options: doc.set(df.fieldname, True) # show this field if df.fieldtype == "Signature" and not doc.get(df.fieldname): placeholder_image = "/assets/frappe/images/signature-placeholder.png" doc.set(df.fieldname, placeholder_image) if is_visible(df, doc) and has_value(df, doc): append_empty_field_dict_to_page_column(page) page[-1]["columns"][-1]["fields"].append(df) # section has fields page[-1]["has_data"] = True # if table, add the row info in the field # if a page break is found, create a new docfield if df.fieldtype == "Table": df.rows = [] df.start = 0 df.end = None for i, row in enumerate(doc.get(df.fieldname)): if row.get("page_break"): # close the earlier row df.end = i # new page, with empty section and column page = [get_new_section()] layout.append(page) append_empty_field_dict_to_page_column(page) # continue the table in a new page df = copy.copy(df) df.start = i df.end = None page[-1]["columns"][-1]["fields"].append(df) return layout def is_visible(df: "DocField", doc: "Document") -> bool: """Return True if docfield is visible in print layout and does not have print_hide set.""" if df.fieldtype in ("Section Break", "Column Break", "Button"): return False if (df.permlevel or 0) > 0 and not doc.has_permlevel_access_to(df.fieldname, df): return False return not doc.is_print_hide(df.fieldname, df) def has_value(df: "DocField", doc: "Document") -> bool: """Return True if given docfield (`df`) has some value in the given document (`doc`).""" value = doc.get(df.fieldname) if value in (None, ""): return False elif isinstance(value, str) and not strip_html(value).strip(): if df.fieldtype in ["Text", "Text Editor"]: return True return False elif isinstance(value, list) and not len(value): return False return True def get_print_style( style: str | None = None, print_format: Optional["PrintFormat"] = None, for_legacy: bool = False ) -> str: print_settings = frappe.get_doc("Print Settings") if not style: style = print_settings.print_style or "" context = { "print_settings": print_settings, "print_style": style, "font": get_font(print_settings, print_format, for_legacy), } css = frappe.get_template("templates/styles/standard.css").render(context) if style and frappe.db.exists("Print Style", style): css = css + "\n" + frappe.db.get_value("Print Style", style, "css") # move @import to top for at_import in list(set(re.findall(r"(@import url\([^\)]+\)[;]?)", css))): css = css.replace(at_import, "") # prepend css with at_import css = at_import + css if print_format and print_format.css: css += "\n\n" + print_format.css return css def get_font( print_settings: "PrintSettings", print_format: Optional["PrintFormat"] = None, for_legacy=False ) -> str: default = "var(--font-stack)" if for_legacy: return default font = None if print_format: if print_format.font and print_format.font != "Default": font = f"{print_format.font}, sans-serif" if not font: if print_settings.font and print_settings.font != "Default": font = f"{print_settings.font}, sans-serif" else: font = default return font def get_visible_columns(data: list, table_meta: "Meta", df: "DocField") -> list["DocField"]: """Return list of visible columns based on print_hide and if all columns have value.""" columns = [] doc = data[0] or frappe.new_doc(df.options) hide_in_print_layout = df.get("hide_in_print_layout") or [] def add_column(col_df: "DocField"): if col_df.fieldname in hide_in_print_layout: return False return is_visible(col_df, doc) and column_has_value(data, col_df.get("fieldname"), col_df) if df.get("visible_columns"): # columns specified by column builder for col_df in df.get("visible_columns"): # load default docfield properties docfield = table_meta.get_field(col_df.get("fieldname")) if not docfield: continue newdf = docfield.as_dict().copy() newdf.update(col_df) if add_column(newdf): columns.append(newdf) else: for col_df in table_meta.fields: if add_column(col_df): columns.append(col_df) return columns def column_has_value(data: list, fieldname: str, col_df: "DocField") -> bool: """Check if at least one cell in column has non-zero and non-blank value""" has_value = False if col_df.fieldtype in ["Float", "Currency"] and not col_df.print_hide_if_no_value: return True for row in data: value = row.get(fieldname) if value: if isinstance(value, str): if strip_html(value).strip(): has_value = True break else: has_value = True break return has_value trigger_print_script = """ """