From 7b852e1a65069606752470ebbbcb62c6e5248632 Mon Sep 17 00:00:00 2001 From: kunhimohamed Date: Fri, 24 Oct 2025 11:51:27 +0400 Subject: [PATCH 1/3] fix(print): add ncr feature in print format --- frappe/printing/page/print/print.js | 16 +- .../public/js/frappe/list/bulk_operations.js | 14 +- frappe/utils/print_format.py | 63 +++++++- frappe/utils/print_utils.py | 147 ++++++++++++++++++ 4 files changed, 232 insertions(+), 8 deletions(-) diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index d905b9705b8a..0ae56ceb7392 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -136,6 +136,13 @@ frappe.ui.form.PrintView = class { this.sidebar_dynamic_section = $(`
`).appendTo( this.sidebar ); + + this.ncr_selector = this.add_sidebar_item({ + fieldtype: "Check", + fieldname: "ncr_check", + label: __("NCR"), + default:0 + }); } add_sidebar_item(df, is_dynamic) { @@ -649,6 +656,7 @@ frappe.ui.form.PrintView = class { } render_page(method, printit = false) { + console.log(this.is_ncr()); let w = window.open( frappe.urllib.get_full_url( method + @@ -665,7 +673,9 @@ frappe.ui.form.PrintView = class { encodeURIComponent(this.get_letterhead()) + "&settings=" + encodeURIComponent(JSON.stringify(this.additional_settings)) + - (this.lang_code ? "&_lang=" + this.lang_code : "") + (this.lang_code ? "&_lang=" + this.lang_code : "") + + "&ncr=" + + encodeURIComponent(this.is_ncr()) ) ); if (!w) { @@ -762,6 +772,10 @@ frappe.ui.form.PrintView = class { return this.print_format_selector.val() || "Standard"; } + is_ncr() { + return this.ncr_selector.value; + } + is_raw_printing(format) { return this.get_print_format(format).raw_printing === 1; } diff --git a/frappe/public/js/frappe/list/bulk_operations.js b/frappe/public/js/frappe/list/bulk_operations.js index 1c045bc6ae80..52a0c9ec32d9 100644 --- a/frappe/public/js/frappe/list/bulk_operations.js +++ b/frappe/public/js/frappe/list/bulk_operations.js @@ -82,13 +82,19 @@ export default class BulkOperations { depends_on: 'eval:doc.page_size == "Custom"', default: print_settings.pdf_page_width, }, + { + fieldtype: "Check", + label: __("NCR"), + fieldname: "ncr_check", + default: 0 + }, { fieldtype: "Check", label: __("Background Print (required for >25 documents)"), fieldname: "background_print", default: valid_docs.length > BACKGROUND_PRINT_THRESHOLD, read_only: valid_docs.length > BACKGROUND_PRINT_THRESHOLD, - }, + } ], }); @@ -96,6 +102,7 @@ export default class BulkOperations { if (!args) return; const default_print_format = frappe.get_meta(this.doctype).default_print_format; const with_letterhead = args.letter_sel == __("No Letterhead") ? 0 : 1; + const ncr_selector = args.ncr_check; const print_format = args.print_sel ? args.print_sel : default_print_format; const json_string = JSON.stringify(valid_docs); const letterhead = args.letter_sel; @@ -122,6 +129,7 @@ export default class BulkOperations { no_letterhead: with_letterhead ? "0" : "1", letterhead: letterhead, options: pdf_options, + ncr:ncr_selector }) .then((response) => { let task_id = response.message.task_id; @@ -154,7 +162,9 @@ export default class BulkOperations { "&letterhead=" + encodeURIComponent(letterhead) + "&options=" + - encodeURIComponent(pdf_options) + encodeURIComponent(pdf_options) + + "&ncr=" + + encodeURIComponent(ncr_selector) ); if (!w) { diff --git a/frappe/utils/print_format.py b/frappe/utils/print_format.py index af32fde74804..49b457ae6ffe 100644 --- a/frappe/utils/print_format.py +++ b/frappe/utils/print_format.py @@ -4,8 +4,10 @@ import uuid from io import BytesIO from typing import Literal +from reportlab.pdfgen import canvas -from pypdf import PdfWriter + +from pypdf import PdfReader, PdfWriter import frappe from frappe import _ @@ -13,6 +15,7 @@ from frappe.translate import print_language from frappe.utils.deprecations import deprecated from frappe.utils.pdf import get_pdf +from .print_utils import ncr_get_print, add_background_color, merge_pdfs no_cache = 1 @@ -30,11 +33,12 @@ def download_multi_pdf( no_letterhead: bool = False, letterhead: str | None = None, options: str | None = None, + ncr: bool = False, ): """ Calls _download_multi_pdf with the given parameters and returns the response """ - return _download_multi_pdf(doctype, name, format, no_letterhead, letterhead, options) + return _download_multi_pdf(doctype, name, format, no_letterhead, letterhead, options, ncr) @frappe.whitelist() @@ -45,6 +49,7 @@ def download_multi_pdf_async( no_letterhead: bool = False, letterhead: str | None = None, options: str | None = None, + ncr: bool = False, ): """ Calls _download_multi_pdf with the given parameters in a background job, returns task ID @@ -64,6 +69,7 @@ def download_multi_pdf_async( no_letterhead=no_letterhead, letterhead=letterhead, options=options, + ncr=ncr, queue="long" if doc_count > 20 else "short", ) frappe.local.response["http_status_code"] = http.HTTPStatus.CREATED @@ -77,6 +83,7 @@ def _download_multi_pdf( no_letterhead: bool = False, letterhead: str | None = None, options: str | None = None, + ncr: bool = False, task_id: str | None = None, ): """Return a PDF compiled by concatenating multiple documents. @@ -118,6 +125,7 @@ def _download_multi_pdf( filename = "" pdf_writer = PdfWriter() + pdf_writer_ncr = PdfWriter() if isinstance(options, str): options = json.loads(options) @@ -140,6 +148,18 @@ def _download_multi_pdf( letterhead=letterhead, pdf_options=options, ) + if ncr: + pdf_writer_ncr = ncr_get_print( + doctype, + ss, + format, + as_pdf=True, + output=pdf_writer_ncr, + letterhead=letterhead, + no_letterhead=no_letterhead, + pdf_options=options + ) + except Exception: if task_id: frappe.publish_realtime(task_id=task_id, message={"message": "Failed"}) @@ -177,6 +197,17 @@ def _download_multi_pdf( letterhead=letterhead, pdf_options=options, ) + if ncr: + pdf_writer_ncr = ncr_get_print( + doctype, + doc_name, + format, + as_pdf=True, + output=pdf_writer_ncr, + letterhead=letterhead, + no_letterhead=no_letterhead, + pdf_options=options + ) except Exception: if task_id: frappe.publish_realtime(task_id=task_id, message="Failed") @@ -201,21 +232,27 @@ def _download_multi_pdf( if task_id is None: frappe.local.response.filename = f"{name}.pdf" - with BytesIO() as merged_pdf: + with BytesIO() as merged_pdf, BytesIO() as merged_pdf_ncr: pdf_writer.write(merged_pdf) + final_pdf = merged_pdf.getvalue() + + if ncr: + pdf_writer_ncr.write(merged_pdf_ncr) + pdf_with_color = add_background_color(merged_pdf_ncr.getvalue()) + final_pdf = merge_pdfs([merged_pdf.getvalue(), pdf_with_color]) if task_id: _file = frappe.get_doc( { "doctype": "File", "file_name": f"{filename}{task_id}.pdf", - "content": merged_pdf.getvalue(), + "content": final_pdf, "is_private": 1, } ) _file.save() frappe.publish_realtime(f"task_complete:{task_id}", message={"file_url": _file.unique_url}) else: - frappe.local.response.filecontent = merged_pdf.getvalue() + frappe.local.response.filecontent = final_pdf frappe.local.response.type = "pdf" @@ -236,6 +273,7 @@ def download_pdf( language=None, letterhead=None, pdf_generator: Literal["wkhtmltopdf", "chrome"] | None = None, + ncr: bool = False, ): doc = doc or frappe.get_doc(doctype, name) validate_print_permission(doc) @@ -251,6 +289,17 @@ def download_pdf( no_letterhead=no_letterhead, pdf_generator=pdf_generator, ) + if ncr: + pdf_file_2 = ncr_get_print( + doctype, + name, + format, + doc=doc, + as_pdf=True, + letterhead=letterhead, + no_letterhead=no_letterhead, + pdf_generator=pdf_generator, + ) title = doc.get_title() if title and title == name: @@ -260,6 +309,10 @@ def download_pdf( title = title[:25] title = title.rstrip() title = " " + title + + if ncr: + jut_list = [pdf_file, add_background_color(pdf_file_2)] + pdf_file = merge_pdfs(jut_list) frappe.local.response.filename = "{name}{title}.pdf".format( name=name.replace(" ", "-").replace("/", "-"), diff --git a/frappe/utils/print_utils.py b/frappe/utils/print_utils.py index 9eb6357efe1f..bac16389f76c 100644 --- a/frappe/utils/print_utils.py +++ b/frappe/utils/print_utils.py @@ -1,6 +1,153 @@ from typing import Literal import frappe +from reportlab.pdfgen import canvas +from pypdf import PdfReader, PdfWriter +from io import BytesIO + +def merge_pdfs(pdf_bytes_list): + """ + Merge multiple PDFs (provided as bytes) into a single PDF (bytes). + pdf_bytes_list: list of bytes objects + Returns: bytes of merged PDF + """ + writer = PdfWriter() + + for pdf_bytes in pdf_bytes_list: + reader = PdfReader(BytesIO(pdf_bytes)) + for page in reader.pages: + writer.add_page(page) + + output = BytesIO() + writer.write(output) + return output.getvalue() + +def add_background_color(input_pdf_bytes, color=(255, 248, 179)): + """ + Overlay a solid color background on every page. + color = (R, G, B) in 0–255 range + """ + reader = PdfReader(BytesIO(input_pdf_bytes)) + writer = PdfWriter() + + for page in reader.pages: + packet = BytesIO() + can = canvas.Canvas(packet, pagesize=page.mediabox[2:]) # match original page size + can.setFillColorRGB(color[0]/255, color[1]/255, color[2]/255) + can.rect(0, 0, page.mediabox.width, page.mediabox.height, fill=1, stroke=0) + can.save() + packet.seek(0) + background_pdf = PdfReader(packet) + bg_page = background_pdf.pages[0] + bg_page.merge_page(page) + writer.add_page(bg_page) + + output = BytesIO() + writer.write(output) + return output.getvalue() + +def ncr_get_print( + doctype=None, + name=None, + print_format=None, + style=None, + as_pdf=False, + doc=None, + output=None, + no_letterhead=0, + password=None, + pdf_options=None, + letterhead=None, + pdf_generator: Literal["wkhtmltopdf", "chrome"] | None = None, +): + """Get Print Format for given document. + :param doctype: DocType of document. + :param name: Name of document. + :param print_format: Print Format name. Default 'Standard', + :param style: Print Format style. + :param as_pdf: Return as PDF. Default False. + :param password: Password to encrypt the pdf with. Default None + :param pdf_generator: PDF generator to use. Default 'wkhtmltopdf' + """ + + """ + local.form_dict.pdf_generator is set from before_request hook (print designer app) for download_pdf endpoint + if it is not set (internal function call) then set it + """ + import copy + + from frappe.utils.pdf import get_pdf + from frappe.website.serve import get_response_without_exception_handling + + local = frappe.local + if "pdf_generator" not in local.form_dict: + # if arg is passed, use that, else get setting from print format + if pdf_generator is None: + pdf_generator = ( + frappe.get_cached_value("Print Format", print_format, "pdf_generator") or "wkhtmltopdf" + ) + local.form_dict.pdf_generator = pdf_generator + + original_form_dict = copy.deepcopy(local.form_dict) + try: + local.form_dict.doctype = doctype + local.form_dict.name = name + local.form_dict.format = print_format + local.form_dict.style = style + local.form_dict.doc = doc + local.form_dict.no_letterhead = no_letterhead + local.form_dict.letterhead = letterhead + + pdf_options = pdf_options or {} + if password: + pdf_options["password"] = password + + response = get_response_without_exception_handling("printview", 200) + html = str(response.data, "utf-8") + finally: + local.form_dict = original_form_dict + + if not as_pdf: + return html + + if "" in html: + html = html.replace("", f""" + + """) + + if local.form_dict.pdf_generator != "wkhtmltopdf": + hook_func = frappe.get_hooks("pdf_generator") + for hook in hook_func: + """ + check pdf_generator value in your hook function. + if it matches run and return pdf else return None + """ + pdf = frappe.call( + hook, + print_format=print_format, + html=html, + options=pdf_options, + output=output, + pdf_generator=local.form_dict.pdf_generator, + ) + # if hook returns a value, assume it was the correct pdf_generator and return it + if pdf: + return pdf + + return get_pdf(html, options=pdf_options, output=output) def get_print( From 983369308d8b93fc7616cd8824a6044837c7b422 Mon Sep 17 00:00:00 2001 From: kunhimohamed Date: Fri, 24 Oct 2025 11:54:42 +0400 Subject: [PATCH 2/3] fix(print): remove unwanted code. --- frappe/printing/page/print/print.js | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index 0ae56ceb7392..c06bc773548a 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -656,7 +656,6 @@ frappe.ui.form.PrintView = class { } render_page(method, printit = false) { - console.log(this.is_ncr()); let w = window.open( frappe.urllib.get_full_url( method + From 76f21a399e9616d7617351ddf042ab139820a807 Mon Sep 17 00:00:00 2001 From: kunhimohamed Date: Fri, 24 Oct 2025 14:25:29 +0400 Subject: [PATCH 3/3] fix(Print): add dependencies at pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 1651d64f2e7a..52d1d565164f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ dependencies = [ "google-auth~=2.40.3", "posthog~=3.21.0", "vobject~=0.9.7", + "reportlab==4.4.4" ] [project.urls]