diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index 3520e56078..18de95b40d 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -288,10 +288,24 @@ function get_watch_config() { assets_json, prev_assets_json } = await write_assets_json(result.metafile); + + let changed_files; if (prev_assets_json) { - log_rebuilt_assets(prev_assets_json, assets_json); + changed_files = get_rebuilt_assets( + prev_assets_json, + assets_json + ); + + let timestamp = new Date().toLocaleTimeString(); + let message = `${timestamp}: Compiled ${changed_files.length} files...`; + log(chalk.yellow(message)); + for (let filepath of changed_files) { + let filename = path.basename(filepath); + log(" " + filename); + } + log(); } - notify_redis({ success: true }); + notify_redis({ success: true, changed_files }); } } }; @@ -461,7 +475,7 @@ function run_build_command_for_apps(apps) { process.chdir(cwd); } -async function notify_redis({ error, success }) { +async function notify_redis({ error, success, changed_files }) { // notify redis which in turns tells socketio to publish this to browser let subscriber = get_redis_subscriber("redis_socketio"); subscriber.on("error", _ => { @@ -484,6 +498,7 @@ async function notify_redis({ error, success }) { if (success) { payload = { success: true, + changed_files, live_reload: argv["live-reload"] }; } @@ -514,7 +529,7 @@ function open_in_editor() { subscriber.subscribe("open_in_editor"); } -function log_rebuilt_assets(prev_assets, new_assets) { +function get_rebuilt_assets(prev_assets, new_assets) { let added_files = []; let old_files = Object.values(prev_assets); let new_files = Object.values(new_assets); @@ -524,17 +539,5 @@ function log_rebuilt_assets(prev_assets, new_assets) { added_files.push(filepath); } } - - log( - chalk.yellow( - `${new Date().toLocaleTimeString()}: Compiled ${ - added_files.length - } files...` - ) - ); - for (let filepath of added_files) { - let filename = path.basename(filepath); - log(" " + filename); - } - log(); + return added_files; } diff --git a/frappe/client.py b/frappe/client.py index 2b80a17feb..0e9be0a7ee 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -258,6 +258,12 @@ def set_default(key, value, parent=None): frappe.db.set_default(key, value, parent or frappe.session.user) frappe.clear_cache(user=frappe.session.user) +@frappe.whitelist() +def get_default(key, parent=None): + """set a user default value""" + return frappe.db.get_default(key, parent) + + @frappe.whitelist(methods=['POST', 'PUT']) def make_width_property_setter(doc): '''Set width Property Setter diff --git a/frappe/data/google_fonts.json b/frappe/data/google_fonts.json new file mode 100644 index 0000000000..232e509e77 --- /dev/null +++ b/frappe/data/google_fonts.json @@ -0,0 +1,56 @@ +[ + "Alegreya Sans", + "Alegreya", + "Andada Pro", + "Anton", + "Archivo Narrow", + "Archivo", + "BioRhyme", + "Cardo", + "Chivo", + "Cormorant", + "Crimson Text", + "DM Sans", + "Eczar", + "Encode Sans", + "Epilogue ", + "Fira Sans", + "Hahmlet", + "IBM Plex Sans", + "Inconsolata", + "Inknut Antiqua", + "Inter", + "JetBrains Mono", + "Karla", + "Lato", + "Libre Baskerville", + "Libre Franklin", + "Lora", + "Manrope", + "Merriweather", + "Montserrat", + "Neuton", + "Nunito", + "Old Standard TT", + "Open Sans", + "Oswald", + "Oxygen", + "Playfair Display", + "Poppins", + "Proza Libre", + "PT Sans", + "PT Serif", + "Raleway", + "Roboto Slab", + "Roboto", + "Rubik", + "Sora", + "Source Sans Pro", + "Source Serif Pro", + "Space Grotesk", + "Space Mono", + "Spectral", + "Syne", + "Work Sans" +] + diff --git a/frappe/printing/doctype/letter_head/letter_head.json b/frappe/printing/doctype/letter_head/letter_head.json index f6c9def567..f723a6b489 100644 --- a/frappe/printing/doctype/letter_head/letter_head.json +++ b/frappe/printing/doctype/letter_head/letter_head.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_rename": 1, "autoname": "field:letter_head_name", "creation": "2012-11-22 17:45:46", @@ -13,6 +14,9 @@ "is_default", "letter_head_image_section", "image", + "image_height", + "image_width", + "align", "header_section", "content", "footer_section", @@ -100,15 +104,34 @@ "fieldname": "footer", "fieldtype": "HTML Editor", "label": "Footer HTML" + }, + { + "default": "Left", + "fieldname": "align", + "fieldtype": "Select", + "label": "Align", + "options": "Left\nRight\nCenter" + }, + { + "fieldname": "image_height", + "fieldtype": "Float", + "label": "Image Height" + }, + { + "fieldname": "image_width", + "fieldtype": "Float", + "label": "Image Width" } ], "icon": "fa fa-font", "idx": 1, + "links": [], "max_attachments": 3, - "modified": "2019-11-11 18:46:43.375120", + "modified": "2021-10-03 14:37:58.314696", "modified_by": "Administrator", "module": "Printing", "name": "Letter Head", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/frappe/printing/doctype/letter_head/letter_head.py b/frappe/printing/doctype/letter_head/letter_head.py index eeaef28393..67c0d236e0 100644 --- a/frappe/printing/doctype/letter_head/letter_head.py +++ b/frappe/printing/doctype/letter_head/letter_head.py @@ -2,7 +2,7 @@ # License: MIT. See LICENSE import frappe -from frappe.utils import is_image +from frappe.utils import is_image, flt from frappe.model.document import Document from frappe import _ @@ -26,7 +26,15 @@ class LetterHead(Document): def set_image(self): if self.source=='Image': if self.image and is_image(self.image): - self.content = ''.format(self.image) + self.image_width = flt(self.image_width) + self.image_height = flt(self.image_height) + dimension = 'width' if self.image_width > self.image_height else 'height' + dimension_value = self.get('image_' + dimension) + self.content = f''' +
+ {self.name} +
+ ''' frappe.msgprint(frappe._('Header HTML set from attachment {0}').format(self.image), alert = True) else: frappe.msgprint(frappe._('Please attach an image file to set HTML'), alert = True, indicator = 'orange') diff --git a/frappe/printing/doctype/print_format/print_format.js b/frappe/printing/doctype/print_format/print_format.js index 7b7009dbaf..3fd1d9d148 100644 --- a/frappe/printing/doctype/print_format/print_format.js +++ b/frappe/printing/doctype/print_format/print_format.js @@ -30,7 +30,11 @@ frappe.ui.form.on("Print Format", { frappe.msgprint(__("Please select DocType first")); return; } - frappe.set_route("print-format-builder", frm.doc.name); + if (frm.doc.print_format_builder_beta) { + frappe.set_route("print-format-builder-beta", frm.doc.name); + } else { + frappe.set_route("print-format-builder", frm.doc.name); + } }); } else if (frm.doc.custom_format && !frm.doc.raw_printing) { diff --git a/frappe/printing/doctype/print_format/print_format.json b/frappe/printing/doctype/print_format/print_format.json index 4032cef209..75ec0fa7fd 100644 --- a/frappe/printing/doctype/print_format/print_format.json +++ b/frappe/printing/doctype/print_format/print_format.json @@ -19,19 +19,26 @@ "html", "raw_commands", "section_break_9", + "margin_top", + "margin_bottom", + "margin_left", + "margin_right", "align_labels_right", "show_section_headings", "line_breaks", "absolute_value", "column_break_11", + "font_size", "font", + "page_number", "css_section", "css", "custom_html_help", "section_break_13", "print_format_help", "format_data", - "print_format_builder" + "print_format_builder", + "print_format_builder_beta" ], "fields": [ { @@ -149,12 +156,10 @@ "options": "Language" }, { - "default": "Default", "depends_on": "eval:!doc.custom_format", "fieldname": "font", - "fieldtype": "Select", - "label": "Font", - "options": "Default\nHelvetica Neue\nArial\nHelvetica\nVerdana\nMonospace" + "fieldtype": "Data", + "label": "Google Font" }, { "depends_on": "eval:!doc.raw_printing", @@ -205,16 +210,60 @@ "fieldname": "absolute_value", "fieldtype": "Check", "label": "Show Absolute Values" + }, + { + "default": "0", + "fieldname": "print_format_builder_beta", + "fieldtype": "Check", + "label": "Print Format Builder Beta" + }, + { + "default": "15", + "fieldname": "margin_top", + "fieldtype": "Float", + "label": "Margin Top" + }, + { + "default": "15", + "fieldname": "margin_bottom", + "fieldtype": "Float", + "label": "Margin Bottom" + }, + { + "default": "15", + "fieldname": "margin_left", + "fieldtype": "Float", + "label": "Margin Left" + }, + { + "default": "15", + "fieldname": "margin_right", + "fieldtype": "Float", + "label": "Margin Right" + }, + { + "default": "14", + "fieldname": "font_size", + "fieldtype": "Int", + "label": "Font Size" + }, + { + "default": "Hide", + "fieldname": "page_number", + "fieldtype": "Select", + "label": "Page Number", + "options": "Hide\nTop Left\nTop Center\nTop Right\nBottom Left\nBottom Center\nBottom Right" } ], "icon": "fa fa-print", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-03-01 15:25:46.578863", + "modified": "2021-10-12 17:52:41.167107", "modified_by": "Administrator", "module": "Printing", "name": "Print Format", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { diff --git a/frappe/printing/doctype/print_format/print_format.py b/frappe/printing/doctype/print_format/print_format.py index 878a864b38..f19c0af9bf 100644 --- a/frappe/printing/doctype/print_format/print_format.py +++ b/frappe/printing/doctype/print_format/print_format.py @@ -7,10 +7,24 @@ import frappe.utils import json from frappe import _ from frappe.utils.jinja import validate_template - +from frappe.utils.weasyprint import get_html, download_pdf from frappe.model.document import Document class PrintFormat(Document): + def onload(self): + templates = frappe.db.get_all( + "Print Format Field Template", + fields=["template", "field", "name"], + filters={"document_type": self.doc_type}, + ) + self.set_onload("print_templates", templates) + + def get_html(self, docname, letterhead=None): + return get_html(self.doc_type, docname, self.name, letterhead) + + def download_pdf(self, docname, letterhead=None): + return download_pdf(self.doc_type, docname, self.name, letterhead) + def validate(self): if (self.standard=="Yes" and not frappe.local.conf.get("developer_mode") @@ -38,6 +52,10 @@ class PrintFormat(Document): def extract_images(self): from frappe.core.doctype.file.file import extract_images_from_html + + if self.print_format_builder_beta: + return + if self.format_data: data = json.loads(self.format_data) for df in data: diff --git a/frappe/printing/doctype/print_format_field_template/__init__.py b/frappe/printing/doctype/print_format_field_template/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/printing/doctype/print_format_field_template/print_format_field_template.js b/frappe/printing/doctype/print_format_field_template/print_format_field_template.js new file mode 100644 index 0000000000..7fbb0d7359 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/print_format_field_template.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Print Format Field Template', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/printing/doctype/print_format_field_template/print_format_field_template.json b/frappe/printing/doctype/print_format_field_template/print_format_field_template.json new file mode 100644 index 0000000000..3b79aae7e8 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/print_format_field_template.json @@ -0,0 +1,101 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2021-10-05 14:23:56.508499", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "document_type", + "field", + "template_file", + "column_break_3", + "module", + "standard", + "section_break_5", + "template" + ], + "fields": [ + { + "depends_on": "eval:!doc.multiple", + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Document Type", + "mandatory_depends_on": "eval:!doc.multiple", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "field", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Default Template For Field" + }, + { + "depends_on": "eval:!doc.standard", + "fieldname": "template", + "fieldtype": "Code", + "label": "Template", + "mandatory_depends_on": "eval:!doc.standard", + "options": "HTML" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_5", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "depends_on": "standard", + "fieldname": "module", + "fieldtype": "Link", + "label": "Module", + "options": "Module Def" + }, + { + "default": "0", + "fieldname": "standard", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Standard" + }, + { + "depends_on": "eval:doc.standard", + "fieldname": "template_file", + "fieldtype": "Data", + "label": "Template File", + "mandatory_depends_on": "eval:doc.standard" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-10-19 17:47:59.577949", + "modified_by": "Administrator", + "module": "Printing", + "name": "Print Format Field Template", + "naming_rule": "Set by user", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/printing/doctype/print_format_field_template/print_format_field_template.py b/frappe/printing/doctype/print_format_field_template/print_format_field_template.py new file mode 100644 index 0000000000..b66afdb6b1 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/print_format_field_template.py @@ -0,0 +1,43 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from frappe import _ + + +class PrintFormatFieldTemplate(Document): + def validate(self): + if self.standard and not (frappe.conf.developer_mode or frappe.flags.in_patch): + frappe.throw(_("Enable developer mode to create a standard Print Template")) + + def before_insert(self): + self.validate_duplicate() + + def on_update(self): + self.validate_duplicate() + self.export_doc() + + def validate_duplicate(self): + if not self.standard: + return + if not self.field: + return + + filters = {"document_type": self.document_type, "field": self.field} + if not self.is_new(): + filters.update({"name": ("!=", self.name)}) + result = frappe.db.get_all("Print Format Field Template", filters=filters, limit=1) + if result: + frappe.throw( + _("A template already exists for field {0} of {1}").format( + frappe.bold(self.field), frappe.bold(self.document_type) + ), + frappe.DuplicateEntryError, + title=_("Duplicate Entry"), + ) + + def export_doc(self): + from frappe.modules.utils import export_module_json + + export_module_json(self, self.standard, self.module) diff --git a/frappe/printing/doctype/print_format_field_template/test_print_format_field_template.py b/frappe/printing/doctype/print_format_field_template/test_print_format_field_template.py new file mode 100644 index 0000000000..f0b1329763 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/test_print_format_field_template.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestPrintFormatFieldTemplate(unittest.TestCase): + pass diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index 7f40fd3127..f10c703589 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -41,7 +41,11 @@ frappe.ui.form.PrintView = class {
- ` + +
+ +
+ ` ); this.print_settings = frappe.model.get_doc( @@ -72,7 +76,7 @@ frappe.ui.form.PrintView = class { this.page.add_button( __('PDF'), - () => this.render_page('/api/method/frappe.utils.print_format.download_pdf?'), + () => this.render_pdf(), { icon: 'small-file' } ); @@ -190,6 +194,13 @@ frappe.ui.form.PrintView = class { this.set_breadcrumbs(); this.setup_customize_dialog(); + // print format builder beta + this.page.add_inner_message(` + + ${__('Try the new Print Format Builder')} + + `); + let tasks = [ this.refresh_print_options, this.set_default_print_language, @@ -233,7 +244,7 @@ frappe.ui.form.PrintView = class { let print_format = this.get_print_format(); let is_custom_format = print_format.name && - print_format.print_format_builder && + (print_format.print_format_builder || print_format.print_format_builder_beta) && print_format.standard === 'No'; let is_standard_but_editable = print_format.name && print_format.custom_format; @@ -243,7 +254,11 @@ frappe.ui.form.PrintView = class { return; } if (is_custom_format) { - frappe.set_route('print-format-builder', print_format.name); + if (print_format.print_format_builder_beta) { + frappe.set_route('print-format-builder-beta', print_format.name); + } else { + frappe.set_route('print-format-builder', print_format.name); + } return; } // start a new print format @@ -261,6 +276,11 @@ frappe.ui.form.PrintView = class { fieldtype: 'Read Only', default: print_format.name || 'Standard', }, + { + label: __('Use the new Print Format Builder'), + fieldname: 'beta', + fieldtype: 'Check' + }, ], (data) => { frappe.route_options = { @@ -268,6 +288,7 @@ frappe.ui.form.PrintView = class { doctype: this.frm.doctype, name: data.print_format_name, based_on: data.based_on, + beta: data.beta }; frappe.set_route('print-format-builder'); this.print_sel.val(data.print_format_name); @@ -380,6 +401,17 @@ frappe.ui.form.PrintView = class { } preview() { + let print_format = this.get_print_format(); + if (print_format.print_format_builder_beta) { + this.print_wrapper.find('.print-preview-wrapper').hide(); + this.print_wrapper.find('.preview-beta-wrapper').show(); + this.preview_beta(); + return; + } + + this.print_wrapper.find('.preview-beta-wrapper').hide(); + this.print_wrapper.find('.print-preview-wrapper').show(); + const $print_format = this.print_wrapper.find('iframe'); this.$print_format_body = $print_format.contents(); this.get_print_html((out) => { @@ -403,6 +435,21 @@ frappe.ui.form.PrintView = class { }); } + preview_beta() { + let print_format = this.get_print_format(); + const iframe = this.print_wrapper.find('.preview-beta-wrapper iframe'); + let params = new URLSearchParams({ + doctype: this.frm.doc.doctype, + name: this.frm.doc.name, + print_format: print_format.name + }); + let letterhead = this.get_letterhead(); + if (letterhead) { + params.append("letterhead", letterhead); + } + iframe.prop('src', `/printpreview?${params.toString()}`); + } + setup_print_format_dom(out, $print_format) { this.print_wrapper.find('.print-format-skeleton').remove(); let base_url = frappe.urllib.get_base_url(); @@ -565,6 +612,26 @@ frappe.ui.form.PrintView = class { }, }); } + + render_pdf() { + let print_format = this.get_print_format(); + if (print_format.print_format_builder_beta) { + let params = new URLSearchParams({ + doctype: this.frm.doc.doctype, + name: this.frm.doc.name, + print_format: print_format.name, + letterhead: this.get_letterhead() + }); + let w = window.open(`/api/method/frappe.utils.weasyprint.download_pdf?${params}`); + if (!w) { + frappe.msgprint(__('Please enable pop-ups')); + return; + } + } else { + this.render_page('/api/method/frappe.utils.print_format.download_pdf?'); + } + } + render_page(method, printit = false) { let w = window.open( frappe.urllib.get_full_url( diff --git a/frappe/printing/page/print_format_builder/print_format_builder.js b/frappe/printing/page/print_format_builder/print_format_builder.js index b73ff31d32..313e8da539 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder.js +++ b/frappe/printing/page/print_format_builder/print_format_builder.js @@ -12,9 +12,9 @@ frappe.pages['print-format-builder'].on_page_show = function(wrapper) { }); } else if(frappe.route_options) { if(frappe.route_options.make_new) { - let { doctype, name, based_on } = frappe.route_options; + let { doctype, name, based_on, beta } = frappe.route_options; frappe.route_options = null; - frappe.print_format_builder.setup_new_print_format(doctype, name, based_on); + frappe.print_format_builder.setup_new_print_format(doctype, name, based_on, beta); } else { frappe.print_format_builder.print_format = frappe.route_options.doc; frappe.route_options = null; @@ -126,18 +126,22 @@ frappe.PrintFormatBuilder = class PrintFormatBuilder { }); } - setup_new_print_format(doctype, name, based_on) { + setup_new_print_format(doctype, name, based_on, beta) { frappe.call({ method: 'frappe.printing.page.print_format_builder.print_format_builder.create_custom_format', args: { doctype: doctype, name: name, - based_on: based_on + based_on: based_on, + beta: Boolean(beta) }, callback: (r) => { - if(!r.exc) { - if(r.message) { - this.print_format = r.message; + if (r.message) { + let print_format = r.message; + if (print_format.print_format_builder_beta) { + frappe.set_route('print-format-builder-beta', print_format.name); + } else { + this.print_format = print_format; this.refresh(); } } diff --git a/frappe/printing/page/print_format_builder/print_format_builder.py b/frappe/printing/page/print_format_builder/print_format_builder.py index d9f57762b0..fae564d3c3 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder.py +++ b/frappe/printing/page/print_format_builder/print_format_builder.py @@ -1,11 +1,16 @@ import frappe @frappe.whitelist() -def create_custom_format(doctype, name, based_on='Standard'): +def create_custom_format(doctype, name, based_on='Standard', beta=False): doc = frappe.new_doc('Print Format') doc.doc_type = doctype doc.name = name - doc.print_format_builder = 1 + beta = frappe.parse_json(beta) + + if beta: + doc.print_format_builder_beta = 1 + else: + doc.print_format_builder = 1 doc.format_data = frappe.db.get_value('Print Format', based_on, 'format_data') \ if based_on != 'Standard' else None doc.insert() diff --git a/frappe/printing/page/print_format_builder_beta/__init__.py b/frappe/printing/page/print_format_builder_beta/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.css b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.css new file mode 100644 index 0000000000..0bd8d9c0f3 --- /dev/null +++ b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.css @@ -0,0 +1,3 @@ +.layout-main-section-wrapper { + margin-bottom: 0; +} diff --git a/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.js b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.js new file mode 100644 index 0000000000..e923bbcb00 --- /dev/null +++ b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.js @@ -0,0 +1,122 @@ +frappe.pages["print-format-builder-beta"].on_page_load = function(wrapper) { + frappe.ui.make_app_page({ + parent: wrapper, + title: __("Print Format Builder"), + single_column: true + }); + + // hot reload in development + if (frappe.boot.developer_mode) { + frappe.hot_update = frappe.hot_update || []; + frappe.hot_update.push(() => load_print_format_builder_beta(wrapper)); + } +}; + +frappe.pages["print-format-builder-beta"].on_page_show = function(wrapper) { + load_print_format_builder_beta(wrapper); +}; + +function load_print_format_builder_beta(wrapper) { + let route = frappe.get_route(); + let $parent = $(wrapper).find(".layout-main-section"); + $parent.empty(); + + if (route.length > 1) { + frappe.require("print_format_builder.bundle.js").then(() => { + frappe.print_format_builder = new frappe.ui.PrintFormatBuilder({ + wrapper: $parent, + page: wrapper.page, + print_format: route[1] + }); + }); + } else { + let d = new frappe.ui.Dialog({ + title: __("Create or Edit Print Format"), + fields: [ + { + label: __("Action"), + fieldname: "action", + fieldtype: "Select", + options: [ + { label: __("Create New"), value: "Create" }, + { label: __("Edit Existing"), value: "Edit" } + ], + change() { + let action = d.get_value("action"); + d.get_primary_btn().text( + action === "Create" ? __("Create") : __("Edit") + ); + } + }, + { + label: __("Select Document Type"), + fieldname: "doctype", + fieldtype: "Link", + options: "DocType", + filters: { + istable: 0 + }, + reqd: 1, + default: frappe.route_options + ? frappe.route_options.doctype + : null + }, + { + label: __("Print Format Name"), + fieldname: "print_format_name", + fieldtype: "Data", + depends_on: doc => doc.action === "Create", + mandatory_depends_on: doc => doc.action === "Create" + }, + { + label: __("Select Print Format"), + fieldname: "print_format", + fieldtype: "Link", + options: "Print Format", + only_select: 1, + depends_on: doc => doc.action === "Edit", + get_query() { + return { + filters: { + doc_type: d.get_value("doctype"), + print_format_builder_beta: 1 + } + }; + }, + mandatory_depends_on: doc => doc.action === "Edit" + } + ], + primary_action_label: __("Edit"), + primary_action({ + action, + doctype, + print_format, + print_format_name + }) { + if (action === "Edit") { + frappe.set_route("print-format-builder-beta", print_format); + } else if (action === "Create") { + d.get_primary_btn().prop("disabled", true); + frappe.db + .insert({ + doctype: "Print Format", + name: print_format_name, + doc_type: doctype, + print_format_builder_beta: 1 + }) + .then(doc => { + frappe.set_route( + "print-format-builder-beta", + doc.name + ); + }) + .finally(() => { + d.get_primary_btn().prop("disabled", false); + }); + } + } + }); + d.set_value("action", "Create"); + d.show(); + } +} diff --git a/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.json b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.json new file mode 100644 index 0000000000..a5b1288bc0 --- /dev/null +++ b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.json @@ -0,0 +1,22 @@ +{ + "content": null, + "creation": "2021-07-10 12:22:16.138485", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2021-07-10 12:22:16.138485", + "modified_by": "Administrator", + "module": "Printing", + "name": "print-format-builder-beta", + "owner": "Administrator", + "page_name": "Print Format Builder Beta", + "roles": [ + { + "role": "System Manager" + } + ], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0 +} \ No newline at end of file diff --git a/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.py b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.py new file mode 100644 index 0000000000..e13412cd07 --- /dev/null +++ b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.py @@ -0,0 +1,17 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe +import functools + + +@frappe.whitelist() +def get_google_fonts(): + return _get_google_fonts() + + +@functools.lru_cache() +def _get_google_fonts(): + file_path = frappe.get_app_path("frappe", "data", "google_fonts.json") + return frappe.parse_json(frappe.read_file(file_path)) diff --git a/frappe/public/js/frappe/build_events/build_events.bundle.js b/frappe/public/js/frappe/build_events/build_events.bundle.js index 13b9c7a334..21960f0b00 100644 --- a/frappe/public/js/frappe/build_events/build_events.bundle.js +++ b/frappe/public/js/frappe/build_events/build_events.bundle.js @@ -7,6 +7,34 @@ let error = null; frappe.realtime.on("build_event", data => { if (data.success) { + // remove executed cache for rebuilt files + let changed_files = data.changed_files; + if (Array.isArray(changed_files)) { + for (let file of changed_files) { + if (file.includes(".bundle.")) { + let parts = file.split(".bundle."); + if (parts.length === 2) { + let filename = parts[0].split("/").slice(-1)[0]; + + frappe.assets.executed_ = frappe.assets.executed_.filter( + asset => !asset.includes(`${filename}.bundle`) + ); + } + } + } + } + // update assets json + frappe.call("frappe.sessions.get_boot_assets_json").then(r => { + if (r.message) { + frappe.boot.assets_json = r.message; + + if (frappe.hot_update) { + frappe.hot_update.forEach(callback => { + callback(); + }); + } + } + }); show_build_success(data); } else if (data.error) { show_build_error(data); diff --git a/frappe/public/js/frappe/form/controls/barcode.js b/frappe/public/js/frappe/form/controls/barcode.js index 04bdb3f19e..3a678b6a97 100644 --- a/frappe/public/js/frappe/form/controls/barcode.js +++ b/frappe/public/js/frappe/form/controls/barcode.js @@ -56,9 +56,6 @@ frappe.ui.form.ControlBarcode = class ControlBarcode extends frappe.ui.form.Cont get_options(value) { // get JsBarcode options let options = {}; - options.background = "var(--control-bg)"; - options.lineColor = "var(--text-color)"; - options.font = "var(--font-stack)"; options.fontSize = "16"; options.width = "3"; options.height = "50"; diff --git a/frappe/public/js/frappe/router.js b/frappe/public/js/frappe/router.js index d4dbf91bdb..7874ffcdde 100644 --- a/frappe/public/js/frappe/router.js +++ b/frappe/public/js/frappe/router.js @@ -58,6 +58,12 @@ $('body').on('click', 'a', function(e) { if (frappe.router.is_app_route(e.currentTarget.pathname)) { // target has "/app, this is a v2 style route. + + frappe.route_options = {}; + let params = new URLSearchParams(e.currentTarget.search); + for (const [key, value] of params) { + frappe.route_options[key] = value; + } return override(e.currentTarget.pathname + e.currentTarget.hash); } diff --git a/frappe/public/js/print_format_builder/ConfigureColumns.vue b/frappe/public/js/print_format_builder/ConfigureColumns.vue new file mode 100644 index 0000000000..da10f99e40 --- /dev/null +++ b/frappe/public/js/print_format_builder/ConfigureColumns.vue @@ -0,0 +1,111 @@ + + + diff --git a/frappe/public/js/print_format_builder/Field.vue b/frappe/public/js/print_format_builder/Field.vue new file mode 100644 index 0000000000..ca53402083 --- /dev/null +++ b/frappe/public/js/print_format_builder/Field.vue @@ -0,0 +1,360 @@ + + + diff --git a/frappe/public/js/print_format_builder/HTMLEditor.vue b/frappe/public/js/print_format_builder/HTMLEditor.vue new file mode 100644 index 0000000000..17024da503 --- /dev/null +++ b/frappe/public/js/print_format_builder/HTMLEditor.vue @@ -0,0 +1,68 @@ + + + diff --git a/frappe/public/js/print_format_builder/LetterHeadEditor.vue b/frappe/public/js/print_format_builder/LetterHeadEditor.vue new file mode 100644 index 0000000000..1eae56f81a --- /dev/null +++ b/frappe/public/js/print_format_builder/LetterHeadEditor.vue @@ -0,0 +1,341 @@ + + + diff --git a/frappe/public/js/print_format_builder/Preview.vue b/frappe/public/js/print_format_builder/Preview.vue new file mode 100644 index 0000000000..35105dee6c --- /dev/null +++ b/frappe/public/js/print_format_builder/Preview.vue @@ -0,0 +1,132 @@ + + + diff --git a/frappe/public/js/print_format_builder/PrintFormat.vue b/frappe/public/js/print_format_builder/PrintFormat.vue new file mode 100644 index 0000000000..1857bae47e --- /dev/null +++ b/frappe/public/js/print_format_builder/PrintFormat.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frappe/public/js/print_format_builder/PrintFormatBuilder.vue b/frappe/public/js/print_format_builder/PrintFormatBuilder.vue new file mode 100644 index 0000000000..bcc3f8300f --- /dev/null +++ b/frappe/public/js/print_format_builder/PrintFormatBuilder.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frappe/public/js/print_format_builder/PrintFormatControls.vue b/frappe/public/js/print_format_builder/PrintFormatControls.vue new file mode 100644 index 0000000000..2eefc22409 --- /dev/null +++ b/frappe/public/js/print_format_builder/PrintFormatControls.vue @@ -0,0 +1,336 @@ + + + + + diff --git a/frappe/public/js/print_format_builder/PrintFormatSection.vue b/frappe/public/js/print_format_builder/PrintFormatSection.vue new file mode 100644 index 0000000000..9a065e5e26 --- /dev/null +++ b/frappe/public/js/print_format_builder/PrintFormatSection.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/frappe/public/js/print_format_builder/print_format_builder.bundle.js b/frappe/public/js/print_format_builder/print_format_builder.bundle.js new file mode 100644 index 0000000000..b2d3372daf --- /dev/null +++ b/frappe/public/js/print_format_builder/print_format_builder.bundle.js @@ -0,0 +1,64 @@ +import PrintFormatBuilderComponent from "./PrintFormatBuilder.vue"; +import { getStore } from "./store"; + +class PrintFormatBuilder { + constructor({ wrapper, page, print_format }) { + this.$wrapper = $(wrapper); + this.page = page; + this.print_format = print_format; + + this.page.clear_actions(); + this.page.clear_icons(); + this.page.clear_custom_actions(); + + this.page.set_title(__("Editing {0}", [this.print_format])); + this.page.set_primary_action(__("Save"), () => { + this.$component.$store.save_changes(); + }); + let $toggle_preview_btn = this.page.add_button( + __("Show Preview"), + () => { + this.$component.toggle_preview(); + } + ); + this.page.add_button(__("Reset Changes"), () => + this.$component.$store.reset_changes() + ); + this.page.add_menu_item(__("Edit Print Format"), () => { + frappe.set_route("Form", "Print Format", this.print_format); + }); + this.page.add_menu_item(__("Change Print Format"), () => { + frappe.set_route("print-format-builder-beta"); + }); + + let $vm = new Vue({ + el: this.$wrapper.get(0), + render: h => + h(PrintFormatBuilderComponent, { + props: { + print_format_name: print_format + } + }) + }); + this.$component = $vm.$children[0]; + let store = getStore(print_format); + store.$watch("dirty", value => { + if (value) { + this.page.set_indicator("Not Saved", "orange"); + $toggle_preview_btn.hide(); + } else { + this.page.clear_indicator(); + $toggle_preview_btn.show(); + } + }); + this.$component.$watch("show_preview", value => { + $toggle_preview_btn.text( + value ? __("Hide Preview") : __("Show Preview") + ); + }); + } +} + +frappe.provide("frappe.ui"); +frappe.ui.PrintFormatBuilder = PrintFormatBuilder; +export default PrintFormatBuilder; diff --git a/frappe/public/js/print_format_builder/store.js b/frappe/public/js/print_format_builder/store.js new file mode 100644 index 0000000000..f531a4a7e0 --- /dev/null +++ b/frappe/public/js/print_format_builder/store.js @@ -0,0 +1,177 @@ +import { create_default_layout, pluck } from "./utils"; + +let stores = {}; + +export function getStore(print_format_name) { + if (stores[print_format_name]) { + return stores[print_format_name]; + } + + let options = { + data() { + return { + print_format_name, + letterhead_name: null, + print_format: null, + letterhead: null, + doctype: null, + meta: null, + layout: null, + dirty: false, + edit_letterhead: false + }; + }, + watch: { + layout: { + deep: true, + handler() { + this.dirty = true; + } + }, + print_format: { + deep: true, + handler() { + this.dirty = true; + } + } + }, + methods: { + fetch() { + return new Promise(resolve => { + frappe.model.clear_doc( + "Print Format", + this.print_format_name + ); + frappe.model.with_doc( + "Print Format", + this.print_format_name, + () => { + let print_format = frappe.get_doc( + "Print Format", + this.print_format_name + ); + frappe.model.with_doctype( + print_format.doc_type, + () => { + this.meta = frappe.get_meta( + print_format.doc_type + ); + this.print_format = print_format; + this.layout = this.get_layout(); + this.$nextTick(() => (this.dirty = false)); + this.edit_letterhead = false; + resolve(); + } + ); + } + ); + }); + }, + update({ fieldname, value }) { + this.$set(this.print_format, fieldname, value); + }, + save_changes() { + frappe.dom.freeze(__("Saving...")); + + this.layout.sections = this.layout.sections + .filter(section => !section.remove) + .map(section => { + section.columns = section.columns.map(column => { + column.fields = column.fields + .filter(df => !df.remove) + .map(df => { + if (df.table_columns) { + df.table_columns = df.table_columns.map( + tf => { + return pluck(tf, [ + "label", + "fieldname", + "fieldtype", + "options", + "width", + "field_template" + ]); + } + ); + } + return pluck(df, [ + "label", + "fieldname", + "fieldtype", + "options", + "table_columns", + "html", + "field_template" + ]); + }); + return column; + }); + return section; + }); + + this.print_format.format_data = JSON.stringify(this.layout); + + frappe + .call("frappe.client.save", { + doc: this.print_format + }) + .then(() => { + if (this.letterhead && this.letterhead._dirty) { + return frappe + .call("frappe.client.save", { + doc: this.letterhead + }) + .then(r => (this.letterhead = r.message)); + } + }) + .then(() => this.fetch()) + .always(() => { + frappe.dom.unfreeze(); + this.$emit("after_save"); + }); + }, + reset_changes() { + this.fetch(); + }, + get_layout() { + if (this.print_format) { + if (typeof this.print_format.format_data == "string") { + return JSON.parse(this.print_format.format_data); + } + return this.print_format.format_data; + } + return null; + }, + get_default_layout() { + return create_default_layout(this.meta, this.print_format); + }, + change_letterhead(letterhead) { + return frappe.db + .get_doc("Letter Head", letterhead) + .then(doc => { + this.letterhead = doc; + }); + } + } + }; + stores[print_format_name] = new Vue(options); + return stores[print_format_name]; +} + +export let storeMixin = { + inject: ["$store"], + computed: { + print_format() { + return this.$store.print_format; + }, + layout() { + return this.$store.layout; + }, + letterhead() { + return this.$store.letterhead; + }, + meta() { + return this.$store.meta; + } + } +}; diff --git a/frappe/public/js/print_format_builder/utils.js b/frappe/public/js/print_format_builder/utils.js new file mode 100644 index 0000000000..879fe9efd2 --- /dev/null +++ b/frappe/public/js/print_format_builder/utils.js @@ -0,0 +1,159 @@ +export function create_default_layout(meta, print_format) { + let layout = { + header: get_default_header(meta), + sections: [] + }; + + let section = null, + column = null; + + function set_column(df) { + if (!section) { + set_section(); + } + column = get_new_column(df); + section.columns.push(column); + } + + function set_section(df) { + section = get_new_section(df); + column = null; + layout.sections.push(section); + } + + function get_new_section(df) { + if (!df) { + df = { label: "" }; + } + return { + label: df.label || "", + columns: [] + }; + } + + function get_new_column(df) { + if (!df) { + df = { label: "" }; + } + return { + label: df.label || "", + fields: [] + }; + } + + for (let df of meta.fields) { + if (df.fieldname) { + // make a copy to avoid mutation bugs + df = JSON.parse(JSON.stringify(df)); + } else { + continue; + } + + if (df.fieldtype === "Section Break") { + set_section(df); + } else if (df.fieldtype === "Column Break") { + set_column(df); + } else if (df.label) { + if (!column) set_column(); + + if (!df.print_hide) { + let field = { + label: df.label, + fieldname: df.fieldname, + fieldtype: df.fieldtype, + options: df.options + }; + + let field_template = get_field_template( + print_format, + df.fieldname + ); + if (field_template) { + field.label = `${__(df.label)} (${__("Field Template")})`; + field.fieldtype = "Field Template"; + field.field_template = field_template.name; + field.fieldname = df.fieldname = "_template"; + } + + if (df.fieldtype === "Table") { + field.table_columns = get_table_columns(df); + } + + column.fields.push(field); + section.has_fields = true; + } + } + } + + // remove empty sections + layout.sections = layout.sections.filter(section => section.has_fields); + + return layout; +} + +export function get_table_columns(df) { + let table_columns = []; + let table_fields = frappe.get_meta(df.options).fields; + let total_width = 0; + for (let tf of table_fields) { + if ( + !in_list(["Section Break", "Column Break"], tf.fieldtype) && + !tf.print_hide && + df.label && + total_width < 100 + ) { + let width = + typeof tf.width == "number" && tf.width < 100 + ? tf.width + : tf.width + ? 20 + : 10; + table_columns.push({ + label: tf.label, + fieldname: tf.fieldname, + fieldtype: tf.fieldtype, + options: tf.options, + width + }); + total_width += width; + } + } + return table_columns; +} + +function get_field_template(print_format, fieldname) { + let templates = print_format.__onload.print_templates || {}; + for (let template of templates) { + if (template.field === fieldname) { + return template; + } + } + return null; +} + +function get_default_header(meta) { + return `
+

${meta.name}

+

{{ doc.name }}

+
`; +} + +export function pluck(object, keys) { + let out = {}; + for (let key of keys) { + if (key in object) { + out[key] = object[key]; + } + } + return out; +} + +export function get_image_dimensions(src) { + return new Promise(resolve => { + let img = new Image(); + img.onload = function() { + resolve({ width: this.width, height: this.height }); + }; + img.src = src; + }); +} diff --git a/frappe/public/scss/common/controls.scss b/frappe/public/scss/common/controls.scss index a10cd454a6..954916c911 100644 --- a/frappe/public/scss/common/controls.scss +++ b/frappe/public/scss/common/controls.scss @@ -231,6 +231,13 @@ textarea.form-control { background-color: var(--control-bg); border-radius: var(--border-radius); padding: var(--padding-md); + + svg > rect { + fill: var(--control-bg) !important; + } + svg > g { + fill: var(--text-color) !important; + } } @media (min-width: 768px) { diff --git a/frappe/public/scss/desk/print_preview.scss b/frappe/public/scss/desk/print_preview.scss index 3c0acc68b8..468b37fe5a 100644 --- a/frappe/public/scss/desk/print_preview.scss +++ b/frappe/public/scss/desk/print_preview.scss @@ -14,6 +14,11 @@ } } +.preview-beta-wrapper { + border-radius: var(--border-radius); + overflow: hidden; +} + .print-toolbar { margin: 0px; padding: var(--padding-md) 0; diff --git a/frappe/public/scss/print_format.bundle.scss b/frappe/public/scss/print_format.bundle.scss new file mode 100644 index 0000000000..b01e669d71 --- /dev/null +++ b/frappe/public/scss/print_format.bundle.scss @@ -0,0 +1,5 @@ +@import "./desk/variables.scss"; +@import "./common/mixins.scss"; +@import "./common/global.scss"; +@import "./common/icons.scss"; +@import "~bootstrap/scss/bootstrap"; diff --git a/frappe/sessions.py b/frappe/sessions.py index c27c17a5cb..9a0f19df80 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -160,6 +160,10 @@ def get(): return bootinfo +@frappe.whitelist() +def get_boot_assets_json(): + return get_assets_json() + def get_csrf_token(): if not frappe.local.session.data.csrf_token: generate_csrf_token() diff --git a/frappe/templates/print_format/macros.html b/frappe/templates/print_format/macros.html new file mode 100644 index 0000000000..ace992e88d --- /dev/null +++ b/frappe/templates/print_format/macros.html @@ -0,0 +1,13 @@ +{% macro render_field(df, doc) %} +{%- set value = doc.get(df.fieldname) -%} +{% include ['templates/print_format/macros/' + df.renderer + '.html', 'templates/print_format/macros/Data.html'] ignore missing %} +{% endmacro %} + +{% macro field_attributes(df) %} +{%- if df.fieldname -%} +data-fieldname="{{ df.fieldname }}" +{%- endif %} +{% if df.fieldtype -%} +data-fieldtype="{{ df.fieldtype }}" +{%- endif -%} +{% endmacro %} diff --git a/frappe/templates/print_format/macros/Attach.html b/frappe/templates/print_format/macros/Attach.html new file mode 100644 index 0000000000..523d9e057a --- /dev/null +++ b/frappe/templates/print_format/macros/Attach.html @@ -0,0 +1,7 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+ {{ value.rsplit('/', 1)[1] }} +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/AttachImage.html b/frappe/templates/print_format/macros/AttachImage.html new file mode 100644 index 0000000000..796662f67a --- /dev/null +++ b/frappe/templates/print_format/macros/AttachImage.html @@ -0,0 +1,7 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+ {{ df.label }} +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Check.html b/frappe/templates/print_format/macros/Check.html new file mode 100644 index 0000000000..fbc43608a5 --- /dev/null +++ b/frappe/templates/print_format/macros/Check.html @@ -0,0 +1,9 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+ + + +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Code.html b/frappe/templates/print_format/macros/Code.html new file mode 100644 index 0000000000..e83457808a --- /dev/null +++ b/frappe/templates/print_format/macros/Code.html @@ -0,0 +1,7 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+
{{ value }}
+
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Color.html b/frappe/templates/print_format/macros/Color.html new file mode 100644 index 0000000000..ef7a2226c6 --- /dev/null +++ b/frappe/templates/print_format/macros/Color.html @@ -0,0 +1,8 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+
+ {{ value }} +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Data.html b/frappe/templates/print_format/macros/Data.html new file mode 100644 index 0000000000..722c42ce1a --- /dev/null +++ b/frappe/templates/print_format/macros/Data.html @@ -0,0 +1,10 @@ +{% if value %} +
+ {%- block label -%} +
{{ df.label }}
+ {%- endblock -%} + {%- block value -%} +
{{ doc.get_formatted(df.fieldname) }}
+ {%- endblock -%} +
+{% endif %} diff --git a/frappe/templates/print_format/macros/Divider.html b/frappe/templates/print_format/macros/Divider.html new file mode 100644 index 0000000000..49fdf3f547 --- /dev/null +++ b/frappe/templates/print_format/macros/Divider.html @@ -0,0 +1,2 @@ +
+
diff --git a/frappe/templates/print_format/macros/FieldTemplate.html b/frappe/templates/print_format/macros/FieldTemplate.html new file mode 100644 index 0000000000..9ea7fabb22 --- /dev/null +++ b/frappe/templates/print_format/macros/FieldTemplate.html @@ -0,0 +1,4 @@ +
+ {% set template = frappe.db.get_value('Print Format Field Template', df.field_template, ['template', 'template_file', 'standard'], as_dict=1) %} + {{ frappe.render_template(template.template_file if template.standard else template.template, {'doc': doc}) }} +
diff --git a/frappe/templates/print_format/macros/HTML.html b/frappe/templates/print_format/macros/HTML.html new file mode 100644 index 0000000000..6bd3659902 --- /dev/null +++ b/frappe/templates/print_format/macros/HTML.html @@ -0,0 +1,3 @@ +
+ {{ frappe.render_template(df.html, {'doc': doc}) }} +
diff --git a/frappe/templates/print_format/macros/Markdown.html b/frappe/templates/print_format/macros/Markdown.html new file mode 100644 index 0000000000..b692283fa0 --- /dev/null +++ b/frappe/templates/print_format/macros/Markdown.html @@ -0,0 +1,9 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+ {{ frappe.utils.md_to_html(doc.get(df.fieldname)) }} +
+{%- endblock -%} + + diff --git a/frappe/templates/print_format/macros/Rating.html b/frappe/templates/print_format/macros/Rating.html new file mode 100644 index 0000000000..2e001fb58f --- /dev/null +++ b/frappe/templates/print_format/macros/Rating.html @@ -0,0 +1,22 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{% macro star(is_active=false) %} + + {%- set color = '#f6c35e' if is_active else '#dce0e3' -%} + + +{% endmacro %} + +{%- block value -%} +
+ {%- for i in range(value) -%} + {{ star(true) }} + {%- endfor -%} + {%- for i in range(5 - value) -%} + {{ star() }} + {%- endfor -%} +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Signature.html b/frappe/templates/print_format/macros/Signature.html new file mode 100644 index 0000000000..128ff2a927 --- /dev/null +++ b/frappe/templates/print_format/macros/Signature.html @@ -0,0 +1,7 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+ {{ df.label }} +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Spacer.html b/frappe/templates/print_format/macros/Spacer.html new file mode 100644 index 0000000000..1a7336e17b --- /dev/null +++ b/frappe/templates/print_format/macros/Spacer.html @@ -0,0 +1,2 @@ +
+
diff --git a/frappe/templates/print_format/macros/Table.html b/frappe/templates/print_format/macros/Table.html new file mode 100644 index 0000000000..27c0be961c --- /dev/null +++ b/frappe/templates/print_format/macros/Table.html @@ -0,0 +1,30 @@ +{% if doc.get(df.fieldname) %} +
+
+ {{ df.label }} +
+ + {% set columns = df.table_columns %} + + + {% for column in columns %} + + {% endfor %} + + + + {% for row in doc.get(df.fieldname) %} + + {% for column in columns %} + + {% endfor %} + + {% endfor %} + +
+ {{ column.label }} +
+ {{ row.get_formatted(column.fieldname) }} +
+
+{% endif %} diff --git a/frappe/templates/print_format/print_footer.html b/frappe/templates/print_format/print_footer.html new file mode 100644 index 0000000000..bd64c0b1b2 --- /dev/null +++ b/frappe/templates/print_format/print_footer.html @@ -0,0 +1,24 @@ + + diff --git a/frappe/templates/print_format/print_format.css b/frappe/templates/print_format/print_format.css new file mode 100644 index 0000000000..480cd19439 --- /dev/null +++ b/frappe/templates/print_format/print_format.css @@ -0,0 +1,131 @@ +{% include "templates/print_format/print_format_font.css" %} + +{% macro render_margin_text(position, content) %} +@{{ position.replace('_', '-') }} { + content: {{ content }} +} +{% endmacro %} + +@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; + padding-top: {{ (header_height or 0) + 8 }}px; + padding-bottom: {{ (footer_height or 0) + 8 }}px; + + /* page number */ + {% set page_number_position = print_format.page_number.lower().replace(' ', '_') %} + {% if page_number_position in ['top_left', 'top_center', 'top_right', 'bottom_left', 'bottom_center', 'bottom_right'] %} + {{ render_margin_text(page_number_position, 'counter(page) " of " counter(pages)') }} + {% endif %} +} + +html, body { + font-size: {{ print_format.font_size }}px; +} + +body { + min-width: {{ body_width | int }}mm !important; + max-width: {{ body_width | int }}mm !important; +} + +/* CSS rules to fix bootstrap column rendering in PDF + https://github.com/Kozea/WeasyPrint/issues/697#issuecomment-542338732 +*/ +@media print { + .col, *[class^="col-"] { + max-width: none !important; + } +} + +@media screen { + html { + background-color: var(--gray-200); + } + body { + background-color: white; + box-shadow: var(--shadow-md); + margin: 2rem auto; + min-height: 297mm; + height: min-content; + 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: 1.2rem; + font-weight: 600; +} + +.field + .field { + margin-top: 0.5rem; +} + +.field .label { + font-weight: bold; +} + +.field.left-right { + display: flex; +} + +.field.left-right .label { + width: 50%; +} + +.field.left-right .value { + width: 50%; +} + +.child-table [data-fieldtype="Currency"] { + text-align: right; +} + +.table-row { + page-break-inside: avoid; +} + +.page-break { + page-break-after: always; +} + +.document-header { + border-bottom-width: 1px; + border-bottom-style: solid; + border-bottom-color: var(--gray-300); +} + +.field[data-fieldtype="Rating"] .rating-star { + width: 1.5rem; +} + +.field[data-fieldtype="Long Text"] .value, .field[data-fieldtype="Text"] .value { + white-space: pre-line; +} + +.field[data-fieldtype="Color"] .value { + display: flex; + align-items: center; +} + +.field[data-fieldtype="Color"] .color-square { + width: 1rem; + height: 1rem; + margin-right: 0.3rem; + border-radius: var(--border-radius); +} + +.field[data-fieldtype="Check"] #icon-tick { + width: 1rem; +} diff --git a/frappe/templates/print_format/print_format.html b/frappe/templates/print_format/print_format.html new file mode 100644 index 0000000000..b9fb95a9d3 --- /dev/null +++ b/frappe/templates/print_format/print_format.html @@ -0,0 +1,40 @@ +{% import "templates/print_format/macros.html" as macros %} + + + + + + + + {{ doc.doctype }}: {{ doc.name }} + {{ include_style('print_format.bundle.css') }} + + {%- if print_style and print_style.css -%} + + {%- endif -%} + {%- if print_format.css -%} + + {%- endif -%} + + + {{ header or '' }} + {% for section in layout.sections %} +
+ {% if section.label %} +
{{ section.label }}
+ {% endif %} + +
+ {% for column in section.columns %} +
+ {% for df in column.fields %} + {{ macros.render_field(df, doc) }} + {% endfor %} +
+ {% endfor %} +
+
+ {% endfor %} + {{ footer or '' }} + + diff --git a/frappe/templates/print_format/print_format_font.css b/frappe/templates/print_format/print_format_font.css new file mode 100644 index 0000000000..6103dbf5a4 --- /dev/null +++ b/frappe/templates/print_format/print_format_font.css @@ -0,0 +1,9 @@ +@charset "UTF-8"; +{% if print_format.font %} +{% set font_family = print_format.font.replace(' ', '+') %} +@import url("https://fonts.googleapis.com/css?family={{ font_family }}:400,500,600,700"); +{% endif %} + +html, body { + font-family: {{ print_format.font or 'Inter' }}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} diff --git a/frappe/templates/print_format/print_header.html b/frappe/templates/print_format/print_header.html new file mode 100644 index 0000000000..9b1357e08c --- /dev/null +++ b/frappe/templates/print_format/print_header.html @@ -0,0 +1,24 @@ + +
+ {%- if letterhead -%} + {{ frappe.render_template(letterhead.content, {'doc': doc}) }} + {%- endif -%} + + {%- if layout.header -%} + {{ frappe.render_template(layout.header, {'doc': doc}) }} + {%- endif -%} +
diff --git a/frappe/utils/weasyprint.py b/frappe/utils/weasyprint.py new file mode 100644 index 0000000000..006bab2dd0 --- /dev/null +++ b/frappe/utils/weasyprint.py @@ -0,0 +1,244 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE + +import frappe +from weasyprint import HTML, CSS + + +@frappe.whitelist() +def download_pdf(doctype, name, print_format, letterhead=None): + doc = frappe.get_doc(doctype, name) + 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) + 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 + 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: + header_html = frappe.render_template( + "templates/print_format/print_header.html", self.context + ) + if self.letterhead: + footer_html = frappe.render_template( + "templates/print_format/print_footer.html", self.context + ) + return header_html, footer_html + + def render_pdf(self): + """ + Returns + ------- + pdf: a bytes sequence + The rendered PDF. + """ + 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) + pdf = main_doc.write_pdf() + + return 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 = 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) diff --git a/frappe/www/printpreview.html b/frappe/www/printpreview.html new file mode 100644 index 0000000000..3c3871ecce --- /dev/null +++ b/frappe/www/printpreview.html @@ -0,0 +1,10 @@ +--- +no_cache: 1 +--- + + +{{ + frappe + .get_doc('Print Format', frappe.form_dict.print_format) + .get_html(frappe.form_dict.name, frappe.form_dict.letterhead) +}} diff --git a/package.json b/package.json index 7472a6b28b..d173b8218c 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,8 @@ "superagent": "^3.8.2", "touch": "^3.1.0", "vue": "2.6.12", - "vue-router": "^2.0.0" + "vue-router": "^2.0.0", + "vuedraggable": "^2.24.3" }, "devDependencies": { "chalk": "^2.3.2", diff --git a/requirements.txt b/requirements.txt index 17f77efa5d..671f6ced11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -73,3 +73,5 @@ wrapt~=1.12.1 xlrd~=2.0.1 zxcvbn-python~=4.4.24 tenacity~=8.0.1 +cairocffi==1.2.0 +WeasyPrint==52.5 diff --git a/yarn.lock b/yarn.lock index 2cf748606c..584a56efa7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4353,6 +4353,11 @@ socket.io@^2.4.0: socket.io-client "2.4.0" socket.io-parser "~3.4.0" +sortablejs@1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.10.2.tgz#6e40364d913f98b85a14f6678f92b5c1221f5290" + integrity sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A== + sortablejs@^1.7.0: version "1.8.3" resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.8.3.tgz#5ae908ef96300966e95440a143340f5dd565a0df" @@ -4922,6 +4927,20 @@ vue@2.6.12: resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.12.tgz#f5ebd4fa6bd2869403e29a896aed4904456c9123" integrity sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg== +vuedraggable@^2.24.3: + version "2.24.3" + resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-2.24.3.tgz#43c93849b746a24ce503e123d5b259c701ba0d19" + integrity sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g== + dependencies: + sortablejs "1.10.2" + +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g= + dependencies: + defaults "^1.0.3" + which-boxed-primitive@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.1.tgz#cbe8f838ebe91ba2471bb69e9edbda67ab5a5ec1"