[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:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user