[IMP] base: add test for wkhtmltopdf generation

The current used version of wkhtmltopdf manages is frozen to 0.12.5
because some features used by odoo are only available in this version
using a patched qt.

Anyway, it become difficult to keep this version up to date, especially
with Ubuntu Jammy and updates of the dependencies used by wkhtmltopdf.
More than that because of qt related issues, wkhtmltopdf maintenance
will end soon.

Adding a test to check some caracteristics of pdf reports may be usefull
to check that current version is still working, and eventually to find
an alternative solution.

This test checks that pdf reports contains the expected headers and
footers elements with the correct page number (2 record of 2 pages)
This also check that the pdf is in the expected A4 format.

Future test may check other formats, margins, layout,
page size/pagebreak combination, (table of content?), ...

This test may also check performances and existing limitations.

closes odoo/odoo#136626

Signed-off-by: Christophe Monniez (moc) <moc@odoo.com>
This commit is contained in:
Xavier-Do
2023-10-19 13:20:28 +00:00
parent cc6f85af2a
commit 4820be3cb7
2 changed files with 471 additions and 12 deletions
+453
View File
@@ -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': "<t></t>",
})
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 = '''
<img t-if="company.logo" t-att-src="image_data_uri(company.logo)" style="max-height: 45px;" alt="Logo"/>
<span>Some header Text</span>
'''
if footer_content is None:
footer_content = '''
<div style="text-align:center">Footer for <t t-esc="o.name"/> Page: <span class="page"/> / <span class="topage"/></div>
'''
if page_content is None:
page_content = '''
<div class="page">
<div style="background-color:red">
Name: <t t-esc="o.name"/>
</div>
</div>
'''
self.report_view.arch = f'''
<t t-name="test_report.test_report_partner">
<t t-set="company" t-value="res_company"/>
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<div class="header" style="font-family:Sans">
{header_content}
</div>
<div class="article" style="font-family:Sans">
{page_content}
</div>
<div class="footer" style="font-family:Sans">
{footer_content}
</div>
</t>
</t>
</t>
'''
# 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 = '''
<div class="page">
<div style="background-color:red">
Name: <t t-esc="o.name"/>
</div>
<div style="page-break-before:always;background-color:blue">
Last page for <t t-esc="o.name"/>
</div>
</div>
'''
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'''
<div class="page">
<div style="background-color:red">
Name: <t t-esc="o.name"/>
<div t-foreach="range({nb_lines})" t-as="pos" t-esc="pos"/>
</div>
</div>
'''
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'''
<div class="page">
<table class="table">
<thead><tr><th> T1 </th><th> T2 </th><th> T3 </th></tr></thead>
<tbody>
<t t-foreach="range({nb_lines})" t-as="pos">
<tr><td><t t-esc="pos"/></td><td><t t-esc="pos"/></td><td><t t-esc="pos"/></td></tr>
</t>
</tbody>
<tfoot><tr><th> T1 </th><th> T2 </th><th> T3 </th></tr></tfoot>
</table>
</div>
'''
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 = '''
<div style="background-color:blue">
<div t-foreach="range(15)" t-as="pos" t-esc="'Header %s' % pos"/>
</div>
'''
page_content = '''
<div class="page">
<div style="background-color:red; margin-left:100px">
<div t-foreach="range(10)" t-as="pos" t-esc="'Content %s' % pos"/>
</div>
</div>
'''
# 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")
+18 -12
View File
@@ -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)