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 = '''
+
+ Some header Text
+ '''
+
+ if footer_content is None:
+ footer_content = '''
+
Footer for Page: /
+ '''
+
+ if page_content is None:
+ page_content = '''
+
+ '''
+
+ self.report_view.arch = f'''
+
+
+
+
+
+
+
+ {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'''
+
+ '''
+ 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)