From 79e6f5832c324204084aeae94248c8285df069d6 Mon Sep 17 00:00:00 2001 From: times-odoo Date: Thu, 18 Jun 2026 17:33:51 +0530 Subject: [PATCH] [IMP] pdf_preview_report: live preview for the accounting reports debug the accounting reports using this module, it can give live preview of changes in the report code --- pdf_preview_report/__init__.py | 3 + pdf_preview_report/__manifest__.py | 18 ++ pdf_preview_report/controllers/__init__.py | 2 + pdf_preview_report/controllers/main.py | 250 ++++++++++++++++++ pdf_preview_report/models/__init__.py | 2 + pdf_preview_report/models/account_report.py | 8 + .../src/components/pdf_preview/pdf_preview.js | 200 ++++++++++++++ .../components/pdf_preview/pdf_preview.xml | 53 ++++ 8 files changed, 536 insertions(+) create mode 100644 pdf_preview_report/__init__.py create mode 100644 pdf_preview_report/__manifest__.py create mode 100644 pdf_preview_report/controllers/__init__.py create mode 100644 pdf_preview_report/controllers/main.py create mode 100644 pdf_preview_report/models/__init__.py create mode 100644 pdf_preview_report/models/account_report.py create mode 100644 pdf_preview_report/static/src/components/pdf_preview/pdf_preview.js create mode 100644 pdf_preview_report/static/src/components/pdf_preview/pdf_preview.xml diff --git a/pdf_preview_report/__init__.py b/pdf_preview_report/__init__.py new file mode 100644 index 00000000000..3b38916015c --- /dev/null +++ b/pdf_preview_report/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import models +from . import controllers diff --git a/pdf_preview_report/__manifest__.py b/pdf_preview_report/__manifest__.py new file mode 100644 index 00000000000..77856bbe011 --- /dev/null +++ b/pdf_preview_report/__manifest__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'PDF Preview Report', + 'version': '1.0', + 'category': 'Accounting/Accounting', + 'summary': 'Add a Preview before PDF export option to account reports', + 'description': """ +This module adds a preview dialog before exporting account reports to PDF. + """, + 'depends': ['account_reports'], + 'assets': { + 'web.assets_backend': [ + 'pdf_preview_report/static/src/components/pdf_preview/pdf_preview.js', + 'pdf_preview_report/static/src/components/pdf_preview/pdf_preview.xml', + ], + }, + 'license': 'LGPL-3', +} diff --git a/pdf_preview_report/controllers/__init__.py b/pdf_preview_report/controllers/__init__.py new file mode 100644 index 00000000000..757b12a1f17 --- /dev/null +++ b/pdf_preview_report/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import main diff --git a/pdf_preview_report/controllers/main.py b/pdf_preview_report/controllers/main.py new file mode 100644 index 00000000000..a865c67d1cb --- /dev/null +++ b/pdf_preview_report/controllers/main.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +import json +import datetime +from odoo import http, fields +from odoo.http import request, route +from odoo.tools import format_datetime + +class AccountReportPreviewController(http.Controller): + + @route('/account_reports/preview/', type='http', auth='user', methods=['GET']) + def preview_report(self, report_id, options=None, **kwargs): + """Render the exact same QWeb HTML that wkhtmltopdf would receive for PDF export.""" + uid = request.env.uid + options = json.loads(options) if options else {} + + allowed_company_ids = request.env['account.report'].get_report_company_ids(options) + if not allowed_company_ids: + company_str = request.cookies.get('cids', str(request.env.user.company_id.id)) + allowed_company_ids = [int(str_id) for str_id in company_str.split('-')] + + report = request.env['account.report'].with_user(uid).with_context( + allowed_company_ids=allowed_company_ids, + ).browse(report_id) + + base_url = ( + request.env['ir.config_parameter'].sudo().get_str('report.url') + or request.env['ir.config_parameter'].sudo().get_str('web.base.url') + ) + + print_options = report.get_options(previous_options={**options, 'export_mode': 'print'}) + + custom_handler_model = report._get_custom_handler_model() + handler = request.env[custom_handler_model] if ( + custom_handler_model and hasattr(request.env[custom_handler_model], '_get_pdf_export_html') + ) else report + + html_content = handler._get_pdf_export_html( + print_options, + report._filter_out_folded_children(report._get_lines(print_options)), + additional_context={'base_url': base_url}, + ) + + is_landscape = len(print_options.get('columns', [])) > 5 or print_options.get('horizontal_split') or print_options.get('force_landscape_printing') + target_width = 990 if is_landscape else 765 + target_height = 765 if is_landscape else 990 + target_zoom = 1.0 + is_landscape_js = "true" if is_landscape else "false" + + # Timezone-aware date and time formatting matching Odoo's report layout + local_dt = fields.Datetime.context_timestamp(request.env.user, fields.Datetime.now()) + current_time = local_dt.strftime('%Y-%m-%d %H:%M') + company_name = request.env.company.name + company_name_js = company_name.replace("'", "\\'") + current_time_js = current_time + + style_inject = f""" + +""" + script_inject = f""" + +""" + if isinstance(html_content, bytes): + html_content = html_content.decode('utf-8') + else: + html_content = str(html_content) + + # Inject styles in head + head_close_idx = html_content.find('') + if head_close_idx != -1: + html_content = html_content[:head_close_idx] + style_inject + html_content[head_close_idx:] + + # Inject adjustZoom scripts in body + body_close_idx = html_content.find('') + if body_close_idx != -1: + html_content = html_content[:body_close_idx] + script_inject + html_content[body_close_idx:] + + return request.make_response(html_content, headers=[ + ('Content-Type', 'text/html; charset=utf-8'), + ]) diff --git a/pdf_preview_report/models/__init__.py b/pdf_preview_report/models/__init__.py new file mode 100644 index 00000000000..a9997a19f9e --- /dev/null +++ b/pdf_preview_report/models/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import account_report diff --git a/pdf_preview_report/models/account_report.py b/pdf_preview_report/models/account_report.py new file mode 100644 index 00000000000..2379214b6bd --- /dev/null +++ b/pdf_preview_report/models/account_report.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +from odoo import models + +class AccountReport(models.Model): + _inherit = 'account.report' + + def _init_options_preview_before_export(self, options, previous_options): + options['preview_before_export'] = True diff --git a/pdf_preview_report/static/src/components/pdf_preview/pdf_preview.js b/pdf_preview_report/static/src/components/pdf_preview/pdf_preview.js new file mode 100644 index 00000000000..cc69e4ba2e0 --- /dev/null +++ b/pdf_preview_report/static/src/components/pdf_preview/pdf_preview.js @@ -0,0 +1,200 @@ +/** @odoo-module **/ + +import { Dialog } from "@web/core/dialog/dialog"; +import { Component, onMounted, onWillDestroy, useRef } from "@odoo/owl"; +import { proxy } from "@odoo/owl"; +import { AccountReportController } from "@account_reports/components/account_report/controller"; + +export class PdfPreviewDialog extends Component { + static template = "pdf_preview_report.PdfPreviewDialog"; + static components = { Dialog }; + static props = { + previewUrl: { type: String }, + onExportPdf: { type: Function }, + close: { type: Function }, + }; + + setup() { + this.state = proxy({ + loading: true, + zoom: 1.0, + }); + this.iframeRef = useRef("previewIframe"); + this._pollInterval = null; + this.savedScrollTop = 0; + this.savedScrollLeft = 0; + this.savedScrollTopWindow = 0; + this.savedScrollLeftWindow = 0; + + onMounted(() => { + const frame = this.iframeRef.el; + if (frame) { + frame.addEventListener("load", () => { + this.state.loading = false; + this.updateIframeZoom(); + try { + const contentWindow = frame.contentWindow; + if (contentWindow && contentWindow.document) { + const doc = contentWindow.document; + const oContent = doc.querySelector(".o_content"); + if (oContent) { + oContent.scrollTop = this.savedScrollTop; + oContent.scrollLeft = this.savedScrollLeft; + } + contentWindow.scrollTo(this.savedScrollLeftWindow, this.savedScrollTopWindow); + } + } catch (e) { + // ignore + } + }); + } + + // 1-second poller that reloads the iframe content smoothly via AJAX + this._pollInterval = setInterval(async () => { + const frame = this.iframeRef.el; + if (frame && frame.contentWindow && frame.contentWindow.document) { + try { + const url = frame.src; + const response = await fetch(url); + if (!response.ok) return; + const html = await response.text(); + + const parser = new DOMParser(); + const newDoc = parser.parseFromString(html, "text/html"); + + const currentDoc = frame.contentWindow.document; + + // 1. Save scroll positions + const oContentCurrent = currentDoc.querySelector(".o_content"); + const scrollTop = oContentCurrent ? oContentCurrent.scrollTop : 0; + const scrollLeft = oContentCurrent ? oContentCurrent.scrollLeft : 0; + const scrollTopWindow = frame.contentWindow.scrollY || currentDoc.documentElement.scrollTop || currentDoc.body.scrollTop || 0; + const scrollLeftWindow = frame.contentWindow.scrollX || currentDoc.documentElement.scrollLeft || currentDoc.body.scrollLeft || 0; + + this.savedScrollTop = scrollTop; + this.savedScrollLeft = scrollLeft; + this.savedScrollTopWindow = scrollTopWindow; + this.savedScrollLeftWindow = scrollLeftWindow; + + // Temporarily lock body height/width to prevent scroll clamping + const originalMinHeight = currentDoc.body.style.minHeight; + const originalMinWidth = currentDoc.body.style.minWidth; + const scrollHeight = currentDoc.documentElement.scrollHeight || currentDoc.body.scrollHeight; + const scrollWidth = currentDoc.documentElement.scrollWidth || currentDoc.body.scrollWidth; + currentDoc.body.style.minHeight = scrollHeight + "px"; + currentDoc.body.style.minWidth = scrollWidth + "px"; + + // 2. Update styles/links in head + currentDoc.head.innerHTML = newDoc.head.innerHTML; + + // 3. Update body content + currentDoc.body.innerHTML = newDoc.body.innerHTML; + + // 4. Re-execute scripts to run adjustZoom script + const scripts = currentDoc.querySelectorAll("script"); + scripts.forEach(oldScript => { + const newScript = currentDoc.createElement("script"); + if (oldScript.src) { + newScript.src = oldScript.src; + } else { + newScript.textContent = oldScript.textContent; + } + oldScript.parentNode.replaceChild(newScript, oldScript); + }); + + // Re-apply zoom state onto the iframe window context + this.updateIframeZoom(); + + // 5. Restore scroll positions + const oContentNew = currentDoc.querySelector(".o_content"); + if (oContentNew) { + oContentNew.scrollTop = scrollTop; + oContentNew.scrollLeft = scrollLeft; + } + frame.contentWindow.scrollTo(scrollLeftWindow, scrollTopWindow); + + // Restore original min-height and min-width styles + currentDoc.body.style.minHeight = originalMinHeight; + currentDoc.body.style.minWidth = originalMinWidth; + } catch (e) { + frame.src = frame.src; + } + } + }, 1000); + }); + + onWillDestroy(() => { + if (this._pollInterval) { + clearInterval(this._pollInterval); + this._pollInterval = null; + } + }); + } + + get zoomPercent() { + return Math.round(this.state.zoom * 100); + } + + updateIframeZoom() { + const frame = this.iframeRef.el; + if (frame && frame.contentWindow) { + frame.contentWindow.pdfPreviewZoom = this.state.zoom; + if (typeof frame.contentWindow.handleEvents === "function") { + frame.contentWindow.handleEvents(); + } + } + } + + onZoomIn() { + this.state.zoom = Math.min(3.0, this.state.zoom + 0.1); + this.updateIframeZoom(); + } + + onZoomOut() { + this.state.zoom = Math.max(0.5, this.state.zoom - 0.1); + this.updateIframeZoom(); + } + + onZoomReset() { + this.state.zoom = 1.0; + this.updateIframeZoom(); + } + + onClose() { + this.props.close(); + } + + async onExportPdf() { + this.props.close(); + await this.props.onExportPdf(); + } +} + +// Monkey-patch AccountReportController to intercept PDF export +const _originalButtonAction = AccountReportController.prototype.buttonAction; +AccountReportController.prototype.buttonAction = function (ev, button) { + if ( + button.action_param === "export_to_pdf" + && this.options + && this.options.preview_before_export + ) { + if (ev) { + ev.preventDefault(); + ev.stopPropagation(); + } + + const options = this.cachedFilterOptions; + const reportId = options.report_id; + const optionsJson = encodeURIComponent(JSON.stringify(options)); + const previewUrl = `/account_reports/preview/${reportId}?options=${optionsJson}`; + + this.dialog.add(PdfPreviewDialog, { + previewUrl, + onExportPdf: async () => { + await this.reportAction(null, button.action, button.action_param, true); + }, + }); + return; + } + return _originalButtonAction.call(this, ev, button); +}; diff --git a/pdf_preview_report/static/src/components/pdf_preview/pdf_preview.xml b/pdf_preview_report/static/src/components/pdf_preview/pdf_preview.xml new file mode 100644 index 00000000000..a1254a69b2f --- /dev/null +++ b/pdf_preview_report/static/src/components/pdf_preview/pdf_preview.xml @@ -0,0 +1,53 @@ + + + + + +
+ +
+
+
+ Loading... +
+
Loading preview...
+
+
+ +