- Letterhead editing - Edit Header and Footer - Margin Text - PrintFormatGenerator class handles generation of HTML and PDF and repeating of Header/Footer - Simplify /printpreview - Separate renderer files for each fieldtypeversion-14
@@ -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 | |||
@@ -7,10 +7,16 @@ 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 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") | |||
@@ -0,0 +1,61 @@ | |||
<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; | |||
} | |||
</style> |
@@ -0,0 +1,108 @@ | |||
<template> | |||
<div class="letterhead"> | |||
<div class="mb-2 d-flex justify-content-end"> | |||
<button | |||
class="btn btn-default btn-xs btn-edit" | |||
@click="toggle_edit_letterhead" | |||
> | |||
{{ !$store.edit_letterhead ? __("Edit") : __("Done") }} | |||
</button> | |||
<button | |||
v-if="type == 'Header'" | |||
class="ml-2 btn btn-default btn-xs btn-change-letterhead" | |||
@click="change_letterhead" | |||
> | |||
{{ __("Change Letter Head") }} | |||
</button> | |||
</div> | |||
<div | |||
v-if="letterhead && !$store.edit_letterhead" | |||
v-html="letterhead[field]" | |||
></div> | |||
<div v-show="letterhead && $store.edit_letterhead" ref="editor"></div> | |||
</div> | |||
</template> | |||
<script> | |||
import { storeMixin } from "./store"; | |||
export default { | |||
name: "LetterHeadEditor", | |||
props: ["type"], | |||
mixins: [storeMixin], | |||
mounted() { | |||
if (!this.letterhead) { | |||
frappe | |||
.call("frappe.client.get_default", { key: "letter_head" }) | |||
.then(r => { | |||
if (r.message) { | |||
this.$store.change_letterhead(r.message); | |||
} | |||
}); | |||
} | |||
}, | |||
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[ | |||
this.field | |||
] = this.control.get_value(); | |||
} | |||
}, | |||
render_input: true, | |||
only_input: true, | |||
no_wrapper: true | |||
}); | |||
} | |||
this.control.set_value(this.letterhead[this.field]); | |||
}, | |||
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.$store.change_letterhead(letterhead); | |||
} | |||
d.hide(); | |||
} | |||
}); | |||
d.show(); | |||
} | |||
}, | |||
computed: { | |||
field() { | |||
return { | |||
Header: "content", | |||
Footer: "footer" | |||
}[this.type]; | |||
} | |||
} | |||
}; | |||
</script> | |||
<style> | |||
.letterhead { | |||
position: relative; | |||
border: 1px solid var(--dark-border-color); | |||
border-radius: var(--border-radius); | |||
padding: 1rem; | |||
margin-bottom: 1rem; | |||
} | |||
</style> |
@@ -0,0 +1,114 @@ | |||
<template> | |||
<button | |||
class="btn btn-xs btn-default margin-text" | |||
:class="{ 'text-extra-muted': !value }" | |||
:style="styles" | |||
@click="edit" | |||
:title="__('Edit {0}', [label])" | |||
> | |||
{{ value || label }} | |||
</button> | |||
</template> | |||
<script> | |||
import { storeMixin } from "./store"; | |||
export default { | |||
name: "MarginText", | |||
props: ["position"], | |||
mixins: [storeMixin], | |||
methods: { | |||
edit() { | |||
let d = new frappe.ui.Dialog({ | |||
title: __("Edit {0}", [this.label]), | |||
fields: [ | |||
{ | |||
label: __("Select Template"), | |||
fieldname: "helper", | |||
fieldtype: "Select", | |||
options: Object.keys(this.helpers), | |||
change: () => { | |||
this.set_helper(d.get_value("helper")); | |||
} | |||
}, | |||
{ | |||
label: this.label, | |||
fieldname: "text", | |||
fieldtype: "Data", | |||
description: | |||
"Use jinja blocks for dynamic content. For e.g., {{ doc.name }}" | |||
} | |||
], | |||
primary_action: ({ text }) => { | |||
this.$set(this.layout, "text_" + this.position, text); | |||
d.hide(); | |||
}, | |||
secondary_action_label: __("Clear"), | |||
secondary_action: () => { | |||
d.set_value("text", ""); | |||
} | |||
}); | |||
d.show(); | |||
d.set_value("text", this.value); | |||
this.dialog = d; | |||
}, | |||
set_helper(helper) { | |||
let value = this.helpers[helper]; | |||
if (value) { | |||
this.dialog.set_value("text", value); | |||
} | |||
} | |||
}, | |||
computed: { | |||
value() { | |||
let text = this.layout["text_" + this.position]; | |||
return text; | |||
}, | |||
label() { | |||
return { | |||
top_left: __("Top Left Text"), | |||
top_center: __("Top Center Text"), | |||
top_right: __("Top Right Text"), | |||
bottom_left: __("Bottom Left Text"), | |||
bottom_center: __("Bottom Center Text"), | |||
bottom_right: __("Bottom Right Text") | |||
}[this.position]; | |||
}, | |||
helpers() { | |||
return { | |||
"Page number (x of y)": 'counter(page) " of " counter(pages)', | |||
"Document Name": '"{{ doc.name }}"' | |||
}; | |||
}, | |||
styles() { | |||
let styles = {}; | |||
if (this.position.includes("top")) { | |||
styles.top = "0.5rem"; | |||
} | |||
if (this.position.includes("bottom")) { | |||
styles.bottom = "0.5rem"; | |||
} | |||
if (this.position.includes("left")) { | |||
styles.left = this.print_format.margin_left + "mm"; | |||
} | |||
if (this.position.includes("right")) { | |||
styles.right = this.print_format.margin_right + "mm"; | |||
} | |||
if (this.position.includes("center")) { | |||
styles.left = "50%"; | |||
styles.transform = "translateX(-50%)"; | |||
} | |||
return styles; | |||
} | |||
} | |||
}; | |||
</script> | |||
<style scoped> | |||
.margin-text { | |||
position: absolute; | |||
z-index: 1; | |||
max-width: 10rem; | |||
white-space: nowrap; | |||
text-overflow: ellipsis; | |||
overflow: hidden; | |||
} | |||
</style> |
@@ -97,6 +97,9 @@ export default { | |||
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` | |||
@@ -1,6 +1,20 @@ | |||
<template> | |||
<div class="print-format-main" :style="rootStyles"> | |||
<MarginText position="top_left" /> | |||
<MarginText position="top_center" /> | |||
<MarginText position="top_right" /> | |||
<MarginText position="bottom_left" /> | |||
<MarginText position="bottom_center" /> | |||
<MarginText position="bottom_right" /> | |||
<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" | |||
@@ -13,11 +27,20 @@ | |||
@add_section_above="add_section_above(section)" | |||
/> | |||
</draggable> | |||
<HTMLEditor | |||
:value="layout.footer" | |||
@change="$set(layout, 'footer', $event)" | |||
:button-label="__('Edit Footer')" | |||
/> | |||
<LetterHeadEditor type="Footer" /> | |||
</div> | |||
</template> | |||
<script> | |||
import draggable from "vuedraggable"; | |||
import HTMLEditor from "./HTMLEditor.vue"; | |||
import LetterHeadEditor from "./LetterHeadEditor.vue"; | |||
import MarginText from "./MarginText.vue"; | |||
import PrintFormatSection from "./PrintFormatSection.vue"; | |||
import { storeMixin } from "./store"; | |||
@@ -26,7 +49,10 @@ export default { | |||
mixins: [storeMixin], | |||
components: { | |||
draggable, | |||
PrintFormatSection | |||
PrintFormatSection, | |||
LetterHeadEditor, | |||
HTMLEditor, | |||
MarginText | |||
}, | |||
computed: { | |||
rootStyles() { | |||
@@ -66,6 +92,7 @@ export default { | |||
<style scoped> | |||
.print-format-main { | |||
position: relative; | |||
margin-right: auto; | |||
margin-left: auto; | |||
background-color: white; | |||
@@ -20,6 +20,7 @@ | |||
type="number" | |||
class="form-control form-control-sm" | |||
:value="print_format[df.fieldname]" | |||
min="0" | |||
@change=" | |||
e => | |||
update_margin( | |||
@@ -11,11 +11,14 @@ export function getStore(print_format_name) { | |||
data() { | |||
return { | |||
print_format_name, | |||
letterhead_name: null, | |||
print_format: null, | |||
letterhead: null, | |||
doctype: null, | |||
meta: null, | |||
layout: null, | |||
dirty: false | |||
dirty: false, | |||
edit_letterhead: false | |||
}; | |||
}, | |||
watch: { | |||
@@ -56,6 +59,7 @@ export function getStore(print_format_name) { | |||
this.print_format = print_format; | |||
this.layout = this.get_layout(); | |||
this.$nextTick(() => (this.dirty = false)); | |||
this.edit_letterhead = false; | |||
resolve(); | |||
} | |||
); | |||
@@ -109,11 +113,19 @@ export function getStore(print_format_name) { | |||
.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(() => this.fetch()) | |||
.always(() => frappe.dom.unfreeze()); | |||
}, | |||
reset_changes() { | |||
this.fetch(); | |||
}, | |||
get_layout() { | |||
if (this.print_format) { | |||
@@ -129,6 +141,11 @@ export function getStore(print_format_name) { | |||
}, | |||
get_default_layout() { | |||
return create_default_layout(this.meta); | |||
}, | |||
change_letterhead(letterhead) { | |||
frappe.db.get_doc("Letter Head", letterhead).then(doc => { | |||
this.letterhead = doc; | |||
}); | |||
} | |||
} | |||
}; | |||
@@ -145,6 +162,9 @@ export let storeMixin = { | |||
layout() { | |||
return this.$store.layout; | |||
}, | |||
letterhead() { | |||
return this.$store.letterhead; | |||
}, | |||
meta() { | |||
return this.$store.meta; | |||
} | |||
@@ -1,20 +1,5 @@ | |||
{% macro render_field(df, doc) %} | |||
{% if df.fieldtype == 'Table' %} | |||
{{ render_table(df, doc) }} | |||
{% elif df.fieldtype == 'HTML' %} | |||
{{ render_custom_html(df, doc) }} | |||
{% else %} | |||
{% if doc.get(fieldname) %} | |||
<div class="field"> | |||
<div class="label" {{ field_attributes(df) }}> | |||
{{ df.label }} | |||
</div> | |||
<div class="value" {{ field_attributes(df) }}> | |||
{{ doc.get_formatted(df.fieldname) }} | |||
</div> | |||
</div> | |||
{% endif %} | |||
{% endif %} | |||
{% include ['templates/print_format/macros/' + df.renderer + '.html', 'templates/print_format/macros/Data.html'] ignore missing %} | |||
{% endmacro %} | |||
{% macro field_attributes(df) %} | |||
@@ -25,43 +10,3 @@ data-fieldname="{{ df.fieldname }}" | |||
data-fieldtype="{{ df.fieldtype }}" | |||
{%- endif -%} | |||
{% endmacro %} | |||
{% macro render_table(df, doc) %} | |||
{% if doc.get(df.fieldname) %} | |||
<div class="child-table" {{ field_attributes(df) }}> | |||
<div class="label"> | |||
{{ df.label }} | |||
</div> | |||
<table class="table"> | |||
{% 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 %} | |||
{% endmacro %} | |||
{% macro render_custom_html(df, doc) %} | |||
<div class="custom-html"> | |||
{{ frappe.render_template(df.html, {'doc': doc}) }} | |||
</div> | |||
{% endmacro %} | |||
@@ -0,0 +1,10 @@ | |||
{% if doc.get(fieldname) %} | |||
<div class="field"> | |||
<div class="label" {{ field_attributes(df) }}> | |||
{{ df.label }} | |||
</div> | |||
<div class="value" {{ field_attributes(df) }}> | |||
{{ doc.get_formatted(df.fieldname) }} | |||
</div> | |||
</div> | |||
{% endif %} |
@@ -0,0 +1,3 @@ | |||
<div class="custom-html"> | |||
{{ frappe.render_template(df.html, {'doc': doc}) }} | |||
</div> |
@@ -0,0 +1,3 @@ | |||
<div class="value" {{ field_attributes(df) }}> | |||
{{ frappe.utils.md_to_html(doc.get(df.fieldname)) }} | |||
</div> |
@@ -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"> | |||
{% 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 %} |
@@ -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> |
@@ -1,7 +1,13 @@ | |||
@charset "UTF-8"; | |||
{% if font_family %} | |||
@import url("https://fonts.googleapis.com/css?family={{ font_family }}:400,500,600,700"); | |||
{% include "templates/print_format/print_format_font.css" %} | |||
{% macro render_margin_text(position) %} | |||
{% set text = layout['text_' + position] %} | |||
{% if text %} | |||
@{{ position.replace('_', '-') }} { | |||
content: {{ text }} | |||
} | |||
{% endif %} | |||
{% endmacro %} | |||
@page { | |||
size: {{ print_settings.pdf_page_size or 'A4' }} portrait; | |||
@@ -9,10 +15,15 @@ | |||
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 }}px; | |||
padding-bottom: {{ footer_height }}px; | |||
html, body { | |||
font-family: {{ print_format.font or 'Inter' }}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; | |||
{{ render_margin_text('top_left') }} | |||
{{ render_margin_text('top_center') }} | |||
{{ render_margin_text('top_right') }} | |||
{{ render_margin_text('bottom_left') }} | |||
{{ render_margin_text('bottom_center') }} | |||
{{ render_margin_text('bottom_right') }} | |||
} | |||
body { | |||
@@ -60,6 +71,10 @@ body { | |||
text-align: right; | |||
} | |||
.table-row { | |||
page-break-inside: avoid; | |||
} | |||
.table-row td, .table-row th { | |||
border-bottom-width: 1px; | |||
border-bottom-style: solid; | |||
@@ -13,6 +13,7 @@ | |||
</style> | |||
</head> | |||
<body> | |||
{{ header }} | |||
{% for section in layout.sections %} | |||
<div class="section {{ resolve_class({'page-break': section.page_break}) }}"> | |||
{% if section.label %} | |||
@@ -30,5 +31,6 @@ | |||
</div> | |||
</div> | |||
{% endfor %} | |||
{{ footer }} | |||
</body> | |||
</html> |
@@ -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; | |||
} |
@@ -0,0 +1,24 @@ | |||
<style> | |||
{% include "templates/print_format/print_format_font.css" %} | |||
@media print { | |||
header { | |||
position: fixed; | |||
top: 0; | |||
left: 0; | |||
width: 100%; | |||
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> |
@@ -4,146 +4,151 @@ | |||
import frappe | |||
from weasyprint import HTML, CSS | |||
@frappe.whitelist() | |||
def download_pdf(doctype, name, print_format, letterhead=None): | |||
html = get_html(doctype, name, print_format, letterhead) | |||
pdf = get_pdf(html.main, html.header, html.footer) | |||
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.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): | |||
print_format = frappe.get_doc('Print Format', print_format) | |||
letterhead = frappe.get_doc('Letter Head', letterhead) if letterhead else None | |||
layout = frappe.parse_json(print_format.format_data) | |||
doc = frappe.get_doc(doctype, name) | |||
generator = PrintFormatGenerator(print_format, doc, letterhead) | |||
return generator.get_html_preview() | |||
print_settings = frappe.get_doc('Print Settings') | |||
page_width_map = { | |||
'A4': 210, | |||
'Letter': 216 | |||
} | |||
page_width = page_width_map.get(print_settings.pdf_page_size) or 210 | |||
body_width = page_width - print_format.margin_left - print_format.margin_right | |||
context = frappe._dict({ | |||
'doc': doc, | |||
'print_format': print_format, | |||
'print_settings': print_settings, | |||
'layout': layout, | |||
'letterhead': letterhead, | |||
'page_width': page_width, | |||
'body_width': body_width, | |||
'font_family': print_format.font.replace(' ', '+') if print_format.font else None | |||
}) | |||
context.css = frappe.render_template('templates/print_format/print_format.css', context) | |||
html = frappe.render_template('templates/print_format/print_format.html', context) | |||
if layout.header: | |||
header = frappe.render_template(layout.header['html'], context) | |||
else: | |||
header = None | |||
if layout.footer: | |||
footer = frappe.render_template(layout.footer['html'], context) | |||
else: | |||
footer = None | |||
return frappe._dict({ | |||
'main': html, | |||
'header': header, | |||
'footer': footer, | |||
}) | |||
def get_pdf(html, header, footer): | |||
return PdfGenerator( | |||
base_url=frappe.utils.get_url(), | |||
main_html=html, | |||
header_html=header, | |||
footer_html=footer | |||
).render_pdf() | |||
class PdfGenerator: | |||
class PrintFormatGenerator: | |||
""" | |||
Generate a PDF out of a rendered template, with the possibility to integrate nicely | |||
a header and a footer if provided. | |||
Notes: | |||
------ | |||
- When Weasyprint renders an html into a PDF, it goes though several intermediate steps. | |||
Here, in this class, we deal mostly with a box representation: 1 `Document` have 1 `Page` | |||
or more, each `Page` 1 `Box` or more. Each box can contain other box. Hence the recursive | |||
method `get_element` for example. | |||
For more, see: | |||
https://weasyprint.readthedocs.io/en/stable/hacking.html#dive-into-the-source | |||
https://weasyprint.readthedocs.io/en/stable/hacking.html#formatting-structure | |||
- Warning: the logic of this class relies heavily on the internal Weasyprint API. This | |||
snippet was written at the time of the release 47, it might break in the future. | |||
- 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 | |||
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 | |||
""" | |||
OVERLAY_LAYOUT = '@page {size: A4 portrait; margin: 0;}' | |||
def __init__(self, main_html, header_html=None, footer_html=None, | |||
base_url=None, side_margin=2, extra_vertical_margin=30): | |||
def __init__(self, print_format, doc, letterhead=None): | |||
""" | |||
Parameters | |||
---------- | |||
main_html: str | |||
An HTML file (most of the time a template rendered into a string) which represents | |||
the core of the PDF to generate. | |||
header_html: str | |||
An optional header html. | |||
footer_html: str | |||
An optional footer html. | |||
base_url: str | |||
An absolute url to the page which serves as a reference to Weasyprint to fetch assets, | |||
required to get our media. | |||
side_margin: int, interpreted in cm, by default 2cm | |||
The margin to apply on the core of the rendered PDF (i.e. main_html). | |||
extra_vertical_margin: int, interpreted in pixel, by default 30 pixels | |||
An extra margin to apply between the main content and header and the footer. | |||
The goal is to avoid having the content of `main_html` touching the header or the | |||
footer. | |||
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.print_settings = frappe.get_doc("Print Settings") | |||
self.build_context() | |||
self.layout = self.get_layout(self.print_format) | |||
self.context.layout = self.layout | |||
def build_context(self): | |||
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 | |||
) | |||
context = frappe._dict( | |||
{ | |||
"doc": self.doc, | |||
"print_format": self.print_format, | |||
"print_settings": self.print_settings, | |||
"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): | |||
""" | |||
self.main_html = main_html | |||
self.header_html = header_html | |||
self.footer_html = footer_html | |||
self.base_url = base_url | |||
self.side_margin = side_margin | |||
self.extra_vertical_margin = extra_vertical_margin | |||
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' | |||
Either 'header' or 'footer' | |||
Returns | |||
------- | |||
element_body: BlockBox | |||
A Weasyprint pre-rendered representation of an html element | |||
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 | |||
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, | |||
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_doc = html.render(stylesheets=[CSS(string=self.OVERLAY_LAYOUT)]) | |||
element_page = element_doc.pages[0] | |||
element_body = PdfGenerator.get_element(element_page._page_box.all_children(), 'body') | |||
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 = PdfGenerator.get_element(element_page._page_box.all_children(), element) | |||
element_html = PrintFormatGenerator.get_element( | |||
element_page._page_box.all_children(), element | |||
) | |||
if element == 'header': | |||
if element == "header": | |||
element_height = element_html.height | |||
if element == 'footer': | |||
if element == "footer": | |||
element_height = element_page.height - element_html.position_y | |||
return element_body, element_height | |||
@@ -155,54 +160,67 @@ class PdfGenerator: | |||
Parameters | |||
---------- | |||
main_doc: Document | |||
The top level representation for a PDF page in Weasyprint. | |||
The top level representation for a PDF page in Weasyprint. | |||
header_body: BlockBox | |||
A representation for an html element in Weasyprint. | |||
A representation for an html element in Weasyprint. | |||
footer_body: BlockBox | |||
A representation for an html element in Weasyprint. | |||
A representation for an html element in Weasyprint. | |||
""" | |||
for page in main_doc.pages: | |||
page_body = PdfGenerator.get_element(page._page_box.all_children(), 'body') | |||
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 render_pdf(self): | |||
""" | |||
Returns | |||
------- | |||
pdf: a bytes sequence | |||
The rendered PDF. | |||
""" | |||
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') | |||
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') | |||
footer_body, footer_height = self._compute_overlay_element("footer") | |||
else: | |||
footer_body, footer_height = None, 0 | |||
margins = '{header_size}px {side_margin} {footer_size}px {side_margin}'.format( | |||
header_size=header_height + self.extra_vertical_margin, | |||
footer_size=footer_height + self.extra_vertical_margin, | |||
side_margin=f'{self.side_margin}cm', | |||
) | |||
content_print_layout = '@page {size: A4 portrait; margin: %s;}' % margins | |||
html = HTML( | |||
string=self.main_html, | |||
base_url=self.base_url, | |||
) | |||
main_doc = html.render(stylesheets=[CSS(string=content_print_layout)]) | |||
if self.header_html or self.footer_html: | |||
self._apply_overlay_on_main(main_doc, header_body, footer_body) | |||
pdf = main_doc.write_pdf() | |||
return pdf | |||
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"] | |||
df["renderer"] = renderers.get(fieldtype) or fieldtype | |||
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): | |||
@@ -215,4 +233,4 @@ class PdfGenerator: | |||
for box in boxes: | |||
if box.element_tag == element: | |||
return box | |||
return PdfGenerator.get_element(box.all_children(), element) | |||
return PrintFormatGenerator.get_element(box.all_children(), element) |
@@ -1,3 +1,10 @@ | |||
<!-- </body> --> | |||
--- | |||
no_cache: 1 | |||
--- | |||
{{ html.main }} | |||
<!-- </body> --> | |||
{{ | |||
frappe | |||
.get_doc('Print Format', frappe.form_dict.print_format) | |||
.get_html(frappe.form_dict.name, frappe.form_dict.letterhead) | |||
}} |
@@ -1,15 +0,0 @@ | |||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors | |||
# MIT License. See license.txt | |||
import frappe | |||
from frappe.utils.weasyprint import get_html | |||
no_cache = 1 | |||
def get_context(context): | |||
doctype = frappe.form_dict.doctype | |||
name = frappe.form_dict.name | |||
print_format = frappe.form_dict.print_format | |||
letterhead = frappe.form_dict.letterhead | |||
context.no_cache = 1 | |||
context.html = get_html(doctype, name, print_format, letterhead) |