mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-19 15:12:01 +02:00
16.0 vanila
This commit is contained in:
parent
956889352c
commit
2e65bf056a
17 changed files with 5293 additions and 17668 deletions
|
|
@ -9,27 +9,8 @@ from . import wizard
|
|||
|
||||
|
||||
def post_init(cr, registry):
|
||||
"""Post-init housekeeping.
|
||||
|
||||
- Rewrites ICP's to force groups (existing behavior)
|
||||
- Removes enterprise/proprietary "to_buy" module promos from Apps list
|
||||
"""
|
||||
"""Rewrite ICP's to force groups"""
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
# Keep existing behavior
|
||||
env['ir.config_parameter'].init(force=True)
|
||||
|
||||
# Remove enterprise/proprietary promotional stubs so they don't show in Apps
|
||||
try:
|
||||
Module = env['ir.module.module']
|
||||
promos = Module.search([
|
||||
('to_buy', '=', True),
|
||||
('license', 'in', ['OEEL-1', 'OEEL', 'OPL-1', 'Proprietary']),
|
||||
])
|
||||
if promos:
|
||||
promos.unlink()
|
||||
except Exception:
|
||||
# Best-effort cleanup; never break base post-init
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -49,8 +49,7 @@ The kernel of Odoo, needed for all installation.
|
|||
'views/ir_qweb_widget_templates.xml',
|
||||
'views/ir_module_views.xml',
|
||||
'data/ir_module_category_data.xml',
|
||||
# Removed enterprise proprietary module promos (to_buy) from base data
|
||||
# 'data/ir_module_module.xml',
|
||||
'data/ir_module_module.xml',
|
||||
'report/ir_module_reports.xml',
|
||||
'report/ir_module_report_templates.xml',
|
||||
'wizard/base_module_update_views.xml',
|
||||
|
|
|
|||
|
|
@ -45,12 +45,6 @@
|
|||
<field name="name">Sales</field>
|
||||
<field name="sequence">5</field>
|
||||
</record>
|
||||
<record model="ir.module.category" id="module_category_sales_sales">
|
||||
<field name="name">Sales</field>
|
||||
<field name="parent_id" ref="module_category_sales"/>
|
||||
<field name="description">Sales Management</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record model="ir.module.category" id="module_category_human_resources">
|
||||
<field name="name">Human Resources</field>
|
||||
|
|
@ -61,34 +55,16 @@
|
|||
<field name="name">Marketing</field>
|
||||
<field name="sequence">40</field>
|
||||
</record>
|
||||
<record model="ir.module.category" id="module_category_marketing_email_marketing">
|
||||
<field name="name">Email Marketing</field>
|
||||
<field name="parent_id" ref="module_category_marketing"/>
|
||||
<field name="description">Email Marketing Tools</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record model="ir.module.category" id="module_category_manufacturing">
|
||||
<field name="name">Manufacturing</field>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
<record model="ir.module.category" id="module_category_manufacturing_manufacturing">
|
||||
<field name="name">Manufacturing</field>
|
||||
<field name="parent_id" ref="module_category_manufacturing"/>
|
||||
<field name="description">Manufacturing Management</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record model="ir.module.category" id="module_category_website">
|
||||
<field name="name">Website</field>
|
||||
<field name="sequence">35</field>
|
||||
</record>
|
||||
<record model="ir.module.category" id="module_category_website_website">
|
||||
<field name="name">Website</field>
|
||||
<field name="parent_id" ref="module_category_website"/>
|
||||
<field name="description">Website Management</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record model="ir.module.category" id="module_category_theme">
|
||||
<field name="name">Theme</field>
|
||||
|
|
@ -129,30 +105,11 @@
|
|||
<field name="name">Field Service</field>
|
||||
<field name="parent_id" ref="module_category_services"/>
|
||||
</record>
|
||||
<record model="ir.module.category" id="module_category_services_project">
|
||||
<field name="name">Project</field>
|
||||
<field name="parent_id" ref="module_category_services"/>
|
||||
<field name="sequence">15</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_services_timesheets">
|
||||
<field name="name">Timesheets</field>
|
||||
<field name="parent_id" ref="module_category_services"/>
|
||||
<field name="description">Helps you manage the timesheets.</field>
|
||||
<field name="sequence">13</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record model="ir.module.category" id="module_category_inventory">
|
||||
<field name="name">Inventory</field>
|
||||
<field name="sequence">25</field>
|
||||
</record>
|
||||
<record model="ir.module.category" id="module_category_inventory_inventory">
|
||||
<field name="name">Inventory</field>
|
||||
<field name="parent_id" ref="module_category_inventory"/>
|
||||
<field name="description">Inventory Management</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record model="ir.module.category" id="module_category_productivity">
|
||||
<field name="name">Productivity</field>
|
||||
|
|
|
|||
|
|
@ -1,176 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record model="ir.module.category" id="module_category_hidden">
|
||||
<field name="name">Technical</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="visible" eval="0" />
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_accounting">
|
||||
<field name="name">Accounting</field>
|
||||
<field name="sequence">15</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_accounting_localizations">
|
||||
<field name="name">Localization</field>
|
||||
<field name="sequence">65</field>
|
||||
<field name="visible" eval="0" />
|
||||
<field name="parent_id" ref="module_category_accounting"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_payroll_localization">
|
||||
<field name="name">Payroll Localization</field>
|
||||
<field name="visible" eval="0" />
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_accounting_localizations_account_charts">
|
||||
<field name="parent_id" ref="module_category_accounting_localizations" />
|
||||
<field name="name">Account Charts</field>
|
||||
<field name="visible" eval="0" />
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_user_type">
|
||||
<field name="name">User types</field>
|
||||
<field name="description">Helps you manage users.</field>
|
||||
<field name="sequence">9</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_accounting_accounting">
|
||||
<field name="name">Invoicing</field>
|
||||
<field name="sequence">4</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_sales">
|
||||
<field name="name">Sales</field>
|
||||
<field name="sequence">5</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_human_resources">
|
||||
<field name="name">Human Resources</field>
|
||||
<field name="sequence">45</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_marketing">
|
||||
<field name="name">Marketing</field>
|
||||
<field name="sequence">40</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_manufacturing">
|
||||
<field name="name">Manufacturing</field>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_website">
|
||||
<field name="name">Website</field>
|
||||
<field name="sequence">35</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_theme">
|
||||
<field name="name">Theme</field>
|
||||
<field name="exclusive" eval="0"/>
|
||||
<field name="sequence">50</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_administration">
|
||||
<field name="name">Administration</field>
|
||||
<field name="sequence">100</field>
|
||||
<field name="parent_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_human_resources_appraisals">
|
||||
<field name="name">Appraisals</field>
|
||||
<field name="description">A user without any rights on Appraisals will be able to see the application, create and manage appraisals for himself and the people he's manager of.</field>
|
||||
<field name="sequence">15</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_sales_sign">
|
||||
<field name="name">Sign</field>
|
||||
<field name="description">Helps you sign and complete your documents easily.</field>
|
||||
<field name="sequence">25</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_services">
|
||||
<field name="name">Services</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_services_helpdesk">
|
||||
<field name="name">Helpdesk</field>
|
||||
<field name="description">After-sales services</field>
|
||||
<field name="sequence">14</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_services_field_service">
|
||||
<field name="name">Field Service</field>
|
||||
<field name="parent_id" ref="module_category_services"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_inventory">
|
||||
<field name="name">Inventory</field>
|
||||
<field name="sequence">25</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_productivity">
|
||||
<field name="name">Productivity</field>
|
||||
<field name="sequence">50</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_customizations">
|
||||
<field name="name">Customizations</field>
|
||||
<field name="sequence">55</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_administration_administration">
|
||||
<field name="name">Administration</field>
|
||||
<field name="parent_id" ref="module_category_administration"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_usability">
|
||||
<field name="name">Extra Rights</field>
|
||||
<field name="sequence">101</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_extra">
|
||||
<field name="name">Other Extra Rights</field>
|
||||
<field name="sequence">102</field>
|
||||
</record>
|
||||
|
||||
<!-- add applications to base groups -->
|
||||
<record model="res.groups" id="group_erp_manager">
|
||||
<field name="category_id" ref="module_category_administration_administration"/>
|
||||
</record>
|
||||
<record model="res.groups" id="group_system">
|
||||
<field name="category_id" ref="module_category_administration_administration"/>
|
||||
</record>
|
||||
|
||||
<record model="res.groups" id="group_user">
|
||||
<field name="category_id" ref="module_category_user_type"/>
|
||||
</record>
|
||||
|
||||
<record model="res.groups" id="group_multi_company">
|
||||
<field name="category_id" ref="module_category_usability"/>
|
||||
</record>
|
||||
|
||||
<record model="res.groups" id="group_multi_currency">
|
||||
<field name="category_id" ref="module_category_usability"/>
|
||||
</record>
|
||||
|
||||
<record model="res.groups" id="group_no_one">
|
||||
<field name="category_id" ref="module_category_usability"/>
|
||||
</record>
|
||||
|
||||
<record id="group_portal" model="res.groups">
|
||||
<field name="category_id" ref="module_category_user_type"/>
|
||||
</record>
|
||||
|
||||
<record id="group_public" model="res.groups">
|
||||
<field name="category_id" ref="module_category_user_type"/>
|
||||
</record>
|
||||
|
||||
<record id="group_partner_manager" model="res.groups">
|
||||
<field name="category_id" ref="module_category_usability"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -883,14 +883,6 @@ class IrActionsActClient(models.Model):
|
|||
params = record.params
|
||||
record.params_store = repr(params) if isinstance(params, dict) else params
|
||||
|
||||
def _get_default_form_view(self):
|
||||
doc = super(IrActionsActClient, self)._get_default_form_view()
|
||||
params = doc.find(".//field[@name='params']")
|
||||
params.getparent().remove(params)
|
||||
params_store = doc.find(".//field[@name='params_store']")
|
||||
params_store.getparent().remove(params_store)
|
||||
return doc
|
||||
|
||||
|
||||
def _get_readable_fields(self):
|
||||
return super()._get_readable_fields() | {
|
||||
|
|
|
|||
|
|
@ -24,33 +24,7 @@ from lxml import etree
|
|||
from contextlib import closing
|
||||
from reportlab.graphics.barcode import createBarcodeDrawing
|
||||
from reportlab.pdfbase.pdfmetrics import getFont, TypeFace
|
||||
try:
|
||||
from PyPDF2 import PdfWriter, PdfReader
|
||||
|
||||
# Create compatibility classes for old PyPDF2 API
|
||||
class PdfFileWriter(PdfWriter):
|
||||
def addPage(self, page):
|
||||
return self.add_page(page)
|
||||
|
||||
def addMetadata(self, metadata):
|
||||
return self.add_metadata(metadata)
|
||||
|
||||
def appendPagesFromReader(self, reader, after_page_append=None):
|
||||
return self.append_pages_from_reader(reader, after_page_append)
|
||||
|
||||
class PdfFileReader(PdfReader):
|
||||
def getNumPages(self):
|
||||
return len(self.pages)
|
||||
|
||||
def getPage(self, page_num):
|
||||
return self.pages[page_num]
|
||||
|
||||
@property
|
||||
def numPages(self):
|
||||
return len(self.pages)
|
||||
|
||||
except ImportError:
|
||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterable
|
||||
from PIL import Image, ImageFile
|
||||
|
|
|
|||
|
|
@ -1512,6 +1512,42 @@ class TestTemplating(ViewCase):
|
|||
" the main view's"
|
||||
)
|
||||
|
||||
def test_branding_remove_add_text(self):
|
||||
view1 = self.View.create({
|
||||
'name': "Base view",
|
||||
'type': 'qweb',
|
||||
'arch': """<root>
|
||||
<item order="1">
|
||||
<item/>
|
||||
</item>
|
||||
</root>""",
|
||||
})
|
||||
view2 = self.View.create({
|
||||
'name': "Extension",
|
||||
'type': 'qweb',
|
||||
'inherit_id': view1.id,
|
||||
'arch': """
|
||||
<data>
|
||||
<xpath expr="/root/item/item" position="replace" />
|
||||
<xpath expr="/root/item" position="inside">A<div/>B</xpath>
|
||||
</data>
|
||||
"""
|
||||
})
|
||||
|
||||
arch_string = view1.with_context(inherit_branding=True).get_combined_arch()
|
||||
arch = etree.fromstring(arch_string)
|
||||
self.View.distribute_branding(arch)
|
||||
|
||||
expected = etree.fromstring(f"""
|
||||
<root>
|
||||
<item order="1">
|
||||
A
|
||||
<div data-oe-id="{view2.id}" data-oe-xpath="/data/xpath[2]/div" data-oe-model="ir.ui.view" data-oe-field="arch"/>
|
||||
B
|
||||
</item>
|
||||
</root>
|
||||
""")
|
||||
self.assertEqual(arch, expected)
|
||||
|
||||
class TestViews(ViewCase):
|
||||
|
||||
|
|
|
|||
|
|
@ -217,7 +217,6 @@
|
|||
<field name="name">Apps</field>
|
||||
<field name="res_model">ir.module.module</field>
|
||||
<field name="view_mode">kanban,tree,form</field>
|
||||
<field name="domain">[("to_buy", "=", False)]</field>
|
||||
<field name="context">{'search_default_app':1}</field>
|
||||
<field name="search_view_id" ref="view_module_filter"/>
|
||||
<field name="help" type="html">
|
||||
|
|
|
|||
|
|
@ -470,9 +470,6 @@ class BaseCase(case.TestCase, metaclass=MetaCase):
|
|||
|
||||
The second form is convenient when used with :func:`users`.
|
||||
"""
|
||||
if not 'is_query_count' in self.test_tags:
|
||||
# change into warning in master
|
||||
self._logger.info('assertQueryCount is used but the test is not tagged `is_query_count`')
|
||||
if self.warm:
|
||||
# mock random in order to avoid random bus gc
|
||||
with patch('random.random', lambda: 1):
|
||||
|
|
@ -729,9 +726,10 @@ class BaseCase(case.TestCase, metaclass=MetaCase):
|
|||
"""Guess if the test_methods is a query_count and adds an `is_query_count` tag on the test
|
||||
"""
|
||||
additional_tags = []
|
||||
method_source = inspect.getsource(test_method) if test_method else ''
|
||||
if 'self.assertQueryCount' in method_source:
|
||||
additional_tags.append('is_query_count')
|
||||
if odoo.tools.config['test_tags'] and 'is_query_count' in odoo.tools.config['test_tags']:
|
||||
method_source = inspect.getsource(test_method) if test_method else ''
|
||||
if 'self.assertQueryCount' in method_source:
|
||||
additional_tags.append('is_query_count')
|
||||
return additional_tags
|
||||
|
||||
savepoint_seq = itertools.count()
|
||||
|
|
@ -1220,6 +1218,13 @@ class ChromeBrowser:
|
|||
self._logger.debug('\n<- %s', msg)
|
||||
except websocket.WebSocketTimeoutException:
|
||||
continue
|
||||
except websocket.WebSocketConnectionClosedException as e:
|
||||
if not self._result.done():
|
||||
del self.ws
|
||||
self._result.set_exception(e)
|
||||
for f in self._responses.values():
|
||||
f.cancel()
|
||||
return
|
||||
except Exception as e:
|
||||
if isinstance(e, ConnectionResetError) and self._result.done():
|
||||
return
|
||||
|
|
@ -1254,8 +1259,8 @@ class ChromeBrowser:
|
|||
def _websocket_request(self, method, *, params=None, timeout=10.0):
|
||||
assert threading.get_ident() != self._receiver.ident,\
|
||||
"_websocket_request must not be called from the consumer thread"
|
||||
if self.ws is None:
|
||||
return
|
||||
if not hasattr(self, 'ws'):
|
||||
return None
|
||||
|
||||
f = self._websocket_send(method, params=params, with_future=True)
|
||||
try:
|
||||
|
|
@ -1268,8 +1273,8 @@ class ChromeBrowser:
|
|||
|
||||
If ``with_future`` is set, returns a ``Future`` for the operation.
|
||||
"""
|
||||
if self.ws is None:
|
||||
return
|
||||
if not hasattr(self, 'ws'):
|
||||
return None
|
||||
|
||||
result = None
|
||||
request_id = next(self._request_id)
|
||||
|
|
@ -1466,7 +1471,8 @@ which leads to stray network requests and inconsistencies."""
|
|||
|
||||
self._logger.info('Asking for screenshot')
|
||||
f = self._websocket_send('Page.captureScreenshot', with_future=True)
|
||||
f.add_done_callback(handler)
|
||||
if f:
|
||||
f.add_done_callback(handler)
|
||||
return f
|
||||
|
||||
def _save_screencast(self, prefix='failed'):
|
||||
|
|
@ -2039,9 +2045,6 @@ class HttpCase(TransactionCase):
|
|||
"""Wrapper for `browser_js` to start the given `tour_name` with the
|
||||
optional delay between steps `step_delay`. Other arguments from
|
||||
`browser_js` can be passed as keyword arguments."""
|
||||
if not 'is_tour' in self.test_tags:
|
||||
# change it into warning in master
|
||||
self._logger.info('start_tour was called from a test not tagged `is_tour`')
|
||||
step_delay = ', %s' % step_delay if step_delay else ''
|
||||
code = kwargs.pop('code', "odoo.startTour('%s'%s)" % (tour_name, step_delay))
|
||||
ready = kwargs.pop('ready', "odoo.__DEBUG__.services['web_tour.tour'].tours['%s'].ready" % tour_name)
|
||||
|
|
@ -2062,9 +2065,10 @@ class HttpCase(TransactionCase):
|
|||
guess if the test_methods is a tour and adds an `is_tour` tag on the test
|
||||
"""
|
||||
additional_tags = super().get_method_additional_tags(test_method)
|
||||
method_source = inspect.getsource(test_method)
|
||||
if 'self.start_tour' in method_source:
|
||||
additional_tags.append('is_tour')
|
||||
if odoo.tools.config['test_tags'] and 'is_tour' in odoo.tools.config['test_tags']:
|
||||
method_source = inspect.getsource(test_method)
|
||||
if 'self.start_tour' in method_source:
|
||||
additional_tags.append('is_tour')
|
||||
return additional_tags
|
||||
|
||||
# kept for backward compatibility
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ def test_uninstall(args):
|
|||
|
||||
def test_standalone(args):
|
||||
""" Tries to launch standalone scripts tagged with @post_testing """
|
||||
odoo.service.db._check_faketime_mode(args.database) # noqa: SLF001
|
||||
# load the registry once for script discovery
|
||||
registry = odoo.registry(args.database)
|
||||
for module_name in registry._init_modules:
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ def file_path(file_path, filter_ext=('',), env=None):
|
|||
:raise ValueError: if the file doesn't have one of the supported extensions (`filter_ext`)
|
||||
"""
|
||||
root_path = os.path.abspath(config['root_path'])
|
||||
addons_paths = list(odoo.addons.__path__) + [root_path]
|
||||
addons_paths = odoo.addons.__path__ + [root_path]
|
||||
if env and hasattr(env.transaction, '__file_open_tmp_paths'):
|
||||
addons_paths += env.transaction.__file_open_tmp_paths
|
||||
is_abs = os.path.isabs(file_path)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -16,7 +16,7 @@ from reportlab.pdfgen import canvas
|
|||
try:
|
||||
# class were renamed in PyPDF2 > 2.0
|
||||
# https://pypdf2.readthedocs.io/en/latest/user/migration-1-to-2.html#classes
|
||||
from PyPDF2 import PdfReader, PdfWriter
|
||||
from PyPDF2 import PdfReader
|
||||
import PyPDF2
|
||||
# monkey patch to discard unused arguments as the old arguments were not discarded in the transitional class
|
||||
# https://pypdf2.readthedocs.io/en/2.0.0/_modules/PyPDF2/_reader.html#PdfReader
|
||||
|
|
@ -26,38 +26,12 @@ try:
|
|||
kwargs["strict"] = True # maintain the default
|
||||
kwargs = {k:v for k, v in kwargs.items() if k in ('strict', 'stream')}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def getNumPages(self):
|
||||
"""Compatibility method for old API"""
|
||||
return len(self.pages)
|
||||
|
||||
def getPage(self, page_num):
|
||||
"""Compatibility method for old API"""
|
||||
return self.pages[page_num]
|
||||
|
||||
class PdfFileWriter(PdfWriter):
|
||||
def _addObject(self, obj):
|
||||
return self._add_object(obj)
|
||||
|
||||
def addPage(self, page):
|
||||
"""Compatibility method for old API"""
|
||||
return self.add_page(page)
|
||||
|
||||
def addMetadata(self, metadata):
|
||||
"""Compatibility method for old API"""
|
||||
return self.add_metadata(metadata)
|
||||
|
||||
def cloneReaderDocumentRoot(self, reader):
|
||||
"""Compatibility method for old API"""
|
||||
return self.clone_reader_document_root(reader)
|
||||
|
||||
PyPDF2.PdfFileReader = PdfFileReader
|
||||
PyPDF2.PdfFileWriter = PdfFileWriter
|
||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||
PdfFileWriter._addObject = PdfFileWriter._add_object
|
||||
except ImportError:
|
||||
try:
|
||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||
except ImportError:
|
||||
from PyPDF2 import PdfWriter as PdfFileWriter, PdfReader as PdfFileReader
|
||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||
|
||||
from PyPDF2.generic import DictionaryObject, NameObject, ArrayObject, DecodedStreamObject, NumberObject, createStringObject, ByteStringObject
|
||||
|
||||
|
|
@ -91,15 +65,10 @@ DictionaryObject.get = _unwrapping_get
|
|||
class BrandedFileWriter(PdfFileWriter):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Use new API method if available, fall back to old API
|
||||
metadata = {
|
||||
self.addMetadata({
|
||||
'/Creator': "Odoo",
|
||||
'/Producer': "Odoo",
|
||||
}
|
||||
if hasattr(self, 'add_metadata'):
|
||||
self.add_metadata(metadata)
|
||||
else:
|
||||
self.addMetadata(metadata)
|
||||
})
|
||||
|
||||
|
||||
PdfFileWriter = BrandedFileWriter
|
||||
|
|
@ -237,8 +206,8 @@ class OdooPdfFileReader(PdfFileReader):
|
|||
if not file_path:
|
||||
return []
|
||||
for i in range(0, len(file_path), 2):
|
||||
attachment = file_path[i+1].get_object()
|
||||
yield (attachment["/F"], attachment["/EF"]["/F"].get_object().get_data())
|
||||
attachment = file_path[i+1].getObject()
|
||||
yield (attachment["/F"], attachment["/EF"]["/F"].getObject().getData())
|
||||
except Exception:
|
||||
# malformed pdf (i.e. invalid xref page)
|
||||
return []
|
||||
|
|
@ -281,10 +250,10 @@ class OdooPdfFileWriter(PdfFileWriter):
|
|||
})
|
||||
if self._root_object.get('/Names') and self._root_object['/Names'].get('/EmbeddedFiles'):
|
||||
names_array = self._root_object["/Names"]["/EmbeddedFiles"]["/Names"]
|
||||
names_array.extend([attachment.get_object()['/F'], attachment])
|
||||
names_array.extend([attachment.getObject()['/F'], attachment])
|
||||
else:
|
||||
names_array = ArrayObject()
|
||||
names_array.extend([attachment.get_object()['/F'], attachment])
|
||||
names_array.extend([attachment.getObject()['/F'], attachment])
|
||||
|
||||
embedded_files_names_dictionary = DictionaryObject()
|
||||
embedded_files_names_dictionary.update({
|
||||
|
|
@ -359,7 +328,7 @@ class OdooPdfFileWriter(PdfFileWriter):
|
|||
icc_profile_file_data = compress(icc_profile.read())
|
||||
|
||||
icc_profile_stream_obj = DecodedStreamObject()
|
||||
icc_profile_stream_obj.set_data(icc_profile_file_data)
|
||||
icc_profile_stream_obj.setData(icc_profile_file_data)
|
||||
icc_profile_stream_obj.update({
|
||||
NameObject("/Filter"): NameObject("/FlateDecode"),
|
||||
NameObject("/N"): NumberObject(3),
|
||||
|
|
@ -389,9 +358,9 @@ class OdooPdfFileWriter(PdfFileWriter):
|
|||
fonts = {}
|
||||
# First browse through all the pages of the pdf file, to get a reference to all the fonts used in the PDF.
|
||||
for page in pages:
|
||||
for font in page.get_object()['/Resources']['/Font'].values():
|
||||
for descendant in font.get_object()['/DescendantFonts']:
|
||||
fonts[descendant.idnum] = descendant.get_object()
|
||||
for font in page.getObject()['/Resources']['/Font'].values():
|
||||
for descendant in font.getObject()['/DescendantFonts']:
|
||||
fonts[descendant.idnum] = descendant.getObject()
|
||||
|
||||
# Then for each font, rewrite the width array with the information taken directly from the font file.
|
||||
# The new width are calculated such as width = round(1000 * font_glyph_width / font_units_per_em)
|
||||
|
|
@ -412,7 +381,7 @@ class OdooPdfFileWriter(PdfFileWriter):
|
|||
else:
|
||||
_logger.warning('The fonttools package is not installed. Generated PDF may not be PDF/A compliant.')
|
||||
|
||||
outlines = self._root_object['/Outlines'].get_object()
|
||||
outlines = self._root_object['/Outlines'].getObject()
|
||||
outlines[NameObject('/Count')] = NumberObject(1)
|
||||
|
||||
# Set odoo as producer
|
||||
|
|
@ -434,7 +403,7 @@ class OdooPdfFileWriter(PdfFileWriter):
|
|||
footer = b'<?xpacket end="w"?>'
|
||||
metadata = b'%s%s%s' % (header, metadata_content, footer)
|
||||
file_entry = DecodedStreamObject()
|
||||
file_entry.set_data(metadata)
|
||||
file_entry.setData(metadata)
|
||||
file_entry.update({
|
||||
NameObject("/Type"): NameObject("/Metadata"),
|
||||
NameObject("/Subtype"): NameObject("/XML"),
|
||||
|
|
@ -455,7 +424,7 @@ class OdooPdfFileWriter(PdfFileWriter):
|
|||
:return:
|
||||
'''
|
||||
file_entry = DecodedStreamObject()
|
||||
file_entry.set_data(attachment['content'])
|
||||
file_entry.setData(attachment['content'])
|
||||
file_entry.update({
|
||||
NameObject("/Type"): NameObject("/EmbeddedFile"),
|
||||
NameObject("/Params"):
|
||||
|
|
|
|||
|
|
@ -1,487 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import io
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from hashlib import md5
|
||||
from logging import getLogger
|
||||
from zlib import compress, decompress
|
||||
from PIL import Image, PdfImagePlugin
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
try:
|
||||
# class were renamed in PyPDF2 > 2.0
|
||||
# https://pypdf2.readthedocs.io/en/latest/user/migration-1-to-2.html#classes
|
||||
from PyPDF2 import PdfReader, PdfWriter
|
||||
import PyPDF2
|
||||
# monkey patch to discard unused arguments as the old arguments were not discarded in the transitional class
|
||||
# https://pypdf2.readthedocs.io/en/2.0.0/_modules/PyPDF2/_reader.html#PdfReader
|
||||
class PdfFileReader(PdfReader):
|
||||
def __init__(self, *args, **kwargs):
|
||||
if "strict" not in kwargs and len(args) < 2:
|
||||
kwargs["strict"] = True # maintain the default
|
||||
kwargs = {k:v for k, v in kwargs.items() if k in ('strict', 'stream')}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def getNumPages(self):
|
||||
"""Compatibility method for old API"""
|
||||
return len(self.pages)
|
||||
|
||||
def getPage(self, page_num):
|
||||
"""Compatibility method for old API"""
|
||||
return self.pages[page_num]
|
||||
|
||||
class PdfFileWriter(PdfWriter):
|
||||
def _addObject(self, obj):
|
||||
return self._add_object(obj)
|
||||
|
||||
def addPage(self, page):
|
||||
"""Compatibility method for old API"""
|
||||
return self.add_page(page)
|
||||
|
||||
def addMetadata(self, metadata):
|
||||
"""Compatibility method for old API"""
|
||||
return self.add_metadata(metadata)
|
||||
|
||||
def cloneReaderDocumentRoot(self, reader):
|
||||
"""Compatibility method for old API"""
|
||||
return self.clone_reader_document_root(reader)
|
||||
|
||||
PyPDF2.PdfFileReader = PdfFileReader
|
||||
PyPDF2.PdfFileWriter = PdfFileWriter
|
||||
except ImportError:
|
||||
try:
|
||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||
except ImportError:
|
||||
from PyPDF2 import PdfWriter as PdfFileWriter, PdfReader as PdfFileReader
|
||||
|
||||
from PyPDF2.generic import DictionaryObject, NameObject, ArrayObject, DecodedStreamObject, NumberObject, createStringObject, ByteStringObject
|
||||
|
||||
try:
|
||||
from fontTools.ttLib import TTFont
|
||||
except ImportError:
|
||||
TTFont = None
|
||||
|
||||
from odoo.tools.misc import file_open
|
||||
|
||||
_logger = getLogger(__name__)
|
||||
DEFAULT_PDF_DATETIME_FORMAT = "D:%Y%m%d%H%M%S+00'00'"
|
||||
REGEX_SUBTYPE_UNFORMATED = re.compile(r'^\w+/[\w-]+$')
|
||||
REGEX_SUBTYPE_FORMATED = re.compile(r'^/\w+#2F[\w-]+$')
|
||||
|
||||
|
||||
# Disable linter warning: this import is needed to make sure a PDF stream can be saved in Image.
|
||||
PdfImagePlugin.__name__
|
||||
|
||||
# make sure values are unwrapped by calling the specialized __getitem__
|
||||
def _unwrapping_get(self, key, default=None):
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
|
||||
DictionaryObject.get = _unwrapping_get
|
||||
|
||||
|
||||
class BrandedFileWriter(PdfFileWriter):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Use new API method if available, fall back to old API
|
||||
metadata = {
|
||||
'/Creator': "Odoo",
|
||||
'/Producer': "Odoo",
|
||||
}
|
||||
if hasattr(self, 'add_metadata'):
|
||||
self.add_metadata(metadata)
|
||||
else:
|
||||
self.addMetadata(metadata)
|
||||
|
||||
|
||||
PdfFileWriter = BrandedFileWriter
|
||||
|
||||
|
||||
def merge_pdf(pdf_data):
|
||||
''' Merge a collection of PDF documents in one.
|
||||
Note that the attachments are not merged.
|
||||
:param list pdf_data: a list of PDF datastrings
|
||||
:return: a unique merged PDF datastring
|
||||
'''
|
||||
writer = PdfFileWriter()
|
||||
for document in pdf_data:
|
||||
reader = PdfFileReader(io.BytesIO(document), strict=False)
|
||||
for page in range(0, reader.getNumPages()):
|
||||
writer.addPage(reader.getPage(page))
|
||||
with io.BytesIO() as _buffer:
|
||||
writer.write(_buffer)
|
||||
return _buffer.getvalue()
|
||||
|
||||
|
||||
def rotate_pdf(pdf):
|
||||
''' Rotate clockwise PDF (90°) into a new PDF.
|
||||
Note that the attachments are not copied.
|
||||
:param pdf: a PDF to rotate
|
||||
:return: a PDF rotated
|
||||
'''
|
||||
writer = PdfFileWriter()
|
||||
reader = PdfFileReader(io.BytesIO(pdf), strict=False)
|
||||
for page in range(0, reader.getNumPages()):
|
||||
page = reader.getPage(page)
|
||||
page.rotateClockwise(90)
|
||||
writer.addPage(page)
|
||||
with io.BytesIO() as _buffer:
|
||||
writer.write(_buffer)
|
||||
return _buffer.getvalue()
|
||||
|
||||
|
||||
def to_pdf_stream(attachment) -> io.BytesIO:
|
||||
"""Get the byte stream of the attachment as a PDF."""
|
||||
stream = io.BytesIO(attachment.raw)
|
||||
if attachment.mimetype == 'application/pdf':
|
||||
return stream
|
||||
elif attachment.mimetype.startswith('image'):
|
||||
output_stream = io.BytesIO()
|
||||
Image.open(stream).convert("RGB").save(output_stream, format="pdf")
|
||||
return output_stream
|
||||
_logger.warning("mimetype (%s) not recognized for %s", attachment.mimetype, attachment)
|
||||
|
||||
|
||||
def add_banner(pdf_stream, text=None, logo=False, thickness=2 * cm):
|
||||
""" Add a banner on a PDF in the upper right corner, with Odoo's logo (optionally).
|
||||
|
||||
:param pdf_stream (BytesIO): The PDF stream where the banner will be applied.
|
||||
:param text (str): The text to be displayed.
|
||||
:param logo (bool): Whether to display Odoo's logo in the banner.
|
||||
:param thickness (float): The thickness of the banner in pixels.
|
||||
:return (BytesIO): The modified PDF stream.
|
||||
"""
|
||||
|
||||
old_pdf = PdfFileReader(pdf_stream, strict=False, overwriteWarnings=False)
|
||||
packet = io.BytesIO()
|
||||
can = canvas.Canvas(packet)
|
||||
odoo_logo = Image.open(file_open('base/static/img/main_partner-image.png', mode='rb'))
|
||||
odoo_color = colors.Color(113 / 255, 75 / 255, 103 / 255, 0.8)
|
||||
|
||||
for p in range(old_pdf.getNumPages()):
|
||||
page = old_pdf.getPage(p)
|
||||
width = float(abs(page.mediaBox.getWidth()))
|
||||
height = float(abs(page.mediaBox.getHeight()))
|
||||
|
||||
can.setPageSize((width, height))
|
||||
can.translate(width, height)
|
||||
can.rotate(-45)
|
||||
|
||||
# Draw banner
|
||||
path = can.beginPath()
|
||||
path.moveTo(-width, -thickness)
|
||||
path.lineTo(-width, -2 * thickness)
|
||||
path.lineTo(width, -2 * thickness)
|
||||
path.lineTo(width, -thickness)
|
||||
can.setFillColor(odoo_color)
|
||||
can.drawPath(path, fill=1, stroke=False)
|
||||
|
||||
# Insert text (and logo) inside the banner
|
||||
can.setFontSize(10)
|
||||
can.setFillColor(colors.white)
|
||||
can.drawRightString(0.75 * thickness, -1.45 * thickness, text)
|
||||
logo and can.drawImage(
|
||||
ImageReader(odoo_logo), 0.25 * thickness, -2.05 * thickness, 40, 40, mask='auto', preserveAspectRatio=True)
|
||||
|
||||
can.showPage()
|
||||
|
||||
can.save()
|
||||
|
||||
# Merge the old pages with the watermark
|
||||
watermark_pdf = PdfFileReader(packet, overwriteWarnings=False)
|
||||
new_pdf = PdfFileWriter()
|
||||
for p in range(old_pdf.getNumPages()):
|
||||
new_page = old_pdf.getPage(p)
|
||||
# Remove annotations (if any), to prevent errors in PyPDF2
|
||||
if '/Annots' in new_page:
|
||||
del new_page['/Annots']
|
||||
new_page.mergePage(watermark_pdf.getPage(p))
|
||||
new_pdf.addPage(new_page)
|
||||
|
||||
# Write the new pdf into a new output stream
|
||||
output = io.BytesIO()
|
||||
new_pdf.write(output)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
# by default PdfFileReader will overwrite warnings.showwarning which is what
|
||||
# logging.captureWarnings does, meaning it essentially reverts captureWarnings
|
||||
# every time it's called which is undesirable
|
||||
old_init = PdfFileReader.__init__
|
||||
PdfFileReader.__init__ = lambda self, stream, strict=True, warndest=None, overwriteWarnings=True: \
|
||||
old_init(self, stream=stream, strict=strict, warndest=None, overwriteWarnings=False)
|
||||
|
||||
class OdooPdfFileReader(PdfFileReader):
|
||||
# OVERRIDE of PdfFileReader to add the management of multiple embedded files.
|
||||
|
||||
''' Returns the files inside the PDF.
|
||||
:raises NotImplementedError: if document is encrypted and uses an unsupported encryption method.
|
||||
'''
|
||||
def getAttachments(self):
|
||||
if self.isEncrypted:
|
||||
# If the PDF is owner-encrypted, try to unwrap it by giving it an empty user password.
|
||||
self.decrypt('')
|
||||
|
||||
try:
|
||||
file_path = self.trailer["/Root"].get("/Names", {}).get("/EmbeddedFiles", {}).get("/Names")
|
||||
|
||||
if not file_path:
|
||||
return []
|
||||
for i in range(0, len(file_path), 2):
|
||||
attachment = file_path[i+1].getObject()
|
||||
yield (attachment["/F"], attachment["/EF"]["/F"].getObject().getData())
|
||||
except Exception:
|
||||
# malformed pdf (i.e. invalid xref page)
|
||||
return []
|
||||
|
||||
|
||||
class OdooPdfFileWriter(PdfFileWriter):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Override of the init to initialise additional variables.
|
||||
:param pdf_content: if given, will initialise the reader with the pdf content.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self._reader = None
|
||||
self.is_pdfa = False
|
||||
|
||||
def addAttachment(self, name, data, subtype=None):
|
||||
"""
|
||||
Add an attachment to the pdf. Supports adding multiple attachment, while respecting PDF/A rules.
|
||||
:param name: The name of the attachement
|
||||
:param data: The data of the attachement
|
||||
:param subtype: The mime-type of the attachement. This is required by PDF/A, but not essential otherwise.
|
||||
It should take the form of "/xxx#2Fxxx". E.g. for "text/xml": "/text#2Fxml"
|
||||
"""
|
||||
adapted_subtype = subtype
|
||||
if subtype:
|
||||
# If we receive the subtype in an 'unformated' (mimetype) format, we'll try to convert it to a pdf-valid one
|
||||
if REGEX_SUBTYPE_UNFORMATED.match(subtype):
|
||||
adapted_subtype = '/' + subtype.replace('/', '#2F')
|
||||
|
||||
if not REGEX_SUBTYPE_FORMATED.match(adapted_subtype):
|
||||
# The subtype still does not match the correct format, so we will not add it to the document
|
||||
_logger.warning("Attempt to add an attachment with the incorrect subtype '%s'. The subtype will be ignored.", subtype)
|
||||
adapted_subtype = ''
|
||||
|
||||
attachment = self._create_attachment_object({
|
||||
'filename': name,
|
||||
'content': data,
|
||||
'subtype': adapted_subtype,
|
||||
})
|
||||
if self._root_object.get('/Names') and self._root_object['/Names'].get('/EmbeddedFiles'):
|
||||
names_array = self._root_object["/Names"]["/EmbeddedFiles"]["/Names"]
|
||||
names_array.extend([attachment.getObject()['/F'], attachment])
|
||||
else:
|
||||
names_array = ArrayObject()
|
||||
names_array.extend([attachment.getObject()['/F'], attachment])
|
||||
|
||||
embedded_files_names_dictionary = DictionaryObject()
|
||||
embedded_files_names_dictionary.update({
|
||||
NameObject("/Names"): names_array
|
||||
})
|
||||
embedded_files_dictionary = DictionaryObject()
|
||||
embedded_files_dictionary.update({
|
||||
NameObject("/EmbeddedFiles"): embedded_files_names_dictionary
|
||||
})
|
||||
self._root_object.update({
|
||||
NameObject("/Names"): embedded_files_dictionary
|
||||
})
|
||||
|
||||
if self._root_object.get('/AF'):
|
||||
attachment_array = self._root_object['/AF']
|
||||
attachment_array.extend([attachment])
|
||||
else:
|
||||
# Create a new object containing an array referencing embedded file
|
||||
# And reference this array in the root catalogue
|
||||
attachment_array = self._addObject(ArrayObject([attachment]))
|
||||
self._root_object.update({
|
||||
NameObject("/AF"): attachment_array
|
||||
})
|
||||
|
||||
def embed_odoo_attachment(self, attachment, subtype=None):
|
||||
assert attachment, "embed_odoo_attachment cannot be called without attachment."
|
||||
self.addAttachment(attachment.name, attachment.raw, subtype=subtype or attachment.mimetype)
|
||||
|
||||
def cloneReaderDocumentRoot(self, reader):
|
||||
super().cloneReaderDocumentRoot(reader)
|
||||
self._reader = reader
|
||||
# Try to read the header coming in, and reuse it in our new PDF
|
||||
# This is done in order to allows modifying PDF/A files after creating them (as PyPDF does not read it)
|
||||
stream = reader.stream
|
||||
stream.seek(0)
|
||||
header = stream.readlines(9)
|
||||
# Should always be true, the first line of a pdf should have 9 bytes (%PDF-1.x plus a newline)
|
||||
if len(header) == 1:
|
||||
# If we found a header, set it back to the new pdf
|
||||
self._header = header[0]
|
||||
# Also check the second line. If it is PDF/A, it should be a line starting by % following by four bytes + \n
|
||||
second_line = stream.readlines(1)[0]
|
||||
if second_line.decode('latin-1')[0] == '%' and len(second_line) == 6:
|
||||
self._header += second_line
|
||||
self.is_pdfa = True
|
||||
# Look if we have an ID in the incoming stream and use it.
|
||||
pdf_id = reader.trailer.get('/ID', None)
|
||||
if pdf_id:
|
||||
self._ID = pdf_id
|
||||
|
||||
def convert_to_pdfa(self):
|
||||
"""
|
||||
Transform the opened PDF file into a PDF/A compliant file
|
||||
"""
|
||||
# Set the PDF version to 1.7 (as PDF/A-3 is based on version 1.7) and make it PDF/A compliant.
|
||||
# See https://github.com/veraPDF/veraPDF-validation-profiles/wiki/PDFA-Parts-2-and-3-rules#rule-612-1
|
||||
|
||||
# " The file header shall begin at byte zero and shall consist of "%PDF-1.n" followed by a single EOL marker,
|
||||
# where 'n' is a single digit number between 0 (30h) and 7 (37h) "
|
||||
# " The aforementioned EOL marker shall be immediately followed by a % (25h) character followed by at least four
|
||||
# bytes, each of whose encoded byte values shall have a decimal value greater than 127 "
|
||||
self._header = b"%PDF-1.7\n%\xFF\xFF\xFF\xFF"
|
||||
|
||||
# Add a document ID to the trailer. This is only needed when using encryption with regular PDF, but is required
|
||||
# when using PDF/A
|
||||
pdf_id = ByteStringObject(md5(self._reader.stream.getvalue()).digest())
|
||||
# The first string is based on the content at the time of creating the file, while the second is based on the
|
||||
# content of the file when it was last updated. When creating a PDF, both are set to the same value.
|
||||
self._ID = ArrayObject((pdf_id, pdf_id))
|
||||
|
||||
with file_open('tools/data/files/sRGB2014.icc', mode='rb') as icc_profile:
|
||||
icc_profile_file_data = compress(icc_profile.read())
|
||||
|
||||
icc_profile_stream_obj = DecodedStreamObject()
|
||||
icc_profile_stream_obj.setData(icc_profile_file_data)
|
||||
icc_profile_stream_obj.update({
|
||||
NameObject("/Filter"): NameObject("/FlateDecode"),
|
||||
NameObject("/N"): NumberObject(3),
|
||||
NameObject("/Length"): NameObject(str(len(icc_profile_file_data))),
|
||||
})
|
||||
|
||||
icc_profile_obj = self._addObject(icc_profile_stream_obj)
|
||||
|
||||
output_intent_dict_obj = DictionaryObject()
|
||||
output_intent_dict_obj.update({
|
||||
NameObject("/S"): NameObject("/GTS_PDFA1"),
|
||||
NameObject("/OutputConditionIdentifier"): createStringObject("sRGB"),
|
||||
NameObject("/DestOutputProfile"): icc_profile_obj,
|
||||
NameObject("/Type"): NameObject("/OutputIntent"),
|
||||
})
|
||||
|
||||
output_intent_obj = self._addObject(output_intent_dict_obj)
|
||||
self._root_object.update({
|
||||
NameObject("/OutputIntents"): ArrayObject([output_intent_obj]),
|
||||
})
|
||||
|
||||
pages = self._root_object['/Pages']['/Kids']
|
||||
|
||||
# PDF/A needs the glyphs width array embedded in the pdf to be consistent with the ones from the font file.
|
||||
# But it seems like it is not the case when exporting from wkhtmltopdf.
|
||||
if TTFont:
|
||||
fonts = {}
|
||||
# First browse through all the pages of the pdf file, to get a reference to all the fonts used in the PDF.
|
||||
for page in pages:
|
||||
for font in page.getObject()['/Resources']['/Font'].values():
|
||||
for descendant in font.getObject()['/DescendantFonts']:
|
||||
fonts[descendant.idnum] = descendant.getObject()
|
||||
|
||||
# Then for each font, rewrite the width array with the information taken directly from the font file.
|
||||
# The new width are calculated such as width = round(1000 * font_glyph_width / font_units_per_em)
|
||||
# See: http://martin.hoppenheit.info/blog/2018/pdfa-validation-and-inconsistent-glyph-width-information/
|
||||
for font in fonts.values():
|
||||
font_file = font['/FontDescriptor']['/FontFile2']
|
||||
stream = io.BytesIO(decompress(font_file._data))
|
||||
ttfont = TTFont(stream)
|
||||
font_upm = ttfont['head'].unitsPerEm
|
||||
glyphs = ttfont.getGlyphSet()._hmtx.metrics
|
||||
glyph_widths = []
|
||||
for key, values in glyphs.items():
|
||||
if key[:5] == 'glyph':
|
||||
glyph_widths.append(NumberObject(round(1000.0 * values[0] / font_upm)))
|
||||
|
||||
font[NameObject('/W')] = ArrayObject([NumberObject(1), ArrayObject(glyph_widths)])
|
||||
stream.close()
|
||||
else:
|
||||
_logger.warning('The fonttools package is not installed. Generated PDF may not be PDF/A compliant.')
|
||||
|
||||
outlines = self._root_object['/Outlines'].getObject()
|
||||
outlines[NameObject('/Count')] = NumberObject(1)
|
||||
|
||||
# Set odoo as producer
|
||||
self.addMetadata({
|
||||
'/Creator': "Odoo",
|
||||
'/Producer': "Odoo",
|
||||
})
|
||||
self.is_pdfa = True
|
||||
|
||||
def add_file_metadata(self, metadata_content):
|
||||
"""
|
||||
Set the XMP metadata of the pdf, wrapping it with the necessary XMP header/footer.
|
||||
These are required for a PDF/A file to be completely compliant. Ommiting them would result in validation errors.
|
||||
:param metadata_content: bytes of the metadata to add to the pdf.
|
||||
"""
|
||||
# See https://wwwimages2.adobe.com/content/dam/acom/en/devnet/xmp/pdfs/XMP%20SDK%20Release%20cc-2016-08/XMPSpecificationPart1.pdf
|
||||
# Page 10/11
|
||||
header = b'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
|
||||
footer = b'<?xpacket end="w"?>'
|
||||
metadata = b'%s%s%s' % (header, metadata_content, footer)
|
||||
file_entry = DecodedStreamObject()
|
||||
file_entry.setData(metadata)
|
||||
file_entry.update({
|
||||
NameObject("/Type"): NameObject("/Metadata"),
|
||||
NameObject("/Subtype"): NameObject("/XML"),
|
||||
NameObject("/Length"): NameObject(str(len(metadata))),
|
||||
})
|
||||
|
||||
# Add the new metadata to the pdf, then redirect the reference to refer to this new object.
|
||||
metadata_object = self._addObject(file_entry)
|
||||
self._root_object.update({NameObject("/Metadata"): metadata_object})
|
||||
|
||||
def _create_attachment_object(self, attachment):
|
||||
''' Create a PyPdf2.generic object representing an embedded file.
|
||||
|
||||
:param attachment: A dictionary containing:
|
||||
* filename: The name of the file to embed (required)
|
||||
* content: The bytes of the file to embed (required)
|
||||
* subtype: The mime-type of the file to embed (optional)
|
||||
:return:
|
||||
'''
|
||||
file_entry = DecodedStreamObject()
|
||||
file_entry.setData(attachment['content'])
|
||||
file_entry.update({
|
||||
NameObject("/Type"): NameObject("/EmbeddedFile"),
|
||||
NameObject("/Params"):
|
||||
DictionaryObject({
|
||||
NameObject('/CheckSum'): createStringObject(md5(attachment['content']).hexdigest()),
|
||||
NameObject('/ModDate'): createStringObject(datetime.now().strftime(DEFAULT_PDF_DATETIME_FORMAT)),
|
||||
NameObject('/Size'): NameObject(f"/{len(attachment['content'])}"),
|
||||
}),
|
||||
})
|
||||
if attachment.get('subtype'):
|
||||
file_entry.update({
|
||||
NameObject("/Subtype"): NameObject(attachment['subtype']),
|
||||
})
|
||||
file_entry_object = self._addObject(file_entry)
|
||||
filename_object = createStringObject(attachment['filename'])
|
||||
filespec_object = DictionaryObject({
|
||||
NameObject("/AFRelationship"): NameObject("/Data"),
|
||||
NameObject("/Type"): NameObject("/Filespec"),
|
||||
NameObject("/F"): filename_object,
|
||||
NameObject("/EF"):
|
||||
DictionaryObject({
|
||||
NameObject("/F"): file_entry_object,
|
||||
NameObject('/UF'): file_entry_object,
|
||||
}),
|
||||
NameObject("/UF"): filename_object,
|
||||
})
|
||||
if attachment.get('description'):
|
||||
filespec_object.update({NameObject("/Desc"): createStringObject(attachment['description'])})
|
||||
return self._addObject(filespec_object)
|
||||
|
|
@ -17,7 +17,7 @@ def add_stripped_items_before(node, spec, extract):
|
|||
text = spec.text or ''
|
||||
|
||||
before_text = ''
|
||||
prev = node.getprevious()
|
||||
prev = next((n for n in node.itersiblings(preceding=True) if not (n.tag == etree.ProcessingInstruction and n.target == "apply-inheritance-specs-node-removal")), None)
|
||||
if prev is None:
|
||||
parent = node.getparent()
|
||||
result = parent.text and RSTRIP_REGEXP.search(parent.text)
|
||||
|
|
|
|||
|
|
@ -1645,7 +1645,7 @@ def _get_translation_upgrade_queries(cr, field):
|
|||
"""
|
||||
migrate_queries.append(cr.mogrify(query, [Model._name, translation_name]).decode())
|
||||
|
||||
query = "DELETE FROM _ir_translation WHERE type = 'model' AND name = %s"
|
||||
query = "DELETE FROM _ir_translation WHERE type = 'model' AND state = 'translated' AND name = %s"
|
||||
cleanup_queries.append(cr.mogrify(query, [translation_name]).decode())
|
||||
|
||||
# upgrade model_terms translation: one update per field per record
|
||||
|
|
@ -1719,7 +1719,7 @@ def _get_translation_upgrade_queries(cr, field):
|
|||
query = f'UPDATE "{Model._table}" SET "{field.name}" = %s WHERE id = %s'
|
||||
migrate_queries.append(cr.mogrify(query, [Json(new_values), id_]).decode())
|
||||
|
||||
query = "DELETE FROM _ir_translation WHERE type = 'model_terms' AND name = %s"
|
||||
query = "DELETE FROM _ir_translation WHERE type = 'model_terms' AND state = 'translated' AND name = %s"
|
||||
cleanup_queries.append(cr.mogrify(query, [translation_name]).decode())
|
||||
|
||||
return migrate_queries, cleanup_queries
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue