├── bin ├── __init__.py └── cli.py ├── facturx ├── flavors │ ├── __init__.py │ ├── flavors.yml │ ├── zugferd │ │ ├── xsd │ │ │ ├── ZUGFeRD1p0.xsd │ │ │ ├── ZUGFeRD1p0_urn_un_unece_uncefact_data_standard_QualifiedDataType_12.xsd │ │ │ └── ZUGFeRD1p0_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_15.xsd │ │ ├── xmp │ │ │ └── ZUGFeRD_extension_schema.xmp │ │ └── xml │ │ │ ├── basic.xml │ │ │ ├── samples │ │ │ ├── basic.xml │ │ │ └── comfort.xml │ │ │ └── comfort.xml │ ├── factur-x │ │ ├── xsd │ │ │ ├── FACTUR-X_EN16931.xsd │ │ │ ├── FACTUR-X_BASIC-WL.xsd │ │ │ ├── FACTUR-X_BASIC-WL_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd │ │ │ ├── FACTUR-X_EN16931_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd │ │ │ ├── FACTUR-X_BASIC-WL_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd │ │ │ ├── FACTUR-X_EN16931_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd │ │ │ ├── FACTUR-X_BASIC-WL_urn_un_unece_uncefact_data_standard_ReusableAggregateBusinessInformationEntity_100.xsd │ │ │ └── FACTUR-X_EN16931_urn_un_unece_uncefact_data_standard_ReusableAggregateBusinessInformationEntity_100.xsd │ │ ├── xml │ │ │ ├── minimum.xml │ │ │ ├── samples │ │ │ │ ├── minimum.xml │ │ │ │ ├── basicwl.xml │ │ │ │ ├── en16931.xml │ │ │ │ └── basic.xml │ │ │ ├── basicwl.xml │ │ │ ├── en16931.xml │ │ │ └── basic.xml │ │ └── xmp │ │ │ └── Factur-X_extension_schema.xmp │ ├── fields.yml │ └── xml_flavor.py ├── tests │ ├── __init__.py │ ├── sample_invoices │ │ ├── embedded_data.pdf │ │ ├── Facture_FR_BASIC.pdf │ │ ├── Facture_UE_BASIC.pdf │ │ ├── no_embedded_data.pdf │ │ ├── Facture_DOM_BASIC.pdf │ │ ├── Facture_DOM_BASICWL.pdf │ │ ├── Facture_DOM_EN16931.pdf │ │ ├── Facture_DOM_MINIMUM.pdf │ │ ├── Facture_FR_BASICWL.pdf │ │ ├── Facture_FR_EN16931.pdf │ │ ├── Facture_FR_MINIMUM.pdf │ │ ├── Facture_UE_BASICWL.pdf │ │ ├── Facture_UE_EN16931.pdf │ │ ├── Facture_UE_MINIMUM.pdf │ │ ├── Avoir_FR_type380_BASIC.pdf │ │ ├── Avoir_FR_type380_BASICWL.pdf │ │ ├── Avoir_FR_type380_EN16931.pdf │ │ ├── Avoir_FR_type380_MINIMUM.pdf │ │ ├── Avoir_FR_type381_BASIC.pdf │ │ ├── Avoir_FR_type381_BASICWL.pdf │ │ ├── Avoir_FR_type381_EN16931.pdf │ │ ├── Avoir_FR_type381_MINIMUM.pdf │ │ ├── zugferd_example_invoice_en.pdf │ │ └── Resultat_TEST-01_BASIC_Avec_xml_inclus.pdf │ ├── test_facturx.py │ └── compare │ │ └── no_embedded_data.xml ├── __init__.py ├── logger.py ├── facturx.py └── pdfwriter.py ├── requirement.txt ├── MANIFEST.in ├── .bumpversion.cfg ├── Makefile ├── setup.py ├── LICENSE ├── DEVELOP.rst ├── .gitignore └── README.rst /bin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /facturx/flavors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /facturx/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirement.txt: -------------------------------------------------------------------------------- 1 | PyPDF2 2 | lxml 3 | pyyaml 4 | pycountry -------------------------------------------------------------------------------- /facturx/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .facturx import FacturX -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/embedded_data.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/embedded_data.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_FR_BASIC.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_FR_BASIC.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_UE_BASIC.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_UE_BASIC.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/no_embedded_data.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/no_embedded_data.pdf -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include requirement.txt 4 | recursive-include facturx/flavors * 5 | recursive-exclude facturx/tests * 6 | -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_DOM_BASIC.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_DOM_BASIC.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_DOM_BASICWL.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_DOM_BASICWL.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_DOM_EN16931.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_DOM_EN16931.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_DOM_MINIMUM.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_DOM_MINIMUM.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_FR_BASICWL.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_FR_BASICWL.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_FR_EN16931.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_FR_EN16931.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_FR_MINIMUM.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_FR_MINIMUM.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_UE_BASICWL.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_UE_BASICWL.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_UE_EN16931.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_UE_EN16931.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Facture_UE_MINIMUM.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Facture_UE_MINIMUM.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Avoir_FR_type380_BASIC.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Avoir_FR_type380_BASIC.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Avoir_FR_type380_BASICWL.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Avoir_FR_type380_BASICWL.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Avoir_FR_type380_EN16931.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Avoir_FR_type380_EN16931.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Avoir_FR_type380_MINIMUM.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Avoir_FR_type380_MINIMUM.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Avoir_FR_type381_BASIC.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Avoir_FR_type381_BASIC.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Avoir_FR_type381_BASICWL.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Avoir_FR_type381_BASICWL.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Avoir_FR_type381_EN16931.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Avoir_FR_type381_EN16931.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Avoir_FR_type381_MINIMUM.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Avoir_FR_type381_MINIMUM.pdf -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/zugferd_example_invoice_en.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/zugferd_example_invoice_en.pdf -------------------------------------------------------------------------------- /facturx/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | FORMAT = '%(asctime)s [%(levelname)s] %(message)s' 3 | logging.basicConfig(format=FORMAT) 4 | logger = logging.getLogger('factur-x') 5 | logger.setLevel(logging.DEBUG) -------------------------------------------------------------------------------- /facturx/tests/sample_invoices/Resultat_TEST-01_BASIC_Avec_xml_inclus.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/invoice-x/factur-x-ng/HEAD/facturx/tests/sample_invoices/Resultat_TEST-01_BASIC_Avec_xml_inclus.pdf -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.8.8 3 | commit = True 4 | tag = True 5 | message = Bump version {current_version} > {new_version} [skip ci] 6 | 7 | [bumpversion:file:setup.py] 8 | 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | release-test: 3 | bumpversion patch 4 | python setup.py clean --all 5 | rm -rf dist 6 | python3 setup.py sdist 7 | twine upload --repository pypitest dist/* 8 | 9 | release: 10 | bumpversion patch 11 | python setup.py clean --all 12 | rm -rf dist 13 | python3 setup.py sdist bdist_wheel 14 | twine upload dist/* -------------------------------------------------------------------------------- /facturx/flavors/flavors.yml: -------------------------------------------------------------------------------- 1 | # Mapping of different XML-flavors and levels. 2 | # Previously in main file. 3 | --- 4 | factur-x: 5 | xmp_schema: Factur-X_extension_schema.xmp 6 | xmp_filename: factur-x.xml 7 | levels: 8 | minimum: 9 | schema: FACTUR-X_BASIC-WL.xsd 10 | xmp_str: MINIMUM 11 | xml: minimum.xml 12 | basicwl: 13 | schema: FACTUR-X_BASIC-WL.xsd 14 | xmp_str: BASIC WL 15 | xml: basicwl.xml 16 | basic: 17 | schema: FACTUR-X_EN16931.xsd 18 | xmp_str: BASIC 19 | xml: basic.xml 20 | en16931: 21 | schema: FACTUR-X_EN16931.xsd 22 | xmp_str: EN 16931 23 | xml: en16931.xml 24 | standards: 25 | country: true 26 | currency: true 27 | zugferd: 28 | xmp_schema: ZUGFeRD_extension_schema.xmp 29 | xmp_filename: ZUGFeRD-invoice.xml 30 | levels: 31 | basic: 32 | schema: ZUGFeRD1p0.xsd 33 | xmp_str: BASIC 34 | xml: basic.xml 35 | comfort: 36 | schema: ZUGFeRD1p0.xsd 37 | xmp_str: COMFORT 38 | xml: comfort.xml 39 | standards: 40 | country: false 41 | currency: true 42 | other-standard: 43 | xmp_filename: something.xml -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup, find_packages 5 | import re 6 | 7 | 8 | setup( 9 | name='factur-x-ng', 10 | version='0.8.8', 11 | author='Alexis de Lattre, Manuel Riel, Harshit Joshi', 12 | author_email='hello@invoice-x.com', 13 | url='https://github.com/invoice-x/factur-x-ng', 14 | description='Factur-X: electronic invoicing standard for Germany & France', 15 | long_description=open('README.rst').read(), 16 | license='BSD', 17 | classifiers=[ 18 | 'Development Status :: 4 - Beta', 19 | 'Intended Audience :: Developers', 20 | 'Programming Language :: Python :: 2.7', 21 | 'Programming Language :: Python :: 3', 22 | 'License :: OSI Approved :: BSD License', 23 | "Operating System :: OS Independent", 24 | ], 25 | keywords='e-invoice ZUGFeRD Factur-X Chorus', 26 | packages=find_packages(), 27 | install_requires=[r.strip() for r in 28 | open('requirement.txt').read().splitlines()], 29 | include_package_data=True, 30 | zip_safe=False, 31 | entry_points={ 32 | 'console_scripts': [ 33 | 'facturx = bin.cli:main', ], 34 | }, 35 | ) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2017, Alexis de Lattre 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * The name of the authors may not be used to endorse or promote products 12 | derived from this software without specific prior written permission. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 15 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 16 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 17 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 18 | HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 19 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 20 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 21 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 22 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 23 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /facturx/flavors/zugferd/xsd/ZUGFeRD1p0.xsd: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /DEVELOP.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | If you are looking to get involved improving ``factur-x``, this 5 | guide will help you get started quickly. 6 | 7 | Development Guide 8 | ----------------- 9 | 10 | 1. Fork the `main repository `_. Click 11 | on the 'Fork' button near the top of the page. This creates a copy of the code 12 | under your account on the GitHub server. 13 | 14 | 2. Clone this copy to your local disk: 15 | 16 | :: 17 | 18 | $ git clone https://github.com/invoice-x/factur-x 19 | $ cd factur-x 20 | 21 | 3. Create a branch to hold your changes and start making changes. Don't work 22 | on ``master`` branch 23 | 24 | :: 25 | 26 | $ git checkout -b my_enhancement 27 | 28 | 4. Send Pull Request to ``master`` branch of this repository 29 | 30 | Organization 31 | ------------ 32 | 33 | Major folders in the ``facturx`` package and their purpose: 34 | 35 | - ``flavors``: Has all the necessary resources of different flavors (Factur-x, 36 | Zugferd, UBL) and related code. ``xml_flavors.py`` detects the flavor of PDF 37 | invoice and gets relevant information based on the flavor. ``fields.yml`` 38 | mentions all the fields that can be edited or viewed using facturx package. 39 | - ``factur-x``: Resources for Factur-X standard (xml, xmp, xsd) 40 | - ``zugferd``: Resources for Zugferd standard (xml, xmp, xsd) 41 | - ``standard_code``: Has standard ISO codes of currency and countries. 42 | 43 | Testing 44 | ------- 45 | 46 | Every new feature should have a test to make sure it still works after modifications done by you or someone else in the future. 47 | 48 | To run tests using the current Python version: python -m unittest discover -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xsd/FACTUR-X_EN16931.xsd: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xsd/FACTUR-X_BASIC-WL.xsd: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | .DS_Store 103 | 104 | # Pycharm 105 | .idea/ 106 | 107 | # VSCode 108 | .vscode -------------------------------------------------------------------------------- /bin/cli.py: -------------------------------------------------------------------------------- 1 | from facturx.facturx import * 2 | from facturx.logger import logger 3 | import logging 4 | import argparse 5 | 6 | 7 | def main(): 8 | parser = argparse.ArgumentParser( 9 | description='PDF invoice with embedded XML' + 10 | ' metadata following the Factur-X standard') 11 | subparsers = parser.add_subparsers( 12 | help='sub-command help', dest="sub_command") 13 | 14 | parser_dump = subparsers.add_parser( 15 | 'dump', help='dump xml meta data to xml|json, takes two arguments') 16 | parser_dump.add_argument('pdf_invoice', type=argparse.FileType('r'), 17 | help='pdf invoice containing embedded xml') 18 | parser_dump.add_argument( 19 | 'output_file', type=str, help='name of export file') 20 | 21 | parser_validate = subparsers.add_parser( 22 | 'validate', help='validate xml meta data from pdf invoice') 23 | parser_validate.add_argument('pdf_invoice', type=argparse.FileType('r'), 24 | help='pdf invoice to validate') 25 | 26 | args = parser.parse_args() 27 | 28 | if args.sub_command == 'dump': 29 | factx = FacturX(args.pdf_invoice.name) 30 | try: 31 | output_format = args.output_file.split('.')[1] 32 | if output_format == 'json': 33 | factx.write_json(args.output_file) 34 | elif output_format == 'xml': 35 | factx.write_xml(args.output_file) 36 | elif output_format == 'yml': 37 | factx.write_yaml(args.output_file) 38 | except IndexError: 39 | logger.error("No extension to output file provided") 40 | 41 | if args.sub_command == 'validate': 42 | factx = FacturX(args.pdf_invoice.name) 43 | factx.is_valid() 44 | 45 | 46 | if __name__ == '__main__': 47 | main() 48 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xsd/FACTUR-X_BASIC-WL_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xml/minimum.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | urn:factur-x.eu:1p0:minimum 9 | 10 | 11 | 12 | 13 | 380 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | EUR 46 | 47 | 0 48 | 0 49 | 0 50 | 0 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Factur-X Python library 2 | ======================= 3 | 4 | Factur-X is a EU standard for embedding XML representations of invoices 5 | in PDF files. This library provides an interface for reading, editing 6 | and saving the this metadata. 7 | 8 | Since there are multiple flavors of the embedded XML data, this library 9 | abstracts them into a Python ``dict``, which can be used to load and 10 | save from/to different flavors. 11 | 12 | This project was forked from `Akretion `_ and continues to be under the same license. We aim to make the library higher-level, make editing fields easier and support more standards and flavors. 13 | 14 | Main features: 15 | -------------- 16 | 17 | - Edit and save existing XML metadata fields. 18 | - Create new XML representation from template and embed in PDF. 19 | - Add existing XML representation to PDF. 20 | - Validate existing XML representation. 21 | 22 | Installation 23 | ------------ 24 | 25 | :: 26 | 27 | pip install PyPDF2 lxml pyyaml pycountry 28 | pip install --index-url https://test.pypi.org/simple/ --upgrade factur-x-ng 29 | 30 | :: 31 | 32 | Usage 33 | ----- 34 | 35 | Load PDF file without XML and assign some values to common fields. 36 | 37 | :: 38 | 39 | from facturx import FacturX 40 | 41 | inv = FacturX('some-file.pdf') 42 | inv['due_date'] = datetime(2018, 10, 10) 43 | inv['seller.name'] = 'Smith Ltd.' 44 | inv['buyer.country'] = 'France' 45 | 46 | Validate and save PDF including XML representation. 47 | 48 | :: 49 | 50 | inv.is_valid() 51 | inv.write_pdf('my-file.pdf') 52 | 53 | Load PDF *with* XML embedded. View and update fields via pivot dict. 54 | 55 | :: 56 | 57 | inv = FacturX('another-file.pdf') 58 | inv_dict = inv.as_dict() 59 | inv_dict['currency'] = 'USD' 60 | inv.update(inv_dict) 61 | 62 | Save XML metadata in separate file in different formats. 63 | 64 | :: 65 | 66 | inv.write_xml('metadata.xml') 67 | inv.write_json('metadata.json') 68 | inv.write_yaml('metadata.yml') 69 | 70 | To have more examples, look at the source code of the command line tools 71 | located in the *bin* subdirectory. 72 | 73 | Command line tools 74 | ------------------ 75 | 76 | Several sub-commands are provided with this lib: 77 | 78 | - Dump embedded metadata: ``facturx dump file-with-xml.pdf metadata.(xml|json|yml)`` 79 | - Validate existing metadata: ``facturx validate file-with-xml.pdf`` 80 | - Add external metadata file: ``facturx add no-xml.pdf metadata.xml`` 81 | - Extract fields from PDF and embed: ``facturx extract no-xml.pdf`` 82 | 83 | All these command line tools have a **-h** option that explains how to 84 | use them and shows all the available options. 85 | 86 | Licence 87 | ------- 88 | 89 | This library is published under the BSD licence (same licence as 90 | `PyPDF2 `__ on which this lib 91 | depends). 92 | 93 | Contributors 94 | ------------ 95 | 96 | - Alexis de Lattre alexis.delattre@akretion.com: Initial version, PDF- and XMP processing. 97 | - Manuel Riel: Python 3 support, support for editing individual fields, separate support for different standards 98 | -------------------------------------------------------------------------------- /facturx/tests/test_facturx.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from facturx.facturx import * 4 | from lxml import etree 5 | 6 | 7 | class TestReading(unittest.TestCase): 8 | def discover_files(self): 9 | self.test_files_dir = os.path.join(os.path.dirname(__file__), 'sample_invoices') 10 | self.test_files = os.listdir(self.test_files_dir) 11 | 12 | def test_from_file(self): 13 | self.discover_files() 14 | for file in self.test_files: 15 | file_path = os.path.join(self.test_files_dir, file) 16 | FacturX(file_path) 17 | 18 | # returning file path for a specific file in 'sample_invoices' 19 | def find_file(self, file_name): 20 | self.discover_files() 21 | for file in self.test_files: 22 | if file == file_name: 23 | file_path = os.path.join(self.test_files_dir, file) 24 | return file_path 25 | 26 | # def test_input_error(self): 27 | # with self.assertRaises(TypeError) as context: 28 | # FacturX('non-existant.pdf') 29 | 30 | def test_file_without_embedded_data(self): 31 | file_path = self.find_file('no_embedded_data.pdf') 32 | self.assertEqual(FacturX(file_path)._xml_from_file(file_path), None) 33 | 34 | def test_file_embedded_data(self, file_name='embedded_data.pdf'): 35 | file_path = self.find_file(file_name) 36 | self.assertTrue(FacturX(file_path)._xml_from_file(file_path) is not None, "The PDF file has no embedded file") 37 | 38 | def test_write_pdf(self): 39 | file_path = self.find_file('no_embedded_data.pdf') 40 | factx = FacturX(file_path) 41 | test_file_path = os.path.join(self.test_files_dir, 'test.pdf') 42 | 43 | # checking if pdf file is made 44 | factx.write_pdf(test_file_path) 45 | self.assertTrue(os.path.isfile(test_file_path)) 46 | self.discover_files() 47 | 48 | # checking that xml is embedded 49 | self.assertTrue(self.test_file_embedded_data(file_name='test.pdf') is None) 50 | 51 | os.remove(test_file_path) 52 | 53 | def test_write_xml(self): 54 | compare_file_dir = os.path.join(os.path.dirname(__file__), 'compare') 55 | expected_file_path = os.path.join(compare_file_dir, 'no_embedded_data.xml') 56 | test_file_path = os.path.join(compare_file_dir, 'test.xml') 57 | 58 | factx = FacturX(self.find_file('no_embedded_data.pdf')) 59 | factx.write_xml(test_file_path) 60 | self.assertTrue(os.path.isfile(test_file_path)) 61 | 62 | with open(expected_file_path, 'r') as expected_file, open(test_file_path, 'r') as test_file: 63 | parser = etree.XMLParser(remove_blank_text=True) 64 | expected_file_root = etree.XML(expected_file.read(), parser) 65 | expected_file_str = etree.tostring(expected_file_root) 66 | 67 | test_file_root = etree.XML(test_file.read(), parser) 68 | test_file_str = etree.tostring(test_file_root) 69 | 70 | self.assertTrue(expected_file_str == test_file_str, "Files don't match") 71 | os.remove(test_file_path) 72 | 73 | 74 | def main(): 75 | unittest.main() 76 | 77 | 78 | if __name__ == '__main__': 79 | main() 80 | -------------------------------------------------------------------------------- /facturx/tests/compare/no_embedded_data.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A1 5 | 6 | 7 | urn:factur-x.eu:1p0:minimum 8 | 9 | 10 | 11 | 2017-TEST-04 12 | 380 13 | 14 | 20171031 15 | 16 | 17 | 18 | 19 | CodeSERVICE A 20 | 21 | FOURNISSEUR F 22 | 23 | 99988877900017 24 | 25 | 26 | FR 27 | 28 | 29 | FR34999888779 30 | 31 | 32 | 33 | CLIENT 1 34 | 35 | 77788899100018 36 | 37 | 38 | 39 | CDE234 40 | 41 | 42 | 43 | 44 | EUR 45 | 46 | 15976.87 47 | 3195.37 48 | 19172.24 49 | 19172.24 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xsd/FACTUR-X_EN16931_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xml/samples/minimum.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | A1 6 | 7 | 8 | urn:factur-x.eu:1p0:minimum 9 | 10 | 11 | 12 | 2017-TEST-04 13 | 380 14 | 15 | 20171031 16 | 17 | 18 | 19 | 20 | CodeSERVICE A 21 | 22 | FOURNISSEUR F 23 | 24 | 99988877900017 25 | 26 | 27 | FR 28 | 29 | 30 | FR34999888779 31 | 32 | 33 | 34 | CLIENT 1 35 | 36 | 77788899100018 37 | 38 | 39 | 40 | CDE234 41 | 42 | 43 | 44 | 45 | EUR 46 | 47 | 15976.87 48 | 3195.37 49 | 19172.24 50 | 19172.24 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /facturx/flavors/zugferd/xsd/ZUGFeRD1p0_urn_un_unece_uncefact_data_standard_QualifiedDataType_12.xsd: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /facturx/flavors/zugferd/xsd/ZUGFeRD1p0_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_15.xsd: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xsd/FACTUR-X_BASIC-WL_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xmp/Factur-X_extension_schema.xmp: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | BASIC 29 | factur-x.xml 30 | INVOICE 31 | 1.0 32 | 33 | 34 | 36 | 40 | 41 | 42 | 43 | 44 | Factur-X PDFA Extension Schema 45 | urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# 46 | fx 47 | 48 | 49 | 50 | DocumentFileName 51 | Text 52 | external 53 | name of the embedded XML invoice file 54 | 55 | 56 | DocumentType 57 | Text 58 | external 59 | INVOICE 60 | 61 | 62 | Version 63 | Text 64 | external 65 | The actual version of the Factur-X XML schema 66 | 67 | 68 | ConformanceLevel 69 | Text 70 | external 71 | The conformance level of the embedded Factur-X data 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /facturx/flavors/zugferd/xmp/ZUGFeRD_extension_schema.xmp: -------------------------------------------------------------------------------- 1 | 44 | 45 | 46 | 47 | 48 | BASIC 49 | ZUGFeRD-invoice.xml 50 | INVOICE 51 | 1.0 52 | 53 | 54 | 56 | 60 | 61 | 62 | 63 | 64 | ZUGFeRD PDFA Extension Schema 65 | urn:ferd:pdfa:CrossIndustryDocument:invoice:1p0# 66 | zf 67 | 68 | 69 | 70 | DocumentFileName 71 | Text 72 | external 73 | name of the embedded XML invoice file 74 | 75 | 76 | DocumentType 77 | Text 78 | external 79 | INVOICE 80 | 81 | 82 | Version 83 | Text 84 | external 85 | The actual version of the ZUGFeRD XML schema 86 | 87 | 88 | ConformanceLevel 89 | Text 90 | external 91 | The conformance level of the embedded ZUGFeRD data 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xsd/FACTUR-X_EN16931_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /facturx/flavors/zugferd/xml/basic.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | urn:ferd:CrossIndustryDocument:invoice:1p0:basic 6 | 7 | 8 | 9 | 10 | 11 | 380 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | EUR 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 0 65 | 66 | 0 67 | 0 68 | 69 | 70 | 0 71 | 0 72 | 0 73 | 0 74 | 0 75 | 0 76 | 77 | 78 | 79 | 80 | 81 | 0 82 | 83 | 84 | 85 | 0 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /facturx/flavors/fields.yml: -------------------------------------------------------------------------------- 1 | # This file maps XML paths to human-readable field names for the most important fields. 2 | # Names from https://github.com/OCA/edi/blob/10.0/account_invoice_import/wizard/account_invoice_import.py#L77 3 | --- 4 | version: 5 | _path: 6 | factur-x: //rsm:ExchangedDocumentContext/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID 7 | zugferd: //rsm:SpecifiedExchangedDocumentContext/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID 8 | ubl: //cbc:ProfileID 9 | _required: true 10 | _default: urn:ferd:CrossIndustryDocument:invoice:1p0:basic 11 | invoice_number: 12 | _path: 13 | factur-x: //rsm:ExchangedDocument/ram:ID 14 | zugferd: //rsm:HeaderExchangedDocument/ram:ID 15 | _required: true 16 | date: 17 | _path: 18 | factur-x: //rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString 19 | zugferd: //rsm:HeaderExchangedDocument/ram:IssueDateTime/udt:DateTimeString 20 | _required: true 21 | date_due: 22 | _path: 23 | factur-x: //rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString 24 | zugferd: //rsm:SpecifiedSupplyChainTradeTransaction/ram:ApplicableSupplyChainTradeSettlement/ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString 25 | _required: false 26 | name: 27 | _path: 28 | factur-x: //rsm:ExchangedDocument/ram:Name 29 | zugferd: //rsm:HeaderExchangedDocument/ram:Name 30 | _default: invoice 31 | _required: false 32 | type: 33 | _path: 34 | factur-x: //rsm:ExchangedDocument/ram:TypeCode 35 | zugferd: //rsm:HeaderExchangedDocument/ram:TypeCode 36 | _required: true 37 | _default: 380 38 | currency: 39 | _path: 40 | factur-x: //rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeSettlement/ram:InvoiceCurrencyCode 41 | zugferd: //rsm:SpecifiedSupplyChainTradeTransaction/ram:ApplicableSupplyChainTradeSettlement/ram:InvoiceCurrencyCode 42 | _required: true 43 | _default: EUR 44 | amount_untaxed: 45 | _path: 46 | factur-x: //rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:LineTotalAmount 47 | zugferd: //rsm:SpecifiedSupplyChainTradeTransaction/ram:ApplicableSupplyChainTradeSettlement/ram:SpecifiedTradeSettlementMonetarySummation/ram:LineTotalAmount 48 | _required: false 49 | amount_tax: 50 | _path: 51 | factur-x: //rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TaxTotalAmount 52 | zugferd: //rsm:SpecifiedSupplyChainTradeTransaction/ram:ApplicableSupplyChainTradeSettlement/ram:SpecifiedTradeSettlementMonetarySummation/ram:TaxTotalAmount 53 | _required: false 54 | amount_total: 55 | _path: 56 | factur-x: //rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount 57 | zugferd: //rsm:SpecifiedSupplyChainTradeTransaction/ram:ApplicableSupplyChainTradeSettlement/ram:SpecifiedTradeSettlementMonetarySummation/ram:GrandTotalAmount 58 | _required: true 59 | notes: 60 | _path: 61 | factur-x: //rsm:HeaderExchangedDocument/ram:IncludedNote/ram:Content 62 | zugferd: //rsm:ExchangedDocument/ram:IncludedNote/ram:Content 63 | _required: false 64 | seller_name: 65 | _path: 66 | factur-x: //rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:Name 67 | zugferd: //rsm:SpecifiedSupplyChainTradeTransaction/ram:ApplicableSupplyChainTradeAgreement/ram:SellerTradeParty/ram:Name 68 | _required: true 69 | seller_country: 70 | _path: 71 | factur-x: //rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:PostalTradeAddress/ram:CountryID 72 | zugferd: //rsm:SpecifiedSupplyChainTradeTransaction/ram:ApplicableSupplyChainTradeAgreement/ram:SellerTradeParty/ram:PostalTradeAddress/ram:CountryID 73 | _required: false 74 | buyer_name: 75 | _path: 76 | factur-x: //rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeAgreement/ram:BuyerTradeParty/ram:Name 77 | zugferd: //rsm:SpecifiedSupplyChainTradeTransaction/ram:ApplicableSupplyChainTradeAgreement/ram:BuyerTradeParty/ram:Name 78 | _required: true 79 | buyer_country: 80 | _path: 81 | factur-x: //rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeAgreement/ram:BuyerTradeParty/ram:PostalTradeAddress/ram:CountryID 82 | zugferd: //rsm:SpecifiedSupplyChainTradeTransaction/ram:ApplicableSupplyChainTradeAgreement/ram:BuyerTradeParty/ram:PostalTradeAddress/ram:CountryID 83 | _required: false 84 | shipping_country: 85 | _path: 86 | factur-x: //rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeDelivery/ram:ShipToTradeParty/ram:PostalTradeAddress/ram:CountryID 87 | zugferd: null 88 | _required: false 89 | -------------------------------------------------------------------------------- /facturx/flavors/zugferd/xml/samples/basic.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | urn:ferd:CrossIndustryDocument:invoice:1p0:basic 6 | 7 | 8 | 9 | 471102 10 | RECHNUNG 11 | 380 12 | 20130305 13 | 14 | Rechnung gemäß Bestellung vom 01.03.2013. 15 | 16 | 17 | Lieferant GmbH 18 | Lieferantenstraße 20 19 | 80333 München 20 | Deutschland 21 | Geschäftsführer: Hans Muster 22 | Handelsregisternummer: H A 123 23 | 24 | 25 | 26 | Unsere GLN: 4000001123452 27 | Ihre GLN: 4000001987658 28 | Ihre Kundennummer: GE2020211 29 | 30 | 31 | Zahlbar innerhalb 30 Tagen netto bis 04.04.2013, 3% Skonto innerhalb 10 Tagen bis 15.03.2013. 32 | 33 | 34 | 35 | 36 | 37 | Lieferant GmbH 38 | 39 | 80333 40 | Lieferantenstraße 20 41 | München 42 | DE 43 | 44 | 45 | 201/113/40209 46 | 47 | 48 | DE123456789 49 | 50 | 51 | 52 | Kunden AG Mitte 53 | 54 | 69876 55 | Hans Muster 56 | Kundenstraße 15 57 | Frankfurt 58 | DE 59 | 60 | 61 | 62 | 63 | 64 | 20130305 65 | 66 | 67 | 68 | 2013-471102 69 | EUR 70 | 71 | 72 | DE08700901001234567890 73 | 74 | 75 | GENODEF1M04 76 | 77 | 78 | 79 | 37.62 80 | VAT 81 | 198.00 82 | 19.00 83 | 84 | 85 | 198.00 86 | 0.00 87 | 0.00 88 | 198.00 89 | 37.62 90 | 235.62 91 | 92 | 93 | 94 | 95 | 96 | 20.0000 97 | 98 | 99 | 100 | 198.00 101 | 102 | 103 | 104 | 4012345001235 105 | TB100A4 106 | GTIN: 4012345001235 107 | Unsere Art.-Nr.: TB100A4 108 | Trennblätter A4 109 | 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /facturx/flavors/xml_flavor.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module abstracts different XML flavors used to represent the underlying invoice data. 3 | 4 | Data kept for each flavor: 5 | 6 | - mapping between "pivot" dict and XML paths 7 | - xmp templates 8 | - xsd files for validation 9 | - xml templates to create new XML representations 10 | """ 11 | 12 | import os 13 | import yaml 14 | import csv 15 | import pycountry 16 | from lxml import etree 17 | from io import BytesIO 18 | from pkg_resources import resource_filename 19 | 20 | from ..logger import logger 21 | 22 | unicode = str 23 | 24 | # Load information on different XML standards and paths from YML. 25 | def _load_yml(filename): 26 | with open(os.path.join(os.path.dirname(__file__), filename)) as f: 27 | return yaml.load(f) 28 | 29 | FIELDS = _load_yml('fields.yml') 30 | FLAVORS = _load_yml('flavors.yml') 31 | 32 | 33 | class XMLFlavor(object): 34 | """A helper class to keep the lookup code out of the main library. 35 | 36 | Represents a XML invoice representation standard, like Factur-X or Zugferd. 37 | """ 38 | 39 | def __init__(self, xml): 40 | self.name = guess_flavor(xml) 41 | self.level = self.get_level(xml) 42 | self.details = FLAVORS[self.name] 43 | 44 | @classmethod 45 | def from_template(cls, flavor, level): 46 | """Creates a new XML tree with the desired level and flavor from an existing template 47 | 48 | Returns lxml.etree and xml_flavor.XMLFlavor instance. 49 | """ 50 | template_filename = os.path.join( 51 | os.path.dirname(__file__), 52 | flavor, 53 | 'xml', 54 | FLAVORS[flavor]['levels'][level]['xml']) 55 | assert os.path.isfile(template_filename), 'Template for this flavor/level does not exist.' 56 | 57 | xml_tree = etree.parse(open(template_filename)).getroot() 58 | return cls(xml_tree), xml_tree 59 | 60 | def get_level(self, facturx_xml_etree): 61 | if not isinstance(facturx_xml_etree, type(etree.Element('pouet'))): 62 | raise ValueError('facturx_xml_etree must be an etree.Element() object') 63 | namespaces = facturx_xml_etree.nsmap 64 | doc_id_xpath = facturx_xml_etree.xpath(self._get_xml_path('version'), namespaces=namespaces) 65 | if not doc_id_xpath: 66 | raise ValueError("Version field not found.") 67 | doc_id = doc_id_xpath[0].text 68 | level = doc_id.split(':')[-1] 69 | if level not in FLAVORS[self.name]['levels']: 70 | level = doc_id.split(':')[-2] 71 | if level not in FLAVORS[self.name]['levels']: 72 | raise ValueError( 73 | "Invalid Factur-X URN: '%s'" % doc_id) 74 | logger.info('Factur-X level is %s (autodetected)', level) 75 | return level 76 | 77 | 78 | def check_xsd(self, etree_to_validate): 79 | """Validate the XML file against the XSD""" 80 | 81 | xsd_filename = FLAVORS[self.name]['levels'][self.level]['schema'] 82 | xsd_file = os.path.join( 83 | os.path.dirname(__file__), 84 | self.name, 'xsd', xsd_filename) 85 | 86 | xsd_etree_obj = etree.parse(open(xsd_file)) 87 | official_schema = etree.XMLSchema(xsd_etree_obj) 88 | try: 89 | official_schema.assertValid(etree_to_validate) 90 | logger.info('XML file successfully validated against XSD') 91 | except Exception as e: 92 | # if the validation of the XSD fails, we arrive here 93 | logger.error( 94 | "The XML file is invalid against the XML Schema Definition") 95 | logger.error('XSD Error: %s', e) 96 | raise Exception( 97 | "The %s XML file is not valid against the official " 98 | "XML Schema Definition. " 99 | "Here is the error, which may give you an idea on the " 100 | "cause of the problem: %s." % (self.name, unicode(e))) 101 | return True 102 | 103 | def get_xmp_xml(self): 104 | xmp_file = os.path.join( 105 | os.path.dirname(__file__), 106 | self.name, 107 | 'xmp', 108 | FLAVORS[self.name]['xmp_schema']) 109 | return etree.parse(open(xmp_file)) 110 | 111 | def _get_xml_path(self, field_name): 112 | """Return XML path based on field_name and flavor""" 113 | 114 | assert field_name in FIELDS.keys(), 'Field not specified. Try working directly on the XML tree.' 115 | field_details = FIELDS[field_name] 116 | if self.name in field_details['_path']: 117 | return field_details['_path'][self.name] 118 | else: 119 | raise KeyError('Path not defined for currenct flavor.') 120 | 121 | def valid_code(self, code_type, field_value): 122 | try: 123 | if code_type == 'country': 124 | pycountry.countries.lookup(field_value) 125 | elif code_type == 'currency': 126 | pycountry.currencies.lookup(field_value) 127 | return True 128 | except LookupError: 129 | return False 130 | 131 | def valid_xmp_filenames(): 132 | result = [] 133 | for flavor in FLAVORS.keys(): 134 | result.append(FLAVORS[flavor]['xmp_filename']) 135 | return result 136 | 137 | def guess_flavor(facturx_xml_etree): 138 | if not isinstance(facturx_xml_etree, type(etree.Element('pouet'))): 139 | raise ValueError('facturx_xml_etree must be an etree.Element() object') 140 | if facturx_xml_etree.tag.startswith('{urn:un:unece:uncefact:'): 141 | flavor = 'factur-x' 142 | elif facturx_xml_etree.tag.startswith('{urn:ferd:'): 143 | flavor = 'zugferd' 144 | else: 145 | raise Exception( 146 | "Could not detect if the invoice is a Factur-X or ZUGFeRD " 147 | "invoice.") 148 | logger.info('Factur-X flavor is %s (autodetected)', flavor) 149 | return flavor 150 | -------------------------------------------------------------------------------- /facturx/flavors/zugferd/xml/comfort.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true 5 | 6 | 7 | urn:ferd:CrossIndustryDocument:invoice:1p0:comfort 8 | 9 | 10 | 11 | 12 | 13 | 380 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | EUR 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 0 72 | 73 | 0 74 | 75 | 0 76 | 77 | 78 | 79 | 80 | 81 | 82 | 0 83 | 0 84 | 0 85 | 0 86 | 0 87 | 0 88 | 0 89 | 0 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 0 99 | 100 | 101 | 0 102 | 103 | 104 | 105 | 0 106 | 107 | 108 | 109 | 110 | 111 | 0 112 | 113 | 114 | 0 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xml/basicwl.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | urn:factur-x.eu:1p0:basicwl 8 | 9 | 10 | 11 | 12 | 380 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | EUR 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 0 91 | 92 | 0 93 | 94 | 95 | 0 96 | 97 | 98 | 99 | false 100 | 101 | 0 102 | 0 103 | 0 104 | 105 | 106 | 107 | 108 | 109 | 0 110 | 111 | 112 | 113 | 114 | 0 115 | 116 | 117 | 118 | 0 119 | 0 120 | 0 121 | 0 122 | 0 123 | 0 124 | 125 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xml/en16931.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | urn:cen.eu:en16931:2017 8 | 9 | 10 | 11 | 12 | 380 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 0 34 | 35 | 36 | 37 | 0 38 | 39 | 40 | 41 | 42 | 43 | 0 44 | 45 | 46 | 0 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | EUR 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 0 128 | 129 | 0 130 | 131 | 132 | 0 133 | 134 | 135 | 136 | false 137 | 138 | 0 139 | 0 140 | 0 141 | 142 | 143 | 144 | 145 | 146 | 0 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 0 156 | 0 157 | 0 158 | 0 159 | 0 160 | 0 161 | 162 | 163 | 164 | 165 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xml/basic.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | urn:factur-x.eu:1p0:basic 8 | 9 | 10 | 11 | 12 | 380 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 0 33 | 34 | 35 | 36 | 0 37 | 38 | 39 | 40 | 41 | 42 | 0 43 | 44 | 45 | 0 46 | 47 | 48 | 49 | 50 | 51 | 52 | 0 53 | 0 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | EUR 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 0 114 | 115 | 0 116 | 117 | 118 | 0 119 | 120 | 121 | 122 | false 123 | 124 | 0 125 | 0 126 | 0 127 | 128 | 129 | 130 | 131 | 132 | 0 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 0 142 | 0 143 | 0 144 | 0 145 | 0 146 | 0 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xml/samples/basicwl.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A1 5 | 6 | 7 | urn:factur-x.eu:1p0:basicwl 8 | 9 | 10 | 11 | 2017-TEST-04 12 | 380 13 | 14 | 20171031 15 | 16 | 17 | FOURNISSEUR F SARL au capital de 50 000 EUR 18 | REG 19 | 20 | 21 | RCS NANTERRE 999 888 777 22 | ABL 23 | 24 | 25 | Taux de pénalités de retard de paiement égal au taux de refinancement de la Banque Centrale Européenne majorée de 10 points de pourcentage 26 | PMD 27 | 28 | 29 | Retard de paiement: Indemnité forfaitaire pour frais de recouvrement de 40 Euros 30 | PMT 31 | 32 | 33 | Aucun escompte en cas de paiement anticipé 34 | AAB 35 | 36 | 37 | 38 | 39 | CodeSERVICE A 40 | 41 | 99988877900017 42 | FOURNISSEUR F 43 | 44 | 99988877900017 45 | 46 | 47 | 92120 48 | 25 rue du Fournisseur 49 | MONTROUGE 50 | FR 51 | 52 | 53 | martin@fournisseurf.fr 54 | 55 | 56 | FR34999888779 57 | 58 | 59 | 60 | 77788899100018 61 | CLIENT 1 62 | 63 | 77788899100018 64 | 65 | 66 | 75015 67 | 1 rue du Client 68 | PARIS 69 | FR 70 | 71 | 72 | FR51777888991 73 | 74 | 75 | 76 | CDE234 77 | 78 | 79 | CT12345 80 | 81 | 82 | 83 | 84 | 85 | 20171023 86 | 87 | 88 | 89 | BL45 90 | 91 | 92 | 93 | EUR 94 | 95 | 96 | 77788899100018 97 | 98 | 99 | 100 | 42 101 | 102 | FR99 3122 1234 0000 9999 1234 008 103 | 104 | 105 | 106 | 3195.37 107 | VAT 108 | 15976.87 109 | S 110 | 5 111 | 20 112 | 113 | 114 | 115 | false 116 | 117 | 3.00 118 | 16471.00 119 | 494.13 120 | Remise négociation commerciale 121 | 122 | VAT 123 | S 124 | 5 125 | 20 126 | 127 | 128 | 129 | 130 | 20171130 131 | 132 | 133 | 134 | 16471.00 135 | 494.13 136 | 15976.87 137 | 3195.37 138 | 19172.24 139 | 19172.24 140 | 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /facturx/facturx.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import yaml 4 | import codecs 5 | from io import BytesIO 6 | from lxml import etree 7 | from tempfile import NamedTemporaryFile 8 | from datetime import datetime 9 | import os.path 10 | import mimetypes 11 | import hashlib 12 | from PyPDF2 import PdfFileReader 13 | from PyPDF2.generic import IndirectObject 14 | import json 15 | 16 | from .flavors import xml_flavor 17 | from .logger import logger 18 | from .pdfwriter import FacturXPDFWriter 19 | 20 | # Python 2 and 3 compat 21 | try: 22 | file_types = (file, io.IOBase) 23 | except NameError: 24 | file_types = (io.IOBase,) 25 | unicode = str 26 | 27 | __all__ = ['FacturX'] 28 | 29 | 30 | class FacturX(object): 31 | """Represents an electronic PDF invoice with embedded XML metadata following the 32 | Factur-X standard. 33 | 34 | The source of truth is always the underlying XML tree. No copy of field 35 | data is kept. Manipulation of the XML tree is either done via Python-style 36 | dict access (available for the most common fields) or by directly accessing 37 | the XML data on `FacturX.xml`. 38 | 39 | Attributes: 40 | - xml: xml tree of machine-readable representation. 41 | - pdf: underlying graphical PDF representation. 42 | - flavor: which flavor (Factur-x or Zugferd) to use. 43 | """ 44 | 45 | def __init__(self, pdf_invoice, flavor='factur-x', level='minimum'): 46 | # Read PDF from path, pointer or string 47 | if isinstance(pdf_invoice, str) and pdf_invoice.endswith('.pdf') and os.path.isfile(pdf_invoice): 48 | with open(pdf_invoice, 'rb') as f: 49 | pdf_file = BytesIO(f.read()) 50 | elif isinstance(pdf_invoice, str): 51 | pdf_file = BytesIO(pdf_invoice) 52 | elif isinstance(pdf_invoice, file_types): 53 | pdf_file = pdf_invoice 54 | else: 55 | raise TypeError( 56 | "The first argument of the method get_facturx_xml_from_pdf must " 57 | "be either a string or a file (it is a %s)." % type(pdf_invoice)) 58 | xml = self._xml_from_file(pdf_file) 59 | self.pdf = pdf_file 60 | 61 | # PDF has metadata embedded 62 | if xml is not None: 63 | self.xml = xml 64 | self.flavor = xml_flavor.XMLFlavor(xml) 65 | logger.info('Read existing XML from PDF. Flavor: %s', self.flavor.name) 66 | # No metadata embedded. Create from template. 67 | else: 68 | self.flavor, self.xml = xml_flavor.XMLFlavor.from_template(flavor, level) 69 | logger.info('PDF does not have XML embedded. Adding from template.') 70 | 71 | self.flavor.check_xsd(self.xml) 72 | self._namespaces = self.xml.nsmap 73 | 74 | def read_xml(self): 75 | """Use XML data from external file. Replaces existing XML or template.""" 76 | pass 77 | 78 | def _xml_from_file(self, pdf_file): 79 | pdf = PdfFileReader(pdf_file) 80 | pdf_root = pdf.trailer['/Root'] 81 | if '/Names' not in pdf_root or '/EmbeddedFiles' not in pdf_root['/Names']: 82 | logger.info('No existing XML file found.') 83 | return None 84 | 85 | for file in pdf_root['/Names']['/EmbeddedFiles']['/Names']: 86 | if isinstance(file, IndirectObject): 87 | obj = file.getObject() 88 | if obj['/F'] in xml_flavor.valid_xmp_filenames(): 89 | xml_root = etree.fromstring(obj['/EF']['/F'].getData()) 90 | xml_content = xml_root 91 | xml_filename = obj['/F'] 92 | logger.info( 93 | 'A valid XML file %s has been found in the PDF file', 94 | xml_filename) 95 | return xml_content 96 | 97 | def __getitem__(self, field_name): 98 | path = self.flavor._get_xml_path(field_name) 99 | value = self.xml.xpath(path, namespaces=self._namespaces) 100 | if value: 101 | value = value[0].text 102 | if 'date' in field_name: 103 | value = datetime.strptime(value, '%Y%m%d') 104 | return value 105 | 106 | def __setitem__(self, field_name, value): 107 | path = self.flavor._get_xml_path(field_name) 108 | res = self.xml.xpath(path, namespaces=self._namespaces) 109 | if len(res) > 1: 110 | raise LookupError('Multiple nodes found for this path. Refusing to edit.') 111 | 112 | if 'date' in field_name: 113 | assert isinstance(value, datetime), 'Please pass date values as DateTime() object.' 114 | value = value.strftime('%Y%m%d') 115 | res[0].attrib['format'] = '102' 116 | res[0].text = value 117 | else: 118 | res[0].text = str(value) 119 | 120 | def is_valid(self): 121 | """Make every effort to validate the current XML. 122 | 123 | Checks: 124 | - all required fields are present and have values. 125 | - XML is valid 126 | - ... 127 | 128 | Returns: true/false (validation passed/failed) 129 | """ 130 | # validate against XSD 131 | try: 132 | self.flavor.check_xsd(self.xml) 133 | except Exception: 134 | return False 135 | 136 | # Check for required fields 137 | fields_data = xml_flavor.FIELDS 138 | for field in fields_data.keys(): 139 | if fields_data[field]['_required']: 140 | r = self.xml.xpath(fields_data[field]['_path'][self.flavor.name], namespaces=self._namespaces) 141 | if not len(r) or r[0].text is None: 142 | if '_default' in fields_data[field].keys(): 143 | logger.info("Required field '%s' is not present. Using default.", field) 144 | self[field] = fields_data[field]['_default'] 145 | else: 146 | logger.error("Required field '%s' is not present", field) 147 | return False 148 | 149 | # Check for codes (ISO:3166, ISO:4217) 150 | codes_to_check = [ 151 | ('currency', 'currency'), 152 | ('country', 'seller_country'), 153 | ('country', 'buyer_country'), 154 | ('country', 'shipping_country') 155 | ] 156 | for code_type, field_name in codes_to_check: 157 | if self[field_name] and not self.flavor.valid_code(code_type, self[field_name]): 158 | logger.error("Field %s is not a valid %s code." % (field_name, code_type)) 159 | return False 160 | 161 | return True 162 | 163 | def write_pdf(self, path): 164 | pdfwriter = FacturXPDFWriter(self) 165 | with open(path, 'wb') as output_f: 166 | pdfwriter.write(output_f) 167 | 168 | logger.info('XML file added to PDF invoice') 169 | return True 170 | 171 | @property 172 | def xml_str(self): 173 | """Calculate MD5 checksum of XML file. Used for PDF attachment.""" 174 | return etree.tostring(self.xml, pretty_print=True) 175 | 176 | def write_xml(self, path): 177 | with open(path, 'wb') as f: 178 | f.write(self.xml_str) 179 | 180 | def to_dict(self): 181 | """Get all available fields as dict.""" 182 | fields_data = xml_flavor.FIELDS 183 | flavor = self.flavor.name 184 | 185 | output_dict = {} 186 | for field in fields_data.keys(): 187 | try: 188 | if fields_data[field]['_path'][flavor] is not None: 189 | r = self.xml.xpath(fields_data[field]['_path'][flavor], 190 | namespaces=self._namespaces) 191 | output_dict[field] = r[0].text 192 | except IndexError: 193 | output_dict[field] = None 194 | 195 | return output_dict 196 | 197 | def write_json(self, json_file_path='output.json'): 198 | json_output = self.to_dict() 199 | if self.is_valid(): 200 | with open(json_file_path, 'w') as json_file: 201 | logger.info("Exporting JSON to %s", json_file_path) 202 | json.dump(json_output, json_file, indent=4, sort_keys=True) 203 | 204 | def write_yaml(self, yml_file_path='output.yml'): 205 | yml_output = self.to_dict() 206 | if self.is_valid(): 207 | with open(yml_file_path, 'w') as yml_file: 208 | logger.info("Exporting YAML to %s", yml_file_path) 209 | yaml.dump(yml_output, yml_file, default_flow_style=False) 210 | -------------------------------------------------------------------------------- /facturx/flavors/zugferd/xml/samples/comfort.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | urn:ferd:CrossIndustryDocument:invoice:1p0:comfort 6 | 7 | 8 | 9 | 471102 10 | RECHNUNG 11 | 380 12 | 20130305 13 | 14 | Rechnung gemäß Bestellung vom 01.03.2013. 15 | 16 | 17 | Lieferant GmbH 18 | Lieferantenstraße 20 19 | 80333 München 20 | Deutschland 21 | Geschäftsführer: Hans Muster 22 | Handelsregisternummer: H A 123 23 | 24 | REG 25 | 26 | 27 | 28 | 29 | 30 | 4000001123452 31 | Lieferant GmbH 32 | 33 | 80333 34 | Lieferantenstraße 20 35 | München 36 | DE 37 | 38 | 39 | 201/113/40209 40 | 41 | 42 | DE123456789 43 | 44 | 45 | 46 | GE2020211 47 | 4000001987658 48 | Kunden AG Mitte 49 | 50 | Hans Muster 51 | 52 | 53 | 69876 54 | Kundenstraße 15 55 | Frankfurt 56 | DE 57 | 58 | 59 | 60 | 61 | 62 | 20130305 63 | 64 | 65 | 66 | 2013-471102 67 | EUR 68 | 69 | 31 70 | Überweisung 71 | 72 | DE08700901001234567890 73 | 74 | 75 | GENODEF1M04 76 | 77 | 78 | 79 | 19.25 80 | VAT 81 | 275.00 82 | S 83 | 7.00 84 | 85 | 86 | 37.62 87 | VAT 88 | 198.00 89 | S 90 | 19.00 91 | 92 | 93 | Zahlbar innerhalb 30 Tagen netto bis 04.04.2013, 3% Skonto innerhalb 10 Tagen bis 15.03.2013 94 | 20130404 95 | 96 | 97 | 473.00 98 | 0.00 99 | 0.00 100 | 473.00 101 | 56.87 102 | 529.87 103 | 0.00 104 | 529.87 105 | 106 | 107 | 108 | 109 | 1 110 | 111 | 112 | 113 | 9.9000 114 | 115 | 116 | 9.9000 117 | 118 | 119 | 120 | 20.0000 121 | 122 | 123 | 124 | VAT 125 | S 126 | 19.00 127 | 128 | 129 | 198.00 130 | 131 | 132 | 133 | 4012345001235 134 | TB100A4 135 | Trennblätter A4 136 | 137 | 138 | 139 | 140 | 2 141 | 142 | 143 | 144 | 5.5000 145 | 146 | 147 | 5.5000 148 | 149 | 150 | 151 | 50.0000 152 | 153 | 154 | 155 | VAT 156 | S 157 | 7.00 158 | 159 | 160 | 275.00 161 | 162 | 163 | 164 | 4000050986428 165 | ARNR2 166 | Joghurt Banane 167 | 168 | 169 | 170 | 171 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xml/samples/en16931.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A1 5 | 6 | 7 | urn:cen.eu:en16931:2017 8 | 9 | 10 | 11 | 2017-TEST-04 12 | 380 13 | 14 | 20171031 15 | 16 | 17 | FOURNISSEUR F SARL au capital de 50 000 EUR 18 | REG 19 | 20 | 21 | RCS NANTERRE 999 888 777 22 | ABL 23 | 24 | 25 | Taux de pénalités de retard de paiement égal au taux de refinancement de la Banque Centrale Européenne majorée de 10 points de pourcentage 26 | PMD 27 | 28 | 29 | Retard de paiement: Indemnité forfaitaire pour frais de recouvrement de 40 Euros 30 | PMT 31 | 32 | 33 | Aucun escompte en cas de paiement anticipé 34 | AAB 35 | 36 | 37 | 38 | 39 | 40 | 1 41 | 42 | 43 | PROD1 44 | AAA 45 | PRODUIT 1 46 | 47 | 48 | 49 | 91.00 50 | 51 | 52 | 53 | 181 54 | 55 | 56 | 57 | VAT 58 | S 59 | 20 60 | 61 | 62 | 16471.00 63 | 64 | 65 | 66 | 67 | CodeSERVICE A 68 | 69 | 99988877900017 70 | 99988877900017 71 | FOURNISSEUR F 72 | 73 | 99988877900017 74 | 75 | 76 | 92120 77 | 25 rue du Fournisseur 78 | MONTROUGE 79 | FR 80 | 81 | 82 | martin@fournisseurf.fr 83 | 84 | 85 | FR34999888779 86 | 87 | 88 | 89 | 77788899100018 90 | CLIENT 1 91 | 92 | 77788899100018 93 | 94 | 95 | 75015 96 | 1 rue du Client 97 | PARIS 98 | FR 99 | 100 | 101 | FR51777888991 102 | 103 | 104 | 105 | CDE234 106 | 107 | 108 | CT12345 109 | 110 | 111 | 112 | 113 | 77788899100018 114 | 115 | 75015 116 | 1 rue du Client 117 | PARIS 118 | FR 119 | 120 | 121 | 122 | 123 | 20171023 124 | 125 | 126 | 127 | BL45 128 | 129 | 130 | 131 | EUR 132 | 133 | 42 134 | 135 | FR99 3122 1234 0000 9999 1234 008 136 | FOURNISSEUR F 137 | 138 | 139 | ZZZAFRPPBBT 140 | 141 | 142 | 143 | 3195.37 144 | VAT 145 | 15976.87 146 | S 147 | 5 148 | 20 149 | 150 | 151 | 152 | false 153 | 154 | 3.00 155 | 16471.00 156 | 494.13 157 | Remise négociation commerciale 158 | 159 | VAT 160 | S 161 | 5 162 | 20 163 | 164 | 165 | 166 | 167 | 20171130 168 | 169 | 170 | 171 | 16471.24 172 | 494.13 173 | 15976.87 174 | 3195.37 175 | 19172.24 176 | 19172.24 177 | 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xml/samples/basic.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A1 5 | 6 | 7 | urn:factur-x.eu:1p0:basic 8 | 9 | 10 | 11 | 2017-TEST-04 12 | 380 13 | 14 | 20171031 15 | 16 | 17 | FOURNISSEUR F SARL au capital de 50 000 EUR 18 | REG 19 | 20 | 21 | RCS NANTERRE 999 888 777 22 | ABL 23 | 24 | 25 | Taux de pénalités de retard de paiement égal au taux de refinancement de la Banque Centrale Européenne majorée de 10 points de pourcentage 26 | PMD 27 | 28 | 29 | Retard de paiement: Indemnité forfaitaire pour frais de recouvrement de 40 Euros 30 | PMT 31 | 32 | 33 | Aucun escompte en cas de paiement anticipé 34 | AAB 35 | 36 | 37 | 38 | 39 | 40 | 1 41 | 42 | 43 | 1234567890123 44 | PRODUIT 1 45 | 46 | 47 | 48 | 91.00 49 | 50 | 51 | 52 | 181 53 | 54 | 55 | 56 | VAT 57 | S 58 | 20 59 | 60 | 61 | 16471.00 62 | 63 | 64 | 65 | 66 | CodeSERVICE A 67 | 68 | 99988877900017 69 | 99988877900017 70 | FOURNISSEUR F 71 | 72 | 99988877900017 73 | 74 | 75 | 92120 76 | 25 rue du Fournisseur 77 | MONTROUGE 78 | FR 79 | 80 | 81 | martin@fournisseurf.fr 82 | 83 | 84 | FR34999888779 85 | 86 | 87 | 88 | 77788899100018 89 | CLIENT 1 90 | 91 | 77788899100018 92 | 93 | 94 | 75015 95 | 1 rue du Client 96 | PARIS 97 | FR 98 | 99 | 100 | FR51777888991 101 | 102 | 103 | 104 | CDE234 105 | 106 | 107 | CT12345 108 | 109 | 110 | 111 | 112 | 113 | 20171023 114 | 115 | 116 | 117 | BL45 118 | 119 | 120 | 121 | EUR 122 | 123 | 42 124 | 125 | FR99 3122 1234 0000 9999 1234 008 126 | 127 | 128 | 129 | 3195.37 130 | VAT 131 | 15976.87 132 | S 133 | 5 134 | 20 135 | 136 | 137 | 138 | false 139 | 140 | 3.00 141 | 16471.00 142 | 494.13 143 | Remise négociation commerciale 144 | 145 | VAT 146 | S 147 | 5 148 | 20 149 | 150 | 151 | 152 | 153 | 20171130 154 | 155 | 156 | 157 | 16471.00 158 | 494.13 159 | 15976.87 160 | 3195.37 161 | 19172.24 162 | 19172.24 163 | 164 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xsd/FACTUR-X_BASIC-WL_urn_un_unece_uncefact_data_standard_ReusableAggregateBusinessInformationEntity_100.xsd: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | -------------------------------------------------------------------------------- /facturx/flavors/factur-x/xsd/FACTUR-X_EN16931_urn_un_unece_uncefact_data_standard_ReusableAggregateBusinessInformationEntity_100.xsd: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | -------------------------------------------------------------------------------- /facturx/pdfwriter.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import yaml 4 | import codecs 5 | from io import BytesIO 6 | from lxml import etree 7 | from tempfile import NamedTemporaryFile 8 | from datetime import datetime 9 | from PyPDF2 import PdfFileWriter, PdfFileReader 10 | from PyPDF2.generic import DictionaryObject, DecodedStreamObject,\ 11 | NameObject, createStringObject, ArrayObject, IndirectObject 12 | from pkg_resources import resource_filename 13 | import os.path 14 | import mimetypes 15 | import hashlib 16 | 17 | from .logger import logger 18 | 19 | # Python 2 and 3 compat 20 | try: 21 | file_types = (file, io.IOBase) 22 | except NameError: 23 | file_types = (io.IOBase,) 24 | unicode = str 25 | 26 | class FacturXPDFWriter(PdfFileWriter): 27 | def __init__(self, facturx, pdf_metadata=None): 28 | """Take a FacturX instance and write the XML to the attached PDF file""" 29 | 30 | super(FacturXPDFWriter, self).__init__() 31 | # TODO: Can handle str/paths and ByteIO? 32 | self.factx = facturx 33 | 34 | original_pdf = PdfFileReader(facturx.pdf) 35 | # Extract /OutputIntents obj from original invoice 36 | output_intents = _get_original_output_intents(original_pdf) 37 | self.appendPagesFromReader(original_pdf) 38 | 39 | original_pdf_id = original_pdf.trailer.get('/ID') 40 | logger.debug('original_pdf_id=%s', original_pdf_id) 41 | if original_pdf_id: 42 | self._ID = original_pdf_id 43 | # else : generate some ? 44 | 45 | if pdf_metadata is None: 46 | base_info = { 47 | 'seller': self.factx['seller_name'], 48 | 'number': self.factx['invoice_number'], 49 | 'date': self.factx['date'], 50 | 'doc_type': self.factx['type'], 51 | } 52 | pdf_metadata = _base_info2pdf_metadata(base_info) 53 | else: 54 | # clean-up pdf_metadata dict 55 | for key, value in pdf_metadata.iteritems(): 56 | if not isinstance(value, (str, unicode)): 57 | pdf_metadata[key] = '' 58 | 59 | self._update_metadata_add_attachment(pdf_metadata, output_intents) 60 | 61 | 62 | def _update_metadata_add_attachment(self, pdf_metadata, output_intents): 63 | '''This method is inspired from the code of the addAttachment() 64 | method of the PyPDF2 lib''' 65 | 66 | # The entry for the file 67 | facturx_xml_str = self.factx.xml_str 68 | md5sum = hashlib.md5().hexdigest() 69 | md5sum_obj = createStringObject(md5sum) 70 | params_dict = DictionaryObject({ 71 | NameObject('/CheckSum'): md5sum_obj, 72 | NameObject('/ModDate'): createStringObject(_get_pdf_timestamp()), 73 | NameObject('/Size'): NameObject(str(len(facturx_xml_str))), 74 | }) 75 | file_entry = DecodedStreamObject() 76 | file_entry.setData(facturx_xml_str) # here we integrate the file itself 77 | file_entry.update({ 78 | NameObject("/Type"): NameObject("/EmbeddedFile"), 79 | NameObject("/Params"): params_dict, 80 | # 2F is '/' in hexadecimal 81 | NameObject("/Subtype"): NameObject("/text#2Fxml"), 82 | }) 83 | file_entry_obj = self._addObject(file_entry) 84 | # The Filespec entry 85 | ef_dict = DictionaryObject({ 86 | NameObject("/F"): file_entry_obj, 87 | NameObject('/UF'): file_entry_obj, 88 | }) 89 | 90 | xmp_filename = self.factx.flavor.details['xmp_filename'] 91 | fname_obj = createStringObject(xmp_filename) 92 | filespec_dict = DictionaryObject({ 93 | NameObject("/AFRelationship"): NameObject("/Data"), 94 | NameObject("/Desc"): createStringObject("Factur-X Invoice"), 95 | NameObject("/Type"): NameObject("/Filespec"), 96 | NameObject("/F"): fname_obj, 97 | NameObject("/EF"): ef_dict, 98 | NameObject("/UF"): fname_obj, 99 | }) 100 | filespec_obj = self._addObject(filespec_dict) 101 | name_arrayobj_cdict = {fname_obj: filespec_obj} 102 | 103 | # TODO: add back additional attachments? 104 | logger.debug('name_arrayobj_cdict=%s', name_arrayobj_cdict) 105 | name_arrayobj_content_sort = list( 106 | sorted(name_arrayobj_cdict.items(), key=lambda x: x[0])) 107 | logger.debug('name_arrayobj_content_sort=%s', name_arrayobj_content_sort) 108 | name_arrayobj_content_final = [] 109 | af_list = [] 110 | for (fname_obj, filespec_obj) in name_arrayobj_content_sort: 111 | name_arrayobj_content_final += [fname_obj, filespec_obj] 112 | af_list.append(filespec_obj) 113 | embedded_files_names_dict = DictionaryObject({ 114 | NameObject("/Names"): ArrayObject(name_arrayobj_content_final), 115 | }) 116 | 117 | # Then create the entry for the root, as it needs a 118 | # reference to the Filespec 119 | embedded_files_dict = DictionaryObject({ 120 | NameObject("/EmbeddedFiles"): embedded_files_names_dict, 121 | }) 122 | res_output_intents = [] 123 | logger.debug('output_intents=%s', output_intents) 124 | for output_intent_dict, dest_output_profile_dict in output_intents: 125 | dest_output_profile_obj = self._addObject( 126 | dest_output_profile_dict) 127 | # TODO detect if there are no other objects in output_intent_dest_obj 128 | # than /DestOutputProfile 129 | output_intent_dict.update({ 130 | NameObject("/DestOutputProfile"): dest_output_profile_obj, 131 | }) 132 | output_intent_obj = self._addObject(output_intent_dict) 133 | res_output_intents.append(output_intent_obj) 134 | 135 | # Update the root 136 | xmp_level_str = self.factx.flavor.details['levels'][self.factx.flavor.level]['xmp_str'] 137 | xmp_template = self.factx.flavor.get_xmp_xml() 138 | metadata_xml_str = _prepare_pdf_metadata_xml(xmp_level_str, xmp_filename, xmp_template, pdf_metadata) 139 | metadata_file_entry = DecodedStreamObject() 140 | metadata_file_entry.setData(metadata_xml_str) 141 | metadata_file_entry.update({ 142 | NameObject('/Subtype'): NameObject('/XML'), 143 | NameObject('/Type'): NameObject('/Metadata'), 144 | }) 145 | metadata_obj = self._addObject(metadata_file_entry) 146 | af_value_obj = self._addObject(ArrayObject(af_list)) 147 | self._root_object.update({ 148 | NameObject("/AF"): af_value_obj, 149 | NameObject("/Metadata"): metadata_obj, 150 | NameObject("/Names"): embedded_files_dict, 151 | # show attachments when opening PDF 152 | NameObject("/PageMode"): NameObject("/UseAttachments"), 153 | }) 154 | logger.debug('res_output_intents=%s', res_output_intents) 155 | if res_output_intents: 156 | self._root_object.update({ 157 | NameObject("/OutputIntents"): ArrayObject(res_output_intents), 158 | }) 159 | metadata_txt_dict = _prepare_pdf_metadata_txt(pdf_metadata) 160 | self.addMetadata(metadata_txt_dict) 161 | 162 | 163 | 164 | def _get_metadata_timestamp(): 165 | now_dt = datetime.now() 166 | # example format : 2014-07-25T14:01:22+02:00 167 | meta_date = now_dt.strftime('%Y-%m-%dT%H:%M:%S+00:00') 168 | return meta_date 169 | 170 | def _base_info2pdf_metadata(base_info): 171 | if base_info['doc_type'] == '381': 172 | doc_type_name = u'Refund' 173 | else: 174 | doc_type_name = u'Invoice' 175 | date_str = datetime.strftime(base_info['date'], '%Y-%m-%d') 176 | title = '%s: %s %s' % ( 177 | base_info['seller'], doc_type_name, base_info['number']) 178 | subject = 'Factur-X %s %s dated %s issued by %s' % ( 179 | doc_type_name, base_info['number'], date_str, base_info['seller']) 180 | pdf_metadata = { 181 | 'author': base_info['seller'], 182 | 'keywords': u'%s, Factur-X' % doc_type_name, 183 | 'title': title, 184 | 'subject': subject, 185 | } 186 | logger.debug('Converted base_info to pdf_metadata: %s', pdf_metadata) 187 | return pdf_metadata 188 | 189 | 190 | def _prepare_pdf_metadata_txt(pdf_metadata): 191 | pdf_date = _get_pdf_timestamp() 192 | info_dict = { 193 | '/Author': pdf_metadata.get('author', ''), 194 | '/CreationDate': pdf_date, 195 | '/Creator': 196 | u'factur-x Python lib', 197 | '/Keywords': pdf_metadata.get('keywords', ''), 198 | '/ModDate': pdf_date, 199 | '/Subject': pdf_metadata.get('subject', ''), 200 | '/Title': pdf_metadata.get('title', ''), 201 | } 202 | return info_dict 203 | 204 | 205 | def _prepare_pdf_metadata_xml(xmp_level_str, xmp_filename, facturx_ext_schema_root, pdf_metadata): 206 | nsmap_x = {'x': 'adobe:ns:meta/'} 207 | nsmap_rdf = {'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'} 208 | nsmap_dc = {'dc': 'http://purl.org/dc/elements/1.1/'} 209 | nsmap_pdf = {'pdf': 'http://ns.adobe.com/pdf/1.3/'} 210 | nsmap_xmp = {'xmp': 'http://ns.adobe.com/xap/1.0/'} 211 | nsmap_pdfaid = {'pdfaid': 'http://www.aiim.org/pdfa/ns/id/'} 212 | nsmap_fx = { 213 | 'fx': 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#'} 214 | ns_x = '{%s}' % nsmap_x['x'] 215 | ns_dc = '{%s}' % nsmap_dc['dc'] 216 | ns_rdf = '{%s}' % nsmap_rdf['rdf'] 217 | ns_pdf = '{%s}' % nsmap_pdf['pdf'] 218 | ns_xmp = '{%s}' % nsmap_xmp['xmp'] 219 | ns_pdfaid = '{%s}' % nsmap_pdfaid['pdfaid'] 220 | ns_fx = '{%s}' % nsmap_fx['fx'] 221 | ns_xml = '{http://www.w3.org/XML/1998/namespace}' 222 | 223 | root = etree.Element(ns_x + 'xmpmeta', nsmap=nsmap_x) 224 | rdf = etree.SubElement( 225 | root, ns_rdf + 'RDF', nsmap=nsmap_rdf) 226 | desc_pdfaid = etree.SubElement( 227 | rdf, ns_rdf + 'Description', nsmap=nsmap_pdfaid) 228 | desc_pdfaid.set(ns_rdf + 'about', '') 229 | etree.SubElement( 230 | desc_pdfaid, ns_pdfaid + 'part').text = '3' 231 | etree.SubElement( 232 | desc_pdfaid, ns_pdfaid + 'conformance').text = 'B' 233 | desc_dc = etree.SubElement( 234 | rdf, ns_rdf + 'Description', nsmap=nsmap_dc) 235 | desc_dc.set(ns_rdf + 'about', '') 236 | dc_title = etree.SubElement(desc_dc, ns_dc + 'title') 237 | dc_title_alt = etree.SubElement(dc_title, ns_rdf + 'Alt') 238 | dc_title_alt_li = etree.SubElement( 239 | dc_title_alt, ns_rdf + 'li') 240 | dc_title_alt_li.text = pdf_metadata.get('title', '') 241 | dc_title_alt_li.set(ns_xml + 'lang', 'x-default') 242 | dc_creator = etree.SubElement(desc_dc, ns_dc + 'creator') 243 | dc_creator_seq = etree.SubElement(dc_creator, ns_rdf + 'Seq') 244 | etree.SubElement( 245 | dc_creator_seq, ns_rdf + 'li').text = pdf_metadata.get('author', '') 246 | dc_desc = etree.SubElement(desc_dc, ns_dc + 'description') 247 | dc_desc_alt = etree.SubElement(dc_desc, ns_rdf + 'Alt') 248 | dc_desc_alt_li = etree.SubElement( 249 | dc_desc_alt, ns_rdf + 'li') 250 | dc_desc_alt_li.text = pdf_metadata.get('subject', '') 251 | dc_desc_alt_li.set(ns_xml + 'lang', 'x-default') 252 | desc_adobe = etree.SubElement( 253 | rdf, ns_rdf + 'Description', nsmap=nsmap_pdf) 254 | desc_adobe.set(ns_rdf + 'about', '') 255 | producer = etree.SubElement( 256 | desc_adobe, ns_pdf + 'Producer') 257 | producer.text = 'PyPDF2' 258 | desc_xmp = etree.SubElement( 259 | rdf, ns_rdf + 'Description', nsmap=nsmap_xmp) 260 | desc_xmp.set(ns_rdf + 'about', '') 261 | creator = etree.SubElement( 262 | desc_xmp, ns_xmp + 'CreatorTool') 263 | creator.text = 'factur-x python lib' 264 | timestamp = _get_metadata_timestamp() 265 | etree.SubElement(desc_xmp, ns_xmp + 'CreateDate').text = timestamp 266 | etree.SubElement(desc_xmp, ns_xmp + 'ModifyDate').text = timestamp 267 | 268 | # The Factur-X extension schema must be embedded into each PDF document 269 | facturx_ext_schema_desc_xpath = facturx_ext_schema_root.xpath( 270 | '//rdf:Description', namespaces=nsmap_rdf) 271 | rdf.append(facturx_ext_schema_desc_xpath[1]) 272 | # Now is the Factur-X description tag 273 | facturx_desc = etree.SubElement( 274 | rdf, ns_rdf + 'Description', nsmap=nsmap_fx) 275 | facturx_desc.set(ns_rdf + 'about', '') 276 | facturx_desc.set( 277 | ns_fx + 'ConformanceLevel', xmp_level_str) 278 | facturx_desc.set(ns_fx + 'DocumentFileName', xmp_filename) 279 | facturx_desc.set(ns_fx + 'DocumentType', 'INVOICE') 280 | facturx_desc.set(ns_fx + 'Version', '1.0') 281 | 282 | # TODO: should be UTF-16be ?? 283 | xml_str = etree.tostring( 284 | root, pretty_print=True, encoding="UTF-8", xml_declaration=False) 285 | head = u''.encode( 286 | 'utf-8') 287 | tail = u''.encode('utf-8') 288 | xml_final_str = head + xml_str + tail 289 | logger.debug('metadata XML:') 290 | # logger.debug(xml_final_str.decode()) 291 | return xml_final_str 292 | 293 | 294 | # def createByteObject(string): 295 | # string_to_encode = u'\ufeff' + string 296 | # x = string_to_encode.encode('utf-16be') 297 | # return ByteStringObject(x) 298 | 299 | 300 | def _filespec_additional_attachments( 301 | pdf_filestream, name_arrayobj_cdict, file_dict, file_bin): 302 | filename = file_dict['filename'] 303 | logger.debug('_filespec_additional_attachments filename=%s', filename) 304 | mod_date_pdf = _get_pdf_timestamp(file_dict['mod_date']) 305 | md5sum = hashlib.md5(file_bin).hexdigest() 306 | md5sum_obj = createStringObject(md5sum) 307 | params_dict = DictionaryObject({ 308 | NameObject('/CheckSum'): md5sum_obj, 309 | NameObject('/ModDate'): createStringObject(mod_date_pdf), 310 | NameObject('/Size'): NameObject(str(len(file_bin))), 311 | }) 312 | file_entry = DecodedStreamObject() 313 | file_entry.setData(file_bin) 314 | file_mimetype = mimetypes.guess_type(filename)[0] 315 | if not file_mimetype: 316 | file_mimetype = 'application/octet-stream' 317 | file_mimetype_insert = '/' + file_mimetype.replace('/', '#2f') 318 | file_entry.update({ 319 | NameObject("/Type"): NameObject("/EmbeddedFile"), 320 | NameObject("/Params"): params_dict, 321 | NameObject("/Subtype"): NameObject(file_mimetype_insert), 322 | }) 323 | file_entry_obj = pdf_filestream._addObject(file_entry) 324 | ef_dict = DictionaryObject({ 325 | NameObject("/F"): file_entry_obj, 326 | }) 327 | fname_obj = createStringObject(filename) 328 | filespec_dict = DictionaryObject({ 329 | NameObject("/AFRelationship"): NameObject("/Unspecified"), 330 | NameObject("/Desc"): createStringObject(file_dict.get('desc', '')), 331 | NameObject("/Type"): NameObject("/Filespec"), 332 | NameObject("/F"): fname_obj, 333 | NameObject("/EF"): ef_dict, 334 | NameObject("/UF"): fname_obj, 335 | }) 336 | filespec_obj = pdf_filestream._addObject(filespec_dict) 337 | name_arrayobj_cdict[fname_obj] = filespec_obj 338 | 339 | # moved to FacturXPDFWriter 340 | def _facturx_update_metadata_add_attachment( 341 | pdf_filestream, facturx_xml_str, pdf_metadata, facturx_level, 342 | output_intents=[], additional_attachments={}): 343 | '''This method is inspired from the code of the addAttachment() 344 | method of the PyPDF2 lib''' 345 | # The entry for the file 346 | md5sum = hashlib.md5(facturx_xml_str).hexdigest() 347 | md5sum_obj = createStringObject(md5sum) 348 | params_dict = DictionaryObject({ 349 | NameObject('/CheckSum'): md5sum_obj, 350 | NameObject('/ModDate'): createStringObject(_get_pdf_timestamp()), 351 | NameObject('/Size'): NameObject(str(len(facturx_xml_str))), 352 | }) 353 | file_entry = DecodedStreamObject() 354 | file_entry.setData(facturx_xml_str) # here we integrate the file itself 355 | file_entry.update({ 356 | NameObject("/Type"): NameObject("/EmbeddedFile"), 357 | NameObject("/Params"): params_dict, 358 | # 2F is '/' in hexadecimal 359 | NameObject("/Subtype"): NameObject("/text#2Fxml"), 360 | }) 361 | file_entry_obj = pdf_filestream._addObject(file_entry) 362 | # The Filespec entry 363 | ef_dict = DictionaryObject({ 364 | NameObject("/F"): file_entry_obj, 365 | NameObject('/UF'): file_entry_obj, 366 | }) 367 | 368 | fname_obj = createStringObject(FACTURX_FILENAME) 369 | filespec_dict = DictionaryObject({ 370 | NameObject("/AFRelationship"): NameObject("/Data"), 371 | NameObject("/Desc"): createStringObject("Factur-X Invoice"), 372 | NameObject("/Type"): NameObject("/Filespec"), 373 | NameObject("/F"): fname_obj, 374 | NameObject("/EF"): ef_dict, 375 | NameObject("/UF"): fname_obj, 376 | }) 377 | filespec_obj = pdf_filestream._addObject(filespec_dict) 378 | name_arrayobj_cdict = {fname_obj: filespec_obj} 379 | for attach_bin, attach_dict in additional_attachments.items(): 380 | _filespec_additional_attachments( 381 | pdf_filestream, name_arrayobj_cdict, attach_dict, attach_bin) 382 | logger.debug('name_arrayobj_cdict=%s', name_arrayobj_cdict) 383 | name_arrayobj_content_sort = list( 384 | sorted(name_arrayobj_cdict.items(), key=lambda x: x[0])) 385 | logger.debug('name_arrayobj_content_sort=%s', name_arrayobj_content_sort) 386 | name_arrayobj_content_final = [] 387 | af_list = [] 388 | for (fname_obj, filespec_obj) in name_arrayobj_content_sort: 389 | name_arrayobj_content_final += [fname_obj, filespec_obj] 390 | af_list.append(filespec_obj) 391 | embedded_files_names_dict = DictionaryObject({ 392 | NameObject("/Names"): ArrayObject(name_arrayobj_content_final), 393 | }) 394 | 395 | # Then create the entry for the root, as it needs a 396 | # reference to the Filespec 397 | embedded_files_dict = DictionaryObject({ 398 | NameObject("/EmbeddedFiles"): embedded_files_names_dict, 399 | }) 400 | res_output_intents = [] 401 | logger.debug('output_intents=%s', output_intents) 402 | for output_intent_dict, dest_output_profile_dict in output_intents: 403 | dest_output_profile_obj = pdf_filestream._addObject( 404 | dest_output_profile_dict) 405 | # TODO detect if there are no other objects in output_intent_dest_obj 406 | # than /DestOutputProfile 407 | output_intent_dict.update({ 408 | NameObject("/DestOutputProfile"): dest_output_profile_obj, 409 | }) 410 | output_intent_obj = pdf_filestream._addObject(output_intent_dict) 411 | res_output_intents.append(output_intent_obj) 412 | 413 | # Update the root 414 | metadata_xml_str = _prepare_pdf_metadata_xml(facturx_level, pdf_metadata) 415 | metadata_file_entry = DecodedStreamObject() 416 | metadata_file_entry.setData(metadata_xml_str) 417 | metadata_file_entry.update({ 418 | NameObject('/Subtype'): NameObject('/XML'), 419 | NameObject('/Type'): NameObject('/Metadata'), 420 | }) 421 | metadata_obj = pdf_filestream._addObject(metadata_file_entry) 422 | af_value_obj = pdf_filestream._addObject(ArrayObject(af_list)) 423 | pdf_filestream._root_object.update({ 424 | NameObject("/AF"): af_value_obj, 425 | NameObject("/Metadata"): metadata_obj, 426 | NameObject("/Names"): embedded_files_dict, 427 | # show attachments when opening PDF 428 | NameObject("/PageMode"): NameObject("/UseAttachments"), 429 | }) 430 | logger.debug('res_output_intents=%s', res_output_intents) 431 | if res_output_intents: 432 | pdf_filestream._root_object.update({ 433 | NameObject("/OutputIntents"): ArrayObject(res_output_intents), 434 | }) 435 | metadata_txt_dict = _prepare_pdf_metadata_txt(pdf_metadata) 436 | pdf_filestream.addMetadata(metadata_txt_dict) 437 | 438 | 439 | def _extract_base_info(facturx_xml_etree): 440 | namespaces = facturx_xml_etree.nsmap 441 | date_xpath = facturx_xml_etree.xpath( 442 | '//rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString', 443 | namespaces=namespaces) 444 | date = date_xpath[0].text 445 | date_dt = datetime.strptime(date, '%Y%m%d') 446 | inv_num_xpath = facturx_xml_etree.xpath( 447 | '//rsm:ExchangedDocument/ram:ID', namespaces=namespaces) 448 | inv_num = inv_num_xpath[0].text 449 | seller_xpath = facturx_xml_etree.xpath( 450 | '//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:Name', 451 | namespaces=namespaces) 452 | seller = seller_xpath[0].text 453 | doc_type_xpath = facturx_xml_etree.xpath( 454 | '//rsm:ExchangedDocument/ram:TypeCode', namespaces=namespaces) 455 | doc_type = doc_type_xpath[0].text 456 | base_info = { 457 | 'seller': seller, 458 | 'number': inv_num, 459 | 'date': date_dt, 460 | 'doc_type': doc_type, 461 | } 462 | logger.debug('Extraction of base_info: %s', base_info) 463 | return base_info 464 | 465 | 466 | def _base_info2pdf_metadata(base_info): 467 | if base_info['doc_type'] == '381': 468 | doc_type_name = u'Refund' 469 | else: 470 | doc_type_name = u'Invoice' 471 | date_str = datetime.strftime(base_info['date'], '%Y-%m-%d') 472 | title = '%s: %s %s' % ( 473 | base_info['seller'], doc_type_name, base_info['number']) 474 | subject = 'Factur-X %s %s dated %s issued by %s' % ( 475 | doc_type_name, base_info['number'], date_str, base_info['seller']) 476 | pdf_metadata = { 477 | 'author': base_info['seller'], 478 | 'keywords': u'%s, Factur-X' % doc_type_name, 479 | 'title': title, 480 | 'subject': subject, 481 | } 482 | logger.debug('Converted base_info to pdf_metadata: %s', pdf_metadata) 483 | return pdf_metadata 484 | 485 | 486 | def _get_original_output_intents(original_pdf): 487 | output_intents = [] 488 | try: 489 | pdf_root = original_pdf.trailer['/Root'] 490 | ori_output_intents = pdf_root['/OutputIntents'] 491 | logger.debug('output_intents_list=%s', ori_output_intents) 492 | for ori_output_intent in ori_output_intents: 493 | ori_output_intent_dict = ori_output_intent.getObject() 494 | logger.debug('ori_output_intents_dict=%s', ori_output_intent_dict) 495 | dest_output_profile_dict =\ 496 | ori_output_intent_dict['/DestOutputProfile'].getObject() 497 | output_intents.append( 498 | (ori_output_intent_dict, dest_output_profile_dict)) 499 | except: 500 | pass 501 | return output_intents 502 | 503 | def _get_pdf_timestamp(date=None): 504 | if date is None: 505 | date = datetime.now() 506 | # example date format: "D:20141006161354+02'00'" 507 | pdf_date = date.strftime("D:%Y%m%d%H%M%S+00'00'") 508 | return pdf_date --------------------------------------------------------------------------------