From 169c0f15db959b4c62a8467fd9e04d5ac9e2f09f Mon Sep 17 00:00:00 2001 From: Akshay Manoj <55233381+akshaymanoj87@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:54:17 +0000 Subject: [PATCH] [ADD] om_excel_reports: Excel export of reports --- om_excel_reports/README.rst | 96 ++ om_excel_reports/__init__.py | 2 + om_excel_reports/__manifest__.py | 57 + om_excel_reports/controllers/__init__.py | 1 + om_excel_reports/controllers/main.py | 122 ++ om_excel_reports/lib/__init__.py | 0 om_excel_reports/lib/html_to_xlsx.py | 1107 +++++++++++++++++ om_excel_reports/models/__init__.py | 1 + om_excel_reports/models/ir_actions_report.py | 35 + .../static/description/banner.png | Bin 0 -> 39656 bytes om_excel_reports/static/description/icon.png | Bin 0 -> 9993 bytes .../static/description/index.html | 60 + .../static/src/js/excel_choice_dialog.js | 26 + .../static/src/js/report_excel_handler.js | 109 ++ .../static/src/scss/excel_choice_dialog.scss | 104 ++ .../static/src/xml/excel_choice_dialog.xml | 34 + .../static/tests/tours/excel_pdf_dialog.js | 53 + .../tests/tours/excel_preview_dialog.js | 47 + .../static/tests/tours/excel_wizard_dialog.js | 53 + om_excel_reports/tests/__init__.py | 1 + om_excel_reports/tests/sample_invoice.html | 34 + om_excel_reports/tests/test_converter.py | 95 ++ om_excel_reports/tests/test_excel_report.py | 251 ++++ 23 files changed, 2288 insertions(+) create mode 100644 om_excel_reports/README.rst create mode 100644 om_excel_reports/__init__.py create mode 100644 om_excel_reports/__manifest__.py create mode 100644 om_excel_reports/controllers/__init__.py create mode 100644 om_excel_reports/controllers/main.py create mode 100644 om_excel_reports/lib/__init__.py create mode 100644 om_excel_reports/lib/html_to_xlsx.py create mode 100644 om_excel_reports/models/__init__.py create mode 100644 om_excel_reports/models/ir_actions_report.py create mode 100644 om_excel_reports/static/description/banner.png create mode 100644 om_excel_reports/static/description/icon.png create mode 100644 om_excel_reports/static/description/index.html create mode 100644 om_excel_reports/static/src/js/excel_choice_dialog.js create mode 100644 om_excel_reports/static/src/js/report_excel_handler.js create mode 100644 om_excel_reports/static/src/scss/excel_choice_dialog.scss create mode 100644 om_excel_reports/static/src/xml/excel_choice_dialog.xml create mode 100644 om_excel_reports/static/tests/tours/excel_pdf_dialog.js create mode 100644 om_excel_reports/static/tests/tours/excel_preview_dialog.js create mode 100644 om_excel_reports/static/tests/tours/excel_wizard_dialog.js create mode 100644 om_excel_reports/tests/__init__.py create mode 100644 om_excel_reports/tests/sample_invoice.html create mode 100644 om_excel_reports/tests/test_converter.py create mode 100644 om_excel_reports/tests/test_excel_report.py diff --git a/om_excel_reports/README.rst b/om_excel_reports/README.rst new file mode 100644 index 000000000..a5a3a9516 --- /dev/null +++ b/om_excel_reports/README.rst @@ -0,0 +1,96 @@ +================================== +Odoo2XL - Odoo Excel Reports +================================== + +Adds Excel export to the Odoo **Print** flow. Any PDF report can be exported as a real +spreadsheet, not a picture of the PDF. The export is generated on the server: no browser +extension is needed and nothing leaves your Odoo server. + +Features +======== + +* **Print chooser:** printing a PDF report (Print menu, Print buttons, wizard reports) first + asks *PDF / Excel / Both / Preview* in a popup ("Choose the format. Esc cancels."). +* **Preview:** the *Preview* button opens the PDF in a new browser tab. The tab is served + inline under the report's real file name (its "Printed Report Name", for example + ``Quotation - CQ/08/0009.pdf``), so the PDF viewer's download keeps that name instead of + an id. Downloads (PDF and Excel) use the same naming. If the browser blocks pop-ups, a + warning is shown and the PDF is downloaded instead. +* Amounts, quantities (with units), percentages and dates are exported as real Excel values; + codes and references (leading zeros, VAT, IBAN...) stay text. +* Bold totals, indentation, frozen header row, filters on the main table and sensible column + widths. +* Several records printed at once give one sheet per document, plus an **All lines** sheet + (pivot-friendly) when they share the same table layout. +* Report filters (dates, journals, target moves) are kept above the table. +* Print-ready: landscape, all columns fit the page width. +* The header and footer repeated by the report layout are dropped. + +Configuration +============= + +* **Chooser:** set the system parameter ``om_excel_reports.ask_format`` to ``0`` to turn off the + PDF / Excel / Both popup for everyone. Printing then always gives the PDF, and the Excel export + is no longer offered in the interface. +* **Reports with their own Excel export:** if a module (for example one built on ``report_xlsx``) + already provides an ``xlsx`` report action on the same model as a PDF report, the popup is not + shown for that report, so users do not get two Excel versions of it. + +Access follows the normal report access of the user: the Excel file is generated from the +same HTML that the report renders. + +Technical notes +=============== + +* The module does not change any ``ir.actions.report`` record or field; it only adds the + ``_render_excel`` method, the routes and the web client dialog. +* Routes: ``/report/excel/[/]`` (download through ``/report/download``, type + ``excel``) and ``/om_excel_reports/preview/[/]`` (inline PDF with its file + name). +* Converter: ``lib/html_to_xlsx.py`` (lxml and xlsxwriter, no Odoo dependency, unit-testable). +* Odoo 20: PDF reports may use an engine-specific type (``qweb-pdf-wkhtmltopdf``, + ``qweb-pdf-paper-muncher``); all ``qweb-pdf*`` reports can be exported. +* Python dependencies: ``xlsxwriter`` and ``lxml``. + +Limitations +=========== + +* Images, logos and barcodes are skipped. +* Reports without tables are exported as plain text rows. +* Number and date recognition uses the user's language settings (decimal and thousands + separators, date format). + +Tests +===== + +:: + + odoo-bin -d -i web_tour,om_excel_reports --test-enable --test-tags=/om_excel_reports + +``web_tour`` is only needed for the browser tours. Converter-only tests:: + + python3 -m unittest discover -s tests -p test_converter.py + +Credits +======= + +Server-side version of the Odoo2XL browser extension by Odoo Mates +(https://github.com/odoomates/odoo2xl): the same conversion rules, re-implemented in Python so +the export runs inside Odoo. + +Odoo2XL is not affiliated with or endorsed by Odoo S.A. + +Contributors +------------ + +* Akshay Manoj + +Author & Maintainer +------------------- + +This module is maintained by the Odoo Mates + +License +======= + +LGPL-3 diff --git a/om_excel_reports/__init__.py b/om_excel_reports/__init__.py new file mode 100644 index 000000000..f7fd9984e --- /dev/null +++ b/om_excel_reports/__init__.py @@ -0,0 +1,2 @@ +# Part of Odoo2XL - Odoo Excel Reports (om_excel_reports). License: LGPL-3. +from . import controllers, models diff --git a/om_excel_reports/__manifest__.py b/om_excel_reports/__manifest__.py new file mode 100644 index 000000000..10b002775 --- /dev/null +++ b/om_excel_reports/__manifest__.py @@ -0,0 +1,57 @@ +# Part of Odoo2XL - Odoo Excel Reports (om_excel_reports). License: LGPL-3. +{ + 'name': 'Odoo2XL - Odoo Excel Reports', + 'version': '1.0', + 'author': 'Odoo Mates', + 'maintainer': 'Odoo Mates', + 'support': 'odoomates@gmail.com', + 'category': 'Productivity', + 'summary': 'Excel Reports, Export PDF Report to Excel, XLSX Report, Print Report in Excel, ' + 'Report to Spreadsheet, Odoo2XL, Excel Export of Any Report', + 'description': """ +Odoo2XL - Odoo Excel Reports +============================ +Adds Excel export to the Odoo Print flow. Any PDF report can be exported, and +the result is a real spreadsheet - not a picture of the PDF. + +* Works for every PDF report: invoices, quotations, delivery slips and any + other report, including custom and third-party ones - no per-report code. +* PDF / Excel / Both / Preview chooser when printing, including wizard reports. +* Reports that already have their own Excel export (an xlsx report action on the + same model) keep it: the chooser is not shown for them. +* Amounts are numbers with currency formatting, dates are real Excel dates, + account codes and references stay text. +* Quantities keep their unit and still sum. +* Bold totals, indented account levels, frozen header row and filters on the + main table. +* Report filters (dates, journals, target moves) are kept above the table. +* Several records: one sheet per document, plus an "All lines" sheet with a + Document column for pivot tables. +* Files are named after the record reference or the report name. +* Print-ready: all columns fit the page width (landscape). +* Generated on the server, inside Odoo - no browser extension and no data + leaves your system. + +Limitations: images, logos and barcodes are skipped; reports without tables +are exported as text rows. + +Server-side version of the Odoo2XL browser extension by Odoo Mates. +Not affiliated with or endorsed by Odoo S.A. +""", + 'license': 'LGPL-3', + 'depends': ['web'], + 'external_dependencies': {'python': ['xlsxwriter', 'lxml']}, + 'assets': { + 'web.assets_backend': [ + 'om_excel_reports/static/src/js/*.js', + 'om_excel_reports/static/src/xml/*.xml', + 'om_excel_reports/static/src/scss/*.scss', + ], + 'web.assets_tests': [ + 'om_excel_reports/static/tests/tours/*.js', + ], + }, + 'images': ['static/description/banner.png'], + 'installable': True, + 'application': False, +} diff --git a/om_excel_reports/controllers/__init__.py b/om_excel_reports/controllers/__init__.py new file mode 100644 index 000000000..12a7e529b --- /dev/null +++ b/om_excel_reports/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/om_excel_reports/controllers/main.py b/om_excel_reports/controllers/main.py new file mode 100644 index 000000000..d378db97a --- /dev/null +++ b/om_excel_reports/controllers/main.py @@ -0,0 +1,122 @@ +# Part of Odoo2XL - Odoo Excel Reports (om_excel_reports). License: LGPL-3. +import json +import logging +from urllib.parse import parse_qsl, urlsplit + +import werkzeug.exceptions + +from odoo.http import request, route +from odoo.http.dispatcher import serialize_exception +from odoo.http.stream import content_disposition +from odoo.tools.misc import html_escape +from odoo.tools.safe_eval import safe_eval, time + +from odoo.addons.web.controllers.report import ReportController + +from ..models.ir_actions_report import XLSX_MIMETYPE, is_pdf_report_type + +_logger = logging.getLogger(__name__) + + +class ReportExcelController(ReportController): + + @staticmethod + def _report_filename(report, docids, extension): + """File name used by Odoo for a printed report, with ``extension``. + + Mirrors core: the report's "Printed Report Name" expression for a single record + (e.g. ``Quotation - CQ/08/0009``), otherwise the report name. + """ + filename = '%s.%s' % (report.name, extension) + if docids: + if isinstance(docids, str): + docids = [int(x) for x in docids.split(',') if x.isdigit()] + records = request.env[report.model].browse(docids) + if report.print_report_name and len(records) == 1: + name = safe_eval(report.print_report_name, {'object': records, 'time': time}) + filename = '%s.%s' % (name, extension) + return filename + + @route() + def report_routes(self, reportname, docids=None, converter=None, **data): + """Add the ``excel`` converter: /report/excel/[/].""" + if converter != 'excel': + return super().report_routes(reportname, docids=docids, converter=converter, **data) + context = dict(request.env.context) + if docids: + docids = [int(i) for i in docids.split(',') if i.isdigit()] + if data.get('options'): + data.update(json.loads(data.pop('options'))) + if data.get('context'): + data['context'] = json.loads(data['context']) + context.update(data['context']) + content = request.env['ir.actions.report'].with_context(context)._render_excel( + reportname, docids, data=data)[0] + return request.make_response(content, headers=[ + ('Content-Type', XLSX_MIMETYPE), ('Content-Length', len(content))]) + + @route() + def report_download(self, data, context=None, token=None, readonly=True): + """Handle the download of ``excel`` reports; everything else goes to core.""" + requestcontent = json.loads(data) + url, type_ = requestcontent[0], requestcontent[1] + if type_ != 'excel': + return super().report_download(data, context=context, token=token, readonly=readonly) + reportname = '???' + try: + reportname = url.split('/report/excel/')[1].split('?')[0] + docids = None + if '/' in reportname: + reportname, docids = reportname.split('/') + if docids: + response = self.report_routes(reportname, docids=docids, converter='excel', context=context) + else: + query = dict(parse_qsl(urlsplit(url).query)) + if 'context' in query: + context = json.dumps({**json.loads(context or '{}'), **json.loads(query.pop('context'))}) + response = self.report_routes(reportname, converter='excel', context=context, **query) + + report = request.env['ir.actions.report']._get_report_from_name(reportname) + filename = self._report_filename(report, docids, 'xlsx') + response.headers.add('Content-Disposition', content_disposition(filename)) + return response + except Exception as exc: + _logger.warning('Error while generating Excel report %s', reportname, exc_info=True) + error = {'code': 0, 'message': 'Odoo Server Error', 'data': serialize_exception(exc)} + res = request.make_response(html_escape(json.dumps(error))) + raise werkzeug.exceptions.InternalServerError(response=res) from exc + + @route([ + '/om_excel_reports/preview/', + '/om_excel_reports/preview//', + ], type='http', auth='user') + def report_preview(self, reportname, docids=None, **data): + """Show a PDF report inline (new browser tab) under its real file name. + + The ``inline`` Content-Disposition carries the same name as a normal download, so + "Download" in the browser's PDF viewer saves e.g. ``Quotation - CQ_08_0009.pdf`` + instead of the record id. + """ + response = self.report_routes(reportname, docids=docids, converter='pdf', **data) + report = request.env['ir.actions.report']._get_report_from_name(reportname) + filename = self._report_filename(report, docids, 'pdf') + response.headers['Content-Disposition'] = content_disposition(filename, disposition_type='inline') + return response + + @route('/om_excel_reports/excel_allowed', type='jsonrpc', auth='user', readonly=True) + def excel_allowed(self, report_name): + """Tell the web client how to treat a PDF report. + + :return: ``{'allowed': bool, 'ask': bool}`` - whether the report may be exported to + Excel, and whether the PDF / Excel / Both chooser should be shown before printing + (system parameter ``om_excel_reports.ask_format``, on by default). A report that + already has a dedicated Excel export (an ``xlsx`` report action on the same model, + from another module) is not offered a second one. + """ + Report = request.env['ir.actions.report'] + report = Report._get_report_from_name(report_name) + dedicated = report and Report.sudo().search_count( + [('model', '=', report.model), ('report_type', '=', 'xlsx')], limit=1) + allowed = bool(report and is_pdf_report_type(report.report_type) and not dedicated) + ask = request.env['ir.config_parameter'].sudo().get_bool('om_excel_reports.ask_format', True) + return {'allowed': allowed, 'ask': ask} diff --git a/om_excel_reports/lib/__init__.py b/om_excel_reports/lib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/om_excel_reports/lib/html_to_xlsx.py b/om_excel_reports/lib/html_to_xlsx.py new file mode 100644 index 000000000..b0efff0d5 --- /dev/null +++ b/om_excel_reports/lib/html_to_xlsx.py @@ -0,0 +1,1107 @@ +# Part of Odoo2XL - Odoo Excel Reports (om_excel_reports). License: LGPL-3. +# +# Server-side converter: Odoo QWeb HTML report -> .xlsx workbook. +# +# The conversion rules (sheet per ``.article``, numeric/date typing, totals, +# frozen header, filters, "All lines" sheet ...) follow the behaviour of the +# Odoo2XL browser extension by Odoo Mates (https://github.com/odoomates/odoo2xl), +# re-implemented in Python so that the export runs inside Odoo and nothing +# leaves the server. +# +# This file is deliberately free of any ``odoo`` import so that it can be unit +# tested on its own. + +import copy +import datetime +import io +import re +from functools import lru_cache + +import xlsxwriter +from lxml import html as lxml_html + +__all__ = ['ConverterOptions', 'convert_report_html'] + +# -------------------------------------------------------------------------- +# Constants +# -------------------------------------------------------------------------- + +SKIP_TAGS = {'script', 'style', 'img', 'svg', 'noscript', 'head', 'meta', 'link', 'canvas', 'video', 'audio'} +BLOCK_TAGS = { + 'div', 'p', 'ul', 'ol', 'li', 'tr', 'table', 'section', 'article', 'header', 'footer', + 'main', 'address', 'blockquote', 'pre', 'hr', 'dl', 'dt', 'dd', 'thead', 'tbody', + 'tfoot', 'form', 'fieldset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', +} +HEADINGS = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'} +BOLD_TAGS = {'th', 'strong', 'b'} | HEADINGS +BOLD_CLASSES = {'fw-bold', 'fw-bolder', 'o_bold', 'font-weight-bold', 'fw-semibold'} +DROP_CLASSES = {'header', 'footer', 'o_report_layout_header', 'o_footer'} +MAX_COLSPAN = 20 +MAX_WIDTH = 60 +DEFAULT_WIDTH = 8 +FREEZE_LIMIT = 25 +SHEET_BAD_CHARS = re.compile(r'[\[\]:*?/\\]') +WS = re.compile(r'[ \t\r\n\f\v]+') +ZERO_WIDTH = dict.fromkeys(map(ord, '​‌‍'), None) +NEG_STYLE = re.compile(r'display\s*:\s*none|visibility\s*:\s*hidden', re.I) +SHOW_CLASS = re.compile( + r'^d-(?:(?:sm|md|lg|xl|xxl)-)?(?:block|inline|inline-block|flex|inline-flex|table|table-cell|table-row|grid)$' + r'|^d-print-(?!none)' +) +WHITE_STYLE = re.compile(r'color\s*:\s*(?:white|#fff(?:fff)?|transparent|rgba?\(\s*255\s*,\s*255\s*,\s*255)', re.I) +BOLD_STYLE = re.compile(r'font-weight\s*:\s*(bold|bolder|[6-9]00)', re.I) +PADDING_STYLE = re.compile(r'(?:padding|margin)-(?:left|inline-start)\s*:\s*([\d.]+)\s*(px|em|rem)', re.I) +TEXT_COLUMN = re.compile( + r'(?:^|\W)(?:code|ref|reference|account|vat|trn|tax\s*id|phone|mobile|tel|iban|bic|swift|sku|barcode|' + r'zip|postcode|postal|serial|lot|number|no\.?|n°|invoice|barcode)(?:$|\W)', re.I) +QTY_COLUMN = re.compile(r'qty|quant|ordered|delivered|demand|done|reserved|uom|units?', re.I) +AMOUNT_COLUMN = re.compile(r'amount|total|price|balance|debit|credit|subtotal|untaxed', re.I) + + +class ConverterOptions: + """Locale / presentation options of the conversion.""" + + def __init__(self, decimal_point='.', thousands_sep=',', date_format='%m/%d/%Y', title='Report'): + self.decimal_point = decimal_point or '.' + self.thousands_sep = thousands_sep or '' + self.date_format = date_format or '%m/%d/%Y' + self.title = title or 'Report' + + +# -------------------------------------------------------------------------- +# DOM helpers +# -------------------------------------------------------------------------- + +def _tag(el): + return el.tag.lower() if isinstance(el.tag, str) else '' + + +def _classes(el): + return (el.get('class') or '').split() + + +def is_hidden(el): + """True if ``el`` would not be visible on the printed report.""" + tag = _tag(el) + if not tag or tag in SKIP_TAGS: + return True + cls = _classes(el) + if 'o_hidden' in cls or 'd-print-none' in cls: + return True + if 'd-none' in cls and not any(SHOW_CLASS.match(c) for c in cls): + return True + style = el.get('style') + if style and NEG_STYLE.search(style): + return True + return False + + +def is_indent_filler(el): + """Spans made of white dots, used by some reports to indent labels.""" + if _tag(el) != 'span': + return False + style = el.get('style') or '' + if not WHITE_STYLE.search(style): + return False + txt = ''.join(el.itertext()).replace('\xa0', '').strip() + return bool(txt) and set(txt) <= {'.'} + + +def _ws(text): + return WS.sub(' ', text) if text else '' + + +def _walk_text(el, out): + if el.text: + out.append(_ws(el.text)) + for ch in el: + if isinstance(ch.tag, str) and not is_hidden(ch) and not is_indent_filler(ch): + tag = _tag(ch) + if tag == 'br': + out.append('\n') + else: + block = tag in BLOCK_TAGS + if block: + out.append('\n') + _walk_text(ch, out) + if block: + out.append('\n') + if ch.tail: + out.append(_ws(ch.tail)) + + +def get_text(el): + """Visible text of an element;
and block elements become line breaks.""" + out = [] + _walk_text(el, out) + raw = ''.join(out).translate(ZERO_WIDTH).replace('\xa0', ' ') + lines = [line.strip() for line in raw.split('\n')] + return '\n'.join(line for line in lines if line) + + +def text_of_nodes(first_text, nodes): + """Visible text of a text prefix followed by sibling nodes (with tails).""" + wrapper = lxml_html.Element('div') + wrapper.text = first_text + for node in nodes: + wrapper.append(copy.deepcopy(node)) + return get_text(wrapper) + + +def has_visible_descendant(el, tags): + for d in el.iterdescendants(): + if isinstance(d.tag, str) and _tag(d) in tags and not _is_hidden_chain(d, el): + return True + return False + + +def _is_hidden_chain(el, root): + node = el + while node is not None and node is not root: + if is_hidden(node): + return True + node = node.getparent() + return False + + +def element_is_bold(el): + if _tag(el) in BOLD_TAGS: + return True + if BOLD_CLASSES.intersection(_classes(el)): + return True + style = el.get('style') + return bool(style and BOLD_STYLE.search(style)) + + +def _bold_state(el, inherited): + """Return (has_text, all_text_bold) for the content of ``el``.""" + bold = inherited or element_is_bold(el) + has_text, all_bold = False, True + if el.text and el.text.strip(): + has_text = True + all_bold = all_bold and bold + for ch in el: + if isinstance(ch.tag, str) and not is_hidden(ch) and not is_indent_filler(ch): + h, a = _bold_state(ch, bold) + has_text = has_text or h + all_bold = all_bold and a + if ch.tail and ch.tail.strip(): + has_text = True + all_bold = all_bold and bold + return has_text, all_bold + + +def cell_is_bold(el): + has_text, all_bold = _bold_state(el, False) + return has_text and all_bold + + +def indent_level(el): + """Indentation depth of a cell (white dot pairs + left padding/margin).""" + level = 0 + dots = 0 + for sp in el.iter('span'): + if is_indent_filler(sp): + dots += len(''.join(sp.itertext()).replace('\xa0', '').strip()) + level += dots // 2 + for node in (el, *list(el)[:1]): + style = node.get('style') if isinstance(node.tag, str) else None + m = style and PADDING_STYLE.search(style) + if m: + px = float(m.group(1)) * (16 if m.group(2) in ('em', 'rem') else 1) + level += int(px // 20) + break + return level + + +def align_of(el): + cls = _classes(el) + if 'text-end' in cls or 'text-right' in cls: + return 'right' + if 'text-center' in cls: + return 'center' + return None + + +# -------------------------------------------------------------------------- +# Value detection +# -------------------------------------------------------------------------- + +@lru_cache(maxsize=32) +def _number_regex(dp, ts): + d = re.escape(dp) + if ts is None or ts == '': + group = None + elif ts.strip() == '': + group = '[   ]' + else: + group = re.escape(ts) + if group: + integer = r'(?:\d{1,3}(?:%s\d{3})+|\d+)' % group + else: + integer = r'\d+' + return re.compile(r'^(?P%s)(?:%s(?P\d+))?$' % (integer, d)), group + + +def parse_number(text, dp, ts): + """Parse a localised number. Returns (value, decimals, grouped) or None.""" + s = text.strip().replace('−', '-').replace('–', '-').replace('\xa0', ' ') + neg = False + if s.startswith('(') and s.endswith(')'): + neg, s = True, s[1:-1].strip() + if s.endswith('-'): + neg, s = True, s[:-1].strip() + if s.startswith('-'): + neg, s = True, s[1:].strip() + elif s.startswith('+'): + s = s[1:].strip() + if not s or not s[0].isdigit(): + return None + regex, group = _number_regex(dp, ts) + if group and ts.strip() == '': + s = s.replace(' ', ' ') + m = regex.match(s) + if not m: + return None + integer, frac = m.group('int'), m.group('frac') + grouped = False + if group: + stripped = re.sub(group, '', integer) + grouped = stripped != integer + integer = stripped + if len(integer) > 1 and integer.startswith('0'): + return None # codes such as 00123 stay text + value = float(integer + ('.' + frac if frac else '')) + return (-value if neg else value), (len(frac) if frac else 0), grouped + + +@lru_cache(maxsize=32) +def _date_regexes(fmt): + tokens = { + '%d': r'(?P\d{1,2})', '%e': r'\s?(?P\d{1,2})', '%m': r'(?P\d{1,2})', + '%Y': r'(?P\d{4})', '%y': r'(?P\d{2})', + } + parts, i, ok = [], 0, True + while i < len(fmt): + if fmt[i] == '%' and i + 1 < len(fmt): + tok = fmt[i:i + 2] + if tok in tokens: + parts.append(tokens[tok]) + elif tok == '%%': + parts.append('%') + else: + ok = False + break + i += 2 + else: + parts.append(re.escape(fmt[i])) + i += 1 + time_re = r'(?P\d{1,2}):(?P\d{2})(?::(?P\d{2}))?(?:\s*(?P[AaPp][Mm]))?' + iso = r'(?P\d{4})-(?P\d{2})-(?P\d{2})' + regexes = [] + if ok: + regexes.append(''.join(parts)) + regexes.append(iso) + return tuple((re.compile('^%s$' % r), re.compile('^%s[ T]%s$' % (r, time_re))) for r in regexes) + + +def parse_date(text, fmt): + """Return ('date'|'datetime', value) or None.""" + s = text.strip() + if len(s) < 8 or len(s) > 30: + return None + for date_re, dt_re in _date_regexes(fmt): + for regex, has_time in ((date_re, False), (dt_re, True)): + m = regex.match(s) + if not m: + continue + g = m.groupdict() + try: + year = int(g['Y']) if g.get('Y') else 2000 + int(g['y']) + day = datetime.date(year, int(g['m']), int(g['d'])) + if not has_time: + return 'date', day + hour = int(g['H']) + ap = (g.get('ap') or '').lower() + if ap == 'pm' and hour < 12: + hour += 12 + elif ap == 'am' and hour == 12: + hour = 0 + return 'datetime', datetime.datetime.combine( + day, datetime.time(hour, int(g['M']), int(g.get('S') or 0))) + except (ValueError, TypeError): + return None + return None + + +def _quote(text): + return '"%s"' % text.replace('"', '').replace('\\', '') + + +def number_format(decimals, grouped, prefix='', suffix='', signed_sections=False): + base = ('#,##0' if grouped else '0') + ('.' + '0' * decimals if decimals else '') + pos = (_quote(prefix) if prefix else '') + base + (_quote(suffix) if suffix else '') + return '%s;-%s' % (pos, pos) if signed_sections else pos + + +class Cell: + __slots__ = ('value', 'kind', 'fmt', 'bold', 'align', 'indent', 'wrap', 'orig', + 'decimals', 'grouped', 'size', 'nowidth') + + def __init__(self, value=None, kind='text', fmt=None, bold=False, align=None, indent=0, + wrap=False, orig='', decimals=0, grouped=False, size=None, nowidth=False): + self.value = value + self.kind = kind # text | number | percent | date | datetime + self.fmt = fmt + self.bold = bold + self.align = align + self.indent = indent + self.wrap = wrap + self.orig = orig + self.decimals = decimals + self.grouped = grouped + self.size = size + self.nowidth = nowidth + + @property + def empty(self): + return self.value is None or self.value == '' + + +def classify_cell(el, header, opts): + """Turn a table cell (or any element) into a typed :class:`Cell`.""" + text = get_text(el) + cell = Cell(value=text or None, kind='text', orig=text, bold=cell_is_bold(el), + align=align_of(el), indent=indent_level(el), wrap='\n' in text) + if not text: + cell.value = None + return cell + header = header or '' + dp, ts = opts.decimal_point, opts.thousands_sep + + # Monetary values (Odoo wraps the amount in span.oe_currency_value). + amounts = [s for s in el.iter('span') if 'oe_currency_value' in _classes(s) and not is_hidden(s)] + if len(amounts) == 1: + amount_text = get_text(amounts[0]) + rest = text.replace(amount_text, '', 1).strip() + neg_prefix = bool(re.search(r'[-−]', rest)) + symbol = re.sub(r'[-−()\s]', '', rest) if rest else '' + if len(symbol) <= 5 and not re.search(r'[\d%]', symbol): + parsed = parse_number(amount_text, dp, ts) + if parsed: + value, decimals, _grouped = parsed + if neg_prefix and value > 0: + value = -value + before = (not symbol) or text.replace('-', '').replace('−', '').strip().startswith(symbol) + cell.value, cell.kind = value, 'number' + cell.decimals, cell.grouped = decimals, True + if symbol: + cell.fmt = number_format(decimals, True, prefix=symbol + ' ' if before else '', + suffix='' if before else ' ' + symbol, signed_sections=True) + else: + cell.fmt = number_format(decimals, True) + return cell + return cell + + if TEXT_COLUMN.search(header) and '%' not in header: + return cell + + # Percentages. + pct_match = re.match(r'^(.*?)\s*%$', text) + if pct_match or '%' in header: + body = pct_match.group(1) if pct_match else text + parsed = parse_number(body, dp, ts) + if parsed and (pct_match or not re.search(r'[a-zA-Z]', text)): + value, decimals, _g = parsed + cell.value, cell.kind = value / 100.0, 'percent' + cell.decimals = decimals + cell.fmt = '0%s%%' % ('.' + '0' * decimals if decimals else '') + return cell + + # Dates / date-times. + dated = parse_date(text, opts.date_format) + if dated: + cell.kind, cell.value = dated + cell.fmt = 'yyyy-mm-dd' if cell.kind == 'date' else 'yyyy-mm-dd hh:mm' + return cell + + # Plain numbers. + parsed = parse_number(text, dp, ts) + if parsed: + value, decimals, grouped = parsed + cell.value, cell.kind, cell.decimals, cell.grouped = value, 'number', decimals, grouped + cell.fmt = number_format(decimals, grouped) + return cell + + # Quantities with a unit ("10.00 Units"). + m = re.match(r'^(?P[(\-−+]?\d[\d.,\s  \']*)\s+(?P[^\d\s%(][^\d]{0,19})$', text) + if m: + parsed = parse_number(m.group('num'), dp, ts) + if parsed and (parsed[1] > 0 or QTY_COLUMN.search(header)): + value, decimals, grouped = parsed + cell.value, cell.kind, cell.decimals, cell.grouped = value, 'number', decimals, grouped + cell.fmt = number_format(decimals, grouped, suffix=' ' + m.group('unit').strip()) + return cell + return cell + + +# -------------------------------------------------------------------------- +# Sheet model +# -------------------------------------------------------------------------- + +class TableInfo: + def __init__(self): + self.header_row = None # absolute row index of the header (last header row) + self.first_row = 0 + self.last_row = 0 + self.offset = 0 + self.width = 0 + self.names = [] # lower-cased header text per column + self.n_data = 0 + + @property + def has_header(self): + return self.header_row is not None + + +class Sheet: + def __init__(self, title): + self.title = title + self.cells = {} + self.merges = [] + self.row = 0 + self.tables = [] + self.freeze_row = None + self.filter = None # (r1, c1, r2, c2) + self.name = None + + def put(self, r, c, cell): + self.cells[(r, c)] = cell + + @property + def main_table(self): + best = None + for t in self.tables: + if t.has_header and t.n_data: + if best is None or t.n_data * t.width > best.n_data * best.width: + best = t + return best + + +# -------------------------------------------------------------------------- +# Conversion +# -------------------------------------------------------------------------- + +class Converter: + def __init__(self, opts): + self.opts = opts + + # -- document ------------------------------------------------------ + def convert(self, html_source): + if isinstance(html_source, bytes): + doc = lxml_html.document_fromstring(html_source) + else: + doc = lxml_html.document_fromstring(html_source.encode('utf-8')) + for el in list(doc.iter()): + if isinstance(el.tag, str) and DROP_CLASSES.intersection(_classes(el)): + if el.getparent() is not None: + el.drop_tree() + articles = doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' article ')]") + # Keep outermost articles only. + articles = [a for a in articles + if not any(anc in articles for anc in a.iterancestors())] + if not articles: + main = doc.xpath('//main') or doc.xpath('//body') + articles = main[:1] or [doc] + sheets = [] + for i, art in enumerate(articles, 1): + sheet = Sheet(self._sheet_title(art) or ('%s %d' % (self.opts.title, i) if len(articles) > 1 + else self.opts.title)) + self._walk_children(art, sheet) + if sheet.cells: + sheets.append(sheet) + if not sheets: + raise ValueError('The report has no content that can be exported to Excel.') + for sh in sheets: + self._finalise(sh) + combined = self._all_lines(sheets) + if combined: + sheets.insert(0, combined) + self._name_sheets(sheets) + return sheets + + def _sheet_title(self, art): + for el in art.iter('h1', 'h2', 'h3'): + if is_hidden(el) or _is_hidden_chain(el, art): + continue + text = get_text(el).replace('\n', ' ').strip() + if text: + return text.split(':', 1)[1].strip() if ':' in text and text.split(':', 1)[1].strip() else text + return None + + @staticmethod + def _name_sheets(sheets): + used = set() + for i, sh in enumerate(sheets, 1): + base = SHEET_BAD_CHARS.sub(' ', sh.title or 'Report %d' % i).strip().strip("'")[:31] or 'Report %d' % i + name, n = base, 1 + while name.lower() in used: + n += 1 + suffix = ' %d' % n + name = base[:31 - len(suffix)] + suffix + used.add(name.lower()) + sh.name = name + + # -- layout walk --------------------------------------------------- + def _walk_children(self, node, sheet): + run_text = node.text or '' + run_nodes = [] + + def flush(): + nonlocal run_text, run_nodes + text = text_of_nodes(run_text, run_nodes) if (run_nodes or run_text.strip()) else '' + if text: + self._emit_text(sheet, text) + run_text, run_nodes = '', [] + + for ch in node: + tag = _tag(ch) + if not tag or is_hidden(ch): + tail = ch.tail or '' + if run_nodes: + run_nodes[-1].tail = (run_nodes[-1].tail or '') + tail + else: + run_text += tail + continue + if tag == 'table': + flush() + self._add_table(ch, sheet) + run_text = ch.tail or '' + elif tag in HEADINGS: + flush() + self._add_heading(ch, sheet) + run_text = ch.tail or '' + elif tag in BLOCK_TAGS: + flush() + self._add_block(ch, sheet) + run_text = ch.tail or '' + else: + run_nodes.append(copy.deepcopy(ch)) + flush() + + def _add_block(self, el, sheet): + if self._is_bootstrap_row(el): + self._add_columns(el, sheet) + elif has_visible_descendant(el, BLOCK_TAGS - {'li'}) or has_visible_descendant(el, {'table'} | HEADINGS): + self._walk_children(el, sheet) + else: + text = get_text(el) + if not text: + return + pair = self._label_value(el) + if pair: + self._emit_cells(sheet, [pair[0], pair[1]]) + else: + self._emit_text(sheet, text, bold=cell_is_bold(el)) + + @staticmethod + def _is_bootstrap_row(el): + if 'row' not in _classes(el): + return False + kids = [c for c in el if isinstance(c.tag, str) and not is_hidden(c)] + return bool(kids) and all(any(k == 'col' or k.startswith('col-') for k in _classes(c)) for c in kids) + + def _add_columns(self, row_el, sheet): + cells = [] + for col in row_el: + if not isinstance(col.tag, str) or is_hidden(col): + continue + if has_visible_descendant(col, {'table'}): + # Tables inside a bootstrap row: flatten vertically. + self._walk_children(col, sheet) + continue + cells.extend(self._pairs_from(col)) + self._emit_cells(sheet, cells) + + def _pairs_from(self, el): + """Return a list of cells: (label, value) pairs for strong/b labels, else one text cell.""" + has_label = any(_tag(c) in ('strong', 'b') and not is_hidden(c) and get_text(c) for c in el) + if not has_label: + text = get_text(el) + return [self._make_cell(text)] if text else [] + # Walk the top-level content of el; split at every direct strong child. + out = [] + prefix = el.text or '' + pending_label, pending_nodes, pending_text = None, [], '' + + def close(): + nonlocal pending_label, pending_nodes, pending_text + if pending_label is not None: + value = text_of_nodes(pending_text, pending_nodes) + out.append(self._make_cell(pending_label, bold=True)) + out.append(self._value_cell(value) if value else Cell()) + pending_label, pending_nodes, pending_text = None, [], '' + + lead = text_of_nodes(prefix, []) + if lead: + out.append(self._make_cell(lead)) + for ch in el: + if not isinstance(ch.tag, str): + continue + if _tag(ch) in ('strong', 'b') and not is_hidden(ch) and get_text(ch): + close() + pending_label = get_text(ch) + pending_text = ch.tail or '' + elif pending_label is not None: + pending_nodes.append(ch) + else: + text = get_text(ch) + if text: + out.append(self._make_cell(text)) + if ch.tail and ch.tail.strip(): + out.append(self._make_cell(_ws(ch.tail).strip())) + close() + return out + + def _label_value(self, el): + """('Label:', 'value') for elements that start with a / label.""" + if (el.text or '').strip(): + return None + kids = [c for c in el if isinstance(c.tag, str)] + if not kids or _tag(kids[0]) not in ('strong', 'b') or is_hidden(kids[0]): + return None + label = get_text(kids[0]) + if not label: + return None + value = text_of_nodes(kids[0].tail or '', kids[1:]) + if not value: + return None + return self._make_cell(label, bold=True), self._value_cell(value) + + def _make_cell(self, text, bold=False): + return Cell(value=text, kind='text', orig=text, bold=bold, wrap='\n' in text) + + def _value_cell(self, text): + """Typed cell (number, date ...) for a free-standing value.""" + span = lxml_html.Element('span') + span.text = text + return classify_cell(span, '', self.opts) + + def _emit_text(self, sheet, text, bold=False): + cell = self._make_cell(text, bold=bold) + cell.nowidth = '\n' not in text + sheet.put(sheet.row, 0, cell) + sheet.row += 1 + + def _emit_cells(self, sheet, cells): + cells = list(cells) + if not cells or all(c.empty for c in cells): + return + for i, c in enumerate(cells): + sheet.put(sheet.row, i, c) + sheet.row += 1 + + def _add_heading(self, el, sheet): + text = get_text(el).replace('\n', ' ') + if not text: + return + cell = Cell(value=text, kind='text', orig=text, bold=True, nowidth=True, + size=14 if _tag(el) in ('h1', 'h2') else None) + sheet.put(sheet.row, 0, cell) + sheet.row += 2 + + # -- tables -------------------------------------------------------- + def _add_table(self, tbl, sheet): + grid_rows = self._table_grid_rows(tbl) + if not grid_rows: + return + n_head = self._header_row_count(grid_rows) + placed, merges, width = self._place_cells(grid_rows) + if not width: + return + names = self._header_names(placed, width, n_head) + offset = self._table_offset(sheet, width, n_head) + + base = sheet.row + info = TableInfo() + info.offset, info.width, info.first_row = offset, width, base + info.names = names + info.last_row = base + len(grid_rows) - 1 + info.n_data = len(grid_rows) - n_head + if n_head: + info.header_row = base + n_head - 1 + + col_cells = self._put_table_cells(sheet, grid_rows, placed, names, n_head, base, offset) + for r1, c1, r2, c2 in merges: + sheet.merges.append((base + r1, offset + c1, base + r2, offset + c2)) + + self._postprocess_columns(col_cells) + sheet.tables.append(info) + sheet.row = info.last_row + 2 + + @staticmethod + def _table_rows(tbl): + """(, section) of the visible rows of a table, in document order.""" + rows = [] + for ch in tbl: + tag = _tag(ch) + if tag in ('thead', 'tbody', 'tfoot'): + if is_hidden(ch): + continue + rows.extend((tr, tag) for tr in ch if _tag(tr) == 'tr' and not is_hidden(tr)) + elif tag == 'tr' and not is_hidden(ch): + rows.append((ch, 'tbody')) + return rows + + def _table_grid_rows(self, tbl): + """(, section, visible cells) of every row that has at least one visible cell.""" + grid_rows = [] + for tr, section in self._table_rows(tbl): + cells = [c for c in tr if _tag(c) in ('td', 'th') and not is_hidden(c)] + if cells: + grid_rows.append((tr, section, cells)) + return grid_rows + + @staticmethod + def _header_row_count(grid_rows): + n_head = sum(1 for _tr, s, _c in grid_rows if s == 'thead') + if not n_head and len(grid_rows) > 1 and all(_tag(c) == 'th' for c in grid_rows[0][2]): + n_head = 1 + return n_head + + def _place_cells(self, grid_rows): + """Place cells on a grid (handles colspan / rowspan): ``(placed, merges, width)``.""" + occupied = set() + placed = [] # (r, c, el, rowspan, colspan) + merges = [] + width = 0 + for r, (_tr, _section, cells) in enumerate(grid_rows): + col = 0 + for el in cells: + while (r, col) in occupied: + col += 1 + cs = self._span(el, 'colspan') + rs = min(self._span(el, 'rowspan'), len(grid_rows) - r) + for dr in range(rs): + for dc in range(cs): + occupied.add((r + dr, col + dc)) + if cs > 1 or rs > 1: + merges.append((r, col, r + rs - 1, col + cs - 1)) + placed.append((r, col, el, rs, cs)) + width = max(width, col + cs) + col += cs + return placed, merges, width + + @staticmethod + def _header_names(placed, width, n_head): + names = [''] * width + for r, c, el, _rs, _cs in placed: + if r < n_head: + text = get_text(el).replace('\n', ' ').strip().lower() + if text: + names[c] = text + return names + + def _table_offset(self, sheet, width, n_head): + """Column offset for 2-column totals tables under a previous table.""" + prev = sheet.tables[-1] if sheet.tables else None + if prev and width == 2 and not n_head and prev.has_header and prev.width >= 3 \ + and sheet.row - 1 <= prev.last_row + 2 and self._last_col_numeric(sheet, prev): + return prev.offset + prev.width - 2 + return 0 + + def _put_table_cells(self, sheet, grid_rows, placed, names, n_head, base, offset): + """Classify and store the cells of a table; returns the data cells by column.""" + col_cells = {} + for r, c, el, _rs, _cs in placed: + tr, section, _cells = grid_rows[r] + is_header = r < n_head + cell = classify_cell(el, names[c] if not is_header else '', self.opts) + if is_header: + self._style_header_cell(cell) + else: + if section == 'tfoot' or element_is_bold(tr): + cell.bold = True + col_cells.setdefault(c, []).append(cell) + sheet.put(base + r, offset + c, cell) + return col_cells + + @staticmethod + def _style_header_cell(cell): + cell.kind, cell.fmt = 'text', None + cell.value = cell.orig or None + cell.bold = True + cell.align = cell.align or 'center' + cell.wrap = True + + @staticmethod + def _span(el, attr): + try: + n = int(el.get(attr) or 1) + except ValueError: + n = 1 + if attr == 'colspan' and n > MAX_COLSPAN: + return 1 + return max(n, 1) + + @staticmethod + def _last_col_numeric(sheet, prev): + c = prev.offset + prev.width - 1 + for r in range(prev.first_row, prev.last_row + 1): + cell = sheet.cells.get((r, c)) + if cell and cell.kind == 'number': + return True + return False + + def _postprocess_columns(self, col_cells): + ts = self.opts.thousands_sep + for _c, cells in col_cells.items(): + kinds = {c.kind for c in cells if not c.empty} + # Mixed percentage / text columns: keep what was printed. + if 'percent' in kinds and ('text' in kinds): + for cell in cells: + if cell.kind == 'percent': + cell.kind, cell.value, cell.fmt = 'text', cell.orig, None + # Whole numbers >= 1000 without a thousands separator are most likely codes. + if ts: + for cell in cells: + if cell.kind == 'number' and cell.decimals == 0 and not cell.grouped \ + and abs(cell.value) >= 1000 and cell.fmt == number_format(0, False): + cell.kind, cell.value, cell.fmt = 'text', cell.orig, None + # Indentation relative to the shallowest cell of the column. + indents = [c.indent for c in cells if not c.empty] + if indents: + low = min(indents) + for cell in cells: + cell.indent = max(0, cell.indent - low) + + # -- finalisation -------------------------------------------------- + def _finalise(self, sheet): + main = sheet.main_table + if main: + if main.header_row < FREEZE_LIMIT: + sheet.freeze_row = main.header_row + 1 + sheet.filter = (main.header_row, main.offset, main.last_row, main.offset + main.width - 1) + + @staticmethod + def _column_keys(table): + """(name, occurrence) of each column, so that repeated header names stay distinct.""" + seen, keys = {}, [] + for name in table.names: + n = seen.get(name, 0) + 1 + seen[name] = n + keys.append((name, n)) + return keys + + def _all_lines(self, sheets): + if len(sheets) < 2: + return None + mains = [s.main_table for s in sheets] + if any(m is None for m in mains): + return None + firsts = {m.names[0] for m in mains if m.names} + if len(firsts) != 1 or not next(iter(firsts)): + return None + + order, display = self._merged_columns(mains) + combined = Sheet('All lines') + for i, c in enumerate(self._all_lines_header(sheets, mains, order, display)): + combined.put(0, i, c) + r_out = self._all_lines_rows(combined, sheets, mains, order) + if r_out == 1: + return None + combined.row = r_out + info = TableInfo() + info.header_row, info.first_row, info.last_row = 0, 0, r_out - 1 + info.width, info.n_data = len(order) + 1, r_out - 1 + combined.tables.append(info) + combined.freeze_row = 1 + combined.filter = (0, 0, r_out - 1, len(order)) + return combined + + def _merged_columns(self, mains): + """Union of the columns of all main tables, keeping their relative order.""" + order = [] + display = {} + for m in mains: + prev = None + for key, raw in zip(self._column_keys(m), m.names, strict=True): + if key not in order: + if prev is None: + order.insert(0, key) + else: + order.insert(order.index(prev) + 1, key) + display.setdefault(key, raw) + prev = key + return order, display + + def _all_lines_header(self, sheets, mains, order, display): + cells = [Cell(value='Document', kind='text', bold=True, align='center')] + for key in order: + title = self._original_title(sheets, mains, key) or display[key] + cells.append(Cell(value=title, kind='text', bold=True, align='center', wrap=True)) + return cells + + def _original_title(self, sheets, mains, key): + """Original capitalisation of a header, taken from the first sheet that has the column.""" + for s, m in zip(sheets, mains, strict=True): + keys = self._column_keys(m) + if key in keys: + cell = s.cells.get((m.header_row, m.offset + keys.index(key))) + if cell and cell.value: + return cell.value + return None + + @staticmethod + def _data_row(sheet, table, r): + """Cells of a data row, or None for empty rows, section headings and subtotals.""" + row_cells = [sheet.cells.get((r, table.offset + c)) for c in range(table.width)] + non_empty = [c for c in row_cells if c is not None and not c.empty] + if not non_empty or all(c.bold for c in non_empty): + return None + return row_cells + + def _all_lines_rows(self, combined, sheets, mains, order): + """Copy the data rows of every main table into ``combined``; returns the next free row.""" + r_out = 1 + for s, m in zip(sheets, mains, strict=True): + idx = {key: i for i, key in enumerate(self._column_keys(m))} + for r in range(m.header_row + 1, m.last_row + 1): + row_cells = self._data_row(s, m, r) + if row_cells is None: + continue + combined.put(r_out, 0, Cell(value=s.title, kind='text')) + self._copy_row(combined, r_out, row_cells, idx, order) + r_out += 1 + return r_out + + @staticmethod + def _copy_row(combined, r_out, row_cells, idx, order): + for j, key in enumerate(order, 1): + src = idx.get(key) + if src is None: + continue + cell = row_cells[src] + if cell is not None and not cell.empty: + new = copy.copy(cell) + new.bold = False + combined.put(r_out, j, new) + + +# -------------------------------------------------------------------------- +# Writing +# -------------------------------------------------------------------------- + +def _cell_width(cell): + if cell.empty or cell.nowidth: + return 0 + if cell.kind in ('number', 'percent'): + digits = len('%.*f' % (cell.decimals, abs(cell.value))) if cell.kind == 'number' else 6 + return digits + 6 + if cell.kind in ('date', 'datetime'): + return 11 if cell.kind == 'date' else 17 + longest = max(len(line) for line in str(cell.value).split('\n')) + return longest + cell.indent * 2 + + +def _format_factory(wb): + """Return ``fmt_for(cell)``: the workbook format of a cell, created once per distinct style.""" + formats = {} + + def fmt_for(cell): + key = (cell.bold, cell.align, cell.fmt, cell.indent, cell.wrap, cell.size) + if key not in formats: + props = {'valign': 'top'} + if cell.bold: + props['bold'] = True + if cell.align: + props['align'] = cell.align + if cell.fmt: + props['num_format'] = cell.fmt + if cell.indent: + props['indent'] = cell.indent + if cell.wrap: + props['text_wrap'] = True + if cell.size: + props['font_size'] = cell.size + formats[key] = wb.add_format(props) + return formats[key] + + return fmt_for + + +def _merged_positions(sheet): + """(all cells covered by merged ranges, merged range by its top-left cell).""" + merged = set() + for r1, c1, r2, c2 in sheet.merges: + for r in range(r1, r2 + 1): + for c in range(c1, c2 + 1): + merged.add((r, c)) + merge_origin = {(r1, c1): (r1, c1, r2, c2) for r1, c1, r2, c2 in sheet.merges} + return merged, merge_origin + + +def _write_value(ws, r, c, cell, fmt): + if cell.kind in ('number', 'percent'): + ws.write_number(r, c, cell.value, fmt) + elif cell.kind in ('date', 'datetime'): + ws.write_datetime(r, c, cell.value, fmt) + else: + ws.write_string(r, c, str(cell.value), fmt) + + +def _write_cells(ws, sheet, fmt_for): + """Write all cells of a sheet; returns the content width needed by each column.""" + merged, merge_origin = _merged_positions(sheet) + widths = {} + for (r, c), cell in sorted(sheet.cells.items()): + if (r, c) in merged and (r, c) not in merge_origin: + continue + fmt = fmt_for(cell) + if (r, c) in merge_origin: + _r1, _c1, r2, c2 = merge_origin[(r, c)] + ws.merge_range(r, c, r2, c2, '', fmt) + if cell.empty: + if (r, c) not in merge_origin: + ws.write_blank(r, c, None, fmt) + continue + _write_value(ws, r, c, cell, fmt) + if (r, c) not in merged: + widths[c] = max(widths.get(c, 0), _cell_width(cell)) + return widths + + +def _write_sheet(wb, sheet, fmt_for): + ws = wb.add_worksheet(sheet.name) + widths = _write_cells(ws, sheet, fmt_for) + max_col = max((c for _r, c in sheet.cells), default=0) + for c in range(max_col + 1): + w = widths.get(c, 0) + ws.set_column(c, c, min(max(w, DEFAULT_WIDTH), MAX_WIDTH)) + if sheet.freeze_row: + ws.freeze_panes(sheet.freeze_row, 0) + if sheet.filter: + ws.autofilter(*sheet.filter) + ws.set_landscape() + ws.fit_to_pages(1, 0) + + +def write_workbook(sheets, title): + buf = io.BytesIO() + wb = xlsxwriter.Workbook(buf, {'in_memory': True, 'default_date_format': 'yyyy-mm-dd', + 'strings_to_numbers': False, 'strings_to_formulas': False, + 'strings_to_urls': False}) + wb.set_properties({'title': title or 'Report', 'comments': 'Generated by Odoo2XL - Odoo Excel Reports'}) + fmt_for = _format_factory(wb) + for sheet in sheets: + _write_sheet(wb, sheet, fmt_for) + wb.close() + return buf.getvalue() + + +def convert_report_html(html_source, options=None): + """Convert the HTML of an Odoo report to the content of an .xlsx file (bytes).""" + opts = options or ConverterOptions() + sheets = Converter(opts).convert(html_source) + title = sheets[0].title if len(sheets) == 1 else opts.title + return write_workbook(sheets, title) diff --git a/om_excel_reports/models/__init__.py b/om_excel_reports/models/__init__.py new file mode 100644 index 000000000..a248cf216 --- /dev/null +++ b/om_excel_reports/models/__init__.py @@ -0,0 +1 @@ +from . import ir_actions_report diff --git a/om_excel_reports/models/ir_actions_report.py b/om_excel_reports/models/ir_actions_report.py new file mode 100644 index 000000000..dc35ca85f --- /dev/null +++ b/om_excel_reports/models/ir_actions_report.py @@ -0,0 +1,35 @@ +# Part of Odoo2XL - Odoo Excel Reports (om_excel_reports). License: LGPL-3. +from odoo import _, api, models +from odoo.exceptions import UserError + +from ..lib.html_to_xlsx import ConverterOptions, convert_report_html + +XLSX_MIMETYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + + +def is_pdf_report_type(report_type): + """Odoo 20: PDF reports are ``qweb-pdf`` or ``qweb-pdf-`` (e.g. ``qweb-pdf-wkhtmltopdf``).""" + return bool(report_type) and report_type.startswith('qweb-pdf') + + +class IrActionsReport(models.Model): + _inherit = 'ir.actions.report' + + @api.model + def _render_excel(self, report_ref, res_ids=None, data=None): + """Render a PDF report as an .xlsx file. Returns ``(content, 'xlsx')``.""" + report = self._get_report(report_ref) + if not is_pdf_report_type(report.report_type): + raise UserError(_("Only PDF reports can be exported to Excel: '%s'.", report.name)) + html, _report_type = self._render_qweb_html(report_ref, res_ids, data=data) + lang = self.env['res.lang']._lang_get(self.env.lang or self.env.user.lang or 'en_US') + options = ConverterOptions( + decimal_point=lang.decimal_point if lang else '.', + thousands_sep=lang.thousands_sep if lang else ',', + date_format=lang.date_format if lang else '%m/%d/%Y', + title=report.name, + ) + try: + return convert_report_html(html, options), 'xlsx' + except ValueError as exc: + raise UserError(str(exc)) from exc diff --git a/om_excel_reports/static/description/banner.png b/om_excel_reports/static/description/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..f23eb3283ca5d35e66f819f0a967a37f165e76e2 GIT binary patch literal 39656 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 literal 0 HcmV?d00001 diff --git a/om_excel_reports/static/description/index.html b/om_excel_reports/static/description/index.html new file mode 100644 index 000000000..ae4860f96 --- /dev/null +++ b/om_excel_reports/static/description/index.html @@ -0,0 +1,60 @@ +
+
+

Odoo2XL - Odoo Excel Reports

+

Export any Odoo PDF report to Excel (.xlsx) when you print it

+
+ Odoo2XL - Odoo Excel Reports +
+
+
+ +
+
+

Adds Excel export to the Odoo Print flow. Any PDF report can be exported, and the result is a real spreadsheet - not a picture of the PDF.

+

Key Features

+
    +
  • Works for every PDF report - invoices, quotations, delivery slips and any other report, including custom and third-party ones. No per-report code.
  • +
  • PDF / Excel / Both / Preview chooser when printing, also for reports printed from a wizard. Preview opens the PDF in a new tab under its real file name.
  • +
  • Amounts are numbers with currency formatting, dates are real Excel dates, account codes and references stay text.
  • +
  • Quantities keep their unit and still sum.
  • +
  • Bold totals, indented account levels, frozen header row and filters on the main table.
  • +
  • Report filters (dates, journals, target moves) are kept above the table.
  • +
  • Several records: one sheet per document, plus an "All lines" sheet with a Document column, ready for pivot tables.
  • +
  • Files are named after the record reference or the report name.
  • +
  • Print-ready: all columns fit the page width.
  • +
  • Generated on the server, inside Odoo: no browser extension and no data leaves your system.
  • +
+
+
+ +
+
+

Configuration

+
    +
  • Reports that already have their own Excel export (an xlsx report action on the same model, for example from report_xlsx) do not get the chooser, so you never get two Excel versions of one report.
  • +
  • To turn off the PDF / Excel / Both chooser for everyone, set the system parameter om_excel_reports.ask_format to 0 (printing then always gives the PDF).
  • +
  • Access follows the user's normal report access rights.
  • +
  • Python requirements: xlsxwriter and lxml (both shipped with Odoo).
  • +
+
+
+ +
+
+

Limitations

+
    +
  • Images, logos and barcodes are not exported.
  • +
  • Reports without tables are exported as plain text rows.
  • +
  • Number and date recognition uses the user's language settings.
  • +
+
+
+ +
+
+

Support

+

Odoo Mates - odoomates@gmail.com

+

Contributor: Akshay Manoj

+

Odoo2XL is not affiliated with or endorsed by Odoo S.A.

+
+
diff --git a/om_excel_reports/static/src/js/excel_choice_dialog.js b/om_excel_reports/static/src/js/excel_choice_dialog.js new file mode 100644 index 000000000..2f204cc9b --- /dev/null +++ b/om_excel_reports/static/src/js/excel_choice_dialog.js @@ -0,0 +1,26 @@ +import { Component, onMounted, signal, t, useProps } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; + +/** + * "Print " chooser: PDF, Excel, both, or a preview in a new tab. + * Esc (handled by the Dialog) cancels; the Excel tile is focused so Enter picks it. + */ +export class ExcelChoiceDialog extends Component { + static template = "om_excel_reports.ExcelChoiceDialog"; + static components = { Dialog }; + props = useProps({ + title: t.string(), + onChoice: t.function(), + close: t.function(), + }); + primary = signal.ref(); + + setup() { + onMounted(() => this.primary()?.focus()); + } + + choose(choice) { + this.props.onChoice(choice); + this.props.close(); + } +} diff --git a/om_excel_reports/static/src/js/report_excel_handler.js b/om_excel_reports/static/src/js/report_excel_handler.js new file mode 100644 index 000000000..fbd7fbfd8 --- /dev/null +++ b/om_excel_reports/static/src/js/report_excel_handler.js @@ -0,0 +1,109 @@ +import { browser } from "@web/core/browser/browser"; +import { _t } from "@web/core/l10n/translation"; +import { download } from "@web/core/network/download"; +import { rpc } from "@web/core/network/rpc"; +import { registry } from "@web/core/registry"; +import { user } from "@web/core/user"; +import { getReportUrl } from "@web/webclient/actions/reports/utils"; +import { ExcelChoiceDialog } from "./excel_choice_dialog"; + +const reportInfoCache = new Map(); + +/** {allowed, ask} for a PDF report: may it be exported, and should we ask the user? */ +function getReportInfo(reportName) { + if (!reportInfoCache.has(reportName)) { + const promise = rpc("/om_excel_reports/excel_allowed", { report_name: reportName }); + promise.catch(() => reportInfoCache.delete(reportName)); + reportInfoCache.set(reportName, promise); + } + return reportInfoCache.get(reportName); +} + +/** Downloads the Excel version of a report action through the report controller. */ +async function downloadExcel(action, env) { + const url = getReportUrl(action, "excel"); + const context = { ...user.context, ...(action.context || {}) }; + env.services.ui.block(); + try { + await download({ + url: "/report/download", + data: { + data: JSON.stringify([url, "excel"]), + context: JSON.stringify(context), + }, + }); + } finally { + env.services.ui.unblock(); + } +} + +/** URL showing the PDF inline, under its real file name (see controller). */ +function previewUrl(action) { + const context = { ...user.context, ...(action.context || {}) }; + const params = new URLSearchParams(); + let url = `/om_excel_reports/preview/${action.report_name}`; + if (action.data && JSON.stringify(action.data) !== "{}") { + params.set("options", JSON.stringify(action.data)); + } else if (context.active_ids?.length) { + url += `/${context.active_ids.join(",")}`; + } + params.set("context", JSON.stringify(context)); + return `${url}?${params.toString()}`; +} + +/** Opens the PDF in a new tab. Returns false when the browser blocked the pop-up. */ +function openPreview(action, env) { + const win = browser.open(previewUrl(action), "_blank"); + if (!win) { + env.services.notification.add( + _t("Your browser blocked the preview tab. Allow pop-ups for this site to preview reports; the PDF is downloaded instead."), + { type: "warning" } + ); + return false; + } + return true; +} + +/** Resolves with "pdf" | "excel" | "both" | "preview", or null when the dialog is dismissed. */ +function askChoice(env, action) { + return new Promise((resolve) => { + env.services.dialog.add( + ExcelChoiceDialog, + { + title: action.name || action.display_name || action.report_name, + onChoice: (choice) => resolve(choice), + }, + { onClose: () => resolve(null) } + ); + }); +} + +/** + * Registered in the "ir.actions.report handlers" registry. Returning true means the + * action was fully handled; returning false lets Odoo continue (PDF download). + */ +async function excelReportHandler(action, options, env) { + if (!action.report_type?.startsWith("qweb-pdf")) { + return false; + } + const info = await getReportInfo(action.report_name); + if (!info.allowed || !info.ask) { + return false; + } + const choice = await askChoice(env, action); + if (choice === null) { + return true; // dialog dismissed: print nothing + } + if (choice === "pdf") { + return false; + } + if (choice === "preview") { + return openPreview(action, env); // false (pop-up blocked) falls back to the download + } + await downloadExcel(action, env); + return choice === "excel"; // "both" falls through to the regular PDF download +} + +registry + .category("ir.actions.report handlers") + .add("om_excel_reports.handler", excelReportHandler, { sequence: 1 }); diff --git a/om_excel_reports/static/src/scss/excel_choice_dialog.scss b/om_excel_reports/static/src/scss/excel_choice_dialog.scss new file mode 100644 index 000000000..c756a9599 --- /dev/null +++ b/om_excel_reports/static/src/scss/excel_choice_dialog.scss @@ -0,0 +1,104 @@ +// Dark "PDF / Excel / Both" chooser, identical in light and dark Odoo themes. +.o_report_excel_choice.modal-content { + background: #1f2227; + color: #f1f3f5; + border: 0; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45); + + .o_rec_title { + margin: 0 0 0.3rem; + font-size: 1.15rem; + font-weight: 700; + color: #f8f9fa; + } + + .o_rec_hint { + margin: 0 0 1.1rem; + font-size: 0.9rem; + color: #9aa1ab; + } + + .o_rec_tiles { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + } + + .o_rec_tile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.2rem; + padding: 1.1rem 0.5rem; + background: #1f2227; + color: #f1f3f5; + border: 1px solid #343a42; + border-radius: 12px; + cursor: pointer; + transition: background-color 0.12s, border-color 0.12s; + + &:hover, + &:focus-visible { + background: #272b31; + border-color: #4b5563; + outline: none; + } + + .o_rec_tile_label { + font-size: 1.05rem; + font-weight: 700; + } + + .o_rec_tile_sub { + font-size: 0.75rem; + color: #9aa1ab; + } + + &.o_rec_tile_primary { + background: #1b2b22; + border-color: #2f6b45; + + .o_rec_tile_label { + color: #4ade80; + } + + &:hover, + &:focus-visible { + background: #203428; + border-color: #3f8f5c; + } + } + } + + .o_rec_footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 1rem; + margin-top: 1.1rem; + } + + .o_rec_preview { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.95rem; + background: transparent; + color: #c9d1d9; + border: 1px solid #343a42; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.12s, border-color 0.12s, color 0.12s; + + &:hover, + &:focus-visible { + background: #272b31; + border-color: #4b5563; + color: #f8f9fa; + outline: none; + } + } +} diff --git a/om_excel_reports/static/src/xml/excel_choice_dialog.xml b/om_excel_reports/static/src/xml/excel_choice_dialog.xml new file mode 100644 index 000000000..518203d7f --- /dev/null +++ b/om_excel_reports/static/src/xml/excel_choice_dialog.xml @@ -0,0 +1,34 @@ + + + + + +

Print “”

+

Choose the format. Esc cancels.

+
+ + + +
+