diff --git a/om_excel_reports/README.rst b/om_excel_reports/README.rst new file mode 100644 index 00000000..a5a3a951 --- /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 00000000..f7fd9984 --- /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 00000000..10b00277 --- /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 00000000..12a7e529 --- /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 00000000..d378db97 --- /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 00000000..e69de29b 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 00000000..b0efff0d --- /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 00000000..a248cf21 --- /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 00000000..dc35ca85 --- /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 00000000..f23eb328 Binary files /dev/null and b/om_excel_reports/static/description/banner.png differ diff --git a/om_excel_reports/static/description/icon.png b/om_excel_reports/static/description/icon.png new file mode 100644 index 00000000..23cb51a8 Binary files /dev/null and b/om_excel_reports/static/description/icon.png differ diff --git a/om_excel_reports/static/description/index.html b/om_excel_reports/static/description/index.html new file mode 100644 index 00000000..ae4860f9 --- /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 00000000..2f204cc9 --- /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 00000000..fbd7fbfd --- /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 00000000..c756a959 --- /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 00000000..518203d7 --- /dev/null +++ b/om_excel_reports/static/src/xml/excel_choice_dialog.xml @@ -0,0 +1,34 @@ + + + + + +

Print “”

+

Choose the format. Esc cancels.

+
+ + + +
+