diff --git a/odoo/addons/base/tests/test_reports.py b/odoo/addons/base/tests/test_reports.py index 6dbb5073f94..7e9f5376b85 100644 --- a/odoo/addons/base/tests/test_reports.py +++ b/odoo/addons/base/tests/test_reports.py @@ -1,11 +1,22 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import io import logging from base64 import b64decode import odoo import odoo.tests +try: + from pdfminer.converter import PDFPageAggregator + from pdfminer.layout import LAParams, LTFigure, LTTextBox + from pdfminer.pdfdocument import PDFDocument + from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter + from pdfminer.pdfpage import PDFPage + from pdfminer.pdfparser import PDFParser + pdfminer = True +except ImportError: + pdfminer = False _logger = logging.getLogger(__name__) @@ -99,3 +110,445 @@ class TestReports(odoo.tests.TransactionCase): self.assertEqual(b64decode(attach_1.datas), b"1") self.assertEqual(pdf[0], b"2") + + +# Some paper format examples +PAPER_SIZES = { + (842, 1190): 'A3', + (595, 842): 'A4', + (420, 595): 'A5', + (297, 420): 'A6', + (612, 792): 'Letter', + (612, 1008): 'Legal', + (792, 1224): 'Ledger', +} + +class Box: + """ + Utility class to help assertions + """ + def __init__(self, obj, page_height, page_width): + self.x1 = round(obj.x0, 1) + self.y1 = round(page_height-obj.y1, 1) + self.x2 = round(obj.x1, 1) + self.y2 = round(page_height-obj.y0, 1) + self.page_height = page_height + self.page_width = page_width + + @property + def height(self): + return self.y2 - self.y1 + + @property + def width(self): + return self.x2 - self.x1 + + @property + def top(self): + return self.y1 + + @property + def left(self): + return self.x1 + + @property + def end_top(self): + return self.y2 + + @property + def end_left(self): + return self.x2 + + @property + def right(self): + return self.page_width - self.x2 + + @property + def bottom(self): + return self.page_height - self.y2 + + def __lt__(self, other): + return (self.y1, self.x1, self.y2, self.x2) < (other.y1, other.x1, other.y2, other.x2) + + +class TestReportsRenderingCommon(odoo.tests.HttpCase): + + def setUp(self): + super().setUp() + self.report = self.env['ir.actions.report'].create({ + 'name': 'Test Report Partner', + 'model': 'res.partner', + 'report_name': 'test_report.test_report_partner', + 'paperformat_id': self.env.ref('base.paperformat_euro').id, + }) + + self.partners = self.env['res.partner'].create([{ + 'name': f'Report record {i}', + } for i in range(2)]) + + self.report_view = self.env['ir.ui.view'].create({ + 'type': 'qweb', + 'name': 'test_report_partner', + 'key': 'test_report.test_report_partner', + 'arch': "", + }) + self.last_pdf_content = None + self.last_pdf_content_saved = False + + def _addError(self, result, test, exc_info): + if self.last_pdf_content and not self.last_pdf_content_saved: + self.last_pdf_content_saved = True + self.save_pdf() + super()._addError(result, test, exc_info) + + def get_paper_format(self, mediabox): + """ + :param: mediabox: a page mediabox. (Example: (0, 0, 595, 842)) + :return: a (format, orientation). Example ('A4', 'portait') + """ + x, y, width, height = mediabox + self.assertEqual((x, y), (0, 0), "Expecting top corner to be 0, 0 ") + orientation = 'portait' + paper_size = (width, height) + if width > height: + orientation = 'landscape' + paper_size = (height, width) + return PAPER_SIZES.get(paper_size, f'custom{paper_size}'), orientation + + def create_pdf(self, partners=None, header_content=None, page_content=None, footer_content=None): + if header_content is None: + header_content = ''' + Logo + Some header Text + ''' + + if footer_content is None: + footer_content = ''' +
Footer for Page: /
+ ''' + + if page_content is None: + page_content = ''' +
+
+ Name: +
+
+ ''' + + self.report_view.arch = f''' + + + + +
+ {header_content} +
+
+ + {page_content} +
+ +
+
+
+ ''' + # this templates doesn't use the "web.external_layout" in order to simplify the final result and make the edition of footer and header easier + # this test does not aims to test company base.document.layout, but the rendering only. + if partners is None: + partners = self.partners + self.last_pdf_content = self.env['ir.actions.report'].with_context(force_report_rendering=True)._render_qweb_pdf(self.report, partners.ids)[0] + return self.last_pdf_content + + def save_pdf(self): + assert self.last_pdf_content + odoo.tests.save_test_file(self._testMethodName, self.last_pdf_content, 'pdf_', 'pdf', document_type='Report PDF', logger=_logger) + + def _get_pdf_pages(self, pdf_content): + ioBytes = io.BytesIO(pdf_content) + parser = PDFParser(ioBytes) + doc = PDFDocument(parser) + return list(PDFPage.create_pages(doc)) + + def _parse_pdf(self, pdf_content, expected_format=('A4', 'portait')): + """ + :param: pdf_content: the bdf binary content + :param: expected_format: a get_paper_format like format. + :return: list[list[(box, Element)]] a list of element per page + Note: box is a 4 float tuple based on the top left corner to ease ordering of elements. + The result is also rounded to one digit + """ + pages = self._get_pdf_pages(pdf_content) + ressource_manager = PDFResourceManager() + device = PDFPageAggregator(ressource_manager, laparams=LAParams()) + interpreter = PDFPageInterpreter(ressource_manager, device) + + parsed_pages = [] + for page in pages: + self.assertEqual( + self.get_paper_format(page.mediabox), + expected_format, + "Expecting pdf to be in A4 portait format", + ) # this is the default expected format and other layout assertions are based on this one. + interpreter.process_page(page) + layout = device.get_result() + elements = [] + parsed_pages.append(elements) + for obj in layout: + box = Box( + obj, + page_height=pages[0].mediabox[3], + page_width=pages[0].mediabox[2], + ) + if isinstance(obj, LTTextBox): + #inverse x to start from top left corner + elements.append((box, obj.get_text().strip())) + elif isinstance(obj, LTFigure): + elements.append((box, 'LTFigure')) + elements.sort() + + return parsed_pages + + def assertPageFormat(self, paper_format, orientation): + pdf_content = self.create_pdf() + pages = self._get_pdf_pages(pdf_content) + self.assertEqual(len(pages), 2) + for page in pages: + self.assertEqual( + self.get_paper_format(page.mediabox), + (paper_format, orientation), + f"Expecting pdf to be in {paper_format} {orientation} format", + ) + + +@odoo.tests.tagged('post_install', '-at_install', 'pdf_rendering') +class TestReportsRendering(TestReportsRenderingCommon): + """ + This test aims to test as much as possible the current pdf rendering, + especially multipage headers and footers + (the main reason why we are currently using wkhtmltopdf with patched qt) + A custom template without web.external_layout is used on purpose in order to + easily test headers and footer regarding rendering only, + without using any comany document.layout logic + """ + + def test_format_A4(self): + self.report.paperformat_id = self.env.ref('base.paperformat_euro') + self.assertPageFormat('A4', 'portait') + + def test_format_letter(self): + self.report.paperformat_id = self.env.ref('base.paperformat_us') + self.assertPageFormat('Letter', 'portait') + + def test_format_landscape(self): + paper_format = self.env.ref('base.paperformat_euro') + paper_format.orientation = 'Landscape' + self.report.paperformat_id = paper_format + self.assertPageFormat('A4', 'landscape') + + def test_layout(self): + pdf_content = self.create_pdf() + pages = self._parse_pdf(pdf_content) + self.assertEqual(len(pages), 2) + + page_contents = [[elem[1] for elem in page] for page in pages] + + expected_pages_content = [[ + 'LTFigure', + 'Some header Text', + f'Name: {partner.name}', + f'Footer for {partner.name} Page: 1 / 1', + ] for partner in self.partners] + + self.assertEqual( + page_contents, + expected_pages_content, + ) + + page_positions = [[elem[0] for elem in page] for page in pages] + logo, header, content, footer = page_positions[0] + + # leaving this as reference but this is to fragile to make a strict assertion + # 14.3, 29.6, 43.1, 137.2 # logo + # 19.1, 137.2, 32.5, 214.2 # header + # 111.3, 29.6, 124.8, 123.7 # content + # 751.6, 220.1, 765.1, 375.0 # footer + + # + # \ \ / // _ \ | | | || _ \ | | + # \ V /| (_) || |_| || / | |__ / _ \/ _` |/ _ \ Some header Text + # |_| \___/ \___/ |_|_\ |____|\___/\__, |\___/ + # + # + # Name: Report record 0 + # + # + # + # + # + # + # Footer for Report record 0 Page: 1 / 1 + # + # + + self.assertEqual(logo.left, content.left, 'Logo and content should have the same left margin') + self.assertEqual(header.left, logo.end_left, 'Header starts after logo') + self.assertGreaterEqual(header.top, logo.top, 'header is vertically centered on logo') + self.assertGreaterEqual(logo.end_top, header.end_top, 'header is vertically centered on logo') + self.assertGreaterEqual(content.top, logo.end_top, 'Content is bellow logo') + self.assertGreaterEqual(footer.top, content.end_top, 'Footer is bellow content') + self.assertGreaterEqual(100, footer.bottom, 'Footer is on the bottom of the page') + self.assertAlmostEqual(footer.left, footer.right, -1, 'Footer is centered on the page') + + def test_report_pdf_page_break(self): + + partners = self.partners[:2] + page_content = ''' +
+
+ Name: +
+
+ Last page for +
+
+ ''' + + pdf_content = self.create_pdf(partners=partners, page_content=page_content) + + pages = self._parse_pdf(pdf_content) + + self.assertEqual(len(pages), 4, "Expecting 2 pages * 2 partners") + + expected_pages_contents = [] + for partner in self.partners: + expected_pages_contents.append([ + 'LTFigure', #logo + 'Some header Text', + f'Name: {partner.name}', + f'Footer for {partner.name} Page: 1 / 2', + ]) + expected_pages_contents.append([ + 'LTFigure', #logo + 'Some header Text', + f'Last page for {partner.name}', + f'Footer for {partner.name} Page: 2 / 2', + ]) + pages_contents = [[elem[1] for elem in page] for page in pages] + self.assertEqual(pages_contents, expected_pages_contents) + + def test_pdf_render_page_overflow(self): + nb_lines = 80 + + page_content = f''' +
+
+ Name: +
+
+
+ ''' + pdf_content = self.create_pdf(page_content=page_content) + pages = self._parse_pdf(pdf_content) + + self.assertEqual(len(pages), 4, '4 pages are expected, 2 per record (you may ensure `nb_lines` has a correct value to generate an oveflow)') + page_break_at = int(pages[1][2][1].split('\n')[0]) # This element should be the first line, 61 when this test was written + + expected_pages_contents = [] + for partner in self.partners: + expected_pages_contents.append([ + 'LTFigure', #logo + 'Some header Text', + f'Name: {partner.name}\n' + '\n'.join([str(i) for i in range(page_break_at)]), + f'Footer for {partner.name} Page: 1 / 2', + ]) + expected_pages_contents.append([ + 'LTFigure', #logo + 'Some header Text', + '\n'.join([str(i) for i in range(page_break_at, nb_lines)]), + f'Footer for {partner.name} Page: 2 / 2', + ]) + pages_contents = [[elem[1] for elem in page] for page in pages] + self.assertEqual(pages_contents, expected_pages_contents) + + def test_thead_tbody_repeat(self): + """ + Check that thead and t-foot are repeated after page break inside a tbody + """ + nb_lines = 50 + page_content = f''' +
+ + + + + + + + +
T1 T2 T3
T1 T2 T3
+
+ ''' + + pdf_content = self.create_pdf(page_content=page_content) + pages = self._parse_pdf(pdf_content) + + self.assertEqual(len(pages), 4, '4 pages are expected, 2 per record (you may ensure `nb_lines` has a correct value to generate an oveflow)') + page_break_at = int(pages[1][5][1]) # This element should be the first line of the table, 28 when this test was written + + def expected_table(start, end): + table = ['T1', 'T2', 'T3'] # thead + for i in range(start, end): + table += [str(i), str(i), str(i)] + table += ['T1', 'T2', 'T3'] # tfoot + return table + + expected_pages_contents = [] + for partner in self.partners: + expected_pages_contents.append([ + 'LTFigure', #logo + 'Some header Text', + * expected_table(0, page_break_at), + f'Footer for {partner.name} Page: 1 / 2', + ]) + expected_pages_contents.append([ + 'LTFigure', #logo + 'Some header Text', + * expected_table(page_break_at, nb_lines), + f'Footer for {partner.name} Page: 2 / 2', + ]) + + pages_contents = [[elem[1] for elem in page] for page in pages] + self.assertEqual(pages_contents, expected_pages_contents) + + +@odoo.tests.tagged('post_install', '-at_install', '-standard', 'pdf_rendering') +class TestReportsRenderingLimitations(TestReportsRenderingCommon): + def test_no_clip(self): + """ + Current version will add a fixed margin on top of document + This test demonstrates this limitation + """ + header_content = ''' +
+
+
+ ''' + page_content = ''' +
+
+
+
+
+ ''' + # adding a margin on page to avoid bot block to me considered as the same + pdf_content = self.create_pdf(page_content=page_content, header_content=header_content) + pages = self._parse_pdf(pdf_content) + self.assertEqual(len(pages), 2, "2 partners") + page = pages[0] + self.assertEqual(len(page), 3, "Expecting 3 box per page, Header, body, footer") + header = page[0][0] + content = page[1][0] + self.assertGreaterEqual(content.top, header.end_top, "EXISTING LIMITATION: large header shouldn't overflow on body, but they do") diff --git a/odoo/tests/common.py b/odoo/tests/common.py index b5c4eb6bcea..02a06e6aa1f 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -866,6 +866,22 @@ def fchain(future, next_callback): return new_future + +def save_test_file(test_name, content, prefix, extension='png', logger=_logger, document_type='Screenshot'): + assert re.fullmatch(r'\w*_', prefix) + assert re.fullmatch(r'[a-z]+', extension) + assert re.fullmatch(r'\w+', test_name) + now = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + screenshots_dir = pathlib.Path(odoo.tools.config['screenshots']) / get_db_name() / 'screenshots' + screenshots_dir.mkdir(parents=True, exist_ok=True) + fname = f'{prefix}{now}_{test_name}.{extension}' + full_path = screenshots_dir / fname + + with full_path.open('wb') as f: + f.write(content) + logger.runbot(f'{document_type} in: {full_path}') + + class ChromeBrowser: """ Helper object to control a Chrome headless process. """ remote_debugging_port = 0 # 9222, change it in a non-git-tracked file @@ -879,9 +895,6 @@ class ChromeBrowser: self.user_data_dir = tempfile.mkdtemp(suffix='_chrome_odoo') otc = odoo.tools.config - self.screenshots_dir = os.path.join(otc['screenshots'], get_db_name(), 'screenshots') - os.makedirs(self.screenshots_dir, exist_ok=True) - self.screencasts_dir = None self.screencast_frames = [] if otc['screencasts']: @@ -1331,21 +1344,14 @@ which leads to stray network requests and inconsistencies.""")) # endGroup, assert, profile, profileEnd, count, timeEnd } - def take_screenshot(self, prefix='sc_', suffix=None): + def take_screenshot(self, prefix='sc_'): def handler(f): base_png = f.result(timeout=0)['data'] if not base_png: self._logger.warning("Couldn't capture screenshot: expected image data, got ?? error ??") return - decoded = base64.b64decode(base_png, validate=True) - fname = '{}{:%Y%m%d_%H%M%S_%f}{}.png'.format( - prefix, datetime.now(), - suffix or '_%s' % self.test_class.__name__) - full_path = os.path.join(self.screenshots_dir, fname) - with open(full_path, 'wb') as f: - f.write(decoded) - self._logger.runbot('Screenshot in: %s', full_path) + save_test_file(self.test_class.__name__, decoded, prefix, logger=self._logger) self._logger.info('Asking for screenshot') f = self._websocket_send('Page.captureScreenshot', with_future=True)