feat: More features
- 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 fieldtype
This commit is contained in:
parent
432378c06f
commit
f4bd62c010
22 changed files with 637 additions and 216 deletions
|
|
@ -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")
|
||||
|
|
|
|||
61
frappe/public/js/print_format_builder/HTMLEditor.vue
Normal file
61
frappe/public/js/print_format_builder/HTMLEditor.vue
Normal file
|
|
@ -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>
|
||||
108
frappe/public/js/print_format_builder/LetterHeadEditor.vue
Normal file
108
frappe/public/js/print_format_builder/LetterHeadEditor.vue
Normal file
|
|
@ -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>
|
||||
114
frappe/public/js/print_format_builder/MarginText.vue
Normal file
114
frappe/public/js/print_format_builder/MarginText.vue
Normal file
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
10
frappe/templates/print_format/macros/Data.html
Normal file
10
frappe/templates/print_format/macros/Data.html
Normal file
|
|
@ -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 %}
|
||||
3
frappe/templates/print_format/macros/HTML.html
Normal file
3
frappe/templates/print_format/macros/HTML.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<div class="custom-html">
|
||||
{{ frappe.render_template(df.html, {'doc': doc}) }}
|
||||
</div>
|
||||
3
frappe/templates/print_format/macros/Markdown.html
Normal file
3
frappe/templates/print_format/macros/Markdown.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<div class="value" {{ field_attributes(df) }}>
|
||||
{{ frappe.utils.md_to_html(doc.get(df.fieldname)) }}
|
||||
</div>
|
||||
30
frappe/templates/print_format/macros/Table.html
Normal file
30
frappe/templates/print_format/macros/Table.html
Normal 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">
|
||||
{% 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 %}
|
||||
24
frappe/templates/print_format/print_footer.html
Normal file
24
frappe/templates/print_format/print_footer.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
9
frappe/templates/print_format/print_format_font.css
Normal file
9
frappe/templates/print_format/print_format_font.css
Normal 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;
|
||||
}
|
||||
24
frappe/templates/print_format/print_header.html
Normal file
24
frappe/templates/print_format/print_header.html
Normal file
|
|
@ -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)
|
||||
|
||||
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,
|
||||
})
|
||||
generator = PrintFormatGenerator(print_format, doc, letterhead)
|
||||
return generator.get_html_preview()
|
||||
|
||||
|
||||
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.
|
||||
Generate a PDF of a Document, with repeatable header and footer if letterhead is 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
|
||||
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.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
|
||||
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):
|
||||
"""
|
||||
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
|
||||
self.header_body = header_body
|
||||
self.header_height = header_height
|
||||
self.footer_body = footer_body
|
||||
self.footer_height = footer_height
|
||||
|
||||
html = HTML(
|
||||
string=self.main_html,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
main_doc = html.render(stylesheets=[CSS(string=content_print_layout)])
|
||||
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
|
||||
|
||||
if self.header_html or self.footer_html:
|
||||
self._apply_overlay_on_main(main_doc, header_body, footer_body)
|
||||
pdf = main_doc.write_pdf()
|
||||
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
|
||||
|
||||
return pdf
|
||||
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)
|
||||
Loading…
Add table
Reference in a new issue