Zhiz Print — a WYSIWYG print designer for Frappe/ERPNext

:waving_hand: Hi everyone — I’m a developer from China. Over the past year I built a print designer on top of the Frappe framework, and the whole thing is built around one principle: what you design is exactly what prints. Source is
here → https://gitee.com/gdzhiz/zhiz_print

The “how do I get Frappe to print nicely” question comes up here a lot, so I wanted to share both the tool and the WYSIWYG approach underneath it.

The problem

Frappe’s native Print Format is powerful, but it’s fundamentally hand-written Jinja + HTML + CSS. For us that kept breaking the “what I see = what I get” promise:

  • Layout changes bounce back to a developer — business users can’t design their own templates.
  • Preview ≠ PDF. When line items overflow, what you preview isn’t always what prints.
  • Odd paper sizes (continuous forms like a 3-copy delivery note on 238×139mm), with barcodes, QR codes, signatures, and a header that repeats every page.
  • Multiple layouts per print job (delivery copy / warehouse receipt / settlement).

WYSIWYG is the whole point

Every design decision in zhiz_print serves one goal — the design canvas, the preview, and the final PDF must be the same thing. Four things make that real:

1. The canvas is the paper. The designer isn’t an abstract editor — it draws the actual sheet at millimeter accuracy, with the real margins overlaid (PX_PER_MM = 4 for px↔mm). A4, a 238×139mm continuous form, a custom
label: what you paint on is the physical page.

:camera_with_flash: [screenshot: designer grid showing the paper + margin lines, a few merged cells, a barcode and a QR cell]

2. One render path. Preview, PDF, Excel, and batch output all flow through a single server entry point (get_preview_for_document). Same HTML, same pagination logic — so they can’t drift apart by construction.

3. Pagination is measured, not estimated. Row heights aren’t guessed — the browser measures them. The server renders an unpaginated “measurement scaffold”; in a hidden iframe, after fonts load, JS reads each row’s real
offsetHeight; a greedy bin-packing pass fits rows into pages, with rowspan-joined rows locked together (union-find) so merged cells never tear across a break. The page-break map then goes back to the server for the final
render. So preview = print = PDF, down to the row.

:camera_with_flash: [screenshot: print preview showing paginated paper pages with shadow]

4. Self-contained output. Barcodes, QR codes, and images are embedded as base64, so what you see in the designer is literally the bytes in the PDF — no relative /files/... URLs that vanish when an engine or server
changes.

What you can place on that paper

  • Grid with cell merge (rowspan/colspan), per-row/column sizing, format painter.
  • 6 cell types: static text, logic (Python expression), data-query binding, barcode (CODE128/CODE39), QR code, image.
  • Three row roles: normal, repeat-as-header (re-emits every page), and data-driven (auto-expanded from a child table — one rendered row per items line).
  • Three display modes: auto-wrap, fixed-height, and auto-shrink-font (canvas measures the text and shrinks the font to fit — again, measured, not guessed).
  • Multi-page templates: page_count gives you N independent grids in one template — delivery copy on page 1, receipt on page 2.

Data binding

Customer: {doc.customer}
{doc.items.item_code}   {doc.items.qty}   {doc.items.rate}   ← row auto-becomes data-driven
Remark: {param.remark}                                   ← collected in a pre-print dialog

Plus three “smart” value semantics on top of plain substitution:

={doc.items.qty}*{doc.items.weight_per_unit}   ← arithmetic after substitution
=rowsum(5:7)                                   ← sum column 7 across the expanded rows of data-row 5

…and a logic cell with helpers like get_value(doctype, name, field) for cross-doctype lookups. When a child table isn’t enough, a query child table takes frappe.qb code (or a function path) and binds the result into
cells.

Swappable PDF engines

Engine Notes
wkhtmltopdf (default) Fast, via pdfkit
WeasyPrint Best CSS support, pure Python
Chromium headless Most accurate rendering

Because output is self-contained HTML, you switch engines without touching templates — and the WYSIWYG contract holds across all three.

Other bits

  • Batch print with auto-match: N documents, each picks its template via an enable_condition, merged into one PDF or multi-sheet Excel.
  • Persistent print log: snapshots the rendered HTML at print time — editing the design later doesn’t rewrite history.
  • Template store: share a design to a central hub; others browse, one-click install, comment.
  • Draft-no-print: a docstatus=0 draft previews but won’t export.

Availability

Source is on Gitee: https://gitee.com/gdzhiz/zhiz_print

[TODO — one honest line on licensing, e.g.: “It uses a machine-bound license (the app_license = MIT in hooks.py is legacy); see the repo for terms. Battle-tested on Frappe v15; v14/v16 not yet validated. UI/docs are
Chinese-first, English in progress.”]


Happy to dig into the architecture, the pagination algorithm, or the WYSIWYG rendering path in the comments. If the mods feel this leans too far into self-promotion, tell me and I’ll trim — my main reason for posting is to
share the approach with anyone stuck on the same print problems. :folded_hands:

3 Likes

Nice App,
I did a try, and i couldn’t add the letter_haed to be at the top,
{doc.letter_head} It just prints the Letter Head name

So, how to add the letter head print format?
@yowlion

1 Like

Good news — you don’t need doc.letter_head here. Super Print Design ships its own header/footer system (page_header_left/center/right, page_footer_*, plus page_left_header and page_right_footer in the designer), so the letter head is something you lay out directly on the canvas. Native
Letter Head support may come later if enough people ask for it — thanks for the feedback!
To help me figure out your problem, please attach a screenshot of your current Zhiz Print design editor screen.

Why {doc.letter_head} only prints the name: that’s native ERPNext behavior, not a bug. letter_head is a Link field on the document — its value is the name string (e.g. Standard). In native ERPNext the rendered logo/header comes from a separate Jinja variable {{ letter_head }} (no
doc.) that Frappe injects at print time.

Today — Super Print Design has its own independent header/footer system (page_header_left/center/right, page_footer_*, plus page_left_header and page_right_footer in the designer), so it doesn’t rely on doc.letter_head. Two ways to set your letter head right now:

  • Design it in the top row (full WYSIWYG): top-left cell → Cell Type = Image, cell_value = /files/yourlogo.png; company info in the cells next to it with {doc.company}, etc.
  • Use the Header section: fill Header Left / Center / Right with raw HTML (e.g. <img src="/files/yourlogo.png" style="height:60px">, {doc.company}) — it repeats on every page.

Coming soon: in an upcoming version you’ll be able to pick “Use Frappe native letter_head header” right from the header/footer settings sidebar, so you can reuse your existing Letter Head without recreating it. :+1: Thanks for the nudge — it’s on the roadmap now.