Merge pull request #14134 from netchampfaris/print-format-builder-beta

feat: New Print Format Builder
This commit is contained in:
Faris Ansari 2021-10-25 12:12:08 +05:30 committed by GitHub
commit 179960d67f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 3511 additions and 45 deletions

View file

@ -288,10 +288,24 @@ function get_watch_config() {
assets_json, assets_json,
prev_assets_json prev_assets_json
} = await write_assets_json(result.metafile); } = await write_assets_json(result.metafile);
let changed_files;
if (prev_assets_json) { 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); 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 // notify redis which in turns tells socketio to publish this to browser
let subscriber = get_redis_subscriber("redis_socketio"); let subscriber = get_redis_subscriber("redis_socketio");
subscriber.on("error", _ => { subscriber.on("error", _ => {
@ -484,6 +498,7 @@ async function notify_redis({ error, success }) {
if (success) { if (success) {
payload = { payload = {
success: true, success: true,
changed_files,
live_reload: argv["live-reload"] live_reload: argv["live-reload"]
}; };
} }
@ -514,7 +529,7 @@ function open_in_editor() {
subscriber.subscribe("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 added_files = [];
let old_files = Object.values(prev_assets); let old_files = Object.values(prev_assets);
let new_files = Object.values(new_assets); let new_files = Object.values(new_assets);
@ -524,17 +539,5 @@ function log_rebuilt_assets(prev_assets, new_assets) {
added_files.push(filepath); added_files.push(filepath);
} }
} }
return added_files;
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();
} }

View file

@ -258,6 +258,12 @@ def set_default(key, value, parent=None):
frappe.db.set_default(key, value, parent or frappe.session.user) frappe.db.set_default(key, value, parent or frappe.session.user)
frappe.clear_cache(user=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']) @frappe.whitelist(methods=['POST', 'PUT'])
def make_width_property_setter(doc): def make_width_property_setter(doc):
'''Set width Property Setter '''Set width Property Setter

View file

@ -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"
]

View file

@ -1,4 +1,5 @@
{ {
"actions": [],
"allow_rename": 1, "allow_rename": 1,
"autoname": "field:letter_head_name", "autoname": "field:letter_head_name",
"creation": "2012-11-22 17:45:46", "creation": "2012-11-22 17:45:46",
@ -13,6 +14,9 @@
"is_default", "is_default",
"letter_head_image_section", "letter_head_image_section",
"image", "image",
"image_height",
"image_width",
"align",
"header_section", "header_section",
"content", "content",
"footer_section", "footer_section",
@ -100,15 +104,34 @@
"fieldname": "footer", "fieldname": "footer",
"fieldtype": "HTML Editor", "fieldtype": "HTML Editor",
"label": "Footer HTML" "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", "icon": "fa fa-font",
"idx": 1, "idx": 1,
"links": [],
"max_attachments": 3, "max_attachments": 3,
"modified": "2019-11-11 18:46:43.375120", "modified": "2021-10-03 14:37:58.314696",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Printing", "module": "Printing",
"name": "Letter Head", "name": "Letter Head",
"naming_rule": "By fieldname",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {

View file

@ -2,7 +2,7 @@
# License: MIT. See LICENSE # License: MIT. See LICENSE
import frappe import frappe
from frappe.utils import is_image from frappe.utils import is_image, flt
from frappe.model.document import Document from frappe.model.document import Document
from frappe import _ from frappe import _
@ -26,7 +26,15 @@ class LetterHead(Document):
def set_image(self): def set_image(self):
if self.source=='Image': if self.source=='Image':
if self.image and is_image(self.image): if self.image and is_image(self.image):
self.content = '<img src="{}">'.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'''
<div style="text-align: {self.align.lower()};">
<img src="{self.image}" alt="{self.name}" {dimension}="{dimension_value}" style="{dimension}: {dimension_value}px;">
</div>
'''
frappe.msgprint(frappe._('Header HTML set from attachment {0}').format(self.image), alert = True) frappe.msgprint(frappe._('Header HTML set from attachment {0}').format(self.image), alert = True)
else: else:
frappe.msgprint(frappe._('Please attach an image file to set HTML'), alert = True, indicator = 'orange') frappe.msgprint(frappe._('Please attach an image file to set HTML'), alert = True, indicator = 'orange')

View file

@ -30,7 +30,11 @@ frappe.ui.form.on("Print Format", {
frappe.msgprint(__("Please select DocType first")); frappe.msgprint(__("Please select DocType first"));
return; 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) { else if (frm.doc.custom_format && !frm.doc.raw_printing) {

View file

@ -19,19 +19,26 @@
"html", "html",
"raw_commands", "raw_commands",
"section_break_9", "section_break_9",
"margin_top",
"margin_bottom",
"margin_left",
"margin_right",
"align_labels_right", "align_labels_right",
"show_section_headings", "show_section_headings",
"line_breaks", "line_breaks",
"absolute_value", "absolute_value",
"column_break_11", "column_break_11",
"font_size",
"font", "font",
"page_number",
"css_section", "css_section",
"css", "css",
"custom_html_help", "custom_html_help",
"section_break_13", "section_break_13",
"print_format_help", "print_format_help",
"format_data", "format_data",
"print_format_builder" "print_format_builder",
"print_format_builder_beta"
], ],
"fields": [ "fields": [
{ {
@ -149,12 +156,10 @@
"options": "Language" "options": "Language"
}, },
{ {
"default": "Default",
"depends_on": "eval:!doc.custom_format", "depends_on": "eval:!doc.custom_format",
"fieldname": "font", "fieldname": "font",
"fieldtype": "Select", "fieldtype": "Data",
"label": "Font", "label": "Google Font"
"options": "Default\nHelvetica Neue\nArial\nHelvetica\nVerdana\nMonospace"
}, },
{ {
"depends_on": "eval:!doc.raw_printing", "depends_on": "eval:!doc.raw_printing",
@ -205,16 +210,60 @@
"fieldname": "absolute_value", "fieldname": "absolute_value",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Show Absolute Values" "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", "icon": "fa fa-print",
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2021-03-01 15:25:46.578863", "modified": "2021-10-12 17:52:41.167107",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Printing", "module": "Printing",
"name": "Print Format", "name": "Print Format",
"naming_rule": "Set by user",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {

View file

@ -7,10 +7,24 @@ import frappe.utils
import json import json
from frappe import _ from frappe import _
from frappe.utils.jinja import validate_template from frappe.utils.jinja import validate_template
from frappe.utils.weasyprint import get_html, download_pdf
from frappe.model.document import Document from frappe.model.document import Document
class PrintFormat(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): def validate(self):
if (self.standard=="Yes" if (self.standard=="Yes"
and not frappe.local.conf.get("developer_mode") and not frappe.local.conf.get("developer_mode")
@ -38,6 +52,10 @@ class PrintFormat(Document):
def extract_images(self): def extract_images(self):
from frappe.core.doctype.file.file import extract_images_from_html from frappe.core.doctype.file.file import extract_images_from_html
if self.print_format_builder_beta:
return
if self.format_data: if self.format_data:
data = json.loads(self.format_data) data = json.loads(self.format_data)
for df in data: for df in data:

View file

@ -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) {
// }
});

View file

@ -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
}

View file

@ -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)

View file

@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies and Contributors
# See license.txt
# import frappe
import unittest
class TestPrintFormatFieldTemplate(unittest.TestCase):
pass

View file

@ -41,7 +41,11 @@ frappe.ui.form.PrintView = class {
</iframe> </iframe>
</div> </div>
<div class="page-break-message text-muted text-center text-medium margin-top"></div> <div class="page-break-message text-muted text-center text-medium margin-top"></div>
</div>` </div>
<div class="preview-beta-wrapper">
<iframe width="100%" height="0" frameBorder="0"></iframe>
</div>
`
); );
this.print_settings = frappe.model.get_doc( this.print_settings = frappe.model.get_doc(
@ -72,7 +76,7 @@ frappe.ui.form.PrintView = class {
this.page.add_button( this.page.add_button(
__('PDF'), __('PDF'),
() => this.render_page('/api/method/frappe.utils.print_format.download_pdf?'), () => this.render_pdf(),
{ icon: 'small-file' } { icon: 'small-file' }
); );
@ -190,6 +194,13 @@ frappe.ui.form.PrintView = class {
this.set_breadcrumbs(); this.set_breadcrumbs();
this.setup_customize_dialog(); this.setup_customize_dialog();
// print format builder beta
this.page.add_inner_message(`
<a style="line-height: 2.4" href="/app/print-format-builder-beta?doctype=${this.frm.doctype}">
${__('Try the new Print Format Builder')}
</a>
`);
let tasks = [ let tasks = [
this.refresh_print_options, this.refresh_print_options,
this.set_default_print_language, this.set_default_print_language,
@ -233,7 +244,7 @@ frappe.ui.form.PrintView = class {
let print_format = this.get_print_format(); let print_format = this.get_print_format();
let is_custom_format = let is_custom_format =
print_format.name && print_format.name &&
print_format.print_format_builder && (print_format.print_format_builder || print_format.print_format_builder_beta) &&
print_format.standard === 'No'; print_format.standard === 'No';
let is_standard_but_editable = let is_standard_but_editable =
print_format.name && print_format.custom_format; print_format.name && print_format.custom_format;
@ -243,7 +254,11 @@ frappe.ui.form.PrintView = class {
return; return;
} }
if (is_custom_format) { 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; return;
} }
// start a new print format // start a new print format
@ -261,6 +276,11 @@ frappe.ui.form.PrintView = class {
fieldtype: 'Read Only', fieldtype: 'Read Only',
default: print_format.name || 'Standard', default: print_format.name || 'Standard',
}, },
{
label: __('Use the new Print Format Builder'),
fieldname: 'beta',
fieldtype: 'Check'
},
], ],
(data) => { (data) => {
frappe.route_options = { frappe.route_options = {
@ -268,6 +288,7 @@ frappe.ui.form.PrintView = class {
doctype: this.frm.doctype, doctype: this.frm.doctype,
name: data.print_format_name, name: data.print_format_name,
based_on: data.based_on, based_on: data.based_on,
beta: data.beta
}; };
frappe.set_route('print-format-builder'); frappe.set_route('print-format-builder');
this.print_sel.val(data.print_format_name); this.print_sel.val(data.print_format_name);
@ -380,6 +401,17 @@ frappe.ui.form.PrintView = class {
} }
preview() { 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'); const $print_format = this.print_wrapper.find('iframe');
this.$print_format_body = $print_format.contents(); this.$print_format_body = $print_format.contents();
this.get_print_html((out) => { 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) { setup_print_format_dom(out, $print_format) {
this.print_wrapper.find('.print-format-skeleton').remove(); this.print_wrapper.find('.print-format-skeleton').remove();
let base_url = frappe.urllib.get_base_url(); 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) { render_page(method, printit = false) {
let w = window.open( let w = window.open(
frappe.urllib.get_full_url( frappe.urllib.get_full_url(

View file

@ -12,9 +12,9 @@ frappe.pages['print-format-builder'].on_page_show = function(wrapper) {
}); });
} else if(frappe.route_options) { } else if(frappe.route_options) {
if(frappe.route_options.make_new) { 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.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 { } else {
frappe.print_format_builder.print_format = frappe.route_options.doc; frappe.print_format_builder.print_format = frappe.route_options.doc;
frappe.route_options = null; 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({ frappe.call({
method: 'frappe.printing.page.print_format_builder.print_format_builder.create_custom_format', method: 'frappe.printing.page.print_format_builder.print_format_builder.create_custom_format',
args: { args: {
doctype: doctype, doctype: doctype,
name: name, name: name,
based_on: based_on based_on: based_on,
beta: Boolean(beta)
}, },
callback: (r) => { callback: (r) => {
if(!r.exc) { if (r.message) {
if(r.message) { let print_format = r.message;
this.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(); this.refresh();
} }
} }

View file

@ -1,11 +1,16 @@
import frappe import frappe
@frappe.whitelist() @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 = frappe.new_doc('Print Format')
doc.doc_type = doctype doc.doc_type = doctype
doc.name = name 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') \ doc.format_data = frappe.db.get_value('Print Format', based_on, 'format_data') \
if based_on != 'Standard' else None if based_on != 'Standard' else None
doc.insert() doc.insert()

View file

@ -0,0 +1,3 @@
.layout-main-section-wrapper {
margin-bottom: 0;
}

View file

@ -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();
}
}

View file

@ -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
}

View file

@ -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))

View file

@ -7,6 +7,34 @@ let error = null;
frappe.realtime.on("build_event", data => { frappe.realtime.on("build_event", data => {
if (data.success) { 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); show_build_success(data);
} else if (data.error) { } else if (data.error) {
show_build_error(data); show_build_error(data);

View file

@ -56,9 +56,6 @@ frappe.ui.form.ControlBarcode = class ControlBarcode extends frappe.ui.form.Cont
get_options(value) { get_options(value) {
// get JsBarcode options // get JsBarcode options
let options = {}; let options = {};
options.background = "var(--control-bg)";
options.lineColor = "var(--text-color)";
options.font = "var(--font-stack)";
options.fontSize = "16"; options.fontSize = "16";
options.width = "3"; options.width = "3";
options.height = "50"; options.height = "50";

View file

@ -58,6 +58,12 @@ $('body').on('click', 'a', function(e) {
if (frappe.router.is_app_route(e.currentTarget.pathname)) { if (frappe.router.is_app_route(e.currentTarget.pathname)) {
// target has "/app, this is a v2 style route. // 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); return override(e.currentTarget.pathname + e.currentTarget.hash);
} }

View file

@ -0,0 +1,111 @@
<template>
<div>
<p class="mb-3 text-muted">
{{ help_message }}
</p>
<div class="row font-weight-bold">
<div class="col-8">
{{ __("Column") }}
</div>
<div class="col-4">
{{ __("Width") }}
({{ __("Total:") }} {{ total_width }})
</div>
</div>
<draggable
:list="df.table_columns"
:animation="200"
:group="df.fieldname"
handle=".icon-drag"
>
<div
class="mt-2 row align-center column-row"
v-for="column in df.table_columns"
>
<div class="col-8">
<div class="column-label d-flex align-center">
<div class="px-2 icon-drag ml-n2">
<svg class="icon icon-xs">
<use xlink:href="#icon-drag"></use>
</svg>
</div>
<div class="mt-1 ml-1">
<input
class="input-column-label"
:class="{ 'text-danger': column.invalid_width }"
type="text"
v-model="column.label"
/>
</div>
</div>
</div>
<div class="col-4 d-flex align-items-center">
<input
type="number"
class="text-right form-control"
:class="{ 'text-danger is-invalid': column.invalid_width }"
v-model.number="column.width"
min="0"
max="100"
step="5"
/>
<button
class="ml-2 btn btn-xs btn-icon"
@click="remove_column(column)"
>
<svg class="icon icon-sm">
<use xlink:href="#icon-close"></use>
</svg>
</button>
</div>
</div>
</draggable>
</div>
</template>
<script>
import draggable from "vuedraggable";
export default {
name: "ConfigureColumns",
props: ["df"],
components: {
draggable
},
methods: {
remove_column(column) {
this.$set(
this.df,
"table_columns",
this.df.table_columns.filter(_column => _column !== column)
);
}
},
computed: {
help_message() {
// prettier-ignore
return __("Drag columns to set order. Column width is set in percentage. The total width should not be more than 100. Columns marked in red will be removed.");
},
total_width() {
return this.df.table_columns.reduce((total, tf) => total + tf.width, 0);
}
}
};
</script>
<style scoped>
.icon-drag {
cursor: grab;
}
.input-column-label {
border: 1px solid transparent;
border-radius: var(--border-radius);
font-size: var(--text-md);
}
.input-column-label:focus {
border-color: var(--border-color);
outline: none;
background-color: var(--control-bg);
}
.input-column-label::placeholder {
font-style: italic;
font-weight: normal;
}
</style>

View file

@ -0,0 +1,360 @@
<template>
<div class="field" :title="df.fieldname" @click="editing = true">
<div class="field-controls">
<div>
<div
class="custom-html"
v-if="df.fieldtype == 'HTML' && df.html"
v-html="df.html"
></div>
<div
class="custom-html"
v-if="df.fieldtype == 'Field Template'"
>
{{ df.label }}
</div>
<input
v-else-if="editing && df.fieldtype != 'HTML'"
ref="label-input"
class="label-input"
type="text"
:placeholder="__('Label')"
v-model="df.label"
@keydown.enter="editing = false"
@blur="editing = false"
/>
<span v-else-if="df.label">{{ df.label }}</span>
<i class="text-muted" v-else>
{{ __("No Label") }} ({{ df.fieldname }})
</i>
</div>
<div class="field-actions">
<button
v-if="df.fieldtype == 'HTML'"
class="btn btn-xs btn-icon"
@click="edit_html"
>
<svg class="icon icon-sm">
<use xlink:href="#icon-edit"></use>
</svg>
</button>
<button
v-if="df.fieldtype == 'Table'"
class="btn btn-xs btn-default"
@click="configure_columns"
>
Configure columns
</button>
<button
class="btn btn-xs btn-icon"
@click="$set(df, 'remove', true)"
>
<svg class="icon icon-sm">
<use xlink:href="#icon-close"></use>
</svg>
</button>
</div>
</div>
<div
v-if="df.fieldtype == 'Table'"
class="table-controls row no-gutters"
:style="{ opacity: 1 }"
>
<div
class="table-column"
:style="{ width: tf.width + '%' }"
v-for="(tf, i) in df.table_columns"
:key="tf.fieldname"
>
<div class="table-field">
{{ tf.label }}
</div>
</div>
</div>
</div>
</template>
<script>
import draggable from "vuedraggable";
import ConfigureColumnsVue from "./ConfigureColumns.vue";
import { storeMixin } from "./store";
export default {
name: "Field",
mixins: [storeMixin],
props: ["df"],
components: {
draggable
},
data() {
return {
editing: false
};
},
watch: {
editing(value) {
if (value) {
this.$nextTick(() => this.$refs["label-input"].focus());
}
},
"df.table_columns": {
deep: true,
handler() {
this.validate_table_columns();
}
}
},
methods: {
edit_html() {
let d = new frappe.ui.Dialog({
title: __("Edit HTML"),
fields: [
{
label: __("HTML"),
fieldname: "html",
fieldtype: "Code",
options: "HTML"
}
],
primary_action: ({ html }) => {
html = frappe.dom.remove_script_and_style(html);
this.$set(this.df, "html", html);
d.hide();
}
});
d.set_value("html", this.df.html);
d.show();
},
configure_columns() {
let dialog = new frappe.ui.Dialog({
title: __("Configure columns for {0}", [this.df.label]),
fields: [
{
fieldtype: "HTML",
fieldname: "columns_area"
},
{
label: "",
fieldtype: "Autocomplete",
placeholder: __("Add Column"),
fieldname: "add_column",
options: this.get_all_columns(),
onchange: () => {
let fieldname = dialog.get_value("add_column");
if (fieldname) {
let column = this.get_column_to_add(fieldname);
if (column) {
this.df.table_columns.push(column);
this.$set(
this.df,
"table_columns",
this.df.table_columns
);
dialog.set_value("add_column", "");
}
}
}
}
],
on_page_show: () => {
new Vue({
el: dialog.get_field("columns_area").$wrapper.get(0),
render: h =>
h(ConfigureColumnsVue, {
props: {
df: this.df
}
})
});
},
on_hide: () => {
this.$set(
this.df,
"table_columns",
this.df.table_columns.filter(col => !col.invalid_width)
);
}
});
dialog.show();
},
get_all_columns() {
let meta = frappe.get_meta(this.df.options);
let more_columns = [
{
label: __("Sr No."),
value: "idx"
}
];
return more_columns.concat(
meta.fields
.map(tf => {
if (frappe.model.no_value_type.includes(tf.fieldtype)) {
return;
}
return {
label: tf.label,
value: tf.fieldname
};
})
.filter(Boolean)
);
},
get_column_to_add(fieldname) {
let standard_columns = {
idx: {
label: __("Sr No."),
fieldtype: "Data",
fieldname: "idx",
width: 10
}
};
if (fieldname in standard_columns) {
return standard_columns[fieldname];
}
return {
...frappe.meta.get_docfield(this.df.options, fieldname),
width: 10
};
},
validate_table_columns() {
if (this.df.fieldtype != "Table") return;
let columns = this.df.table_columns;
let total_width = 0;
for (let column of columns) {
if (!column.width) {
column.width = 10;
}
total_width += column.width;
if (total_width > 100) {
column.invalid_width = true;
} else {
column.invalid_width = false;
}
}
}
}
};
</script>
<style>
.field {
text-align: left;
width: 100%;
background-color: var(--bg-light-gray);
border-radius: var(--border-radius);
border: 1px dashed var(--gray-400);
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
}
.field-controls {
display: flex;
justify-content: space-between;
align-items: center;
}
.field:not(:first-child) {
margin-top: 0.5rem;
}
.custom-html {
padding-right: var(--padding-xs);
word-break: break-all;
}
.label-input {
background-color: transparent;
border: none;
padding: 0;
}
.label-input:focus {
outline: none;
}
.field:focus-within {
border-style: solid;
border-color: var(--gray-600);
}
.field-actions {
flex: none;
}
.field-actions .btn {
opacity: 0;
}
.field-actions .btn-icon {
box-shadow: none;
}
.btn-icon {
padding: 2px;
}
.btn-icon:hover {
background-color: white;
}
.field:hover .btn {
opacity: 1;
}
.table-controls {
display: flex;
margin-top: 1rem;
}
.table-column {
position: relative;
}
.table-field {
text-align: left;
width: 100%;
background-color: white;
border-radius: var(--border-radius);
border: 1px dashed var(--gray-400);
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
user-select: none;
white-space: nowrap;
overflow: hidden;
}
.column-resize {
position: absolute;
right: 0;
top: 0;
width: 6px;
border-radius: 2px;
height: 80%;
background-color: var(--gray-600);
transform: translate(50%, 10%);
z-index: 999;
cursor: col-resize;
}
.column-resize-actions {
position: absolute;
top: 0;
right: 0;
height: 100%;
display: flex;
align-items: center;
padding-right: 0.25rem;
}
.column-resize-actions .btn-icon {
background: white;
}
.column-resize-actions .btn-icon:hover {
background: var(--bg-light-gray);
}
.columns-input {
padding: var(--padding-sm);
}
</style>

View file

@ -0,0 +1,68 @@
<template>
<div class="html-editor">
<div class="d-flex justify-content-end">
<button
class="btn btn-default btn-xs btn-edit"
@click="toggle_edit"
>
{{ !editing ? buttonLabel : __("Done") }}
</button>
</div>
<div v-if="!editing" v-html="value"></div>
<div v-show="editing" ref="editor"></div>
</div>
</template>
<script>
export default {
name: "HTMLEditor",
props: ["value", "button-label"],
data() {
return {
editing: false
};
},
methods: {
toggle_edit() {
if (this.editing) {
this.$emit("change", this.get_value());
this.editing = false;
return;
}
this.editing = true;
if (!this.control) {
this.control = frappe.ui.form.make_control({
parent: this.$refs.editor,
df: {
fieldname: "editor",
fieldtype: "HTML Editor",
min_lines: 10,
max_lines: 30,
change: () => {
this.$emit("change", this.get_value());
}
},
render_input: true
});
}
this.control.set_value(this.value);
},
get_value() {
return frappe.dom.remove_script_and_style(this.control.get_value());
}
}
};
</script>
<style>
.html-editor {
position: relative;
border: 1px solid var(--dark-border-color);
border-radius: var(--border-radius);
padding: 1rem;
margin-bottom: 1rem;
}
.html-editor:last-child {
margin-bottom: 0;
}
</style>

View file

@ -0,0 +1,341 @@
<template>
<div class="letterhead">
<div class="mb-4 d-flex justify-content-between">
<div class="d-flex align-items-center">
<div
v-if="letterhead && $store.edit_letterhead"
class="btn-group"
role="group"
aria-label="Align Letterhead"
>
<button
v-for="direction in ['Left', 'Center', 'Right']"
type="button"
class="btn btn-xs"
@click="letterhead.align = direction"
:class="
letterhead.align == direction
? 'btn-secondary'
: 'btn-default'
"
>
{{ direction }}
</button>
</div>
<input
class="ml-4 custom-range"
v-if="letterhead && $store.edit_letterhead"
type="range"
name="image-resize"
min="20"
:max="range_input_field === 'image_width' ? 700 : 500"
:value="letterhead[range_input_field]"
@input="
e =>
(letterhead[range_input_field] = parseFloat(
e.target.value
))
"
/>
</div>
<div>
<button
class="ml-2 btn btn-default btn-xs"
v-if="letterhead && $store.edit_letterhead"
@click="upload_image"
>
{{ __("Change Image") }}
</button>
<button
v-if="letterhead && $store.edit_letterhead"
class="ml-2 btn btn-default btn-xs btn-change-letterhead"
@click="change_letterhead"
>
{{ __("Change Letter Head") }}
</button>
<button
v-if="letterhead"
class="ml-2 btn btn-default btn-xs btn-edit"
@click="toggle_edit_letterhead"
>
{{
!$store.edit_letterhead
? __("Edit Letter Head")
: __("Done")
}}
</button>
<button
v-if="!letterhead"
class="ml-2 btn btn-default btn-xs btn-edit"
@click="create_letterhead"
>
{{ __("Create Letter Head") }}
</button>
</div>
</div>
<div
v-if="letterhead && !$store.edit_letterhead"
v-html="letterhead.content"
></div>
<!-- <div v-show="letterhead && $store.edit_letterhead" ref="editor"></div> -->
<div
class="edit-letterhead"
v-if="letterhead && $store.edit_letterhead"
:style="{
justifyContent: {
Left: 'flex-start',
Center: 'center',
Right: 'flex-end'
}[letterhead.align]
}"
>
<div class="edit-image">
<div v-if="letterhead.image">
<img
:src="letterhead.image"
:style="{
width:
range_input_field === 'image_width'
? letterhead.image_width + 'px'
: null,
height:
range_input_field === 'image_height'
? letterhead.image_height + 'px'
: null
}"
/>
</div>
<button v-else class="btn btn-default" @click="upload_image">
{{ __("Upload Image") }}
</button>
</div>
</div>
</div>
</template>
<script>
import { storeMixin } from "./store";
import { get_image_dimensions } from "./utils";
export default {
name: "LetterHeadEditor",
mixins: [storeMixin],
data() {
return {
range_input_field: null,
aspect_ratio: null
};
},
watch: {
letterhead: {
deep: true,
immediate: true,
handler(letterhead) {
if (!letterhead) return;
if (letterhead.image_width && letterhead.image_height) {
let dimension =
letterhead.image_width > letterhead.image_height
? "width"
: "height";
let dimension_value = letterhead["image_" + dimension];
letterhead.content = `
<div style="text-align: ${letterhead.align.toLowerCase()};">
<img
src="${letterhead.image}"
alt="${letterhead.name}"
${dimension}="${dimension_value}"
style="${dimension}: ${dimension_value}px;">
</div>
`;
}
}
}
},
mounted() {
if (!this.letterhead) {
frappe
.call("frappe.client.get_default", { key: "letter_head" })
.then(r => {
if (r.message) {
this.set_letterhead(r.message);
}
});
}
this.$watch(
function() {
return this.letterhead
? this.letterhead[this.range_input_field]
: null;
},
function() {
if (this.aspect_ratio === null) return;
let update_field =
this.range_input_field == "image_width"
? "image_height"
: "image_width";
this.letterhead[update_field] =
update_field == "image_width"
? this.aspect_ratio * this.letterhead.image_height
: this.letterhead.image_width / this.aspect_ratio;
}
);
},
methods: {
toggle_edit_letterhead() {
if (this.$store.edit_letterhead) {
this.$store.edit_letterhead = false;
return;
}
this.$store.edit_letterhead = true;
if (!this.control) {
this.control = frappe.ui.form.make_control({
parent: this.$refs.editor,
df: {
fieldname: "letterhead",
fieldtype: "Comment",
change: () => {
this.letterhead._dirty = true;
this.letterhead.content = this.control.get_value();
}
},
render_input: true,
only_input: true,
no_wrapper: true
});
}
this.control.set_value(this.letterhead.content);
},
change_letterhead() {
let d = new frappe.ui.Dialog({
title: __("Change Letter Head"),
fields: [
{
label: __("Letter Head"),
fieldname: "letterhead",
fieldtype: "Link",
options: "Letter Head"
}
],
primary_action: ({ letterhead }) => {
if (letterhead) {
this.set_letterhead(letterhead);
}
d.hide();
}
});
d.show();
},
upload_image() {
new frappe.ui.FileUploader({
folder: "Home/Attachments",
on_success: file_doc => {
get_image_dimensions(file_doc.file_url).then(
({ width, height }) => {
this.$set(
this.letterhead,
"image",
file_doc.file_url
);
let new_width = width;
let new_height = height;
this.aspect_ratio = width / height;
this.range_input_field =
this.aspect_ratio > 1
? "image_width"
: "image_height";
if (width > 200) {
new_width = 200;
new_height = new_width / aspect_ratio;
}
if (height > 80) {
new_height = 80;
new_width = aspect_ratio * new_height;
}
this.$set(
this.letterhead,
"image_height",
new_height
);
this.$set(
this.letterhead,
"image_width",
new_width
);
}
);
}
});
},
set_letterhead(letterhead) {
this.$store.change_letterhead(letterhead).then(() => {
get_image_dimensions(this.letterhead.image).then(
({ width, height }) => {
this.aspect_ratio = width / height;
this.range_input_field =
this.aspect_ratio > 1
? "image_width"
: "image_height";
}
);
});
},
create_letterhead() {
let d = new frappe.ui.Dialog({
title: __("Create Letter Head"),
fields: [
{
label: __("Letter Head Name"),
fieldname: "name",
fieldtype: "Data"
}
],
primary_action: ({ name }) => {
return frappe.db
.insert({
doctype: "Letter Head",
letter_head_name: name,
source: "Image"
})
.then(doc => {
d.hide();
this.$store.change_letterhead(doc.name).then(() => {
this.toggle_edit_letterhead();
});
});
}
});
d.show();
}
}
};
</script>
<style scoped>
.letterhead {
position: relative;
border: 1px solid var(--dark-border-color);
border-radius: var(--border-radius);
padding: 1rem;
margin-bottom: 1rem;
}
.edit-letterhead {
display: flex;
align-items: center;
}
.edit-image {
min-width: 40px;
min-height: 40px;
border: 1px solid var(--border-color);
}
.edit-image img {
height: 100%;
}
.edit-title {
margin-left: 1rem;
border: 1px solid transparent;
border-radius: var(--border-radius);
font-size: var(--text-md);
font-weight: 600;
}
</style>

View file

@ -0,0 +1,132 @@
<template>
<div class="h-100">
<div class="row">
<div class="col">
<div class="preview-control" ref="doc-select"></div>
</div>
<div class="col">
<div class="preview-control" ref="preview-type"></div>
</div>
<div class="col d-flex">
<a
v-if="url"
class="btn btn-default btn-sm btn-new-tab"
target="_blank"
:href="url"
>
{{ __("Open in a new tab") }}
</a>
<button
v-if="url"
class="ml-3 btn btn-default btn-sm btn-new-tab"
@click="refresh"
>
{{ __("Refresh") }}
</button>
</div>
</div>
<div v-if="url && !preview_loaded">Generating preview...</div>
<iframe
ref="iframe"
:src="url"
v-if="url"
v-show="preview_loaded"
class="preview-iframe"
@load="preview_loaded = true"
></iframe>
</div>
</template>
<script>
import { storeMixin } from "./store";
export default {
name: "Preview",
mixins: [storeMixin],
data() {
return {
type: "PDF",
docname: null,
preview_loaded: false
};
},
mounted() {
this.doc_select = frappe.ui.form.make_control({
parent: this.$refs["doc-select"],
df: {
label: __("Select {0}", [__(this.doctype)]),
fieldname: "docname",
fieldtype: "Link",
options: this.doctype,
change: () => {
this.docname = this.doc_select.get_value();
}
},
render_input: true
});
this.preview_type = frappe.ui.form.make_control({
parent: this.$refs["preview-type"],
df: {
label: __("Preview type"),
fieldname: "docname",
fieldtype: "Select",
options: ["PDF", "HTML"],
change: () => {
this.type = this.preview_type.get_value();
}
},
render_input: true
});
this.preview_type.set_value(this.type);
this.get_default_docname().then(
docname => docname && this.doc_select.set_value(docname)
);
this.$store.$on("after_save", () => {
this.refresh();
});
},
methods: {
refresh() {
this.$refs.iframe.contentWindow.location.reload();
},
get_default_docname() {
return frappe.db.get_list(this.doctype, { limit: 1 }).then(doc => {
return doc.length > 0 ? doc[0].name : null;
});
}
},
computed: {
doctype() {
return this.print_format.doc_type;
},
url() {
if (!this.docname) return null;
let params = new URLSearchParams();
params.append("doctype", this.doctype);
params.append("name", this.docname);
params.append("print_format", this.print_format.name);
if (this.$store.letterhead) {
params.append("letterhead", this.$store.letterhead.name);
}
let url =
this.type == "PDF"
? `/api/method/frappe.utils.weasyprint.download_pdf`
: "/printpreview";
return `${url}?${params.toString()}`;
}
}
};
</script>
<style scoped>
.preview-iframe {
width: 100%;
height: 96%;
border: none;
border-radius: var(--border-radius);
}
.btn-new-tab {
margin-top: auto;
margin-bottom: 1.2rem;
}
.preview-control >>> .form-control {
background: var(--control-bg-on-gray);
}
</style>

View file

@ -0,0 +1,136 @@
<template>
<div class="print-format-main" :style="rootStyles">
<div :style="page_number_style">{{ __("1 of 2") }}</div>
<LetterHeadEditor type="Header" />
<HTMLEditor
:value="layout.header"
@change="$set(layout, 'header', $event)"
:button-label="__('Edit Header')"
/>
<draggable
class="mb-4"
v-model="layout.sections"
group="sections"
filter=".section-columns, .column, .field"
:animation="200"
>
<PrintFormatSection
v-for="(section, i) in layout.sections"
:key="i"
:section="section"
@add_section_above="add_section_above(section)"
/>
</draggable>
<HTMLEditor
:value="layout.footer"
@change="$set(layout, 'footer', $event)"
:button-label="__('Edit Footer')"
/>
<HTMLEditor
v-if="letterhead"
:value="letterhead.footer"
@change="update_letterhead_footer"
:button-label="__('Edit Letter Head Footer')"
/>
</div>
</template>
<script>
import draggable from "vuedraggable";
import HTMLEditor from "./HTMLEditor.vue";
import LetterHeadEditor from "./LetterHeadEditor.vue";
import PrintFormatSection from "./PrintFormatSection.vue";
import { storeMixin } from "./store";
export default {
name: "PrintFormat",
mixins: [storeMixin],
components: {
draggable,
PrintFormatSection,
LetterHeadEditor,
HTMLEditor
},
computed: {
rootStyles() {
let {
margin_top = 0,
margin_bottom = 0,
margin_left = 0,
margin_right = 0
} = this.print_format;
return {
padding: `${margin_top}mm ${margin_right}mm ${margin_bottom}mm ${margin_left}mm`,
width: "210mm",
minHeight: "297mm"
};
},
page_number_style() {
let style = {
position: "absolute",
background: "white",
padding: "4px",
borderRadius: "var(--border-radius)",
border: "1px solid var(--border-color)"
};
if (this.print_format.page_number.includes("Top")) {
style.top = this.print_format.margin_top / 2 + "mm";
style.transform = "translateY(-50%)";
}
if (this.print_format.page_number.includes("Left")) {
style.left = this.print_format.margin_left + "mm";
}
if (this.print_format.page_number.includes("Right")) {
style.right = this.print_format.margin_right + "mm";
}
if (this.print_format.page_number.includes("Bottom")) {
style.bottom = this.print_format.margin_bottom / 2 + "mm";
style.transform = "translateY(50%)";
}
if (this.print_format.page_number.includes("Center")) {
style.left = "50%";
style.transform += " translateX(-50%)";
}
if (this.print_format.page_number.includes("Hide")) {
style.display = "none";
}
return style;
}
},
methods: {
add_section_above(section) {
let sections = [];
for (let _section of this.layout.sections) {
if (_section === section) {
sections.push({
label: "",
columns: [
{ label: "", fields: [] },
{ label: "", fields: [] }
]
});
}
sections.push(_section);
}
this.$set(this.layout, "sections", sections);
},
update_letterhead_footer(val) {
this.letterhead.footer = val;
this.letterhead._dirty = true;
}
}
};
</script>
<style scoped>
.print-format-main {
position: relative;
margin-right: auto;
margin-left: auto;
background-color: white;
box-shadow: var(--shadow-lg);
border-radius: var(--border-radius);
}
</style>

View file

@ -0,0 +1,74 @@
<template>
<div class="layout-main-section row" v-if="shouldRender">
<div class="col-3">
<PrintFormatControls />
</div>
<div class="print-format-container col-9">
<keep-alive>
<Preview v-if="show_preview" />
<PrintFormat v-else />
</keep-alive>
</div>
</div>
</template>
<script>
import PrintFormat from "./PrintFormat.vue";
import Preview from "./Preview.vue";
import PrintFormatControls from "./PrintFormatControls.vue";
import { getStore } from "./store";
export default {
name: "PrintFormatBuilder",
props: ["print_format_name"],
components: {
PrintFormat,
PrintFormatControls,
Preview
},
data() {
return {
show_preview: false
};
},
provide() {
return {
$store: this.$store
};
},
mounted() {
this.$store.fetch().then(() => {
if (!this.$store.layout) {
this.$store.layout = this.$store.get_default_layout();
this.$store.save_changes();
}
});
},
methods: {
toggle_preview() {
this.show_preview = !this.show_preview;
}
},
computed: {
$store() {
return getStore(this.print_format_name);
},
shouldRender() {
return Boolean(
this.$store.print_format &&
this.$store.meta &&
this.$store.layout
);
}
}
};
</script>
<style scoped>
.print-format-container {
height: calc(100vh - 140px);
overflow-y: auto;
padding-top: 0.5rem;
padding-bottom: 4rem;
}
</style>

View file

@ -0,0 +1,336 @@
<template>
<div class="layout-side-section">
<div class="form-sidebar">
<div class="sidebar-menu">
<div class="sidebar-label">{{ __("Page Margins") }}</div>
<div class="margin-controls">
<div
class="form-group"
v-for="df in margins"
:key="df.fieldname"
>
<div class="clearfix">
<label class="control-label">
{{ df.label }}
</label>
</div>
<div class="control-input-wrapper">
<div class="control-input">
<input
type="number"
class="form-control form-control-sm"
:value="print_format[df.fieldname]"
min="0"
@change="
e =>
update_margin(
df.fieldname,
e.target.value
)
"
/>
</div>
</div>
</div>
</div>
</div>
<div class="sidebar-menu">
<div class="sidebar-label">{{ __("Google Font") }}</div>
<div class="form-group">
<div class="control-input-wrapper">
<div class="control-input">
<select
class="form-control form-control-sm"
v-model="print_format.font"
>
<option
v-for="font in google_fonts"
:value="font"
>
{{ font }}
</option>
</select>
</div>
</div>
</div>
</div>
<div class="sidebar-menu">
<div class="sidebar-label">{{ __("Font Size") }}</div>
<div class="form-group">
<div class="control-input-wrapper">
<div class="control-input">
<input
type="number"
class="form-control form-control-sm"
placeholder="12, 13, 14"
:value="print_format.font_size"
@change="
e =>
(print_format.font_size = parseFloat(
e.target.value
))
"
/>
</div>
</div>
</div>
</div>
<div class="sidebar-menu">
<div class="sidebar-label">{{ __("Page Number") }}</div>
<div class="form-group">
<div class="control-input-wrapper">
<div class="control-input">
<select
class="form-control form-control-sm"
v-model="print_format.page_number"
>
<option
v-for="position in page_number_positions"
:value="position.value"
>
{{ position.label }}
</option>
</select>
</div>
</div>
</div>
</div>
<div class="sidebar-menu">
<div class="sidebar-label">{{ __("Fields") }}</div>
<input
class="mb-2 form-control form-control-sm"
type="text"
:placeholder="__('Search fields')"
v-model="search_text"
/>
<draggable
class="fields-container"
:list="fields"
:group="{ name: 'fields', pull: 'clone', put: false }"
:sort="false"
:clone="clone_field"
>
<div
class="field"
v-for="df in fields"
:key="df.fieldname"
:title="df.fieldname"
>
{{ df.label }}
</div>
</draggable>
</div>
</div>
</div>
</template>
<script>
import draggable from "vuedraggable";
import { get_table_columns, pluck } from "./utils";
import { storeMixin } from "./store";
export default {
name: "PrintFormatControls",
mixins: [storeMixin],
data() {
return {
search_text: "",
google_fonts: []
};
},
components: {
draggable
},
mounted() {
let method =
"frappe.printing.page.print_format_builder_beta.print_format_builder_beta.get_google_fonts";
frappe.call(method).then(r => {
this.google_fonts = r.message || [];
if (!this.google_fonts.includes(this.print_format.font)) {
this.google_fonts.push(this.print_format.font);
}
});
},
methods: {
update_margin(fieldname, value) {
value = parseFloat(value);
if (value < 0) {
value = 0;
}
this.$store.print_format[fieldname] = value;
},
clone_field(df) {
let cloned = pluck(df, [
"label",
"fieldname",
"fieldtype",
"options",
"table_columns",
"html",
"field_template"
]);
if (cloned.custom) {
// generate unique fieldnames for custom blocks
cloned.fieldname += "_" + frappe.utils.get_random(8);
}
return cloned;
}
},
computed: {
margins() {
return [
{ label: __("Top"), fieldname: "margin_top" },
{ label: __("Bottom"), fieldname: "margin_bottom" },
{ label: __("Left"), fieldname: "margin_left" },
{ label: __("Right"), fieldname: "margin_right" }
];
},
fields() {
let fields = this.meta.fields
.filter(df => {
if (
["Section Break", "Column Break"].includes(df.fieldtype)
) {
return false;
}
if (this.search_text) {
if (df.fieldname.includes(this.search_text)) {
return true;
}
if (df.label && df.label.includes(this.search_text)) {
return true;
}
return false;
} else {
return true;
}
})
.map(df => {
let out = {
label: df.label,
fieldname: df.fieldname,
fieldtype: df.fieldtype,
options: df.options
};
if (df.fieldtype == "Table") {
out.table_columns = get_table_columns(df);
}
return out;
});
return [
{
label: __("Custom HTML"),
fieldname: "custom_html",
fieldtype: "HTML",
html: "",
custom: 1
},
{
label: __("ID (name)"),
fieldname: "name",
fieldtype: "Data"
},
{
label: __("Spacer"),
fieldname: "spacer",
fieldtype: "Spacer",
custom: 1
},
{
label: __("Divider"),
fieldname: "divider",
fieldtype: "Divider",
custom: 1
},
...this.print_templates,
...fields
];
},
print_templates() {
let templates = this.print_format.__onload.print_templates || {};
let out = [];
for (let template of templates) {
let df;
if (template.field) {
df = frappe.meta.get_docfield(
this.meta.name,
template.field
);
} else {
df = {
label: template.name,
fieldname: frappe.scrub(template.name)
};
}
out.push({
label: `${__(df.label)} (${__("Field Template")})`,
fieldname: df.fieldname + "_template",
fieldtype: "Field Template",
field_template: template.name
});
}
return out;
},
page_number_positions() {
return [
{ label: __("Hide"), value: "Hide" },
{ label: __("Top Left"), value: "Top Left" },
{ label: __("Top Center"), value: "Top Center" },
{ label: __("Top Right"), value: "Top Right" },
{ label: __("Bottom Left"), value: "Bottom Left" },
{ label: __("Bottom Center"), value: "Bottom Center" },
{ label: __("Bottom Right"), value: "Bottom Right" }
];
}
}
};
</script>
<style scoped>
.margin-controls {
display: flex;
}
.form-control {
background: var(--control-bg-on-gray);
}
.margin-controls > .form-group + .form-group {
margin-left: 0.5rem;
}
.margin-controls > .form-group {
margin-bottom: 0;
}
.fields-container {
max-height: calc(100vh - 34rem);
overflow-y: auto;
}
.field {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
background-color: var(--bg-light-gray);
border-radius: var(--border-radius);
border: 1px dashed var(--gray-400);
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
cursor: pointer;
}
.field:not(:first-child) {
margin-top: 0.5rem;
}
.sidebar-menu:last-child {
margin-bottom: 0;
}
.control-font >>> .frappe-control[data-fieldname="font"] label {
display: none;
}
</style>

View file

@ -0,0 +1,245 @@
<template>
<div class="print-format-section-container" v-if="!section.remove">
<div class="print-format-section">
<div class="section-header">
<input
class="input-section-label w-50"
type="text"
:placeholder="__('Section Title')"
v-model="section.label"
/>
<div class="d-flex align-items-center">
<div
class="mr-2 text-small text-muted d-flex"
v-if="section.field_orientation == 'left-right'"
:title="
// prettier-ignore
__('Render labels to the left and values to the right in this section')
"
>
Label Value
</div>
<div class="dropdown">
<button
class="btn btn-xs btn-section dropdown-button"
data-toggle="dropdown"
>
<svg class="icon icon-sm">
<use xlink:href="#icon-dot-horizontal"></use>
</svg>
</button>
<div
class="dropdown-menu dropdown-menu-right"
role="menu"
>
<button
v-for="option in section_options"
class="dropdown-item"
@click="option.action"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</div>
<div class="row section-columns">
<div
class="column col"
v-for="(column, i) in section.columns"
:key="i"
>
<draggable
class="drag-container"
:style="{
backgroundColor: column.fields.length
? null
: 'var(--gray-50)'
}"
v-model="column.fields"
group="fields"
:animation="150"
>
<Field
v-for="df in get_fields(column)"
:key="df.fieldname"
:df="df"
/>
</draggable>
</div>
</div>
</div>
<div
class="my-4 text-center text-muted font-italic"
v-if="section.page_break"
>
{{ __("Page Break") }}
</div>
</div>
</template>
<script>
import draggable from "vuedraggable";
import Field from "./Field.vue";
import { storeMixin } from "./store";
export default {
name: "PrintFormatSection",
mixins: [storeMixin],
props: ["section"],
components: {
draggable,
Field
},
methods: {
add_column() {
if (this.section.columns.length < 4) {
this.section.columns.push({
label: "",
fields: []
});
}
},
remove_column() {
if (this.section.columns.length <= 1) return;
let columns = this.section.columns.slice();
let last_column_fields = columns.slice(-1)[0].fields.slice();
let index = columns.length - 1;
columns = columns.slice(0, index);
let last_column = columns[index - 1];
last_column.fields = [...last_column.fields, ...last_column_fields];
this.$set(this.section, "columns", columns);
},
add_page_break() {
this.$set(this.section, "page_break", true);
},
remove_page_break() {
this.$set(this.section, "page_break", false);
},
get_fields(column) {
return column.fields.filter(df => !df.remove);
}
},
computed: {
section_options() {
return [
{
label: __("Add section above"),
action: () => this.$emit("add_section_above")
},
{
label: __("Add column"),
action: this.add_column,
condition: () => this.section.columns.length < 4
},
{
label: __("Remove column"),
action: this.remove_column,
condition: () => this.section.columns.length > 1
},
{
label: __("Add page break"),
action: this.add_page_break,
condition: () => !this.section.page_break
},
{
label: __("Remove page break"),
action: this.remove_page_break,
condition: () => this.section.page_break
},
{
label: __("Remove section"),
action: () => this.$set(this.section, "remove", true)
},
{
label: __("Field Orientation (Left-Right)"),
condition: () => !this.section.field_orientation,
action: () =>
this.$set(
this.section,
"field_orientation",
"left-right"
)
},
{
label: __("Field Orientation (Top-Down)"),
condition: () =>
this.section.field_orientation == "left-right",
action: () =>
this.$set(this.section, "field_orientation", "")
}
].filter(option => (option.condition ? option.condition() : true));
}
}
};
</script>
<style scoped>
.print-format-section-container:not(:last-child) {
margin-bottom: 1rem;
}
.print-format-section {
background-color: white;
border: 1px solid var(--dark-border-color);
border-radius: var(--border-radius);
padding: 1rem;
cursor: pointer;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.75rem;
}
.input-section-label {
border: 1px solid transparent;
border-radius: var(--border-radius);
font-size: var(--text-md);
font-weight: 600;
}
.input-section-label:focus {
border-color: var(--border-color);
outline: none;
background-color: var(--control-bg);
}
.input-section-label::placeholder {
font-style: italic;
font-weight: normal;
}
.btn-section {
padding: var(--padding-xs);
box-shadow: none;
}
.btn-section:hover {
background-color: var(--bg-light-gray);
}
.print-format-section:not(:first-child) {
margin-top: 1rem;
}
.section-columns {
margin-left: -8px;
margin-right: -8px;
}
.column {
padding-left: 8px;
padding-right: 8px;
}
.drag-container {
height: 100%;
min-height: 2rem;
border-radius: var(--border-radius);
}
</style>

View file

@ -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;

View file

@ -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;
}
}
};

View file

@ -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 `<div class="document-header">
<h3>${meta.name}</h3>
<p>{{ doc.name }}</p>
</div>`;
}
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;
});
}

View file

@ -231,6 +231,13 @@ textarea.form-control {
background-color: var(--control-bg); background-color: var(--control-bg);
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: var(--padding-md); padding: var(--padding-md);
svg > rect {
fill: var(--control-bg) !important;
}
svg > g {
fill: var(--text-color) !important;
}
} }
@media (min-width: 768px) { @media (min-width: 768px) {

View file

@ -14,6 +14,11 @@
} }
} }
.preview-beta-wrapper {
border-radius: var(--border-radius);
overflow: hidden;
}
.print-toolbar { .print-toolbar {
margin: 0px; margin: 0px;
padding: var(--padding-md) 0; padding: var(--padding-md) 0;

View file

@ -0,0 +1,5 @@
@import "./desk/variables.scss";
@import "./common/mixins.scss";
@import "./common/global.scss";
@import "./common/icons.scss";
@import "~bootstrap/scss/bootstrap";

View file

@ -160,6 +160,10 @@ def get():
return bootinfo return bootinfo
@frappe.whitelist()
def get_boot_assets_json():
return get_assets_json()
def get_csrf_token(): def get_csrf_token():
if not frappe.local.session.data.csrf_token: if not frappe.local.session.data.csrf_token:
generate_csrf_token() generate_csrf_token()

View file

@ -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 %}

View file

@ -0,0 +1,7 @@
{% extends "templates/print_format/macros/Data.html" %}
{%- block value -%}
<div class="value">
<a href="{{ value }}">{{ value.rsplit('/', 1)[1] }}</a>
</div>
{%- endblock -%}

View file

@ -0,0 +1,7 @@
{% extends "templates/print_format/macros/Data.html" %}
{%- block value -%}
<div class="value">
<img class="w-100" src="{{ value }}" alt="{{ df.label }}">
</div>
{%- endblock -%}

View file

@ -0,0 +1,9 @@
{% extends "templates/print_format/macros/Data.html" %}
{%- block value -%}
<div class="value">
<svg viewBox="0 0 16 16" fill="transparent" stroke="#1F272E" stroke-width="2" xmlns="http://www.w3.org/2000/svg" id="icon-tick">
<path d="M2 9.66667L5.33333 13L14 3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
{%- endblock -%}

View file

@ -0,0 +1,7 @@
{% extends "templates/print_format/macros/Data.html" %}
{%- block value -%}
<div class="value">
<pre><code>{{ value }}</code></pre>
</div>
{%- endblock -%}

View file

@ -0,0 +1,8 @@
{% extends "templates/print_format/macros/Data.html" %}
{%- block value -%}
<div class="value">
<div class="color-square" style="background-color: {{ value }};"></div>
{{ value }}
</div>
{%- endblock -%}

View file

@ -0,0 +1,10 @@
{% if value %}
<div class="field {{ df.section.field_orientation or '' }}" {{ field_attributes(df) }}>
{%- block label -%}
<div class="label">{{ df.label }}</div>
{%- endblock -%}
{%- block value -%}
<div class="value">{{ doc.get_formatted(df.fieldname) }}</div>
{%- endblock -%}
</div>
{% endif %}

View file

@ -0,0 +1,2 @@
<div style="height: 1px; margin: 0.5rem 0; border-bottom: 1px solid; border-bottom-color: var(--dark-border-color);">
</div>

View file

@ -0,0 +1,4 @@
<div class="field-template" {{ field_attributes(df) }}>
{% 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}) }}
</div>

View file

@ -0,0 +1,3 @@
<div class="custom-html" {{ field_attributes(df) }}>
{{ frappe.render_template(df.html, {'doc': doc}) }}
</div>

View file

@ -0,0 +1,9 @@
{% extends "templates/print_format/macros/Data.html" %}
{%- block value -%}
<div class="value">
{{ frappe.utils.md_to_html(doc.get(df.fieldname)) }}
</div>
{%- endblock -%}

View file

@ -0,0 +1,22 @@
{% extends "templates/print_format/macros/Data.html" %}
{% macro star(is_active=false) %}
<svg id="icon-star" class="rating-star {{ is_active and 'active' or '' }}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
{%- set color = '#f6c35e' if is_active else '#dce0e3' -%}
<path
fill="{{ color }}" stroke="{{ color }}"
d="M11.5516 2.90849C11.735 2.53687 12.265 2.53687 12.4484 2.90849L14.8226 7.71919C14.8954 7.86677 15.0362 7.96905 15.1991 7.99271L20.508 8.76415C20.9181 8.82374 21.0818 9.32772 20.7851 9.61699L16.9435 13.3616C16.8257 13.4765 16.7719 13.642 16.7997 13.8042L17.7066 19.0916C17.7766 19.5001 17.3479 19.8116 16.9811 19.6187L12.2327 17.1223C12.087 17.0457 11.913 17.0457 11.7673 17.1223L7.01888 19.6187C6.65207 19.8116 6.22335 19.5001 6.29341 19.0916L7.20028 13.8042C7.2281 13.642 7.17433 13.4765 7.05648 13.3616L3.21491 9.61699C2.91815 9.32772 3.08191 8.82374 3.49202 8.76415L8.80094 7.99271C8.9638 7.96905 9.10458 7.86677 9.17741 7.71919L11.5516 2.90849Z"
/>
</svg>
{% endmacro %}
{%- block value -%}
<div class="value">
{%- for i in range(value) -%}
{{ star(true) }}
{%- endfor -%}
{%- for i in range(5 - value) -%}
{{ star() }}
{%- endfor -%}
</div>
{%- endblock -%}

View file

@ -0,0 +1,7 @@
{% extends "templates/print_format/macros/Data.html" %}
{%- block value -%}
<div class="value">
<img src="{{ value }}" alt="{{ df.label }}">
</div>
{%- endblock -%}

View file

@ -0,0 +1,2 @@
<div style="height: 1rem">
</div>

View file

@ -0,0 +1,30 @@
{% if doc.get(df.fieldname) %}
<div class="child-table" {{ field_attributes(df) }}>
<div class="label">
{{ df.label }}
</div>
<table class="table table-bordered">
{% set columns = df.table_columns %}
<thead>
<tr class="table-row">
{% for column in columns %}
<th class="column-header" width="{{ column.width }}%" {{ field_attributes(column) }}>
{{ column.label }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in doc.get(df.fieldname) %}
<tr class="table-row {{ loop.cycle('odd', 'even') }}" data-idx="{{ row.idx }}">
{% for column in columns %}
<td class="column-value" width="{{ column.width }}%" {{ field_attributes(column) }}>
{{ row.get_formatted(column.fieldname) }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}

View file

@ -0,0 +1,24 @@
<style>
{% include "templates/print_format/print_format_font.css" %}
@media print {
footer {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
padding-bottom: {{ print_format.margin_bottom | int }}mm;
padding-left: {{ print_format.margin_left | int }}mm;
padding-right: {{ print_format.margin_right | int }}mm;
}
}
</style>
<footer>
{%- if layout.footer -%}
{{ frappe.render_template(layout.footer, {'doc': doc}) }}
{%- endif -%}
{%- if letterhead -%}
{{ frappe.render_template(letterhead.footer, {'doc': doc}) }}
{%- endif -%}
</footer>

View file

@ -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;
}

View file

@ -0,0 +1,40 @@
{% import "templates/print_format/macros.html" as macros %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ doc.doctype }}: {{ doc.name }}</title>
{{ include_style('print_format.bundle.css') }}
<style>{{ css }}</style>
{%- if print_style and print_style.css -%}
<style>{{ print_style.css }}</style>
{%- endif -%}
{%- if print_format.css -%}
<style>{{ print_format.css }}</style>
{%- endif -%}
</head>
<body>
{{ header or '' }}
{% for section in layout.sections %}
<div class="section {{ resolve_class({'page-break': section.page_break}) }}">
{% if section.label %}
<div class="section-label">{{ section.label }}</div>
{% endif %}
<div class="section-columns row">
{% for column in section.columns %}
<div class="column col">
{% for df in column.fields %}
{{ macros.render_field(df, doc) }}
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{{ footer or '' }}
</body>
</html>

View file

@ -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;
}

View file

@ -0,0 +1,24 @@
<style>
{% include "templates/print_format/print_format_font.css" %}
@media print {
header {
position: fixed;
top: 0;
left: 0;
width: {{ body_width | int }}mm;
padding-top: {{ print_format.margin_top | int }}mm;
padding-left: {{ print_format.margin_left | int }}mm;
padding-right: {{ print_format.margin_right | int }}mm;
}
}
</style>
<header>
{%- if letterhead -%}
{{ frappe.render_template(letterhead.content, {'doc': doc}) }}
{%- endif -%}
{%- if layout.header -%}
{{ frappe.render_template(layout.header, {'doc': doc}) }}
{%- endif -%}
</header>

244
frappe/utils/weasyprint.py Normal file
View file

@ -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)

View file

@ -0,0 +1,10 @@
---
no_cache: 1
---
<!-- </body> -->
{{
frappe
.get_doc('Print Format', frappe.form_dict.print_format)
.get_html(frappe.form_dict.name, frappe.form_dict.letterhead)
}}

View file

@ -62,7 +62,8 @@
"superagent": "^3.8.2", "superagent": "^3.8.2",
"touch": "^3.1.0", "touch": "^3.1.0",
"vue": "2.6.12", "vue": "2.6.12",
"vue-router": "^2.0.0" "vue-router": "^2.0.0",
"vuedraggable": "^2.24.3"
}, },
"devDependencies": { "devDependencies": {
"chalk": "^2.3.2", "chalk": "^2.3.2",

View file

@ -73,3 +73,5 @@ wrapt~=1.12.1
xlrd~=2.0.1 xlrd~=2.0.1
zxcvbn-python~=4.4.24 zxcvbn-python~=4.4.24
tenacity~=8.0.1 tenacity~=8.0.1
cairocffi==1.2.0
WeasyPrint==52.5

View file

@ -4353,6 +4353,11 @@ socket.io@^2.4.0:
socket.io-client "2.4.0" socket.io-client "2.4.0"
socket.io-parser "~3.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: sortablejs@^1.7.0:
version "1.8.3" version "1.8.3"
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.8.3.tgz#5ae908ef96300966e95440a143340f5dd565a0df" 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" resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.12.tgz#f5ebd4fa6bd2869403e29a896aed4904456c9123"
integrity sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg== 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: which-boxed-primitive@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.1.tgz#cbe8f838ebe91ba2471bb69e9edbda67ab5a5ec1" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.1.tgz#cbe8f838ebe91ba2471bb69e9edbda67ab5a5ec1"