├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── Makefile ├── conf.py ├── examples.rst ├── index.rst ├── make.bat ├── modules.rst ├── modules │ ├── base.rst │ ├── color.rst │ ├── content.rst │ ├── document.rst │ ├── encoders.rst │ ├── fonts.rst │ ├── image.rst │ ├── page.rst │ ├── parser.rst │ ├── pdf.rst │ ├── table.rst │ ├── text.rst │ └── utils.rst ├── requirements.txt └── tutorial.rst ├── pdfme ├── __init__.py ├── base.py ├── color.py ├── content.py ├── document.py ├── encoders.py ├── fonts.py ├── image.py ├── page.py ├── parser.py ├── pdf.py ├── table.py ├── text.py └── utils.py ├── pyproject.toml ├── setup.cfg ├── test.py ├── tests ├── __init__.py ├── group_element.py ├── image_test.jpg ├── image_test.png ├── running_section_per_page.py ├── test_content.py ├── test_table.py ├── test_text.py └── utils.py └── tutorial.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | jobs: 16 | deploy: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: '3.x' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install build 30 | - name: Build package 31 | run: python -m build 32 | - name: Publish package 33 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | build/ 3 | dist/ 4 | *.egg-info 5 | *.pdf 6 | 7 | .vscode/ 8 | test_*.json 9 | test_*.pdf 10 | docs/_build/ 11 | 12 | venv/ 13 | __pycache__/ 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | - Add possibility of embedding ttf and unicode fonts. 9 | 10 | ## [0.4.12] - 2025-02-13 11 | ### Fixed 12 | - Fixed error when using empty string as color. 13 | 14 | ## [0.4.11] - 2022-04-05 15 | ### Fixed 16 | - Fixed error when using in-memory images. 17 | 18 | ## [0.4.10] - 2022-04-05 19 | ### Fixed 20 | - Fixed error when using in-memory images in tables and error when in-memory images 21 | overflow to a new section. 22 | 23 | ## [0.4.9] - 2022-02-18 24 | ### Changed 25 | - Improved how group elements work, with "min_height" and "shrink" style 26 | properties. 27 | ### Fixed 28 | - Fixed error with margins of elements in content boxes. 29 | 30 | ## [0.4.8] - 2022-02-12 31 | ### Added 32 | - Added support for png images embedding. 33 | - Added per page running sections and styles. 34 | ### Fixed 35 | - Added "min_height" style property to make the images downsize to min height 36 | when they don't fit in the available height. 37 | 38 | ## [0.4.7] - 2022-02-10 39 | ### Added 40 | - Added group elements. 41 | - Added per page running sections. 42 | ### Fixed 43 | - Fixed some typos in the docs. 44 | 45 | ## [0.4.6] - 2021-11-11 46 | ### Fixed 47 | - Fixed issue found in table module. 48 | 49 | ## [0.4.5] - 2021-10-04 50 | ### Fixed 51 | - Fixed issue #10 related with error with hexagesimal colors. 52 | 53 | ## [0.4.4] - 2021-08-18 54 | ### Added 55 | - Numbers in paragraph parts are now converted to strings. PR #9 56 | 57 | ## [0.4.3] - 2021-08-18 58 | ### Added 59 | - Fixed issues #7 related with incomplete borders when combining cells. 60 | 61 | ## [0.4.2] - 2021-08-18 62 | ### Fixed 63 | - Fixed issues #3 and #5 related with footnotes and incomplete sections. 64 | 65 | ## [0.4.1] - 2021-07-26 66 | ### Fixed 67 | - An alternative for deepcopy was created, and some modifications were made in 68 | the rest of the code to replace deepcopy with our alternative. Some simple 69 | time measurements were made and this fix has improved the speed of content 70 | module 8 to 10 times. 71 | 72 | ## [0.4.0] - 2021-07-19 73 | ### Added 74 | - `page_style` properties, in `document` module are now defined inside `style` 75 | key. 76 | 77 | ## [0.3.0] - 2021-07-16 78 | ### Added 79 | - PDF Outlines are now available. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Andrés Felipe Sierra Parra 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pdfme 2 | 3 | This is a powerful library to create PDF documents easily. 4 | 5 | The way you create a PDF document with pdfme is very similar to how you create 6 | documents with LaTex: you just tell pdfme at a very high level what elements you 7 | want to be in the document, without worrying about wrapping text in a box, 8 | positioning every element inside the page, creating the lines of a table, or the 9 | internals of the PDF Document Format. pdfme will put every element 10 | below the last one, and when a page is full it will add a new page to keep 11 | adding elements to the document, and will keep adding pages until all of the 12 | elements are inside the document. It just works. 13 | 14 | If you want the power to place elements wherever you want and mess with the PDF 15 | Document Format internals, pdfme got you covered too. Give the docs a look to 16 | check how you can do this. 17 | 18 | ## Main features 19 | 20 | * You can create a document without having to worry about the position of each 21 | element in the document. But you have the possibility to place any element 22 | wherever you want too. 23 | * You can add rich text paragraphs (paragraphs with text in different sizes, 24 | fonts, colors and styles). 25 | * You can add tables and place whatever you want on their cells, span columns 26 | and rows, and change the fills and borders in the easiest way possible. 27 | * You can add content boxes, a multi-column element where you can add 28 | paragraphs, images, tables and even content boxes themselves. The elements 29 | inside this content boxes are added from top to bottom and from left to right. 30 | * You can add url links (to web pages), labels/references, footnotes and 31 | outlines anywhere in the document. 32 | * You can add running sections, content boxes that will be included in every 33 | page you add to the document. Headers and footers are the most common running 34 | sections, but you can add running sections anywhere in the page. 35 | ## Installation 36 | 37 | You can install using pip: 38 | ``` 39 | pip install pdfme 40 | ``` 41 | 42 | ## Documentation 43 | 44 | * Docs and examples: https://pdfme.readthedocs.io 45 | 46 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | from pathlib import Path 15 | import sys 16 | sys.path.insert(0, str(Path('../.'))) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'pdfme' 22 | copyright = '2021, Andres Felipe Sierra Parra' 23 | author = 'Andres Felipe Sierra Parra' 24 | 25 | 26 | # -- General configuration --------------------------------------------------- 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | 'sphinxcontrib.napoleon', 33 | 'sphinx.ext.autodoc' 34 | ] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # List of patterns, relative to source directory, that match files and 40 | # directories to ignore when looking for source files. 41 | # This pattern also affects html_static_path and html_extra_path. 42 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 43 | 44 | 45 | # -- Options for HTML output ------------------------------------------------- 46 | 47 | # The theme to use for HTML and HTML Help pages. See the documentation for 48 | # a list of builtin themes. 49 | # 50 | html_theme = 'sphinx_rtd_theme' 51 | 52 | # Add any paths that contain custom static files (such as style sheets) here, 53 | # relative to this directory. They are copied after the builtin static files, 54 | # so a file named "default.css" will overwrite the builtin "default.css". 55 | html_static_path = ['_static'] 56 | 57 | autodoc_typehints = 'none' 58 | autodoc_member_order = 'bysource' -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | Example of a PDF document created with :func:`pdfme.document.build_pdf` using 5 | almost all of the functionalities of this library. 6 | 7 | .. code-block:: 8 | 9 | import random 10 | 11 | from pdfme import build_pdf 12 | 13 | 14 | random.seed(1) 15 | abc = 'abcdefghijklmnñopqrstuvwxyzABCDEFGHIJKLMNÑOPQRSTUVWXYZáéíóúÁÉÍÓÚ' 16 | 17 | def gen_word(): 18 | return ''.join(random.choice(abc) for _ in range(random.randint(1, 10))) 19 | 20 | def gen_text(n): 21 | return random.choice(['',' ']) + (' '.join(gen_word() for _ in range(n))) + random.choice(['',' ']) 22 | 23 | def gen_paragraphs(n): 24 | return [gen_text(random.randint(50, 200)) for _ in range(n)] 25 | 26 | document = { 27 | "style": { 28 | "margin_bottom": 15, "text_align": "j", 29 | "page_size": "letter", "margin": [60, 50] 30 | }, 31 | "formats": { 32 | "url": {"c": "blue", "u": 1}, 33 | "title": {"b": 1, "s": 13} 34 | }, 35 | "running_sections": { 36 | "header": { 37 | "x": "left", "y": 20, "height": "top", "style": {"text_align": "r"}, 38 | "content": [{".b": "This is a header"}] 39 | }, 40 | "footer": { 41 | "x": "left", "y": 740, "height": "bottom", "style": {"text_align": "c"}, 42 | "content": [{".": ["Page ", {"var": "$page"}]}] 43 | } 44 | }, 45 | "sections": [ 46 | { 47 | "style": {"page_numbering_style": "roman"}, 48 | "running_sections": ["footer"], 49 | "content": [ 50 | 51 | { 52 | ".": "A Title", "style": "title", "label": "title1", 53 | "outline": {"level": 1, "text": "A different title 1"} 54 | }, 55 | 56 | ["This is a paragraph with a ", {".b": "bold part"}, ", a ", 57 | {".": "link", "style": "url", "uri": "https://some.url.com"}, 58 | ", a footnote", {"footnote": "description of the footnote"}, 59 | " and a reference to ", 60 | {".": "Title 2.", "style": "url", "ref": "title2"}], 61 | 62 | {"image": "path/to/some/image.jpg"}, 63 | 64 | *gen_paragraphs(7), 65 | 66 | { 67 | "widths": [1.5, 2.5, 1, 1.5, 1, 1], 68 | "style": {"s": 9}, 69 | "table": [ 70 | [ 71 | gen_text(4), 72 | { 73 | "colspan": 5, 74 | "style": { 75 | "cell_fill": [0.57, 0.8, 0.3], 76 | "text_align": "c", "cell_margin_top": 13 77 | }, 78 | ".b;c:1;s:12": gen_text(4) 79 | },None, None, None, None 80 | ], 81 | [ 82 | {"colspan": 2, ".": [{".b": gen_text(3)}, gen_text(3)]}, None, 83 | {".": [{".b": gen_text(1) + "\n"}, gen_text(3)]}, 84 | {".": [{".b": gen_text(1) + "\n"}, gen_text(3)]}, 85 | {".": [{".b": gen_text(1) + "\n"}, gen_text(3)]}, 86 | {".": [{".b": gen_text(1) + "\n"}, gen_text(3)]} 87 | ], 88 | [ 89 | { 90 | "colspan": 6, "cols": {"count": 3, "gap": 20}, 91 | "style": {"s": 8}, 92 | "content": gen_paragraphs(10) 93 | }, 94 | None, None, None, None, None 95 | ] 96 | ] 97 | }, 98 | 99 | *gen_paragraphs(10), 100 | ] 101 | }, 102 | { 103 | "style": { 104 | "page_numbering_reset": True, "page_numbering_style": "arabic" 105 | }, 106 | "running_sections": ["header", "footer"], 107 | "content": [ 108 | 109 | { 110 | ".": "Title 2", "style": "title", "label": "title2", 111 | "outline": {} 112 | }, 113 | 114 | ["This is a paragraph with a reference to ", 115 | {".": "Title 1.", "style": "url", "ref": "title1"}], 116 | 117 | { 118 | "style": {"list_text": "1. "}, 119 | ".": "And this is a list paragraph." + gen_text(40) 120 | }, 121 | 122 | *gen_paragraphs(10) 123 | ] 124 | }, 125 | ] 126 | } 127 | 128 | with open('document.pdf', 'wb') as f: 129 | build_pdf(document, f) 130 | 131 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | 2 | pdfme 3 | ===== 4 | This is a powerful library to create PDF documents easily. 5 | 6 | The way you create a PDF document with pdfme is very similar to how you create 7 | documents with LaTex: you just tell pdfme at a very high level what elements you 8 | want to be in the document, without worrying about wrapping text in a box, 9 | positioning every element inside the page, creating the lines of a table, or the 10 | internals of the PDF Document Format. pdfme will put every element 11 | below the last one, and when a page is full it will add a new page to keep 12 | adding elements to the document, and will keep adding pages until all of the 13 | elements are inside the document. It just works. 14 | 15 | If you want the power to place elements wherever you want and mess with the PDF 16 | Document Format internals, pdfme got you covered too. Give the docs a look to 17 | check how you can do this. 18 | 19 | Main features 20 | ------------- 21 | 22 | * You can create a document without having to worry about the position of each 23 | element in the document. But you have the possibility to place any element 24 | wherever you want too. 25 | 26 | * You can add rich text paragraphs (paragraphs with text in different sizes, 27 | fonts, colors and styles). 28 | 29 | * You can add images. 30 | 31 | * You can add tables and place whatever you want on their cells, span columns 32 | and rows, and change the fills and borders in the easiest way possible. 33 | 34 | * You can add group elements that contain paragraphs, images or tables, and 35 | guarantee that all of the children elements in the group element will be in 36 | the same page. 37 | 38 | * You can add content boxes, a multi-column element where you can add 39 | paragraphs, images, tables and even content boxes themselves. The elements 40 | inside this content boxes are added from top to bottom and from left to right. 41 | 42 | * You can add url links (to web pages), labels/references, footnotes and 43 | outlines anywhere in the document. 44 | 45 | * You can add running sections, content boxes that will be included in every 46 | page you add to the document. Headers and footers are the most common running 47 | sections, but you can add running sections anywhere in the page. 48 | 49 | 50 | Installation 51 | ------------ 52 | You can install using pip: 53 | 54 | .. code-block:: 55 | 56 | pip install pdfme 57 | 58 | About this docs 59 | --------------- 60 | 61 | We recommend starting with the tutorial in :doc:`tutorial`, but you can find 62 | the description and instructions for each feature inside the docs for each 63 | class representing the feature, so in :class:`pdfme.text.PDFText` class you'll 64 | learn how to build a paragraph, in :class:`pdfme.table.PDFTable` class you'll 65 | learn how to build a table, in :class:`pdfme.content.PDFContent` class you'll 66 | learn how to build a content box, in :class:`pdfme.document.PDFDocument` class 67 | you'll learn how to build a PDF from a nested-dict structure (Json) and in 68 | :class:`pdfme.pdf.PDF` class you'll learn how to use the main class of this 69 | library, the one that represents the PDF document. 70 | 71 | Usage 72 | ----- 73 | 74 | You can use this library to create PDF documents by using one of the following 75 | strategies: 76 | 77 | * The recommended way is to use the function :func:`pdfme.document.build_pdf`, 78 | passing a dictionary with the description and styling of the document as its 79 | argument. :doc:`tutorial` section uses this method to build a PDF document, 80 | and you can get more information about this approach in 81 | :class:`pdfme.document.PDFDocument` definition. 82 | 83 | * Use the :class:`pdfme.pdf.PDF` class and use its methods to build the PDF 84 | document. For more information about this approach see :class:`pdfme.pdf.PDF` 85 | class definition. 86 | 87 | Shortcomings 88 | ------------ 89 | 90 | * Currently this library only supports the standard 14 PDF fonts. 91 | * Currently this library only supports ``jpg`` and ``png`` image formats (png 92 | images are converted to jpg images using Pillow, so you have to install it to 93 | be able to embed png images). 94 | 95 | You can explore the rest of this library components in the following links: 96 | 97 | .. toctree:: 98 | :maxdepth: 3 99 | 100 | tutorial 101 | examples 102 | modules 103 | 104 | Indices and tables 105 | ------------------ 106 | 107 | * :ref:`genindex` 108 | * :ref:`modindex` 109 | * :ref:`search` 110 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | Modules 2 | ======= 3 | 4 | .. toctree:: 5 | :maxdepth: 3 6 | :glob: 7 | 8 | modules/* 9 | 10 | -------------------------------------------------------------------------------- /docs/modules/base.rst: -------------------------------------------------------------------------------- 1 | pdfme.base 2 | ========== 3 | 4 | .. automodule:: pdfme.base 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/modules/color.rst: -------------------------------------------------------------------------------- 1 | pdfme.color 2 | =========== 3 | 4 | .. automodule:: pdfme.color 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/modules/content.rst: -------------------------------------------------------------------------------- 1 | pdfme.content 2 | ============= 3 | 4 | .. automodule:: pdfme.content 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: -------------------------------------------------------------------------------- /docs/modules/document.rst: -------------------------------------------------------------------------------- 1 | pdfme.document 2 | ============== 3 | 4 | .. automodule:: pdfme.document 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: -------------------------------------------------------------------------------- /docs/modules/encoders.rst: -------------------------------------------------------------------------------- 1 | pdfme.encoders 2 | ============== 3 | 4 | .. automodule:: pdfme.encoders 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: -------------------------------------------------------------------------------- /docs/modules/fonts.rst: -------------------------------------------------------------------------------- 1 | pdfme.fonts 2 | =========== 3 | 4 | .. automodule:: pdfme.fonts 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: -------------------------------------------------------------------------------- /docs/modules/image.rst: -------------------------------------------------------------------------------- 1 | pdfme.image 2 | =========== 3 | 4 | .. automodule:: pdfme.image 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: -------------------------------------------------------------------------------- /docs/modules/page.rst: -------------------------------------------------------------------------------- 1 | pdfme.page 2 | ========== 3 | 4 | .. automodule:: pdfme.page 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: -------------------------------------------------------------------------------- /docs/modules/parser.rst: -------------------------------------------------------------------------------- 1 | pdfme.parser 2 | ============ 3 | 4 | .. automodule:: pdfme.parser 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: -------------------------------------------------------------------------------- /docs/modules/pdf.rst: -------------------------------------------------------------------------------- 1 | pdfme.pdf 2 | ========= 3 | 4 | .. automodule:: pdfme.pdf 5 | :members: 6 | :undoc-members: 7 | :private-members: _text, _table, _content 8 | :show-inheritance: -------------------------------------------------------------------------------- /docs/modules/table.rst: -------------------------------------------------------------------------------- 1 | pdfme.table 2 | ============ 3 | 4 | .. automodule:: pdfme.table 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: -------------------------------------------------------------------------------- /docs/modules/text.rst: -------------------------------------------------------------------------------- 1 | pdfme.text 2 | ========== 3 | 4 | .. automodule:: pdfme.text 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: -------------------------------------------------------------------------------- /docs/modules/utils.rst: -------------------------------------------------------------------------------- 1 | pdfme.utils 2 | =========== 3 | 4 | .. automodule:: pdfme.utils 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinxcontrib-napoleon 3 | sphinx_rtd_theme -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | 4 | In this tutorial we will create a PDF document using pdfme to showcase some 5 | of its functionalities. 6 | 7 | We will use the preferred way to build a document in pdfme, that is using 8 | :func:`pdfme.document.build_pdf` function. This function receives as its first 9 | argument a nested dict structure with the contents and styling of the document, 10 | and most of this tutorial will be focused on building this dict. 11 | 12 | In every step we will tell you the class or function definition where you 13 | can get more information. 14 | 15 | Let's start importing the library and creating the root dictionary where the 16 | definitions that affect the whole document will be stated: ``document``. 17 | 18 | .. code-block:: 19 | 20 | from pdfme import build_pdf 21 | 22 | document = {} 23 | 24 | 25 | Now add the ``style`` key to this dictionary, with the styling that all of the 26 | sections will inherit. 27 | 28 | .. code-block:: 29 | 30 | document['style'] = { 31 | 'margin_bottom': 15, 32 | 'text_align': 'j' 33 | } 34 | 35 | 36 | In our example we define the ``margin_bottom`` property, that will be the 37 | default space below every element in the document, and ``text_align`` will 38 | be the default text alignment for all the paragraphs in the document. 39 | In this dict you can set the default value for the style properties that affect 40 | the paragraphs (``text_align``, ``line_height``, ``indent``, ``list_text``, 41 | ``list_style``, ``list_indent``, ``b``, ``i``, ``s``, ``f``, ``u``, ``c``, 42 | ``bg``, ``r``), images (``image_place``), tables (``cell_margin``, 43 | ``cell_margin_left``, ``cell_margin_top``, ``cell_margin_right``, 44 | ``cell_margin_bottom``, ``cell_fill``, ``border_width``, ``border_color``, 45 | ``border_style``) and content boxes (``margin_top``, ``margin_left``, 46 | ``margin_bottom``, ``margin_right``) inside the document. 47 | For information about paragraph properties see :class:`pdfme.text.PDFText`, 48 | about table properties see :class:`pdfme.table.PDFTable`, and about image and 49 | content properties see :class:`pdfme.content.PDFContent`. 50 | 51 | You can set page related properties in ``style`` too, like ``page_size``, 52 | ``rotate_page``, ``margin``, ``page_numbering_offset`` and 53 | ``page_numbering_style`` (see :class:`pdfme.pdf.PDF` definition). 54 | 55 | You can also define named style instructions or formats (something like CSS 56 | classes) in the ``document`` dict like this: 57 | 58 | .. code-block:: 59 | 60 | document['formats'] = { 61 | 'url': {'c': 'blue', 'u': 1}, 62 | 'title': {'b': 1, 's': 13} 63 | } 64 | 65 | Every key in ``formats`` dict will be the name of a format that you will be able 66 | to use anywhere in the document. In the example above we define a format for 67 | urls, the typical blue underlined style, and a format for titles with a bigger 68 | font size and bolded text. Given you can use this formats anywhere, the 69 | properties you can add to them are the same you can add to the document's 70 | ``style`` we described before. 71 | 72 | One more key you can add to ``document`` dict is ``running_sections``. In here 73 | you can define named content boxes that when referenced in a section, will be 74 | added to every page of it. Let's see how we can define a header and footer for 75 | our document using running sections: 76 | 77 | .. code-block:: 78 | 79 | document['running_sections'] = { 80 | 'header': { 81 | 'x': 'left', 'y': 20, 'height': 'top', 82 | 'style': {'text_align': 'r'}, 83 | 'content': [{'.b': 'This is a header'}] 84 | }, 85 | 'footer': { 86 | 'x': 'left', 'y': 800, 'height': 'bottom', 87 | 'style': {'text_align': 'c'}, 88 | 'content': [{'.': ['Page ', {'var': '$page'}]}] 89 | } 90 | } 91 | 92 | Here we defined running sections ``header`` and ``footer``, with their 93 | respective positions and styles. To know more about running sections see 94 | :class:`pdfme.document.PDFDocument` definition. 95 | We will talk about text formatting later, but one important thing to note here 96 | is the use of ``$page`` variable inside footer's ``content``. This is the way 97 | you can include the number of the page inside a paragraph in pdfme. 98 | 99 | Just defining these running sections won't add them to every page of the 100 | document; you will have to reference them in the section you want to really use 101 | them, or add a ``per_page`` dictionary like this: 102 | 103 | .. code-block:: 104 | 105 | document['per_page'] = [ 106 | {'pages': '1:1000:2', 'style': {'margin': [60, 100, 60, 60]}}, 107 | {'pages': '0:1000:2', 'style': {'margin': [60, 60, 60, 100]}}, 108 | {'pages': '0:4:2', 'running_sections': {'include': ['header']}}, 109 | ] 110 | 111 | This dictionary will style, include or exclude running sections from the pages 112 | you set in the property ``pages``. This key is a string of comma separated 113 | ranges of pages, and in this particular case we will add ``header`` to pages 0 114 | and 2, and will add more left margin in odd pages, and more right margin in even 115 | pages. 116 | To know more about ``per_page`` dict see :class:`pdfme.document.PDFDocument`. 117 | Keep reading to see how we add ``header`` and ``footer`` per sections. 118 | 119 | Finally we are going to talk about *sections*. These can have their own page 120 | layout, page numbering, running sections and style, and are the places where we 121 | define the contents of the document. It's important to note that after every 122 | section there's a page break. 123 | 124 | Let's create ``sections`` list to contain the documents sections, and add 125 | our first section ``section1``. 126 | 127 | .. code-block:: 128 | 129 | document['sections'] = [] 130 | section1 = {} 131 | document['sections'].append(section1) 132 | 133 | A section is just a content box, a multi-column element where you can add 134 | paragraphs, images, tables and even content boxes themselves (see 135 | :class:`pdfme.content.PDFContent` for more informarion about content boxes). 136 | pdfme will put every element from a section in the PDF document from top to 137 | bottom, and when the first page is full it will add a new page to keep 138 | adding elements to the document, and will keep adding pages until all of the 139 | elements are inside the document. 140 | 141 | Like a regular content box you can add a ``style`` key to a section, where you 142 | can reference a format (from the ``formats`` dict we created before), or add a 143 | new ``style`` dict, and with this you can overwrite any of the default style 144 | properties of the document. 145 | 146 | .. code-block:: 147 | 148 | section1['style'] = { 149 | 'page_numbering_style': 'roman' 150 | } 151 | 152 | Here we overwrite only ``page_numbering_style``, a property that sets the style 153 | of the page numbers inside the section (see :class:`pdfme.pdf.PDF` definition). 154 | Default value is ``arabic`` style, and here we change it to ``roman`` (at least 155 | for this section). 156 | 157 | Now we are going to reference the running sections that we will use in this 158 | section. 159 | 160 | .. code-block:: 161 | 162 | section1['running_sections'] = ['footer'] 163 | 164 | In this first section we will only use the ``footer``. pdfme 165 | will add all of the running_sections referenced in ``running_sections`` list, in 166 | the order they are in this list, to every page of this section. 167 | 168 | And finally we will define the contents of this section, inside ``content1`` 169 | list. 170 | 171 | .. code-block:: 172 | 173 | section1['content'] = content1 = [] 174 | 175 | We will first add a title for this section: 176 | 177 | .. code-block:: 178 | 179 | content1.append({ 180 | '.': 'A Title', 'style': 'title', 'label': 'title1', 181 | 'outline': {'level': 1, 'text': 'A different title 1'} 182 | }) 183 | 184 | We added a paragraph dict, and it's itself what we call a paragraph part. A 185 | paragraph part can have other nested paragraph parts, as it's explained in 186 | :class:`pdfme.text.PDFText` definition. This is like an HTML structure, where 187 | you can define a style in a root element and its style will be passed to all of 188 | its descendants. 189 | 190 | The first key in this dictionary we added is what we call a dot key, 191 | and is where we place the contents of a paragraph part, and its descendants. 192 | We won't extend much on the format for paragraphs, as it's explained in 193 | :class:`pdfme.text.PDFText` definition, so let's talk about the other keys in 194 | this dict. First we have a ``style`` key, with the name of a format that we 195 | defined before in the document's ``formats`` dict. This will apply all of the 196 | properties of that format into this paragraph part. We have a ``label`` key too, 197 | defining a position in the PDF document called ``title1``. 198 | Thanks to this we will be able to navigate to this position from any place in 199 | the document, just by using a reference to this label (keep reading to see how 200 | we reference this title in the second section). 201 | Finally, we have an ``outline`` key with a dictionary defining a PDF outline, 202 | a position in the PDF document, to which we can navigate to from the outline 203 | panel of the pdf reader. More information about outlines in 204 | :class:`pdfme.text.PDFText`. 205 | 206 | Now we will add our first paragraph. 207 | 208 | .. code-block:: 209 | 210 | content1.append( 211 | ['This is a paragraph with a ', {'.b;c:green': 'bold green part'}, ', a ', 212 | {'.': 'link', 'style': 'url', 'uri': 'https://some.url.com'}, 213 | ', a footnote', {'footnote': 'description of the footnote'}, 214 | ' and a reference to ', 215 | {'.': 'Title 2.', 'style': 'url', 'ref': 'title2'}] 216 | ) 217 | 218 | Note that this paragraph is not a dict, like the title we added before. Here we 219 | use a list of paragraph parts, a shortcut when you have a paragraph with 220 | different styles or with labels, references, urls, outlines or footnotes. 221 | 222 | We give format to the second paragraph part by using its dot key. This way of 223 | giving format to a paragraph part is something like the inline styles in HTML 224 | elements, and in particular in this example we are making the text inside this 225 | part bold and green. 226 | 227 | The rest of this list paragraph parts are examples of how to add a url, 228 | a footnote and a reference (clickable links to go to the location in the 229 | document of the label we reference) to the second title of this document ( 230 | located in the second section). 231 | 232 | Next we will add an image to the document, located in the relative path 233 | ``path/to/some_image.jpg``. 234 | 235 | .. code-block:: 236 | 237 | content1.append({ 238 | 'image': 'path/to/some_image.jpg', 239 | 'style': {'margin_left': 100, 'margin_right': 100} 240 | }) 241 | 242 | 243 | In ``style`` dict we set ``margin_left`` and ``margin_right`` to 100 244 | to make our image narrower and center it in the page. 245 | 246 | Next we will add a group element, containing an image and a paragraph with the 247 | image description. This guarantees that both the image and its description will 248 | be placed in the same page. To know more about group elements, and how to 249 | control the its height check :class:`pdfme.content.PDFContent`. 250 | 251 | .. code-block:: 252 | 253 | content1.append({ 254 | "style": {"margin_left": 80, "margin_right": 80}, 255 | "group": [ 256 | {"image": 'path/to/some_image.jpg'}, 257 | {".": "Figure 1: Description of figure 1"} 258 | ] 259 | }) 260 | 261 | Next we will add our first table to the document, a table with summary 262 | statistics from a database table. 263 | 264 | .. code-block:: 265 | 266 | table_def1 = { 267 | 'widths': [1.5, 1, 1, 1], 268 | 'style': {'border_width': 0, 'margin_left': 70, 'margin_right': 70}, 269 | 'fills': [{'pos': '1::2;:', 'color': 0.7}], 270 | 'borders': [{'pos': 'h0,1,-1;:', 'width': 0.5}], 271 | 'table': [ 272 | ['', 'column 1', 'column 2', 'column 3'], 273 | ['count', '2000', '2000', '2000'], 274 | ['mean', '28.58', '2643.66', '539.41'], 275 | ['std', '12.58', '2179.94', '421.49'], 276 | ['min', '1.00', '2.00', '1.00'], 277 | ['25%', '18.00', '1462.00', '297.00'], 278 | ['50%', '29.00', '2127.00', '434.00'], 279 | ['75%', '37.00', '3151.25', '648.25'], 280 | ['max', '52.00', '37937.00', '6445.00'] 281 | ] 282 | } 283 | 284 | content1.append(table_def1) 285 | 286 | In ``widths`` list we defined the width for every column in the table. The 287 | numbers here are not percentages or fractions but proportions. For example, 288 | in our table the first column is 1.5 times larger than the second one, and 289 | the third and fourth one are the same length as the second one. 290 | 291 | In ``style`` dict we set the ``border_width`` of the table to 0, thus hiding 292 | all of this table lines. We also set ``margin_left`` and ``margin_right`` to 70 293 | to make our table narrower and center it in the page. 294 | 295 | In ``fills`` we overwrite the default value of ``cell_fill``, for some of the 296 | rows in the table. The format of this ``fills`` list is explained in 297 | :class:`pdfme.table.PDFTable` definition, but in short, we are setting the fill 298 | color of the even rows to a gray color. 299 | 300 | In ``borders`` we overwrite the default value of ``border_width`` (which we set 301 | to 0 in ``style``) for some of the horizontal borders in the table. The format 302 | of this ``borders`` list is explained in :class:`pdfme.table.PDFTable` 303 | definition too, but in short, we are setting the border width of the first, 304 | second and last horizontal borders to 0.5. 305 | 306 | And finally we are adding the table contents in the ``table`` key. Each list, 307 | in this ``table`` list, represents a row of the table, and each element in a row 308 | list represents a cell. 309 | 310 | Next we will add our second table to the document, a form table with some 311 | cells combined. 312 | 313 | .. code-block:: 314 | 315 | table_def2 = { 316 | 'widths': [1.2, .8, 1, 1], 317 | 'table': [ 318 | [ 319 | { 320 | 'colspan': 4, 321 | 'style': { 322 | 'cell_fill': [0.8, 0.53, 0.3], 323 | 'text_align': 'c' 324 | }, 325 | '.b;c:1;s:12': 'Fake Form' 326 | },None, None, None 327 | ], 328 | [ 329 | {'colspan': 2, '.': [{'.b': 'First Name\n'}, 'Fakechael']}, None, 330 | {'colspan': 2, '.': [{'.b': 'Last Name\n'}, 'Fakinson Faker']}, None 331 | ], 332 | [ 333 | [{'.b': 'Email\n'}, 'fakeuser@fakemail.com'], 334 | [{'.b': 'Age\n'}, '35'], 335 | [{'.b': 'City of Residence\n'}, 'Fake City'], 336 | [{'.b': 'Cell Number\n'}, '33333333333'], 337 | ] 338 | ] 339 | } 340 | 341 | content1.append(table_def2) 342 | 343 | In the first row we combined the 4 columns to show the title of the form; in 344 | the second row we combine the first 2 columns for the first name, and the other 345 | 2 columns for the last name; and in the last row we use the four cells to the 346 | rest of the information. 347 | 348 | Notice that cells that are below or to the right of a merged cell must be equal 349 | to ``None``, and that instead of using strings inside the cells, like we did 350 | in the first table, we used paragraph parts in the cells. And besides paragraphs 351 | you can add a content box, an image or even another table to a cell. 352 | 353 | Now we will add a second section. 354 | 355 | .. code-block:: 356 | 357 | document['sections'].append({ 358 | 'style': { 359 | 'page_numbering_reset': True, 'page_numbering_style': 'arabic' 360 | }, 361 | 'running_sections': ['header', 'footer'], 362 | 'content': [ 363 | 364 | { 365 | '.': 'Title 2', 'style': 'title', 'label': 'title2', 366 | 'outline': {} 367 | }, 368 | 369 | { 370 | 'style': {'list_text': '1. '}, 371 | '.': ['This is a list paragraph with a reference to ', 372 | {'.': 'Title 1.', 'style': 'url', 'ref': 'title1'}] 373 | } 374 | ] 375 | }) 376 | 377 | In this section we set the page numbering style back to the default value, 378 | ``arabic``, and we reset the page count to 1 by including 379 | ``page_numbering_reset`` in the ``style`` dict. 380 | 381 | We also added running section ``header``, additional to the running section 382 | ``footer`` we used in the first section. 383 | 384 | And we added the second title of the document, with its label and outline, and a 385 | list paragraph (a paragraph with text ``'1. '`` on the left of the paragraph) 386 | with a reference to the first title of the document. 387 | 388 | Finally, we will generate the PDF document from the dict ``document`` we just 389 | built, by using ``build_pdf`` function. 390 | 391 | .. code-block:: 392 | 393 | with open('document.pdf', 'wb') as f: 394 | build_pdf(document, f) 395 | 396 | Following these steps we will have a PDF document called ``document.pdf`` with 397 | all of the contents we added to ``document`` dict. -------------------------------------------------------------------------------- /pdfme/__init__.py: -------------------------------------------------------------------------------- 1 | from .pdf import PDF 2 | from .document import build_pdf, PDFDocument -------------------------------------------------------------------------------- /pdfme/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | from uuid import uuid4 3 | 4 | class PDFBase: 5 | """This class represents a PDF file, and deals with parsing python 6 | objects you add to it (with method ``add``) to PDF indirect objects. 7 | The python types that are parsable to their equivalent PDF types are 8 | ``dict`` (parsed to PDF Dictionaries), ``list``, ``tuple``, ``set`` 9 | (parsed to PDF Arrays), ``bytes`` (no parsing is done with this type), 10 | ``bool`` (parsed to PDF Boolean), ``int`` (parsed to PDF Integer), 11 | ``float`` (parsed to PDF Real), ``str`` (parsed to PDF String) and 12 | ``PDFObject``, a python representation of a PDF object. 13 | 14 | When you are done adding objects to an instance of this class, you just 15 | have to call its ``output`` method to create the PDF file, and we will 16 | take care of creating the head, the objects, the streams, the xref 17 | table, the trailer, etc. 18 | 19 | As mentioned before, you can use python type ``bytes`` to add anything 20 | to the PDF file, and this can be used to add PDF objects like *Names*. 21 | 22 | For ``dict`` objects, the keys must be of type ``str`` and you don't 23 | have to use PDF Names for the keys, because they are automatically 24 | transformed into PDF Names when the PDF file is being created. For 25 | example, to add a page dict, the keys would be ``Type``, ``Content`` and 26 | ``Resources``, instead of ``/Type``, ``/Content`` and 27 | ``/Resources``, like this: 28 | 29 | .. code-block:: python 30 | 31 | base = PDFBase() 32 | page_dict = { 33 | 'Type': b'/Page', 'Contents': stream_obj_ref, 'Resources': {} 34 | } 35 | base.add(page_dict) 36 | 37 | You can add a ``stream`` object by adding a ``dict`` like the one described 38 | in function :func:`pdfme.parser.parse_stream`. 39 | 40 | This class behaves like a ``list``, and you can get a ``PDFObject`` by 41 | index (you can get the index from a ``PDFObject.id`` attribute), update 42 | by index, iterate through the PDF PDFObjects and use ``len`` to get the 43 | amount of objects in this list-like class. 44 | 45 | Args: 46 | version (str, optional): Version of the PDF file. Defaults to '1.5'. 47 | trailer (dict, optional): You can create your own trailer dict and 48 | pass it as this argument. 49 | 50 | Raises: 51 | ValueError: If trailer is not dict type 52 | """ 53 | 54 | def __init__(self, version: str='1.5', trailer: dict=None) -> None: 55 | self.version = version 56 | self.content = [] 57 | if trailer is None: 58 | self.trailer = {} 59 | elif not isinstance(trailer, dict): 60 | raise ValueError('trailer must be a dict') 61 | else: 62 | self.trailer = trailer 63 | self.count = 1 64 | 65 | def add( 66 | self, py_obj: Union[ 67 | dict, list, tuple, set, bytes, bool, int, float, str, 'PDFObject' 68 | ] 69 | ) -> 'PDFObject': 70 | """Add a new object to the PDF file 71 | 72 | Args: 73 | py_obj(dict, list, tuple, set, bytes, bool, int, float, str, PDFObject): Object 74 | to be added. 75 | 76 | Raises: 77 | TypeError: If ``py_obj`` arg is not an allowed type. 78 | 79 | Returns: 80 | PDFObject: A PDFObject representing the object added 81 | """ 82 | 83 | allowed_types = ( 84 | dict, list, tuple, set, bytes, bool, int, float, str, PDFObject 85 | ) 86 | if not isinstance(py_obj, allowed_types): 87 | raise TypeError('object type not allowed') 88 | obj = PDFObject(PDFRef(self.count), py_obj) 89 | self.content.append(obj) 90 | self.count += 1 91 | return obj 92 | 93 | def __getitem__(self, i: int) -> 'PDFObject': 94 | if i == 0: return None 95 | return self.content[i - 1] 96 | 97 | def __setitem__(self, i: int, value: 'PDFObject') -> None: 98 | if i > 0: 99 | self.content[i - 1] = value 100 | 101 | def __iter__(self) -> None: 102 | for el in [None] + self.content: 103 | yield el 104 | 105 | def __len__(self) -> int: 106 | return len(self.content) 107 | 108 | def __str__(self) -> str: 109 | return str(self.content) 110 | 111 | def __repr__(self) -> str: 112 | return str(self.content) 113 | 114 | def _trailer_id(self) -> bytes: 115 | return b'<' + str(uuid4()).replace('-', '').encode('latin') + b'>' 116 | 117 | def output(self, buffer: Any) -> None: 118 | """Create the PDF file. 119 | 120 | Args: 121 | buffer (file_like): A file-like object to write the PDF file into. 122 | """ 123 | header = subs('%PDF-{}\n%%\x129\x129\x129\n', self.version) 124 | count = len(header) 125 | buffer.write(header) 126 | 127 | xref = '\nxref\n0 {}\n0000000000 65535 f \n'.format(self.count) 128 | 129 | for i, obj in enumerate(self.content): 130 | xref += str(count).zfill(10) + ' 00000 n \n' 131 | 132 | obj_bytes = parse_obj(obj) 133 | bytes_ = subs('{} 0 obj\n', i + 1) + obj_bytes + \ 134 | '\nendobj\n'.encode('latin') 135 | count += len(bytes_) 136 | buffer.write(bytes_) 137 | 138 | self.trailer['Size'] = self.count 139 | if 'ID' not in self.trailer: 140 | self.trailer['ID'] = [self._trailer_id(), self._trailer_id()] 141 | trailer = parse_obj(self.trailer) 142 | 143 | footer = '\nstartxref\n{}\n%%EOF'.format(count + 1) 144 | 145 | buffer.write( 146 | (xref + 'trailer\n').encode('latin') + trailer + 147 | footer.encode('latin') 148 | ) 149 | 150 | from .parser import PDFObject, PDFRef, parse_obj 151 | from .utils import subs 152 | -------------------------------------------------------------------------------- /pdfme/color.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Union 3 | 4 | colors = { 5 | 'aliceblue': [0.941, 0.973, 1.0], 6 | 'antiquewhite': [0.98, 0.922, 0.844], 7 | 'aqua': [0, 1.0, 1.0], 8 | 'aquamarine': [0.5, 1.0, 0.832], 9 | 'azure': [0.941, 1.0, 1.0], 10 | 'beige': [0.961, 0.961, 0.863], 11 | 'bisque': [1.0, 0.895, 0.77], 12 | 'black': [0, 0, 0], 13 | 'blanchedalmond': [1.0, 0.922, 0.805], 14 | 'blue': [0, 0, 1.0], 15 | 'blueviolet': [0.543, 0.172, 0.887], 16 | 'brown': [0.648, 0.168, 0.168], 17 | 'burlywood': [0.871, 0.723, 0.531], 18 | 'cadetblue': [0.375, 0.621, 0.629], 19 | 'chartreuse': [0.5, 1.0, 0], 20 | 'chocolate': [0.824, 0.414, 0.121], 21 | 'coral': [1.0, 0.5, 0.316], 22 | 'cornflowerblue': [0.395, 0.586, 0.93], 23 | 'cornsilk': [1.0, 0.973, 0.863], 24 | 'crimson': [0.863, 0.082, 0.238], 25 | 'cyan': [0, 1.0, 1.0], 26 | 'darkblue': [0, 0, 0.547], 27 | 'darkcyan': [0, 0.547, 0.547], 28 | 'darkgoldenrod': [0.723, 0.527, 0.047], 29 | 'darkgray': [0.664, 0.664, 0.664], 30 | 'darkgrey': [0.664, 0.664, 0.664], 31 | 'darkgreen': [0, 0.395, 0], 32 | 'darkkhaki': [0.742, 0.719, 0.422], 33 | 'darkmagenta': [0.547, 0, 0.547], 34 | 'darkolivegreen': [0.336, 0.422, 0.188], 35 | 'darkorange': [1.0, 0.551, 0], 36 | 'darkorchid': [0.602, 0.199, 0.801], 37 | 'darkred': [0.547, 0, 0], 38 | 'darksalmon': [0.914, 0.59, 0.48], 39 | 'darkseagreen': [0.562, 0.738, 0.562], 40 | 'darkslateblue': [0.285, 0.242, 0.547], 41 | 'darkslategray': [0.188, 0.312, 0.312], 42 | 'darkslategrey': [0.188, 0.312, 0.312], 43 | 'darkturquoise': [0, 0.809, 0.82], 44 | 'darkviolet': [0.582, 0, 0.828], 45 | 'deeppink': [1.0, 0.082, 0.578], 46 | 'deepskyblue': [0, 0.75, 1.0], 47 | 'dimgray': [0.414, 0.414, 0.414], 48 | 'dimgrey': [0.414, 0.414, 0.414], 49 | 'dodgerblue': [0.121, 0.566, 1.0], 50 | 'firebrick': [0.699, 0.137, 0.137], 51 | 'floralwhite': [1.0, 0.98, 0.941], 52 | 'forestgreen': [0.137, 0.547, 0.137], 53 | 'fuchsia': [1.0, 0, 1.0], 54 | 'gainsboro': [0.863, 0.863, 0.863], 55 | 'ghostwhite': [0.973, 0.973, 1.0], 56 | 'gold': [1.0, 0.844, 0], 57 | 'goldenrod': [0.855, 0.648, 0.129], 58 | 'gray': [0.504, 0.504, 0.504], 59 | 'grey': [0.504, 0.504, 0.504], 60 | 'green': [0, 0.504, 0], 61 | 'greenyellow': [0.68, 1.0, 0.188], 62 | 'honeydew': [0.941, 1.0, 0.941], 63 | 'hotpink': [1.0, 0.414, 0.707], 64 | 'indianred': [0.805, 0.363, 0.363], 65 | 'indigo': [0.297, 0, 0.512], 66 | 'ivory': [1.0, 1.0, 0.941], 67 | 'khaki': [0.941, 0.902, 0.551], 68 | 'lavender': [0.902, 0.902, 0.98], 69 | 'lavenderblush': [1.0, 0.941, 0.961], 70 | 'lawngreen': [0.488, 0.988, 0], 71 | 'lemonchiffon': [1.0, 0.98, 0.805], 72 | 'lightblue': [0.68, 0.848, 0.902], 73 | 'lightcoral': [0.941, 0.504, 0.504], 74 | 'lightcyan': [0.879, 1.0, 1.0], 75 | 'lightgoldenrodyellow': [0.98, 0.98, 0.824], 76 | 'lightgray': [0.828, 0.828, 0.828], 77 | 'lightgrey': [0.828, 0.828, 0.828], 78 | 'lightgreen': [0.566, 0.934, 0.566], 79 | 'lightpink': [1.0, 0.715, 0.758], 80 | 'lightsalmon': [1.0, 0.629, 0.48], 81 | 'lightseagreen': [0.129, 0.699, 0.668], 82 | 'lightskyblue': [0.531, 0.809, 0.98], 83 | 'lightslategray': [0.469, 0.535, 0.602], 84 | 'lightslategrey': [0.469, 0.535, 0.602], 85 | 'lightsteelblue': [0.691, 0.77, 0.871], 86 | 'lightyellow': [1.0, 1.0, 0.879], 87 | 'lime': [0, 1.0, 0], 88 | 'limegreen': [0.199, 0.805, 0.199], 89 | 'linen': [0.98, 0.941, 0.902], 90 | 'magenta': [1.0, 0, 1.0], 91 | 'maroon': [0.504, 0, 0], 92 | 'mediumaquamarine': [0.402, 0.805, 0.668], 93 | 'mediumblue': [0, 0, 0.805], 94 | 'mediumorchid': [0.73, 0.336, 0.828], 95 | 'mediumpurple': [0.578, 0.441, 0.859], 96 | 'mediumseagreen': [0.238, 0.703, 0.445], 97 | 'mediumslateblue': [0.484, 0.41, 0.934], 98 | 'mediumspringgreen': [0, 0.98, 0.605], 99 | 'mediumturquoise': [0.285, 0.82, 0.801], 100 | 'mediumvioletred': [0.781, 0.086, 0.523], 101 | 'midnightblue': [0.102, 0.102, 0.441], 102 | 'mintcream': [0.961, 1.0, 0.98], 103 | 'mistyrose': [1.0, 0.895, 0.883], 104 | 'moccasin': [1.0, 0.895, 0.711], 105 | 'navajowhite': [1.0, 0.871, 0.68], 106 | 'navy': [0, 0, 0.504], 107 | 'oldlace': [0.992, 0.961, 0.902], 108 | 'olive': [0.504, 0.504, 0], 109 | 'olivedrab': [0.422, 0.559, 0.141], 110 | 'orange': [1.0, 0.648, 0], 111 | 'orangered': [1.0, 0.273, 0], 112 | 'orchid': [0.855, 0.441, 0.84], 113 | 'palegoldenrod': [0.934, 0.91, 0.668], 114 | 'palegreen': [0.598, 0.984, 0.598], 115 | 'paleturquoise': [0.688, 0.934, 0.934], 116 | 'palevioletred': [0.859, 0.441, 0.578], 117 | 'papayawhip': [1.0, 0.938, 0.836], 118 | 'peachpuff': [1.0, 0.855, 0.727], 119 | 'peru': [0.805, 0.523, 0.25], 120 | 'pink': [1.0, 0.754, 0.797], 121 | 'plum': [0.867, 0.629, 0.867], 122 | 'powderblue': [0.691, 0.879, 0.902], 123 | 'purple': [0.504, 0, 0.504], 124 | 'rebeccapurple': [0.402, 0.203, 0.602], 125 | 'red': [1.0, 0, 0], 126 | 'rosybrown': [0.738, 0.562, 0.562], 127 | 'royalblue': [0.258, 0.414, 0.883], 128 | 'saddlebrown': [0.547, 0.273, 0.078], 129 | 'salmon': [0.98, 0.504, 0.449], 130 | 'sandybrown': [0.957, 0.645, 0.379], 131 | 'seagreen': [0.184, 0.547, 0.344], 132 | 'seashell': [1.0, 0.961, 0.934], 133 | 'sienna': [0.629, 0.324, 0.18], 134 | 'silver': [0.754, 0.754, 0.754], 135 | 'skyblue': [0.531, 0.809, 0.922], 136 | 'slateblue': [0.418, 0.355, 0.805], 137 | 'slategray': [0.441, 0.504, 0.566], 138 | 'slategrey': [0.441, 0.504, 0.566], 139 | 'snow': [1.0, 0.98, 0.98], 140 | 'springgreen': [0, 1.0, 0.5], 141 | 'steelblue': [0.277, 0.512, 0.707], 142 | 'tan': [0.824, 0.707, 0.551], 143 | 'teal': [0, 0.504, 0.504], 144 | 'thistle': [0.848, 0.75, 0.848], 145 | 'tomato': [1.0, 0.391, 0.281], 146 | 'turquoise': [0.254, 0.879, 0.816], 147 | 'violet': [0.934, 0.512, 0.934], 148 | 'wheat': [0.961, 0.871, 0.703], 149 | 'white': [1.0, 1.0, 1.0], 150 | 'whitesmoke': [0.961, 0.961, 0.961], 151 | 'yellow': [1.0, 1.0, 0], 152 | 'yellowgreen': [0.605, 0.805, 0.199] 153 | } 154 | 155 | ColorType = Union[int, float, str, list, tuple] 156 | class PDFColor: 157 | """Class that generates a PDF color string (with function ``str()``) 158 | using the rules described in :func:`pdfme.color.parse_color`. 159 | 160 | Args: 161 | color (int, float, list, tuple, str, PDFColor): The color 162 | specification. 163 | stroke (bool, optional): Whether this is a color for stroke(True) 164 | or for fill(False). Defaults to False. 165 | """ 166 | 167 | def __init__( 168 | self, color: Union[ColorType, 'PDFColor'], stroke: bool=False 169 | ) -> None: 170 | if isinstance(color, PDFColor): 171 | self.color = copy(color.color) 172 | else: 173 | self.color = parse_color(color) 174 | self.stroke = stroke 175 | 176 | def __eq__(self, color): 177 | if color is None: return self.color is None 178 | if not isinstance(color, PDFColor): 179 | return False 180 | return self.color == color.color and self.stroke == color.stroke 181 | 182 | def __neq__(self, color): 183 | if color is None: return not self.color is None 184 | if not isinstance(color, PDFColor): 185 | raise TypeError("Can't compare PDFColor with {}".format(type(color))) 186 | return self.color != color.color or self.stroke != color.stroke 187 | 188 | def __str__(self): 189 | if self.color is None: 190 | return '' 191 | if len(self.color) == 1: 192 | return '{} {}'.format( 193 | round(self.color[0], 3), 'G' if self.stroke else 'g' 194 | ) 195 | if len(self.color) == 3: 196 | return '{} {} {} {}'.format( 197 | *[round(color, 3) for color in self.color[0:3]], 198 | 'RG' if self.stroke else 'rg' 199 | ) 200 | 201 | def parse_color(color: ColorType) -> list: 202 | """Function to parse ``color`` into a list representing a PDF color. 203 | 204 | The scale of the colors is between 0 and 1, instead of 0 and 256, so all the 205 | numbers in ``color`` must be between 0 and 1. 206 | 207 | ``color`` of type int or float represents a gray color between black (0) and 208 | white (1). 209 | 210 | ``color`` of type list or tuple is a gray color if its length is 1, a rgb 211 | color if its length is 3, and a rgba color if its length is 4 (not yet 212 | supported). 213 | 214 | ``color`` of type str can be a hex color of the form "#aabbcc", the name 215 | of a color in the variable ``colors`` in file `color.py`_, or a space 216 | separated list of numbers, that is parsed as an rgb color, like 217 | the one described before in the list ``color`` type. 218 | 219 | Args: 220 | color (int, float, list, tuple, str): The color specification. 221 | 222 | Returns: 223 | list: list representing the PDF color. 224 | 225 | .. _color.py: https://github.com/aFelipeSP/pdfme/blob/main/pdfme/color.py 226 | """ 227 | 228 | if color is None: 229 | return None 230 | if isinstance(color, (int, float)): 231 | return [color] 232 | if isinstance(color, str): 233 | if color == '': 234 | return None 235 | elif color in colors: 236 | return colors[color] 237 | elif color[0] == '#' and len(color) in [4,5,7,9]: 238 | try: int(color[1:], 16) 239 | except: 240 | raise TypeError("Couldn't parse hexagesimal color value: {}".format(color)) 241 | 242 | n = len(color) 243 | if n in [4, 5]: 244 | return [int(color[i:1+i] + color[i:1+i], 16)/255 for i in range(1,4)] 245 | else: 246 | return [int(color[i:2+i], 16)/255 for i in range(1,7,2)] 247 | else: 248 | color = re.split(',| ', color) 249 | 250 | if not isinstance(color, (list, tuple)): 251 | raise TypeError('Invalid color value type: {}'.format(type(color))) 252 | 253 | if len(color) == 1: 254 | v = color[0] 255 | if isinstance(v, str): 256 | try: 257 | return [float(v)] 258 | except: 259 | raise TypeError("Couldn't parse numeric color value: {}".format(v)) 260 | elif isinstance(v, (int, float)): 261 | return [v] 262 | else: 263 | raise TypeError("Invalid color value type: {}".format(type(v))) 264 | elif len(color) in [3,4]: 265 | try: 266 | return [float(c) for c in color[:4]] 267 | except: 268 | raise TypeError("Couldn't parse numeric color value: {}".format(color)) 269 | 270 | from .utils import copy 271 | -------------------------------------------------------------------------------- /pdfme/document.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Any, Iterable, Union 3 | 4 | from pdfme.utils import parse_range_string 5 | 6 | STYLE_PROPS = dict( 7 | f='font_family', s='font_size', c='font_color', text_align='text_align', 8 | line_height='line_height', indent='indent' 9 | ) 10 | 11 | PAGE_PROPS = ('page_size', 'rotate_page', 'margin') 12 | PAGE_NUMBERING = ('page_numbering_offset', 'page_numbering_style') 13 | 14 | class PDFDocument: 15 | """Class that helps to build a PDF document from a dict (``document`` 16 | argument) describing the document contents. 17 | 18 | This class uses an instance of :class:`pdfme.pdf.PDF` internally to build 19 | the PDF document, but adds some functionalities to allow the user to 20 | build a PDF document from a JSONish dict, add footnotes and other 21 | functions explained here. 22 | 23 | A document is made up of sections, that can have their own page layout, 24 | page numbering, running sections and style. 25 | 26 | ``document`` dict can have the following keys: 27 | 28 | * ``style``: the default style of each section inside the document. A dict 29 | with all of the keys that a content box can have (see 30 | :class:`pdfme.content.PDFContent` for more information about content 31 | box, and for the default values of the attributes of this dict see 32 | :class:`pdfme.pdf.PDF`). Additional to the keys of content box style, you 33 | can add the following keys: ``outlines_level``, ``page_size``, 34 | ``rotate_page``, ``margin``, ``page_numbering_offset`` and 35 | ``page_numbering_style``. For more information about this page attributes 36 | and their default values see :class:`pdfme.pdf.PDF` definition. 37 | 38 | * ``formats``: a dict with the global styles of the document that can be 39 | used anywhere in the document. For more information about this dict 40 | see :class:`pdfme.pdf.PDF` definition. 41 | 42 | * ``running_sections``: a dict with the running sections that will be used 43 | by each section in the document. Each section can have, in turn, a 44 | ``running_section`` list, with the name of the running sections defined in 45 | this argument that should be included in the section. For information 46 | about running sections see :class:`pdfme.pdf.PDF`. 47 | If ``width`` key is equal to ``'left'``, it takes the value of the left 48 | margin, if equal to ``'right'`` it takes the value of the right margin, if 49 | equal to ``'full'`` it takes the value of the whole page width, and if it 50 | is not defined or is None it will take the value of the content width of 51 | the page. 52 | If ``height`` key is equal to ``'top'``, it takes the value of the top 53 | margin, if equal to ``'bottom'`` it takes the value of the bottom margin, 54 | if equal to ``'full'`` it takes the value of the whole page height, and if 55 | it is not defined or is None it will take the value the content height of 56 | the page. 57 | If ``x`` key is equal to ``'left'``, it takes the value of the left 58 | margin, if equal to ``'right'`` it takes the value of the whole page width 59 | minus the right margin, and if it is not defined or is None it will be 0. 60 | If ``y`` key is equal to ``'top'``, it takes the value of the top 61 | margin, if equal to ``'bottom'`` it takes the value of the whole page 62 | height minus the bottom margin, and if it is not defined or is None i 63 | will be 0. 64 | 65 | * ``per_page``: a list of dicts, each with a mandatory key ``pages``, a 66 | comma separated string of indexes or ranges (python style), and any of the 67 | following optional keys: 68 | 69 | * ``style``: a style dict with page related style properties (page_size, 70 | rotate_page, margin) that will be applied to every page in the ``pages`` 71 | ranges. 72 | * ``running_sections``: a dict with optional ``exclude`` and ``include`` 73 | lists of running sections names to be included and excluded in every 74 | page in the ``pages`` ranges. 75 | 76 | * ``sections``: an iterable with the sections of the document. 77 | 78 | Each section in ``sections`` iterable is a dict like the one that can be 79 | passed to :class:`pdfme.content.PDFContent`, so each section ends up being 80 | a content box. This class will add as many pages as it is needed to add 81 | all the contents of every section (content box) to the PDF document. 82 | 83 | Additional to the keys from a content box dict, you can 84 | include a ``running_sections`` list with the name of the 85 | running sections that you want to be included in all of the pages of the 86 | section. There is a special key that you can include in a section's 87 | ``style`` dict called ``page_numbering_reset``, that if True, resets 88 | the numbering of the pages. 89 | 90 | You can also include footnotes in any paragraph, by adding a dict with the 91 | key ``footnote`` with the description of the footnote as its value, to the 92 | list of elements of the dot key (see :class:`pdfme.text.PDFText` for more 93 | informarion about the structure of a paragraph and the dot key). 94 | 95 | Here is an example of a document dict, and how it can be used to build a 96 | PDF document using the helper function :func:`pdfme.document.build_pdf`. 97 | 98 | .. code-block:: python 99 | 100 | from pdfme import build_pdf 101 | 102 | document = { 103 | "style": { 104 | "page_size": "letter", "margin": [70, 60], 105 | "s": 10, "c": 0.3, "f": "Times", "text_align": "j", 106 | "margin_bottom": 10 107 | }, 108 | "formats": { 109 | "link": {"c": "blue", "u": True}, 110 | "title": {"s": 12, "b": True} 111 | }, 112 | "running_sections": { 113 | "header": { 114 | "x": "left", "y": 40, "height": "top", 115 | "content": ["Document with header"] 116 | }, 117 | "footer": { 118 | "x": "left", "y": "bottom", "height": "bottom", 119 | "style": {"text_align": "c"}, 120 | "content": [{".": ["Page ", {"var": "$page"}]}] 121 | } 122 | }, 123 | "sections": [ 124 | { 125 | "running_sections": ["header", "footer"], 126 | "style": {"margin": 60}, 127 | "content": [ 128 | {".": "This is a title", "style": "title"}, 129 | {".": [ 130 | "Here we include a footnote", 131 | {"footnote": "Description of a footnote"}, 132 | ". And here we include a ", 133 | { 134 | ".": "link", "style": "link", 135 | "uri": "https://some.url.com" 136 | } 137 | ]} 138 | ] 139 | }, 140 | { 141 | "running_sections": ["footer"], 142 | "style": {"rotate_page": True}, 143 | "content": [ 144 | "This is a rotated page" 145 | ] 146 | } 147 | ] 148 | } 149 | 150 | with open('document.pdf', 'wb') as f: 151 | build_pdf(document, f) 152 | 153 | Args: 154 | document (dict): a dict like the one just described. 155 | context (dict, optional): a dict containing the context of the inner 156 | :class:`pdfme.pdf.PDF` instance. 157 | """ 158 | def __init__(self, document: dict, context: dict=None) -> None: 159 | context = {} if context is None else context 160 | style = copy(document.get('style', {})) 161 | style_args = { 162 | v: style[k] for k, v in STYLE_PROPS.items() if k in style 163 | } 164 | 165 | page_args = { 166 | k: style[k] for k in PAGE_PROPS + PAGE_NUMBERING if k in style 167 | } 168 | 169 | self.pdf = PDF( 170 | outlines_level=style.get('outlines_level', 1), 171 | **page_args, **style_args 172 | ) 173 | 174 | self.style = style 175 | 176 | self.pdf.context.update(context) 177 | 178 | self.pdf.formats = {} 179 | self.pdf.formats['$footnote'] = {'r': 0.5, 's': 6} 180 | self.pdf.formats['$footnotes'] = {'s': 10, 'c': 0} 181 | self.pdf.formats.update(document.get('formats', {})) 182 | 183 | self.running_sections = document.get('running_sections', {}) 184 | 185 | self.per_page = [] 186 | for range_dict in document.get('per_page', []): 187 | new_range_dict = copy(range_dict) 188 | new_range_dict['pages'] = parse_range_string(range_dict['pages']) 189 | self.per_page.append(new_range_dict) 190 | 191 | self.sections = document.get('sections', []) 192 | 193 | self.x = self.y = self.width = self.height = 0 194 | 195 | self.footnotes = [] 196 | self._traverse_document_footnotes(self.sections) 197 | 198 | self.footnotes_margin = 10 199 | 200 | def _traverse_document_footnotes( 201 | self, element: Union[list, tuple, dict] 202 | ) -> None: 203 | """Method to traverse the document sections, trying to find footnotes 204 | dicts, to prepare them for being processed by the inner PDF instance. 205 | 206 | Args: 207 | element (list, tuple, dict): the element to be tarversed. 208 | 209 | Raises: 210 | TypeError: 211 | """ 212 | if isinstance(element, (list, tuple)): 213 | for child in element: 214 | self._traverse_document_footnotes(child) 215 | elif isinstance(element, dict): 216 | if 'footnote' in element: 217 | element.setdefault('ids', []) 218 | name = '$footnote:' + str(len(self.footnotes)) 219 | element['ids'].append(name) 220 | element['style'] = '$footnote' 221 | element['var'] = name 222 | self.pdf.context[name] = '0' 223 | 224 | footnote = element['footnote'] 225 | 226 | if not isinstance(footnote, (dict, str, list, tuple)): 227 | footnote = str(footnote) 228 | if isinstance(footnote, (str, list, tuple)): 229 | footnote = {'.': footnote} 230 | 231 | if not isinstance(footnote, dict): 232 | raise TypeError( 233 | 'footnotes must be of type dict, str, list or tuple:{}' 234 | .format(footnote) 235 | ) 236 | 237 | self.footnotes.append(footnote) 238 | else: 239 | for value in element.values(): 240 | if isinstance(value, (list, tuple, dict)): 241 | self._traverse_document_footnotes(value) 242 | 243 | def _set_running_sections( 244 | self, running_sections: Iterable, page_width: 'Number', 245 | page_height: 'Number', margin: dict 246 | ): 247 | """Method to set the running sections for every section in the document. 248 | 249 | Args: 250 | running_sections (list, tuple): the list of the running sections 251 | of the current section being added. 252 | """ 253 | self.pdf.running_sections = [] 254 | for name in running_sections: 255 | section = copy(self.running_sections[name]) 256 | 257 | if section.get('width') in ['left', 'right']: 258 | section['width'] = margin[section.get('width')] 259 | if section.get('width') == 'full': 260 | section['width'] = page_width 261 | if section.get('height') in ['top', 'bottom']: 262 | section['height'] = margin[section.get('height')] 263 | if section.get('height') == 'full': 264 | section['height'] = page_height 265 | if section.get('x') == 'left': 266 | section['x'] = margin['left'] 267 | if section.get('x') == 'right': 268 | section['x'] = page_width - margin['right'] 269 | if section.get('y') == 'top': 270 | section['y'] = margin['top'] 271 | if section.get('y') == 'bottom': 272 | section['y'] = page_height - margin['bottom'] 273 | 274 | width = section.get('width', ( 275 | page_width - margin['right'] - margin['left'] 276 | )) 277 | height = section.get('height', ( 278 | page_height - margin['top'] - margin['bottom'] 279 | )) 280 | x = section.get('x', 0) 281 | y = section.get('y', 0) 282 | self.pdf.add_running_section(section, width, height, x, y) 283 | 284 | def run(self) -> None: 285 | """Method to process this document sections. 286 | """ 287 | for section in self.sections: 288 | self._process_section(section) 289 | 290 | def _process_section(self, section: dict) -> None: 291 | """Method to process a section from this document. 292 | 293 | Args: 294 | section (dict): a dict representing the section to be processed. 295 | """ 296 | section_style = copy(self.style) 297 | section_style.update(process_style(section.get('style', {}), self.pdf)) 298 | 299 | if 'page_numbering_offset' in section_style: 300 | self.pdf.page_numbering_offset = section_style['page_numbering_offset'] 301 | if 'page_numbering_style' in section_style: 302 | self.pdf.page_numbering_style = section_style['page_numbering_style'] 303 | if section_style.get('page_numbering_reset', False): 304 | self.pdf.page_numbering_offset = -len(self.pdf.pages) 305 | 306 | section['style'] = section_style 307 | 308 | self.section = self.pdf._create_content( 309 | section, self.width, self.height, self.x, self.y 310 | ) 311 | 312 | section_page_args = { 313 | k: section_style[k] for k in PAGE_PROPS if k in section_style 314 | } 315 | 316 | while True: 317 | page_n = len(self.pdf.pages) 318 | 319 | page_args = section_page_args.copy() 320 | 321 | running_sections = set(section.get('running_sections', [])) 322 | 323 | for range_dict in self.per_page: 324 | if page_n in range_dict['pages']: 325 | if 'style' in range_dict: 326 | page_style = range_dict['style'] 327 | page_args.update({ 328 | k: page_style[k] for k in PAGE_PROPS 329 | if k in page_style 330 | }) 331 | if 'running_sections' in range_dict: 332 | per_page_rs = range_dict['running_sections'] 333 | running_sections -= set(per_page_rs.get('exclude', [])) 334 | running_sections.update( 335 | set(per_page_rs.get('include', [])) 336 | ) 337 | 338 | self.pdf.setup_page(**page_args) 339 | 340 | page_width, page_height = self.pdf.page_width, self.pdf.page_height 341 | if self.pdf.rotate_page: 342 | page_width, page_height = page_height, page_width 343 | 344 | self.x = self.pdf.margin['left'] 345 | self.width = page_width - self.pdf.margin['right'] - self.x 346 | self.y = page_height - self.pdf.margin['top'] 347 | self.height = self.y - self.pdf.margin['bottom'] 348 | 349 | self.section.setup(self.x, self.y, self.width, self.height) 350 | 351 | self._set_running_sections( 352 | running_sections, page_width, page_height, self.pdf.margin 353 | ) 354 | 355 | self.pdf.add_page() 356 | 357 | self._add_content() 358 | 359 | if self.section.finished: 360 | break 361 | 362 | def _add_content(self) -> None: 363 | """Method to add the section contents to the current page. 364 | 365 | Raises: 366 | Exception: if the footnotes added to the page are very large. 367 | """ 368 | 369 | section_state = self.section.get_state() \ 370 | if self.section.pdf_content_part is not None else { 371 | 'section_element_index': 0, 372 | 'section_delayed': [], 373 | 'children_memory': [] 374 | } 375 | 376 | self.section.run(height=self.height) 377 | footnotes_obj = self._process_footnotes() 378 | 379 | if footnotes_obj is None: 380 | self.pdf._add_graphics([*self.section.fills,*self.section.lines]) 381 | self.pdf._add_parts(self.section.parts) 382 | self.pdf.page._y -= self.section.current_height 383 | else: 384 | footnotes_height = footnotes_obj.current_height 385 | if footnotes_height >= self.height - self.footnotes_margin - 20: 386 | raise Exception( 387 | "footnotes are very large and don't fit in one page" 388 | ) 389 | new_height = self.height - footnotes_obj.current_height \ 390 | - self.footnotes_margin 391 | 392 | if section_state is not None: 393 | self.section.set_state(**section_state) 394 | self.section.finished = False 395 | self.pdf._content(self.section, height=new_height) 396 | 397 | footnotes_obj = self._process_footnotes() 398 | 399 | if footnotes_obj is not None: 400 | self.pdf.page._y = self.pdf.margin['bottom'] + footnotes_height 401 | self.pdf.page.x = self.x 402 | 403 | x_line = round(self.pdf.page.x, 3) 404 | y_line = round(self.pdf.page._y + self.footnotes_margin/2, 3) 405 | self.pdf.page.add(' q 0 G 0.5 w {} {} m {} {} l S Q'.format( 406 | x_line, y_line, x_line + 150, y_line 407 | )) 408 | self.pdf._content(footnotes_obj, height=self.height) 409 | 410 | def _check_footnote(self, ids: dict, page_footnotes: list) -> None: 411 | """Method that extract the footnotes from the ids passed as argument, 412 | and adds them to the ``page_footnotes`` list argument. 413 | 414 | Args: 415 | ids (dict): the ids list. 416 | page_footnotes (list): the list of the page footnotes to save the 417 | footnotes found in the ids. 418 | """ 419 | for id_, rects in ids.items(): 420 | if len(rects) == 0: 421 | continue 422 | if id_.startswith('$footnote:'): 423 | index = int(id_[10:]) 424 | page_footnotes.append(self.footnotes[index]) 425 | self.pdf.context[id_] = len(page_footnotes) 426 | 427 | def _check_footnotes(self, page_footnotes: list) -> None: 428 | """Method that loops through the current section parts, extracting the 429 | footnotes from each part's ids. 430 | 431 | Args: 432 | page_footnotes (list): the list of the page footnotes to save the 433 | footnotes found in the ids. 434 | """ 435 | for part in self.section.parts: 436 | if part['type'] == 'paragraph': 437 | self._check_footnote(part['ids'], page_footnotes) 438 | 439 | def _get_footnotes_obj(self, page_footnotes: list) -> 'PDFContent': 440 | """Method to create the PDFContent object containing the footnotes of 441 | the current page. 442 | 443 | Args: 444 | page_footnotes (list): the list of the page footnotes 445 | 446 | Returns: 447 | PDFContent: object containing the footnotes. 448 | """ 449 | content = {'style': '$footnotes', 'content': []} 450 | for index, footnote in enumerate(page_footnotes): 451 | footnote = copy(footnote) 452 | style = footnote.setdefault('style', {}) 453 | style.update(dict( 454 | list_text=str(index + 1) + ' ', list_style='$footnote' 455 | )) 456 | content['content'].append(footnote) 457 | 458 | footnote_obj = self.pdf._create_content( 459 | content, self.width, self.height, self.x, self.y 460 | ) 461 | footnote_obj.run() 462 | return footnote_obj 463 | 464 | def _process_footnotes(self) -> 'PDFContent': 465 | """Method to extract the footnotes from the current section parts, and 466 | create the PDFContent object containing the footnotes of 467 | the current page. 468 | 469 | Returns: 470 | PDFContent: object containing the footnotes. 471 | """ 472 | page_footnotes = [] 473 | self._check_footnotes(page_footnotes) 474 | if len(page_footnotes) == 0: 475 | return None 476 | return self._get_footnotes_obj(page_footnotes) 477 | 478 | def output(self, buffer: Any) -> None: 479 | """Method to create the PDF file. 480 | 481 | Args: 482 | buffer (file_like): a file-like object to write the PDF file into. 483 | """ 484 | self.pdf.output(buffer) 485 | 486 | def build_pdf(document: dict, buffer: Any, context: dict=None) -> None: 487 | """Function to build a PDF document using a PDFDocument instance. This is 488 | the easiest way to build a PDF document file in this library. For more 489 | information about arguments ``document``, and ``context`` see 490 | :class:`pdfme.document.PDFDocument`. 491 | 492 | Args: 493 | buffer (file_like): a file-like object to write the PDF file into. 494 | """ 495 | doc = PDFDocument(document, context) 496 | doc.run() 497 | doc.output(buffer) 498 | 499 | from .content import Number, PDFContent 500 | from .pdf import PDF 501 | from .utils import process_style, copy -------------------------------------------------------------------------------- /pdfme/encoders.py: -------------------------------------------------------------------------------- 1 | import zlib 2 | 3 | def encode_stream(stream: bytes, filter: bytes, parameters: dict=None) -> bytes: 4 | """Function to use ``filter`` method to encode ``stream``, using 5 | ``parameters`` if required. 6 | 7 | Args: 8 | stream (bytes): the stream to be encoded. 9 | filter (bytes): the method to use for the encoding process. 10 | parameters (dict, optional): if necessary, this dict contains the 11 | parameters required by the ``filter`` method. 12 | 13 | Raises: 14 | NotImplementedError: if the filter passed is not implemented yet. 15 | Exception: if the filter passed doesn't exist. 16 | 17 | Returns: 18 | bytes: the encoded stream. 19 | """ 20 | if parameters is None: 21 | parameters = {} 22 | 23 | if filter == b'/FlateDecode': 24 | return flate_encode(stream) 25 | elif filter == b'/ASCIIHexDecode': 26 | raise NotImplementedError('/ASCIIHexDecode') 27 | elif filter == b'/ASCII85Decode': 28 | raise NotImplementedError('/ASCII85Decode') 29 | elif filter == b'/LZWDecode': 30 | raise NotImplementedError('/LZWDecode') 31 | elif filter == b'/RunLengthDecode': 32 | raise NotImplementedError('/RunLengthDecode') 33 | elif filter == b'/CCITTFaxDecode': 34 | raise NotImplementedError('/CCITTFaxDecode') 35 | elif filter == b'/JBIG2Decode': 36 | raise NotImplementedError('/JBIG2Decode') 37 | elif filter == b'/DCTDecode': 38 | raise NotImplementedError('/DCTDecode') 39 | elif filter == b'/JPXDecode': 40 | raise NotImplementedError('/JPXDecode') 41 | elif filter == b'/Crypt': 42 | raise NotImplementedError('/Crypt') 43 | else: 44 | raise Exception("Filter {} not found".format(filter.decode('latin'))) 45 | 46 | def flate_encode(stream: bytes) -> bytes: 47 | """Function that encodes a bytes stream using the zlib.compress method. 48 | 49 | Args: 50 | stream (bytes): stream to be encoded. 51 | 52 | Returns: 53 | bytes: the encoded stream. 54 | """ 55 | return zlib.compress(stream) 56 | 57 | -------------------------------------------------------------------------------- /pdfme/fonts.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from io import BytesIO 3 | from pathlib import Path 4 | 5 | helvetica = {'\x00': 278, '\x01': 278, '\x02': 278, '\x03': 278, '\x04': 278, '\x05': 278, '\x06': 278, '\x07': 278, '\x08': 278, '\t': 278, '\n': 278, '\x0b': 278, '\x0c': 278, '\r': 278, '\x0e': 278, '\x0f': 278, '\x10': 278, '\x11': 278, '\x12': 278, '\x13': 278, '\x14': 278, '\x15': 278, '\x16': 278, '\x17': 278, '\x18': 278, '\x19': 278, '\x1a': 278, '\x1b': 278, '\x1c': 278, '\x1d': 278, '\x1e': 278, '\x1f': 278, ' ': 278, '!': 278, '"': 355, '#': 556, '$': 556, '%': 889, '&': 667, "'": 191, '(': 333, ')': 333, '*': 389, '+': 584, ',': 278, '-': 333, '.': 278, '/': 278, '0': 556, '1': 556, '2': 556, '3': 556, '4': 556, '5': 556, '6': 556, '7': 556, '8': 556, '9': 556, ':': 278, ';': 278, '<': 584, '=': 584, '>': 584, '?': 556, '@': 1015, 'A': 667, 'B': 667, 'C': 722, 'D': 722, 'E': 667, 'F': 611, 'G': 778, 'H': 722, 'I': 278, 'J': 500, 'K': 667, 'L': 556, 'M': 833, 'N': 722, 'O': 778, 'P': 667, 'Q': 778, 'R': 722, 'S': 667, 'T': 611, 'U': 722, 'V': 667, 'W': 944, 'X': 667, 'Y': 667, 'Z': 611, '[': 278, '\\': 278, ']': 278, '^': 469, '_': 556, '`': 333, 'a': 556, 'b': 556, 'c': 500, 'd': 556, 'e': 556, 'f': 278, 'g': 556, 'h': 556, 'i': 222, 'j': 222, 'k': 500, 'l': 222, 'm': 833, 'n': 556, 'o': 556, 'p': 556, 'q': 556, 'r': 333, 's': 500, 't': 278, 'u': 556, 'v': 500, 'w': 722, 'x': 500, 'y': 500, 'z': 500, '{': 334, '|': 260, '}': 334, '~': 584, '\x7f': 350, '\x80': 556, '\x81': 350, '\x82': 222, '\x83': 556, '\x84': 333, '\x85': 1000, '\x86': 556, '\x87': 556, '\x88': 333, '\x89': 1000, '\x8a': 667, '\x8b': 333, '\x8c': 1000, '\x8d': 350, '\x8e': 611, '\x8f': 350, '\x90': 350, '\x91': 222, '\x92': 222, '\x93': 333, '\x94': 333, '\x95': 350, '\x96': 556, '\x97': 1000, '\x98': 333, '\x99': 1000, '\x9a': 500, '\x9b': 333, '\x9c': 944, '\x9d': 350, '\x9e': 500, '\x9f': 667, '\xa0': 278, '¡': 333, '¢': 556, '£': 556, '¤': 556, '¥': 556, '¦': 260, '§': 556, '¨': 333, '©': 737, 'ª': 370, '«': 556, '¬': 584, '\xad': 333, '®': 737, '¯': 333, '°': 400, '±': 584, '²': 333, '³': 333, '´': 333, 'µ': 556, '¶': 537, '·': 278, '¸': 333, '¹': 333, 'º': 365, '»': 556, '¼': 834, '½': 834, '¾': 834, '¿': 611, 'À': 667, 'Á': 667, 'Â': 667, 'Ã': 667, 'Ä': 667, 'Å': 667, 'Æ': 1000, 'Ç': 722, 'È': 667, 'É': 667, 'Ê': 667, 'Ë': 667, 'Ì': 278, 'Í': 278, 'Î': 278, 'Ï': 278, 'Ð': 722, 'Ñ': 722, 'Ò': 778, 'Ó': 778, 'Ô': 778, 'Õ': 778, 'Ö': 778, '×': 584, 'Ø': 778, 'Ù': 722, 'Ú': 722, 'Û': 722, 'Ü': 722, 'Ý': 667, 'Þ': 667, 'ß': 611, 'à': 556, 'á': 556, 'â': 556, 'ã': 556, 'ä': 556, 'å': 556, 'æ': 889, 'ç': 500, 'è': 556, 'é': 556, 'ê': 556, 'ë': 556, 'ì': 278, 'í': 278, 'î': 278, 'ï': 278, 'ð': 556, 'ñ': 556, 'ò': 556, 'ó': 556, 'ô': 556, 'õ': 556, 'ö': 556, '÷': 584, 'ø': 611, 'ù': 556, 'ú': 556, 'û': 556, 'ü': 556, 'ý': 500, 'þ': 556, 'ÿ': 500} 6 | helveticaB = {'\x00': 278, '\x01': 278, '\x02': 278, '\x03': 278, '\x04': 278, '\x05': 278, '\x06': 278, '\x07': 278, '\x08': 278, '\t': 278, '\n': 278, '\x0b': 278, '\x0c': 278, '\r': 278, '\x0e': 278, '\x0f': 278, '\x10': 278, '\x11': 278, '\x12': 278, '\x13': 278, '\x14': 278, '\x15': 278, '\x16': 278, '\x17': 278, '\x18': 278, '\x19': 278, '\x1a': 278, '\x1b': 278, '\x1c': 278, '\x1d': 278, '\x1e': 278, '\x1f': 278, ' ': 278, '!': 333, '"': 474, '#': 556, '$': 556, '%': 889, '&': 722, "'": 238, '(': 333, ')': 333, '*': 389, '+': 584, ',': 278, '-': 333, '.': 278, '/': 278, '0': 556, '1': 556, '2': 556, '3': 556, '4': 556, '5': 556, '6': 556, '7': 556, '8': 556, '9': 556, ':': 333, ';': 333, '<': 584, '=': 584, '>': 584, '?': 611, '@': 975, 'A': 722, 'B': 722, 'C': 722, 'D': 722, 'E': 667, 'F': 611, 'G': 778, 'H': 722, 'I': 278, 'J': 556, 'K': 722, 'L': 611, 'M': 833, 'N': 722, 'O': 778, 'P': 667, 'Q': 778, 'R': 722, 'S': 667, 'T': 611, 'U': 722, 'V': 667, 'W': 944, 'X': 667, 'Y': 667, 'Z': 611, '[': 333, '\\': 278, ']': 333, '^': 584, '_': 556, '`': 333, 'a': 556, 'b': 611, 'c': 556, 'd': 611, 'e': 556, 'f': 333, 'g': 611, 'h': 611, 'i': 278, 'j': 278, 'k': 556, 'l': 278, 'm': 889, 'n': 611, 'o': 611, 'p': 611, 'q': 611, 'r': 389, 's': 556, 't': 333, 'u': 611, 'v': 556, 'w': 778, 'x': 556, 'y': 556, 'z': 500, '{': 389, '|': 280, '}': 389, '~': 584, '\x7f': 350, '\x80': 556, '\x81': 350, '\x82': 278, '\x83': 556, '\x84': 500, '\x85': 1000, '\x86': 556, '\x87': 556, '\x88': 333, '\x89': 1000, '\x8a': 667, '\x8b': 333, '\x8c': 1000, '\x8d': 350, '\x8e': 611, '\x8f': 350, '\x90': 350, '\x91': 278, '\x92': 278, '\x93': 500, '\x94': 500, '\x95': 350, '\x96': 556, '\x97': 1000, '\x98': 333, '\x99': 1000, '\x9a': 556, '\x9b': 333, '\x9c': 944, '\x9d': 350, '\x9e': 500, '\x9f': 667, '\xa0': 278, '¡': 333, '¢': 556, '£': 556, '¤': 556, '¥': 556, '¦': 280, '§': 556, '¨': 333, '©': 737, 'ª': 370, '«': 556, '¬': 584, '\xad': 333, '®': 737, '¯': 333, '°': 400, '±': 584, '²': 333, '³': 333, '´': 333, 'µ': 611, '¶': 556, '·': 278, '¸': 333, '¹': 333, 'º': 365, '»': 556, '¼': 834, '½': 834, '¾': 834, '¿': 611, 'À': 722, 'Á': 722, 'Â': 722, 'Ã': 722, 'Ä': 722, 'Å': 722, 'Æ': 1000, 'Ç': 722, 'È': 667, 'É': 667, 'Ê': 667, 'Ë': 667, 'Ì': 278, 'Í': 278, 'Î': 278, 'Ï': 278, 'Ð': 722, 'Ñ': 722, 'Ò': 778, 'Ó': 778, 'Ô': 778, 'Õ': 778, 'Ö': 778, '×': 584, 'Ø': 778, 'Ù': 722, 'Ú': 722, 'Û': 722, 'Ü': 722, 'Ý': 667, 'Þ': 667, 'ß': 611, 'à': 556, 'á': 556, 'â': 556, 'ã': 556, 'ä': 556, 'å': 556, 'æ': 889, 'ç': 556, 'è': 556, 'é': 556, 'ê': 556, 'ë': 556, 'ì': 278, 'í': 278, 'î': 278, 'ï': 278, 'ð': 611, 'ñ': 611, 'ò': 611, 'ó': 611, 'ô': 611, 'õ': 611, 'ö': 611, '÷': 584, 'ø': 611, 'ù': 611, 'ú': 611, 'û': 611, 'ü': 611, 'ý': 556, 'þ': 611, 'ÿ': 556} 7 | times = {'\x00': 250, '\x01': 250, '\x02': 250, '\x03': 250, '\x04': 250, '\x05': 250, '\x06': 250, '\x07': 250, '\x08': 250, '\t': 250, '\n': 250, '\x0b': 250, '\x0c': 250, '\r': 250, '\x0e': 250, '\x0f': 250, '\x10': 250, '\x11': 250, '\x12': 250, '\x13': 250, '\x14': 250, '\x15': 250, '\x16': 250, '\x17': 250, '\x18': 250, '\x19': 250, '\x1a': 250, '\x1b': 250, '\x1c': 250, '\x1d': 250, '\x1e': 250, '\x1f': 250, ' ': 250, '!': 333, '"': 408, '#': 500, '$': 500, '%': 833, '&': 778, "'": 180, '(': 333, ')': 333, '*': 500, '+': 564, ',': 250, '-': 333, '.': 250, '/': 278, '0': 500, '1': 500, '2': 500, '3': 500, '4': 500, '5': 500, '6': 500, '7': 500, '8': 500, '9': 500, ':': 278, ';': 278, '<': 564, '=': 564, '>': 564, '?': 444, '@': 921, 'A': 722, 'B': 667, 'C': 667, 'D': 722, 'E': 611, 'F': 556, 'G': 722, 'H': 722, 'I': 333, 'J': 389, 'K': 722, 'L': 611, 'M': 889, 'N': 722, 'O': 722, 'P': 556, 'Q': 722, 'R': 667, 'S': 556, 'T': 611, 'U': 722, 'V': 722, 'W': 944, 'X': 722, 'Y': 722, 'Z': 611, '[': 333, '\\': 278, ']': 333, '^': 469, '_': 500, '`': 333, 'a': 444, 'b': 500, 'c': 444, 'd': 500, 'e': 444, 'f': 333, 'g': 500, 'h': 500, 'i': 278, 'j': 278, 'k': 500, 'l': 278, 'm': 778, 'n': 500, 'o': 500, 'p': 500, 'q': 500, 'r': 333, 's': 389, 't': 278, 'u': 500, 'v': 500, 'w': 722, 'x': 500, 'y': 500, 'z': 444, '{': 480, '|': 200, '}': 480, '~': 541, '\x7f': 350, '\x80': 500, '\x81': 350, '\x82': 333, '\x83': 500, '\x84': 444, '\x85': 1000, '\x86': 500, '\x87': 500, '\x88': 333, '\x89': 1000, '\x8a': 556, '\x8b': 333, '\x8c': 889, '\x8d': 350, '\x8e': 611, '\x8f': 350, '\x90': 350, '\x91': 333, '\x92': 333, '\x93': 444, '\x94': 444, '\x95': 350, '\x96': 500, '\x97': 1000, '\x98': 333, '\x99': 980, '\x9a': 389, '\x9b': 333, '\x9c': 722, '\x9d': 350, '\x9e': 444, '\x9f': 722, '\xa0': 250, '¡': 333, '¢': 500, '£': 500, '¤': 500, '¥': 500, '¦': 200, '§': 500, '¨': 333, '©': 760, 'ª': 276, '«': 500, '¬': 564, '\xad': 333, '®': 760, '¯': 333, '°': 400, '±': 564, '²': 300, '³': 300, '´': 333, 'µ': 500, '¶': 453, '·': 250, '¸': 333, '¹': 300, 'º': 310, '»': 500, '¼': 750, '½': 750, '¾': 750, '¿': 444, 'À': 722, 'Á': 722, 'Â': 722, 'Ã': 722, 'Ä': 722, 'Å': 722, 'Æ': 889, 'Ç': 667, 'È': 611, 'É': 611, 'Ê': 611, 'Ë': 611, 'Ì': 333, 'Í': 333, 'Î': 333, 'Ï': 333, 'Ð': 722, 'Ñ': 722, 'Ò': 722, 'Ó': 722, 'Ô': 722, 'Õ': 722, 'Ö': 722, '×': 564, 'Ø': 722, 'Ù': 722, 'Ú': 722, 'Û': 722, 'Ü': 722, 'Ý': 722, 'Þ': 556, 'ß': 500, 'à': 444, 'á': 444, 'â': 444, 'ã': 444, 'ä': 444, 'å': 444, 'æ': 667, 'ç': 444, 'è': 444, 'é': 444, 'ê': 444, 'ë': 444, 'ì': 278, 'í': 278, 'î': 278, 'ï': 278, 'ð': 500, 'ñ': 500, 'ò': 500, 'ó': 500, 'ô': 500, 'õ': 500, 'ö': 500, '÷': 564, 'ø': 500, 'ù': 500, 'ú': 500, 'û': 500, 'ü': 500, 'ý': 500, 'þ': 500, 'ÿ': 500} 8 | timesB = {'\x00': 250, '\x01': 250, '\x02': 250, '\x03': 250, '\x04': 250, '\x05': 250, '\x06': 250, '\x07': 250, '\x08': 250, '\t': 250, '\n': 250, '\x0b': 250, '\x0c': 250, '\r': 250, '\x0e': 250, '\x0f': 250, '\x10': 250, '\x11': 250, '\x12': 250, '\x13': 250, '\x14': 250, '\x15': 250, '\x16': 250, '\x17': 250, '\x18': 250, '\x19': 250, '\x1a': 250, '\x1b': 250, '\x1c': 250, '\x1d': 250, '\x1e': 250, '\x1f': 250, ' ': 250, '!': 333, '"': 555, '#': 500, '$': 500, '%': 1000, '&': 833, "'": 278, '(': 333, ')': 333, '*': 500, '+': 570, ',': 250, '-': 333, '.': 250, '/': 278, '0': 500, '1': 500, '2': 500, '3': 500, '4': 500, '5': 500, '6': 500, '7': 500, '8': 500, '9': 500, ':': 333, ';': 333, '<': 570, '=': 570, '>': 570, '?': 500, '@': 930, 'A': 722, 'B': 667, 'C': 722, 'D': 722, 'E': 667, 'F': 611, 'G': 778, 'H': 778, 'I': 389, 'J': 500, 'K': 778, 'L': 667, 'M': 944, 'N': 722, 'O': 778, 'P': 611, 'Q': 778, 'R': 722, 'S': 556, 'T': 667, 'U': 722, 'V': 722, 'W': 1000, 'X': 722, 'Y': 722, 'Z': 667, '[': 333, '\\': 278, ']': 333, '^': 581, '_': 500, '`': 333, 'a': 500, 'b': 556, 'c': 444, 'd': 556, 'e': 444, 'f': 333, 'g': 500, 'h': 556, 'i': 278, 'j': 333, 'k': 556, 'l': 278, 'm': 833, 'n': 556, 'o': 500, 'p': 556, 'q': 556, 'r': 444, 's': 389, 't': 333, 'u': 556, 'v': 500, 'w': 722, 'x': 500, 'y': 500, 'z': 444, '{': 394, '|': 220, '}': 394, '~': 520, '\x7f': 350, '\x80': 500, '\x81': 350, '\x82': 333, '\x83': 500, '\x84': 500, '\x85': 1000, '\x86': 500, '\x87': 500, '\x88': 333, '\x89': 1000, '\x8a': 556, '\x8b': 333, '\x8c': 1000, '\x8d': 350, '\x8e': 667, '\x8f': 350, '\x90': 350, '\x91': 333, '\x92': 333, '\x93': 500, '\x94': 500, '\x95': 350, '\x96': 500, '\x97': 1000, '\x98': 333, '\x99': 1000, '\x9a': 389, '\x9b': 333, '\x9c': 722, '\x9d': 350, '\x9e': 444, '\x9f': 722, '\xa0': 250, '¡': 333, '¢': 500, '£': 500, '¤': 500, '¥': 500, '¦': 220, '§': 500, '¨': 333, '©': 747, 'ª': 300, '«': 500, '¬': 570, '\xad': 333, '®': 747, '¯': 333, '°': 400, '±': 570, '²': 300, '³': 300, '´': 333, 'µ': 556, '¶': 540, '·': 250, '¸': 333, '¹': 300, 'º': 330, '»': 500, '¼': 750, '½': 750, '¾': 750, '¿': 500, 'À': 722, 'Á': 722, 'Â': 722, 'Ã': 722, 'Ä': 722, 'Å': 722, 'Æ': 1000, 'Ç': 722, 'È': 667, 'É': 667, 'Ê': 667, 'Ë': 667, 'Ì': 389, 'Í': 389, 'Î': 389, 'Ï': 389, 'Ð': 722, 'Ñ': 722, 'Ò': 778, 'Ó': 778, 'Ô': 778, 'Õ': 778, 'Ö': 778, '×': 570, 'Ø': 778, 'Ù': 722, 'Ú': 722, 'Û': 722, 'Ü': 722, 'Ý': 722, 'Þ': 611, 'ß': 556, 'à': 500, 'á': 500, 'â': 500, 'ã': 500, 'ä': 500, 'å': 500, 'æ': 722, 'ç': 444, 'è': 444, 'é': 444, 'ê': 444, 'ë': 444, 'ì': 278, 'í': 278, 'î': 278, 'ï': 278, 'ð': 500, 'ñ': 556, 'ò': 500, 'ó': 500, 'ô': 500, 'õ': 500, 'ö': 500, '÷': 570, 'ø': 500, 'ù': 556, 'ú': 556, 'û': 556, 'ü': 556, 'ý': 500, 'þ': 556, 'ÿ': 500} 9 | timesBI = {'\x00': 250, '\x01': 250, '\x02': 250, '\x03': 250, '\x04': 250, '\x05': 250, '\x06': 250, '\x07': 250, '\x08': 250, '\t': 250, '\n': 250, '\x0b': 250, '\x0c': 250, '\r': 250, '\x0e': 250, '\x0f': 250, '\x10': 250, '\x11': 250, '\x12': 250, '\x13': 250, '\x14': 250, '\x15': 250, '\x16': 250, '\x17': 250, '\x18': 250, '\x19': 250, '\x1a': 250, '\x1b': 250, '\x1c': 250, '\x1d': 250, '\x1e': 250, '\x1f': 250, ' ': 250, '!': 389, '"': 555, '#': 500, '$': 500, '%': 833, '&': 778, "'": 278, '(': 333, ')': 333, '*': 500, '+': 570, ',': 250, '-': 333, '.': 250, '/': 278, '0': 500, '1': 500, '2': 500, '3': 500, '4': 500, '5': 500, '6': 500, '7': 500, '8': 500, '9': 500, ':': 333, ';': 333, '<': 570, '=': 570, '>': 570, '?': 500, '@': 832, 'A': 667, 'B': 667, 'C': 667, 'D': 722, 'E': 667, 'F': 667, 'G': 722, 'H': 778, 'I': 389, 'J': 500, 'K': 667, 'L': 611, 'M': 889, 'N': 722, 'O': 722, 'P': 611, 'Q': 722, 'R': 667, 'S': 556, 'T': 611, 'U': 722, 'V': 667, 'W': 889, 'X': 667, 'Y': 611, 'Z': 611, '[': 333, '\\': 278, ']': 333, '^': 570, '_': 500, '`': 333, 'a': 500, 'b': 500, 'c': 444, 'd': 500, 'e': 444, 'f': 333, 'g': 500, 'h': 556, 'i': 278, 'j': 278, 'k': 500, 'l': 278, 'm': 778, 'n': 556, 'o': 500, 'p': 500, 'q': 500, 'r': 389, 's': 389, 't': 278, 'u': 556, 'v': 444, 'w': 667, 'x': 500, 'y': 444, 'z': 389, '{': 348, '|': 220, '}': 348, '~': 570, '\x7f': 350, '\x80': 500, '\x81': 350, '\x82': 333, '\x83': 500, '\x84': 500, '\x85': 1000, '\x86': 500, '\x87': 500, '\x88': 333, '\x89': 1000, '\x8a': 556, '\x8b': 333, '\x8c': 944, '\x8d': 350, '\x8e': 611, '\x8f': 350, '\x90': 350, '\x91': 333, '\x92': 333, '\x93': 500, '\x94': 500, '\x95': 350, '\x96': 500, '\x97': 1000, '\x98': 333, '\x99': 1000, '\x9a': 389, '\x9b': 333, '\x9c': 722, '\x9d': 350, '\x9e': 389, '\x9f': 611, '\xa0': 250, '¡': 389, '¢': 500, '£': 500, '¤': 500, '¥': 500, '¦': 220, '§': 500, '¨': 333, '©': 747, 'ª': 266, '«': 500, '¬': 606, '\xad': 333, '®': 747, '¯': 333, '°': 400, '±': 570, '²': 300, '³': 300, '´': 333, 'µ': 576, '¶': 500, '·': 250, '¸': 333, '¹': 300, 'º': 300, '»': 500, '¼': 750, '½': 750, '¾': 750, '¿': 500, 'À': 667, 'Á': 667, 'Â': 667, 'Ã': 667, 'Ä': 667, 'Å': 667, 'Æ': 944, 'Ç': 667, 'È': 667, 'É': 667, 'Ê': 667, 'Ë': 667, 'Ì': 389, 'Í': 389, 'Î': 389, 'Ï': 389, 'Ð': 722, 'Ñ': 722, 'Ò': 722, 'Ó': 722, 'Ô': 722, 'Õ': 722, 'Ö': 722, '×': 570, 'Ø': 722, 'Ù': 722, 'Ú': 722, 'Û': 722, 'Ü': 722, 'Ý': 611, 'Þ': 611, 'ß': 500, 'à': 500, 'á': 500, 'â': 500, 'ã': 500, 'ä': 500, 'å': 500, 'æ': 722, 'ç': 444, 'è': 444, 'é': 444, 'ê': 444, 'ë': 444, 'ì': 278, 'í': 278, 'î': 278, 'ï': 278, 'ð': 500, 'ñ': 556, 'ò': 500, 'ó': 500, 'ô': 500, 'õ': 500, 'ö': 500, '÷': 570, 'ø': 500, 'ù': 556, 'ú': 556, 'û': 556, 'ü': 556, 'ý': 444, 'þ': 500, 'ÿ': 444} 10 | timesI = {'\x00': 250, '\x01': 250, '\x02': 250, '\x03': 250, '\x04': 250, '\x05': 250, '\x06': 250, '\x07': 250, '\x08': 250, '\t': 250, '\n': 250, '\x0b': 250, '\x0c': 250, '\r': 250, '\x0e': 250, '\x0f': 250, '\x10': 250, '\x11': 250, '\x12': 250, '\x13': 250, '\x14': 250, '\x15': 250, '\x16': 250, '\x17': 250, '\x18': 250, '\x19': 250, '\x1a': 250, '\x1b': 250, '\x1c': 250, '\x1d': 250, '\x1e': 250, '\x1f': 250, ' ': 250, '!': 333, '"': 420, '#': 500, '$': 500, '%': 833, '&': 778, "'": 214, '(': 333, ')': 333, '*': 500, '+': 675, ',': 250, '-': 333, '.': 250, '/': 278, '0': 500, '1': 500, '2': 500, '3': 500, '4': 500, '5': 500, '6': 500, '7': 500, '8': 500, '9': 500, ':': 333, ';': 333, '<': 675, '=': 675, '>': 675, '?': 500, '@': 920, 'A': 611, 'B': 611, 'C': 667, 'D': 722, 'E': 611, 'F': 611, 'G': 722, 'H': 722, 'I': 333, 'J': 444, 'K': 667, 'L': 556, 'M': 833, 'N': 667, 'O': 722, 'P': 611, 'Q': 722, 'R': 611, 'S': 500, 'T': 556, 'U': 722, 'V': 611, 'W': 833, 'X': 611, 'Y': 556, 'Z': 556, '[': 389, '\\': 278, ']': 389, '^': 422, '_': 500, '`': 333, 'a': 500, 'b': 500, 'c': 444, 'd': 500, 'e': 444, 'f': 278, 'g': 500, 'h': 500, 'i': 278, 'j': 278, 'k': 444, 'l': 278, 'm': 722, 'n': 500, 'o': 500, 'p': 500, 'q': 500, 'r': 389, 's': 389, 't': 278, 'u': 500, 'v': 444, 'w': 667, 'x': 444, 'y': 444, 'z': 389, '{': 400, '|': 275, '}': 400, '~': 541, '\x7f': 350, '\x80': 500, '\x81': 350, '\x82': 333, '\x83': 500, '\x84': 556, '\x85': 889, '\x86': 500, '\x87': 500, '\x88': 333, '\x89': 1000, '\x8a': 500, '\x8b': 333, '\x8c': 944, '\x8d': 350, '\x8e': 556, '\x8f': 350, '\x90': 350, '\x91': 333, '\x92': 333, '\x93': 556, '\x94': 556, '\x95': 350, '\x96': 500, '\x97': 889, '\x98': 333, '\x99': 980, '\x9a': 389, '\x9b': 333, '\x9c': 667, '\x9d': 350, '\x9e': 389, '\x9f': 556, '\xa0': 250, '¡': 389, '¢': 500, '£': 500, '¤': 500, '¥': 500, '¦': 275, '§': 500, '¨': 333, '©': 760, 'ª': 276, '«': 500, '¬': 675, '\xad': 333, '®': 760, '¯': 333, '°': 400, '±': 675, '²': 300, '³': 300, '´': 333, 'µ': 500, '¶': 523, '·': 250, '¸': 333, '¹': 300, 'º': 310, '»': 500, '¼': 750, '½': 750, '¾': 750, '¿': 500, 'À': 611, 'Á': 611, 'Â': 611, 'Ã': 611, 'Ä': 611, 'Å': 611, 'Æ': 889, 'Ç': 667, 'È': 611, 'É': 611, 'Ê': 611, 'Ë': 611, 'Ì': 333, 'Í': 333, 'Î': 333, 'Ï': 333, 'Ð': 722, 'Ñ': 667, 'Ò': 722, 'Ó': 722, 'Ô': 722, 'Õ': 722, 'Ö': 722, '×': 675, 'Ø': 722, 'Ù': 722, 'Ú': 722, 'Û': 722, 'Ü': 722, 'Ý': 556, 'Þ': 611, 'ß': 500, 'à': 500, 'á': 500, 'â': 500, 'ã': 500, 'ä': 500, 'å': 500, 'æ': 667, 'ç': 444, 'è': 444, 'é': 444, 'ê': 444, 'ë': 444, 'ì': 278, 'í': 278, 'î': 278, 'ï': 278, 'ð': 500, 'ñ': 500, 'ò': 500, 'ó': 500, 'ô': 500, 'õ': 500, 'ö': 500, '÷': 675, 'ø': 500, 'ù': 500, 'ú': 500, 'û': 500, 'ü': 500, 'ý': 444, 'þ': 500, 'ÿ': 444} 11 | symbol = {'\x00': 250, '\x01': 250, '\x02': 250, '\x03': 250, '\x04': 250, '\x05': 250, '\x06': 250, '\x07': 250, '\x08': 250, '\t': 250, '\n': 250, '\x0b': 250, '\x0c': 250, '\r': 250, '\x0e': 250, '\x0f': 250, '\x10': 250, '\x11': 250, '\x12': 250, '\x13': 250, '\x14': 250, '\x15': 250, '\x16': 250, '\x17': 250, '\x18': 250, '\x19': 250, '\x1a': 250, '\x1b': 250, '\x1c': 250, '\x1d': 250, '\x1e': 250, '\x1f': 250, ' ': 250, '!': 333, '"': 713, '#': 500, '$': 549, '%': 833, '&': 778, "'": 439, '(': 333, ')': 333, '*': 500, '+': 549, ',': 250, '-': 549, '.': 250, '/': 278, '0': 500, '1': 500, '2': 500, '3': 500, '4': 500, '5': 500, '6': 500, '7': 500, '8': 500, '9': 500, ':': 278, ';': 278, '<': 549, '=': 549, '>': 549, '?': 444, '@': 549, 'A': 722, 'B': 667, 'C': 722, 'D': 612, 'E': 611, 'F': 763, 'G': 603, 'H': 722, 'I': 333, 'J': 631, 'K': 722, 'L': 686, 'M': 889, 'N': 722, 'O': 722, 'P': 768, 'Q': 741, 'R': 556, 'S': 592, 'T': 611, 'U': 690, 'V': 439, 'W': 768, 'X': 645, 'Y': 795, 'Z': 611, '[': 333, '\\': 863, ']': 333, '^': 658, '_': 500, '`': 500, 'a': 631, 'b': 549, 'c': 549, 'd': 494, 'e': 439, 'f': 521, 'g': 411, 'h': 603, 'i': 329, 'j': 603, 'k': 549, 'l': 549, 'm': 576, 'n': 521, 'o': 549, 'p': 549, 'q': 521, 'r': 549, 's': 603, 't': 439, 'u': 576, 'v': 713, 'w': 686, 'x': 493, 'y': 686, 'z': 494, '{': 480, '|': 200, '}': 480, '~': 549, '\x7f': 0, '\x80': 0, '\x81': 0, '\x82': 0, '\x83': 0, '\x84': 0, '\x85': 0, '\x86': 0, '\x87': 0, '\x88': 0, '\x89': 0, '\x8a': 0, '\x8b': 0, '\x8c': 0, '\x8d': 0, '\x8e': 0, '\x8f': 0, '\x90': 0, '\x91': 0, '\x92': 0, '\x93': 0, '\x94': 0, '\x95': 0, '\x96': 0, '\x97': 0, '\x98': 0, '\x99': 0, '\x9a': 0, '\x9b': 0, '\x9c': 0, '\x9d': 0, '\x9e': 0, '\x9f': 0, '\xa0': 750, '¡': 620, '¢': 247, '£': 549, '¤': 167, '¥': 713, '¦': 500, '§': 753, '¨': 753, '©': 753, 'ª': 753, '«': 1042, '¬': 987, '\xad': 603, '®': 987, '¯': 603, '°': 400, '±': 549, '²': 411, '³': 549, '´': 549, 'µ': 713, '¶': 494, '·': 460, '¸': 549, '¹': 549, 'º': 549, '»': 549, '¼': 1000, '½': 603, '¾': 1000, '¿': 658, 'À': 823, 'Á': 686, 'Â': 795, 'Ã': 987, 'Ä': 768, 'Å': 768, 'Æ': 823, 'Ç': 768, 'È': 768, 'É': 713, 'Ê': 713, 'Ë': 713, 'Ì': 713, 'Í': 713, 'Î': 713, 'Ï': 713, 'Ð': 768, 'Ñ': 713, 'Ò': 790, 'Ó': 790, 'Ô': 890, 'Õ': 823, 'Ö': 549, '×': 250, 'Ø': 713, 'Ù': 603, 'Ú': 603, 'Û': 1042, 'Ü': 987, 'Ý': 603, 'Þ': 987, 'ß': 603, 'à': 494, 'á': 329, 'â': 790, 'ã': 790, 'ä': 786, 'å': 713, 'æ': 384, 'ç': 384, 'è': 384, 'é': 384, 'ê': 384, 'ë': 384, 'ì': 494, 'í': 494, 'î': 494, 'ï': 494, 'ð': 0, 'ñ': 329, 'ò': 274, 'ó': 686, 'ô': 686, 'õ': 686, 'ö': 384, '÷': 384, 'ø': 384, 'ù': 384, 'ú': 384, 'û': 384, 'ü': 494, 'ý': 494, 'þ': 494, 'ÿ': 0} 12 | zapfdingbats = {'\x00': 0, '\x01': 0, '\x02': 0, '\x03': 0, '\x04': 0, '\x05': 0, '\x06': 0, '\x07': 0, '\x08': 0, '\t': 0, '\n': 0, '\x0b': 0, '\x0c': 0, '\r': 0, '\x0e': 0, '\x0f': 0, '\x10': 0, '\x11': 0, '\x12': 0, '\x13': 0, '\x14': 0, '\x15': 0, '\x16': 0, '\x17': 0, '\x18': 0, '\x19': 0, '\x1a': 0, '\x1b': 0, '\x1c': 0, '\x1d': 0, '\x1e': 0, '\x1f': 0, ' ': 278, '!': 974, '"': 961, '#': 974, '$': 980, '%': 719, '&': 789, "'": 790, '(': 791, ')': 690, '*': 960, '+': 939, ',': 549, '-': 855, '.': 911, '/': 933, '0': 911, '1': 945, '2': 974, '3': 755, '4': 846, '5': 762, '6': 761, '7': 571, '8': 677, '9': 763, ':': 760, ';': 759, '<': 754, '=': 494, '>': 552, '?': 537, '@': 577, 'A': 692, 'B': 786, 'C': 788, 'D': 788, 'E': 790, 'F': 793, 'G': 794, 'H': 816, 'I': 823, 'J': 789, 'K': 841, 'L': 823, 'M': 833, 'N': 816, 'O': 831, 'P': 923, 'Q': 744, 'R': 723, 'S': 749, 'T': 790, 'U': 792, 'V': 695, 'W': 776, 'X': 768, 'Y': 792, 'Z': 759, '[': 707, '\\': 708, ']': 682, '^': 701, '_': 826, '`': 815, 'a': 789, 'b': 789, 'c': 707, 'd': 687, 'e': 696, 'f': 689, 'g': 786, 'h': 787, 'i': 713, 'j': 791, 'k': 785, 'l': 791, 'm': 873, 'n': 761, 'o': 762, 'p': 762, 'q': 759, 'r': 759, 's': 892, 't': 892, 'u': 788, 'v': 784, 'w': 438, 'x': 138, 'y': 277, 'z': 415, '{': 392, '|': 392, '}': 668, '~': 668, '\x7f': 0, '\x80': 390, '\x81': 390, '\x82': 317, '\x83': 317, '\x84': 276, '\x85': 276, '\x86': 509, '\x87': 509, '\x88': 410, '\x89': 410, '\x8a': 234, '\x8b': 234, '\x8c': 334, '\x8d': 334, '\x8e': 0, '\x8f': 0, '\x90': 0, '\x91': 0, '\x92': 0, '\x93': 0, '\x94': 0, '\x95': 0, '\x96': 0, '\x97': 0, '\x98': 0, '\x99': 0, '\x9a': 0, '\x9b': 0, '\x9c': 0, '\x9d': 0, '\x9e': 0, '\x9f': 0, '\xa0': 0, '¡': 732, '¢': 544, '£': 544, '¤': 910, '¥': 667, '¦': 760, '§': 760, '¨': 776, '©': 595, 'ª': 694, '«': 626, '¬': 788, '\xad': 788, '®': 788, '¯': 788, '°': 788, '±': 788, '²': 788, '³': 788, '´': 788, 'µ': 788, '¶': 788, '·': 788, '¸': 788, '¹': 788, 'º': 788, '»': 788, '¼': 788, '½': 788, '¾': 788, '¿': 788, 'À': 788, 'Á': 788, 'Â': 788, 'Ã': 788, 'Ä': 788, 'Å': 788, 'Æ': 788, 'Ç': 788, 'È': 788, 'É': 788, 'Ê': 788, 'Ë': 788, 'Ì': 788, 'Í': 788, 'Î': 788, 'Ï': 788, 'Ð': 788, 'Ñ': 788, 'Ò': 788, 'Ó': 788, 'Ô': 894, 'Õ': 838, 'Ö': 1016, '×': 458, 'Ø': 748, 'Ù': 924, 'Ú': 748, 'Û': 918, 'Ü': 927, 'Ý': 928, 'Þ': 928, 'ß': 834, 'à': 873, 'á': 828, 'â': 924, 'ã': 924, 'ä': 917, 'å': 930, 'æ': 931, 'ç': 463, 'è': 883, 'é': 836, 'ê': 836, 'ë': 867, 'ì': 867, 'í': 696, 'î': 696, 'ï': 874, 'ð': 0, 'ñ': 874, 'ò': 760, 'ó': 946, 'ô': 771, 'õ': 865, 'ö': 771, '÷': 888, 'ø': 967, 'ù': 888, 'ú': 831, 'û': 873, 'ü': 927, 'ý': 970, 'þ': 918, 'ÿ': 0} 13 | courier = {chr(i): 600 for i in range(256)} 14 | 15 | STANDARD_FONTS = { 16 | 'Helvetica': { 17 | 'n': { 'base_font': 'Helvetica', 'widths': helvetica }, 18 | 'b': { 'base_font': 'Helvetica-Bold', 'widths': helveticaB }, 19 | 'i': { 'base_font': 'Helvetica-Oblique', 'widths': helvetica}, 20 | 'bi': { 'base_font': 'Helvetica-BoldOblique', 'widths': helveticaB } 21 | }, 22 | 'Times': { 23 | 'n': { 'base_font': 'Times-Roman', 'widths': times }, 24 | 'b': { 'base_font': 'Times-Bold', 'widths': timesB }, 25 | 'i': { 'base_font': 'Times-Italic', 'widths': timesI }, 26 | 'bi': { 'base_font': 'Times-BoldItalic', 'widths': timesBI } 27 | }, 28 | 'Courier': { 29 | 'n': { 'base_font': 'Courier', 'widths': courier }, 30 | 'b': { 'base_font': 'Courier-Bold', 'widths': courier }, 31 | 'i': { 'base_font': 'Courier-Oblique', 'widths': courier }, 32 | 'bi': { 'base_font': 'Courier-BoldOblique', 'widths': courier } 33 | }, 34 | 'Symbol': {'n': {'base_font': 'Symbol', 'widths': symbol}}, 35 | 'ZapfDingbats': {'n': {'base_font': 'ZapfDingbats', 'widths': zapfdingbats}} 36 | } 37 | 38 | to_unicode = ( 39 | '/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n' 40 | '/CIDSystemInfo\n<> def\n/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n' 42 | '1 begincodespacerange\n<0000> \nendcodespacerange\n1 beginbfrange\n' 43 | '<0000> <0000>\nendbfrange\nendcmap\n' 44 | 'CMapName currentdict /CMap defineresource pop\nend\nend' 45 | ).encode('latin') 46 | 47 | class PDFFont(abc.ABC): 48 | """Abstract class that represents a PDF font. 49 | 50 | Args: 51 | ref (str): the name of the font, included in every paragraph and page 52 | that uses this font. 53 | """ 54 | def __init__(self, ref: str) -> None: 55 | self._ref = ref 56 | 57 | @property 58 | def ref(self) -> str: 59 | """Property that returns the name (ref) of this font. 60 | 61 | Returns: 62 | str: the name of this font 63 | """ 64 | return self._ref 65 | 66 | @abc.abstractproperty 67 | def base_font(self) -> str: 68 | """Abstract property that should return this font's base font name. 69 | 70 | Returns: 71 | str: the base font name 72 | """ 73 | pass 74 | 75 | @abc.abstractmethod 76 | def get_char_width(self, char: str) -> float: 77 | """Abstract method that should return the width of ``char`` character 78 | in this font. 79 | 80 | Args: 81 | char (str): the character. 82 | 83 | Returns: 84 | float: the character's width. 85 | """ 86 | pass 87 | 88 | @abc.abstractmethod 89 | def get_text_width(self, text: str) -> float: 90 | """Abstract method that should return the width of the ``text`` string 91 | in this font. 92 | 93 | Args: 94 | text (str): the sentence to measure. 95 | 96 | Returns: 97 | float: the sentence's width. 98 | """ 99 | pass 100 | 101 | @abc.abstractmethod 102 | def add_font(self, base: 'PDFBase') -> 'PDFObject': 103 | """Abstract method that should add this font to the PDFBase instance, 104 | passed as argument. 105 | 106 | Args: 107 | base (PDFBase): the base instance to add this font. 108 | """ 109 | pass 110 | class PDFStandardFont(PDFFont): 111 | """This class represents a standard PDF font. 112 | 113 | Args: 114 | ref (str): the name of this font. 115 | base_font (str): the base font name of this font. 116 | widths (dict): the widths of each character in this font. 117 | """ 118 | def __init__(self, ref: str, base_font: str, widths: dict) -> None: 119 | super().__init__(ref) 120 | self._base_font = base_font 121 | self.widths = widths 122 | 123 | @property 124 | def base_font(self) -> str: 125 | """See :meth:`pdfme.fonts.PDFFont.base_font`""" 126 | return self._base_font 127 | 128 | def get_char_width(self, char: str) -> float: 129 | """See :meth:`pdfme.fonts.PDFFont.get_char_width`""" 130 | return self.widths[char] / 1000 131 | 132 | def get_text_width(self, text) -> float: 133 | """See :meth:`pdfme.fonts.PDFFont.get_text_width`""" 134 | return sum(self.widths[char] for char in text) / 1000 135 | 136 | def add_font(self, base: 'PDFBase') -> 'PDFObject': 137 | """See :meth:`pdfme.fonts.PDFFont.add_font`""" 138 | font = base.add({ 139 | 'Type': b'/Font', 140 | 'Subtype': b'/Type1', 141 | 'BaseFont': subs('/{}', self.base_font), 142 | 'Encoding': b'/WinAnsiEncoding' 143 | }) 144 | if self.base_font not in ['Symbol', 'ZapfDingbats']: 145 | font['Encoding'] = b'/WinAnsiEncoding' 146 | return font 147 | 148 | class PDFTrueTypeFont(PDFFont): 149 | """This class represents a TrueType PDF font. 150 | 151 | This class is not working yet. 152 | 153 | Args: 154 | ref (str): the name of this font. 155 | base_font (str): the base font name of this font. 156 | widths (dict): the widths of each character in this font. 157 | """ 158 | def __init__(self, ref: str, filename:str=None) -> None: 159 | super().__init__(ref) 160 | self._base_font = None 161 | if filename is not None: 162 | self.load_font(filename) 163 | 164 | @property 165 | def base_font(self) -> str: 166 | """See :meth:`pdfme.fonts.PDFFont.base_font`""" 167 | return self._base_font 168 | 169 | def _get_char_width(self, char: str) -> float: 170 | """Method to get the width of the ``char`` character string. 171 | 172 | Args: 173 | char (str): the character. 174 | 175 | Returns: 176 | float: the character's width. 177 | """ 178 | index = ord(char) 179 | if index in self.cmap: 180 | glyph = self.cmap[index] 181 | if glyph in self.glyph_set: 182 | return self.glyph_set[self.cmap[ord(char)]].width 183 | 184 | return self.glyph_set['.notdef'].width 185 | 186 | def get_char_width(self, char: str) -> float: 187 | """See :meth:`pdfme.fonts.PDFFont.get_char_width`""" 188 | return self._get_char_width(char) / self.units_per_em 189 | 190 | def get_text_width(self, text) -> float: 191 | """See :meth:`pdfme.fonts.PDFFont.get_text_width`""" 192 | return sum(self._get_char_width(c) for c in text) / self.units_per_em 193 | 194 | def load_font(self, filename: str) -> None: 195 | """Method to extract information needed by the PDF document about this 196 | font, from font file in ``filename`` path. 197 | 198 | Args: 199 | filename (str): font file path. 200 | 201 | Raises: 202 | ImportError: if ``fonttools`` library is not installed. 203 | """ 204 | try: 205 | from fontTools import ttLib 206 | except: 207 | raise ImportError( 208 | 'You need to install library fonttools to add new fonts: ' 209 | 'pip install fonttools' 210 | ) 211 | self.filename = str(Path(filename)) 212 | self.font = ttLib.TTFont(self.filename) 213 | 214 | # TODO: cmap needs to be modifiedfor this to work 215 | self.cmap = self.font['cmap'].getcmap(3,1).cmap 216 | self.glyph_set = self.font.getGlyphSet() 217 | 218 | self.font_descriptor = self._get_font_descriptor() 219 | 220 | def _get_font_descriptor(self) -> dict: 221 | """Method to extract information needed by the PDF document from the 222 | font file, to build a PDF object called "font descriptor". 223 | 224 | Raises: 225 | Exception: if font file has copyright restrictions. 226 | 227 | Returns: 228 | dict: dict representing this font's "font descriptor". 229 | """ 230 | self._base_font = self.font['name'].names[6].toStr() 231 | head = self.font["head"] 232 | self.units_per_em = head.unitsPerEm 233 | scale = 1000 / self.units_per_em 234 | xMax = head.xMax * scale 235 | xMin = head.xMin * scale 236 | yMin = head.yMin * scale 237 | yMax = head.yMax * scale 238 | 239 | hhea = self.font.get('hhea') 240 | 241 | if hhea: 242 | ascent = hhea.ascent * scale 243 | descent = hhea.descent * scale 244 | 245 | os2 = self.font.get('OS/2') 246 | 247 | if os2: 248 | usWeightClass = os2.usWeightClass 249 | fsType = os2.fsType 250 | if (fsType == 0x0002 or (fsType & 0x0300) != 0): 251 | error = 'Font file in {} has copyright restrictions.' 252 | raise Exception(error.format(self.filename)) 253 | 254 | if hhea is None: 255 | ascent = os2.sTypoAscender * scale 256 | descent = os2.sTypoDescender * scale 257 | capHeight = os2.sCapHeight * scale if os2.version > 1 else ascent 258 | else: 259 | usWeightClass = 500 260 | if hhea is None: 261 | ascent = yMax 262 | descent = yMin 263 | capHeight = self.ascent 264 | 265 | stemV = 50 + int(pow((usWeightClass / 65.0),2)) 266 | 267 | post = self.font['post'] 268 | italicAngle = post.italicAngle 269 | 270 | flags = 4 271 | if (italicAngle!= 0): 272 | flags = flags | 64 273 | if (usWeightClass >= 600): 274 | flags = flags | 262144 275 | if (post.isFixedPitch): 276 | flags = flags | 1 277 | 278 | return { 279 | 'Type': b'/FontDescriptor', 280 | 'FontName': subs('/{}', self.base_font), 281 | 'Flags': flags, 282 | 'FontBBox': [xMin, yMin, xMax, yMax], 283 | 'ItalicAngle': italicAngle, 284 | 'Ascent': ascent, 285 | 'Descent': descent, 286 | 'CapHeight': capHeight, 287 | 'StemV': stemV 288 | } 289 | 290 | 291 | def add_font(self, base: 'PDFBase') -> 'PDFObject': 292 | """See :meth:`pdfme.fonts.PDFFont.add_font`""" 293 | font_file = BytesIO() 294 | self.font.save(font_file) 295 | font_file_bytes = font_file.getvalue() 296 | font_file_stream = base.add({ 297 | 'Filter': b'/FlateDecode', 298 | '__stream__': font_file_bytes, 299 | 'Length1': len(font_file_bytes) 300 | }) 301 | 302 | font_descriptor = base.add(self.font_descriptor) 303 | font_descriptor['FontFile2'] = font_file_stream.id 304 | 305 | font_cid = base.add({ 306 | 'Type': b'/Font', 307 | 'FontDescriptor': font_descriptor.id, 308 | 'BaseFont': subs('/{}', self.base_font), 309 | 'Subtype': b'/CIDFontType2', 310 | 'CIDToGIDMap': b'/Identity', 311 | 'CIDSystemInfo': { 312 | 'Registry': 'Adobe', 313 | 'Ordering': 'Identity', 314 | 'Supplement': 0 315 | } 316 | }) 317 | 318 | to_unicode_stream = base.add({ 319 | 'Filter': b'/FlateDecode', 320 | '__stream__': to_unicode 321 | }) 322 | 323 | return base.add({ 324 | 'Type': b'/Font', 325 | 'Subtype': b'/Type0', 326 | 'BaseFont': subs('/{}', self.base_font), 327 | 'Encoding': b'/Identity-H', 328 | 'DescendantFonts': [font_cid.id], 329 | 'ToUnicode': to_unicode_stream.id 330 | }) 331 | class PDFFonts: 332 | """Class that represents the set of all the fonts added to a PDF document. 333 | """ 334 | def __init__(self) -> None: 335 | self.fonts = {} 336 | self.index = 1 337 | for font_family, modes in STANDARD_FONTS.items(): 338 | self.fonts[font_family] = {} 339 | for mode, font in modes.items(): 340 | self.fonts[font_family][mode] = PDFStandardFont( 341 | 'F'+str(self.index), font['base_font'], font['widths'] 342 | ) 343 | self.index += 1 344 | 345 | def get_font(self, font_family: str, mode: str) -> PDFFont: 346 | """Method to get a font from its ``font_family`` and ``mode``. 347 | 348 | Args: 349 | font_family (str): the name of the font family 350 | mode (str): the mode of the font you want to get. ``n``, ``b``, 351 | ``i`` or ``bi``. 352 | 353 | Returns: 354 | PDFFont: an object that represents a PDF font. 355 | """ 356 | family = self.fonts[font_family] 357 | return family['n'] if mode not in family else family[mode] 358 | 359 | def load_font(self, path: str, font_family: str, mode: str='n') -> None: 360 | """Method to add a TrueType font to this instance. 361 | 362 | Args: 363 | path (str): the location of the font file. 364 | font_family (str): the name of the font family 365 | mode (str, optional): the mode of the font you want to get. 366 | ``n``, ``b``, ``i`` or ``bi``. 367 | """ 368 | font = PDFTrueTypeFont('F'+str(self.index), path) 369 | if not font_family in self.fonts: 370 | self.fonts[font_family] = {'n': font} 371 | self.fonts[font_family][mode] = font 372 | self.index += 1 373 | 374 | from .parser import PDFObject 375 | from .base import PDFBase 376 | from .utils import subs 377 | -------------------------------------------------------------------------------- /pdfme/image.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import traceback 3 | from io import BytesIO, BufferedIOBase 4 | from pathlib import Path 5 | from typing import Optional, Union, BinaryIO 6 | 7 | ImageType = Union[str, Path, BufferedIOBase] 8 | 9 | 10 | class PDFImage: 11 | """Class that represents a PDF image. 12 | 13 | You can pass the location path (``str`` or ``pathlib.Path`` format) of the 14 | image, or pass a file-like object (``io.BufferedIOBase``) with the image bytes, the 15 | extension of the image, and the image name. 16 | 17 | Only JPEG and PNG image formats are supported in this moment. PNG images are 18 | converted to JPEG, and for this Pillow library is required. 19 | 20 | Args: 21 | image (str, pathlib.Path, BufferedIOBase): The path or file-like object of the 22 | image. 23 | extension (str, optional): If ``image`` is path-like object, this 24 | argument should contain the extension of the image. Options are 25 | [``jpg``, ``jpeg``, ``png``]. 26 | image_name (str, optional): If ``image`` is path-like object, this 27 | argument should contain the name of the image. This name should be 28 | unique among the images added to the same PDF document. 29 | """ 30 | 31 | def __init__(self, image: ImageType, extension: str = None, image_name: str = None): 32 | image_bytes = None 33 | try: 34 | if isinstance(image, str): 35 | image_bytes = Path(image).open("rb") 36 | self.image_name = image 37 | if extension is None: 38 | extension = image.rpartition(".")[-1] 39 | elif isinstance(image, Path): 40 | image_bytes = image.open("rb") 41 | self.image_name = str(image) 42 | if extension is None: 43 | extension = image.suffix 44 | elif isinstance(image, BufferedIOBase): 45 | image_bytes = image 46 | if image_name is None: 47 | raise TypeError( 48 | "when image is of type io.BufferedIOBase, image_name must be " 49 | "provided" 50 | ) 51 | self.image_name = image_name 52 | if extension is None: 53 | raise TypeError( 54 | "when image is of type io.BufferedIOBase, extension must be " 55 | "provided" 56 | ) 57 | else: 58 | raise TypeError( 59 | "image must be of type str, pathlib.Path or io.BufferedIOBase" 60 | ) 61 | 62 | if not isinstance(extension, str): 63 | raise TypeError("extension type is str") 64 | 65 | if len(extension) > 0 and extension[0] == ".": 66 | extension = extension[1:] 67 | 68 | extension = extension.strip().lower() 69 | 70 | if extension in ["jpg", "jpeg"]: 71 | self.parse_jpg(image_bytes) 72 | elif extension == "png": 73 | self.parse_png(image_bytes) 74 | else: 75 | raise NotImplementedError( 76 | 'Images of type "{}" are not yet supported'.format(extension) 77 | ) 78 | finally: 79 | if not isinstance(image, BufferedIOBase) and image_bytes is not None: 80 | image_bytes.close() 81 | 82 | def parse_jpg(self, bytes_: BufferedIOBase) -> None: 83 | """Method to extract metadata from a JPEG image ``bytes_`` needed to 84 | embed this image in a PDF document. 85 | 86 | This method creates this instance's attibute ``pdf_obj``, containing 87 | a dict that can be added to a :class:`pdfme.base.PDFBase` instance as 88 | a PDF Stream object that represents this image. 89 | 90 | Args: 91 | bytes_ (BufferedIOBase): A file-like object containing the 92 | image. 93 | """ 94 | try: 95 | while True: 96 | markerHigh, markerLow = struct.unpack("BB", bytes_.read(2)) 97 | if markerHigh != 0xFF or markerLow < 0xC0: 98 | raise SyntaxError("No JPEG marker found") 99 | elif markerLow == 0xDA: 100 | raise SyntaxError("No JPEG SOF marker found") 101 | elif ( 102 | markerLow == 0xC8 103 | or (markerLow >= 0xD0 and markerLow <= 0xD9) 104 | or (markerLow >= 0xF0 and markerLow <= 0xFD) 105 | ): 106 | continue 107 | else: 108 | (data_size,) = struct.unpack(">H", bytes_.read(2)) 109 | data = bytes_.read(data_size - 2) if data_size > 2 else b"" 110 | if ( 111 | (markerLow >= 0xC0 and markerLow <= 0xC3) 112 | or (markerLow >= 0xC5 and markerLow <= 0xC7) 113 | or (markerLow >= 0xC9 and markerLow <= 0xCB) 114 | or (markerLow >= 0xCD and markerLow <= 0xCF) 115 | ): 116 | depth, h, w, layers = struct.unpack_from(">BHHB", data) 117 | 118 | if layers == 3: 119 | colspace = b"/DeviceRGB" 120 | elif layers == 4: 121 | colspace = b"/DeviceCMYK" 122 | else: 123 | colspace = b"/DeviceGray" 124 | 125 | break 126 | except Exception: 127 | traceback.print_exc() 128 | raise ValueError("Couldn't process image: {}".format(self.image_name)) 129 | 130 | bytes_.seek(0) 131 | image_data = bytes_.read() 132 | bytes_.seek(0) 133 | 134 | self.width = int(w) 135 | self.height = int(h) 136 | 137 | self.pdf_obj = { 138 | "Type": b"/XObject", 139 | "Subtype": b"/Image", 140 | "Width": self.width, 141 | "Height": self.height, 142 | "ColorSpace": colspace, 143 | "BitsPerComponent": int(depth), 144 | "Filter": b"/DCTDecode", 145 | "__skip_filter__": True, 146 | "__stream__": image_data, 147 | } 148 | 149 | def parse_png(self, bytes_: BinaryIO) -> None: 150 | """Method to convert a PNG image to a JPEG image and later parse it as 151 | a JPEG image. 152 | 153 | This method creates this instance's attibute ``pdf_obj``, containing 154 | a dict that can be added to a :class:`pdfme.base.PDFBase` instance as 155 | a PDF Stream object that represents this image. 156 | 157 | Args: 158 | bytes_ (BinaryIO): A file-like object containing the 159 | image. 160 | """ 161 | from PIL import Image # type: ignore 162 | 163 | im = Image.open(bytes_).convert("RGB") 164 | bytes_io = BytesIO() 165 | im.save(bytes_io, "JPEG") 166 | bytes_io.seek(0) 167 | self.parse_jpg(bytes_io) 168 | bytes_io.close() 169 | im.close() 170 | -------------------------------------------------------------------------------- /pdfme/page.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Union 2 | 3 | Number = Union[int, float] 4 | PageType = Union[Number, str, Iterable[Number]] 5 | MarginType = Union[int, float, Iterable[Number], dict] 6 | 7 | class PDFPage: 8 | """Class that represents a PDF page, and has methods to add stream parts 9 | into the internal page PDF Stream Object, and other things like 10 | fonts, annotations and images. 11 | 12 | This object have ``x`` and ``y`` coordinates used by the 13 | :class:`pdfme.pdf.PDF` insance that contains this page. This point is called 14 | ``cursor`` in this class. 15 | 16 | Args: 17 | base (PDFBase): [description] 18 | width (Number): [description] 19 | height (Number): [description] 20 | margin_top (Number, optional): [description]. Defaults to 0. 21 | margin_bottom (Number, optional): [description]. Defaults to 0. 22 | margin_left (Number, optional): [description]. Defaults to 0. 23 | margin_right (Number, optional): [description]. Defaults to 0. 24 | """ 25 | def __init__( 26 | self, base: 'PDFBase', width: Number, height: Number, 27 | margin_top: Number=0, margin_bottom: Number=0, 28 | margin_left: Number=0, margin_right: Number=0 29 | ): 30 | self.margin_top = margin_top 31 | self.margin_bottom = margin_bottom 32 | self.margin_left = margin_left 33 | self.margin_right = margin_right 34 | 35 | self.width = width 36 | self.height = height 37 | self.go_to_beginning() 38 | 39 | self.stream = base.add({'Filter': b'/FlateDecode', '__stream__': {}}) 40 | self.page = base.add({ 41 | 'Type': b'/Page', 'Contents': self.stream.id, 'Resources': {} 42 | }) 43 | self.x_objects = {} 44 | self.current_id = 0 45 | 46 | @property 47 | def y(self) -> Number: 48 | """ 49 | Returns: 50 | Number: The current vertical position of the page's cursor, from 51 | top (0) to bottom. This is different from ``_y`` attribute, the 52 | position from bottom (0) to top. 53 | """ 54 | return self.height - self._y 55 | 56 | @y.setter 57 | def y(self, value): 58 | self._y = self.height - value 59 | 60 | def go_to_beginning(self) -> None: 61 | """Method to set the position of the cursor's page to the origin point 62 | of the page, considering this page margins. The origin is at the 63 | left-top corner of the rectangle that will contain the page's contents. 64 | """ 65 | self.content_width = self.width - self.margin_right - self.margin_left 66 | self.content_height = self.height - self.margin_top - self.margin_bottom 67 | 68 | self.x = self.margin_left 69 | self._y = self.height - self.margin_top 70 | 71 | def add(self, content: Union[str, bytes]) -> int: 72 | """Method to add some bytes (if a string is passed, it's transformed 73 | into a bytes object) representing a stream portion, into this page's PDF 74 | internal Stream Object. 75 | 76 | Args: 77 | content (str, bytes): the stream portion to be added to this page's 78 | stream. 79 | 80 | Returns: 81 | int: the id of the portion added to the page's stream 82 | """ 83 | if isinstance(content, str): 84 | content = content.encode('latin') 85 | current_id = self.current_id 86 | self.stream['__stream__'][current_id] = content 87 | self.current_id += 1 88 | return current_id 89 | 90 | def add_font(self, font_ref: str, font_obj_id: 'PDFRef') -> None: 91 | """Method to reference a PDF font in this page, that will be used inside 92 | this page's stream. 93 | 94 | Args: 95 | font_ref (str): the ``ref`` attribute of the 96 | :class:`pdfme.fonts.PDFFont` instance that will be referenced in 97 | this page. 98 | font_obj_id (PDFRef): the object id of the font being referenced 99 | here, already added to a :class:`pdfme.base.PDFBase` instance. 100 | """ 101 | self.page['Resources'].setdefault('Font', {}) 102 | self.page['Resources']['Font'][font_ref] = font_obj_id 103 | 104 | def add_annot(self, obj: dict, rect: list) -> None: 105 | """Method to add a PDF annotation to this page. 106 | 107 | The ``object`` dict should have the keys describing the annotation to 108 | be added. By default, this object will have the following key/values 109 | by default: ``Type = /Annot`` and ``Subtype = /Link``. 110 | You can include these keys in ``object`` if you want to overwrite any of 111 | the default values for them. 112 | 113 | Args: 114 | obj (dict): the annotation object. 115 | rect (list): a list with the following information about the 116 | annotation: [x, y, width, height]. 117 | """ 118 | if not 'Annots' in self.page: 119 | self.page['Annots'] = [] 120 | _obj = {'Type': b'/Annot', 'Subtype': b'/Link'} 121 | _obj.update(obj) 122 | _obj['Rect'] = rect 123 | self.page['Annots'].append(_obj) 124 | 125 | def add_link(self, uri_id: 'PDFRef', rect: list) -> None: 126 | """Method to add a link annotation (a URI that opens a webpage from the 127 | PDF document) to this page. 128 | 129 | Args: 130 | uri_id (PDFRef): the object id of the action object created to open 131 | this link. 132 | rect (list): a list with the following information about the 133 | annotation: [x, y, width, height]. 134 | """ 135 | self.add_annot({'A': uri_id, 'H': b'/N'}, rect) 136 | 137 | def add_reference(self, dest: str, rect: list) -> None: 138 | """Method to add a reference annotation (a clickable area, that takes 139 | the user to a destination) to this page. 140 | 141 | Args: 142 | dest (str): the name of the dest being referenced. 143 | rect (list): a list with the following information about the 144 | annotation: [x, y, width, height]. 145 | """ 146 | self.add_annot({'Dest': dest}, rect) 147 | 148 | def add_image( 149 | self, image_obj_id: 'PDFRef', width: Number, height: Number 150 | ) -> None: 151 | """Method to add an image to this page. 152 | 153 | The position of the image will be the same as ``x`` and ``y`` 154 | coordinates of this page. 155 | 156 | Args: 157 | image_obj_id (PDFRef): the object id of the image PDF object. 158 | width (int, float): the width of the image. 159 | height (int, float): the height of the image. 160 | """ 161 | self.page['Resources'].setdefault('XObject', {}) 162 | if not image_obj_id in self.x_objects: 163 | image_id = 'Im{}'.format(len(self.page['Resources']['XObject'])) 164 | self.page['Resources']['XObject'][image_id] = image_obj_id 165 | self.x_objects[image_obj_id] = image_id 166 | 167 | self.add( 168 | ' q {} 0 0 {} {} {} cm /{} Do Q'.format( 169 | round(width, 3), round(height, 3), round(self.x, 3), 170 | round(self._y, 3), self.x_objects[image_obj_id] 171 | ) 172 | ) 173 | 174 | from .base import PDFBase 175 | from .parser import PDFRef -------------------------------------------------------------------------------- /pdfme/parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Iterable, Union 3 | class PDFObject: 4 | """A class that represents a PDF object. 5 | 6 | This object has a :class:`pdfme.parser.PDFRef` ``id`` attribute representing 7 | the id of this object inside the PDF document, and acts as a dict, so the 8 | user can update any property of this PDF object like you would do with a 9 | dict. 10 | 11 | Args: 12 | id_ (PDFRef): The id of this object inside the PDF document. 13 | obj (dict, optional): the dict representing the PDF object. 14 | """ 15 | def __init__(self, id_: 'PDFRef', obj: dict=None) -> None: 16 | if not isinstance(id_, PDFRef): 17 | raise TypeError('id_ argument must be of type PDFRef') 18 | self.id = id_ 19 | self.value = {} if obj is None else obj 20 | 21 | def __getitem__(self, name): 22 | return self.value[name] 23 | 24 | def __setitem__(self, name, value): 25 | self.value[name] = value 26 | 27 | def __delitem__(self, name): 28 | del self.value[name] 29 | 30 | def __contains__(self, name): 31 | return name in self.value 32 | 33 | class PDFRef(int): 34 | """An ``int`` representing the id of a PDF object. 35 | 36 | This is a regular ``int`` that has an additional property called ``ref`` 37 | with a representation of this object, to be referenced elsewhere in the PDF 38 | document. 39 | """ 40 | def __new__(cls, id_): 41 | return int.__new__(cls, id_) 42 | @property 43 | def ref(self) -> bytes: 44 | """ 45 | Returns: 46 | bytes: bytes with a representation of this object, to be referenced 47 | elsewhere in the PDF document. 48 | """ 49 | return subs('{} 0 R', self) 50 | 51 | ObjectType = Union[ 52 | PDFObject, PDFRef, dict, list, tuple, set, bytes, bool, int, float, str 53 | ] 54 | 55 | def parse_obj(obj: ObjectType) -> bytes: 56 | """Function to convert a python object to a bytes object representing the 57 | corresponding PDF object. 58 | 59 | Args: 60 | obj (PDFObject, PDFRef, dict, list, tuple, set, bytes, bool, int, float, 61 | str): the object to be converted to a PDF object. 62 | 63 | Returns: 64 | bytes: bytes representing the corresponding PDF object. 65 | """ 66 | if isinstance(obj, PDFObject): 67 | return parse_obj(obj.value) 68 | elif isinstance(obj, PDFRef): 69 | return obj.ref 70 | elif isinstance(obj, dict): 71 | if '__stream__' in obj: 72 | return parse_stream(obj) 73 | else: 74 | return parse_dict(obj) 75 | elif isinstance(obj, (list, tuple, set)): 76 | return parse_list(obj) 77 | elif isinstance(obj, bytes): 78 | return obj 79 | elif isinstance(obj, bool): 80 | return b'true' if obj else b'false' 81 | elif isinstance(obj, (int, float)): 82 | return str(obj).encode('latin') 83 | elif isinstance(obj, str): 84 | return ('(' + re.sub(r'([()])', r'\\\1', obj) + ')').encode('latin') 85 | 86 | 87 | def parse_dict(obj: dict) -> bytes: 88 | """Function to convert a python dict to a bytes object representing the 89 | corresponding PDF Dictionary. 90 | 91 | Args: 92 | obj (dict): the dict to be converted to a PDF Dictionary. 93 | 94 | Returns: 95 | bytes: bytes representing the corresponding PDF Dictionary. 96 | """ 97 | bytes_ = b'<<' 98 | for key, value in obj.items(): 99 | bytes_ += b'/' + key.encode('latin') 100 | ret = parse_obj(value) 101 | if not ret[0] in [b'/', b'(', b'<']: bytes_ += b' ' 102 | bytes_ += ret 103 | 104 | return bytes_ + b'>>' 105 | 106 | def parse_list(obj: Iterable) -> bytes: 107 | """Function to convert a python iterable to a bytes object representing the 108 | corresponding PDF Array. 109 | 110 | Args: 111 | obj (iterable): the iterable to be converted to a PDF Array. 112 | 113 | Returns: 114 | bytes: bytes representing the corresponding PDF Array. 115 | """ 116 | bytes_ = b'[' 117 | for i, value in enumerate(obj): 118 | ret = parse_obj(value) 119 | if not ret[0] in [b'/', b'(', b'<'] and i != 0: bytes_ += b' ' 120 | bytes_ += ret 121 | 122 | return bytes_ + b']' 123 | 124 | def parse_stream(obj: dict) -> bytes: 125 | """Function to convert a dict representing a PDF Stream object to a bytes 126 | object. 127 | 128 | A dict representing a PDF stream should have a ``'__stream__`` key 129 | containing the stream bytes. You don't have to include ``Length`` key in the 130 | dict, as it is calculated by us. The value of ``'__stream__'`` key must 131 | be of type ``bytes`` or a dict whose values are of type ``bytes``. 132 | If you include a ``Filter`` key, a encoding is automatically done in the 133 | stream (see :meth:`pdfme.encoders.encode_stream` function for 134 | supported encoders). If the contents of the stream are already encoded 135 | using the filter in ``Filter`` key, you can skip the encoding process 136 | by including the ``__skip_filter__`` key. 137 | 138 | Args: 139 | obj (dict): the dict representing a PDF stream. 140 | 141 | Returns: 142 | bytes: bytes representing the corresponding PDF Stream. 143 | """ 144 | stream_ = obj.pop('__stream__') 145 | skip_filter = obj.pop('__skip_filter__', False) 146 | 147 | if isinstance(stream_, bytes): 148 | stream_str = stream_ 149 | elif isinstance(stream_, dict): 150 | stream_str = b''.join(stream_.values()) 151 | else: 152 | raise Exception( 153 | 'streams must be bytes or a dict of bytes: ' + str(stream_) 154 | ) 155 | 156 | stream = encode_stream(stream_str, obj['Filter']) \ 157 | if 'Filter' in obj and not skip_filter else stream_str 158 | 159 | obj['Length'] = len(stream) 160 | ret = parse_dict(obj) + b'stream\n' + stream + b'\nendstream' 161 | obj['__stream__'] = stream_ 162 | if skip_filter: 163 | obj['__skip_filter__'] = True 164 | return ret 165 | 166 | from .encoders import encode_stream 167 | from .utils import subs 168 | -------------------------------------------------------------------------------- /pdfme/table.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Optional, Union 2 | 3 | PARAGRAPH_PROPS = ( 4 | 'text_align', 'line_height', 'indent', 'list_text', 5 | 'list_style', 'list_indent' 6 | ) 7 | 8 | TABLE_PROPS = ('widths', 'borders', 'fills') 9 | 10 | 11 | Number = Union[int, float] 12 | CellType = Union[dict, str, list, tuple] 13 | 14 | class PDFTable: 15 | """Class that represents a PDF table. 16 | 17 | The ``content`` argument is an iterable representing the rows of the table, 18 | and each row should be an iterable too, representing each of 19 | the columns in the row. The elements on a row iterable could be any of the 20 | elements that you pass to argument ``content`` list in class 21 | :class:`pdfme.content.PDFContent`. Because of this you can add paragraphs, 22 | images and content boxes into a table cell. 23 | 24 | Argument ``widths``, if passed, should be an iterable with the width of 25 | each column in the table. If not passed, all the columns will have the same 26 | width. 27 | 28 | Argument ``style``, if passed, should be a dict with any of the following 29 | keys: 30 | 31 | * ``cell_margin``: the margin of the four sides of the cells in the table. 32 | Default value is ``5``. 33 | 34 | * ``cell_margin_left``: left margin of the cells in the table. 35 | Default value is ``cell_margin``. 36 | 37 | * ``cell_margin_top``: top margin of the cells in the table. 38 | Default value is ``cell_margin``. 39 | 40 | * ``cell_margin_right``: right margin of the cells in the table. 41 | Default value is ``cell_margin``. 42 | 43 | * ``cell_margin_bottom``: bottom margin of the cells in the table. 44 | Default value is ``cell_margin``. 45 | 46 | * ``cell_fill``: the color of all the cells in the table. Default value is 47 | ``None`` (transparent). See :func:`pdfme.color.parse_color` for information 48 | about this attribute. 49 | 50 | * ``border_width``: the width of all the borders in the table. Default value 51 | is ``0.5``. 52 | 53 | * ``border_color``: the color of all the borders in the table .Default value 54 | is ``'black'``. See :func:`pdfme.color.parse_color` for information 55 | about this attribute. 56 | 57 | * ``border_style``: the style of all the borders in the table. It can be 58 | ``solid``, ``dotted`` or ``solid``. Default value is ``solid``. 59 | 60 | You can overwrite the default values for the cell fills and the borders with 61 | ``fills`` and ``borders`` arguments. 62 | These arguments, if passed, should be iterables of dicts. Each dict should 63 | have a ``pos`` key that contains a string with information of the vertical 64 | (rows) and horizontal (columns) position of the fills or borders you want 65 | to change, and for this, such a string should has 2 parts separated by a 66 | semi colon, the first one for the vertical position and the second one for 67 | the horizontal position. 68 | The position can be a single int, a comma-separated list of ints, or a slice 69 | (range), like the one you pass to get a slice of a python list. For borders 70 | you have to include a ``h`` or a ``v`` before the positions, to tell if you 71 | want to change vertical or horizontal borders. The indexes in this string 72 | can be negative, referring to positions from the end to the beginning. 73 | 74 | The following are examples of valid ``pos`` strings: 75 | 76 | * ``'h0,1,-1;:'`` to modify the first, second and last horizontal lines in 77 | the table. The horizontal position is a single colon, and thus the whole 78 | horizontal lines are affected. 79 | 80 | * ``'::2;:'`` to modify all of the fills horizontally, every two rows. This 81 | would set the current fill to all the cells in the first row, the third 82 | row, the fifth row and so on. 83 | 84 | Additional to the ``pos`` key for dicts inside ``fills`` iterable, you 85 | have to include a ``color`` key, with a valid color value. See 86 | :func:`pdfme.color.parse_color` for information about this attribute. 87 | 88 | Additional to the ``pos`` key for dicts inside ``borders`` iterable, you 89 | can include ``width`` (border width), ``color`` (border color) and 90 | ``style`` (border style) keys. 91 | 92 | If a cell element is a dict it's ``style`` dict can have any of the 93 | following keys: ``cell_margin``, ``cell_margin_left``, ``cell_margin_top``, 94 | ``cell_margin_right``, ``cell_margin_bottom`` and ``cell_fill``, to overwrite 95 | the default value of any of these parameters on its cell. 96 | In a cell dict, you can also include ``colspan`` and ``rowspan`` keys, to 97 | span it horizontally and vertically respectively. The cells being merged to 98 | this spanned cell should be None. 99 | 100 | Here's an example of a valid ``content`` value: 101 | 102 | .. code-block:: python 103 | 104 | [ 105 | ['row 1, col 1', 'row 1, col 2', 'row 1, col 3'], 106 | [ 107 | 'row2 col1', 108 | { 109 | 'style': {'cell_margin': 10, } 110 | 'colspan': 2, 'rowspan': 2 111 | '.': 'rows 2 to 3, cols 2 to 3', 112 | }, 113 | None 114 | ], 115 | ['row 3, col 1', None, None], 116 | ] 117 | 118 | Use method :meth:`pdfme.table.PDFTable.run` to add as many rows as possible 119 | to the rectangle defined by ``x``, ``y```, ``width`` and ``height``. 120 | The rows are added to this rectangle, until 121 | they are all inside of it, or until all of the vertical space is used and 122 | the rest of the rows can not be added. In these two cases method ``run`` 123 | finishes, and the property ``finished`` will be True if all the elements 124 | were added, and False if the vertical space ran out. 125 | If ``finished`` is False, you can set a new rectangle (on a new page for 126 | example) and use method ``run`` again (passing the parameters of the new 127 | rectangle) to add the remaining elements that couldn't be added in 128 | the last rectangle. You can keep doing this until all of the elements are 129 | added and therefore property ``finished`` is True. 130 | 131 | By using this method the rows are not really added to the PDF object. 132 | After calling ``run``, the properties ``fills`` and ``lines`` will be 133 | populated with the fills and lines of the tables that fitted inside the 134 | rectangle, and ``parts`` will be filled with the paragraphs and images that 135 | fitted inside the table rectangle too, and you have to add them by yourself 136 | to the PDF object before using method ``run`` again (in case ``finished`` is 137 | False), because they will be redefined for the next rectangle after calling 138 | it again. You can check the ``table`` method in `PDF`_ module to see how 139 | this process is done. 140 | 141 | Args: 142 | content (iterable): like the one just explained. 143 | fonts (PDFFonts): a PDFFonts object used to build paragraphs. 144 | x (int, float): the x position of the left of the table. 145 | y (int, float): the y position of the top of the table. 146 | width (int, float): the width of the table. 147 | height (int, float): the height of the table. 148 | widths (Iterable, optional): the widths of each column. 149 | style (Union[dict, str], optional): the default style of the table. 150 | borders (Iterable, optional): borders of the table. 151 | fills (Iterable, optional): fills of the table. 152 | pdf (PDF, optional): A PDF object used to get string styles inside the 153 | elements. 154 | 155 | .. _PDF: https://github.com/aFelipeSP/pdfme/blob/main/pdfme/pdf.py 156 | """ 157 | def __init__( 158 | self, content: Iterable, fonts: 'PDFFonts', x: Number, y: Number, 159 | width: Number, height: Number, widths: Iterable=None, 160 | style: Union[dict, str]=None, borders: Iterable=None, 161 | fills: Iterable=None, pdf: 'PDF'=None 162 | ): 163 | if not isinstance(content, (list, tuple)): 164 | raise Exception('content must be a list or tuple') 165 | 166 | self.setup(x, y, width, height) 167 | self.content = content 168 | self.current_index = 0 169 | self.fonts = fonts 170 | self.pdf = pdf 171 | self.finished = False 172 | if len(content) == 0 or len(content[0]) == 0: 173 | return 174 | 175 | self.cols_count = len(content[0]) 176 | self.delayed = {} 177 | 178 | if widths is not None: 179 | if not isinstance(widths, (list, tuple)): 180 | raise Exception('widths must be a list or tuple') 181 | if len(widths) != self.cols_count: 182 | raise Exception('widths count must be equal to cols count') 183 | try: 184 | widths_sum = sum(widths) 185 | self.widths = [w/widths_sum for w in widths] 186 | except TypeError: 187 | raise Exception('widths must be numbers') 188 | except ZeroDivisionError: 189 | raise Exception('sum of widths must be greater than zero') 190 | else: 191 | self.widths = [1 / self.cols_count] * self.cols_count 192 | 193 | self.style = {'cell_margin': 5, 'cell_fill': None} 194 | self.style.update(process_style(style, self.pdf)) 195 | self.set_default_border() 196 | self.setup_borders([] if borders is None else borders) 197 | self.setup_fills([] if fills is None else fills) 198 | self.first_row = True 199 | 200 | def setup( 201 | self, x: Number=None, y: Number=None, 202 | width: Number=None, height: Number=None 203 | ) -> None: 204 | """Method to change the size and position of the table. 205 | 206 | Args: 207 | x (int, float, optional): the x position of the left of the table. 208 | y (int, float, optional): the y position of the top of the table. 209 | width (int, float, optional): the width of the table. 210 | height (int, float, optional): the height of the table. 211 | """ 212 | if x is not None: 213 | self.x = x 214 | if y is not None: 215 | self.y = y 216 | if width is not None: 217 | self.width = width 218 | if height is not None: 219 | self.height = height 220 | 221 | def get_state(self) -> dict: 222 | """Method to get the current state of this table. This can be used 223 | later in method :meth:`pdfme.table.PDFTable.set_state` to 224 | restore this state in this table (like a checkpoint in a 225 | videogame). 226 | 227 | Returns: 228 | dict: a dict with the state of this table. 229 | """ 230 | return { 231 | 'current_index': self.current_index, 'delayed': copy(self.delayed) 232 | } 233 | 234 | def set_state(self, current_index: int=None, delayed: dict=None) -> None: 235 | """Method to set the state of this table. 236 | 237 | The arguments of this method define the current state of this table, 238 | and with this method you can change that state. 239 | 240 | Args: 241 | current_index (int, optional): the index of the current row being 242 | added. 243 | delayed (dict, optional): a dict with delayed cells that should be 244 | added before the next row. 245 | """ 246 | if current_index is not None: 247 | self.current_index = current_index 248 | elif delayed is not None: 249 | self.delayed = delayed 250 | 251 | def set_default_border(self) -> None: 252 | """Method to create attribute ``default_border`` containing the default 253 | border values. 254 | """ 255 | self.default_border = {} 256 | self.default_border['width'] = self.style.pop('border_width', 0.5) 257 | color = self.style.pop('border_color', 0) 258 | self.default_border['color'] = PDFColor(color, True) 259 | self.default_border['style'] = self.style.pop('border_style', 'solid') 260 | 261 | def parse_pos_string(self, pos: str, counts: int): 262 | """Method to convert a position string like the ones used in 263 | ``borders`` and ``fills`` arguments of this class, into a generator 264 | of positions obtained from this string. 265 | 266 | For more information, see the definition of this class. 267 | 268 | Args: 269 | pos (str): position string. 270 | counts (int): the amount of columns or rows. 271 | 272 | Yields: 273 | tuple: the horizontal and vertical index of each position obtained 274 | from the ``pos`` string. 275 | """ 276 | range_strs = pos.split(';') 277 | if len(range_strs) == 1: 278 | range_strs.append(':') 279 | ranges = [ 280 | self.parse_range_string(range_str, count) 281 | for range_str, count in zip(range_strs, counts) 282 | ] 283 | for i in ranges[0]: 284 | for j in ranges[1]: 285 | yield i, j 286 | 287 | def parse_range_string(self, data: str, count: int) -> Iterable: 288 | """Method to convert one of the parts of a position string like the ones 289 | used in ``borders`` and ``fills`` arguments of this class, into a 290 | iterator with all the positions obtained from this string. 291 | 292 | For more information, see the definition of this class. 293 | 294 | Args: 295 | data (str): one of the parts of a position string. 296 | counts (int): the amount of columns or rows. 297 | 298 | Returns: 299 | iterable: a list of indexes, or a ``range`` object. 300 | """ 301 | data = ':' if data == '' else data 302 | if ':' in data: 303 | parts = data.split(':') 304 | num = 0 if parts[0].strip() == '' else int(parts[0]) 305 | parts[0] = num if num >= 0 else count + num 306 | if len(parts) > 1: 307 | num = count if parts[1].strip() == '' else int(parts[1]) 308 | parts[1] = num if num >= 0 else count + num 309 | if len(parts) > 2: 310 | parts[2] = 1 if parts[2].strip() == '' else int(parts[2]) 311 | return range(*parts) 312 | else: 313 | return [ 314 | count + int(i) if int(i) < 0 else int(i) 315 | for i in data.split(',') 316 | ] 317 | 318 | def setup_borders(self, borders: Iterable) -> None: 319 | """Method to process the ``borders`` argument passed to this class, and 320 | populate attributes ``borders_h`` and ``borders_v``. 321 | 322 | Args: 323 | borders (iterable): the ``borders`` argument passed to this class. 324 | """ 325 | rows = len(self.content) 326 | cols = self.cols_count 327 | self.borders_h = {} 328 | self.borders_v = {} 329 | for b in borders: 330 | border = copy(self.default_border) 331 | border.update(b) 332 | border['color'] = PDFColor(border['color'], True) 333 | pos = border.pop('pos', None) 334 | if pos is None: 335 | continue 336 | is_vert = pos[0].lower() == 'v' 337 | counts = (rows, cols + 1) if is_vert else (rows + 1, cols) 338 | border_l = self.borders_v if is_vert else self.borders_h 339 | for i, j in self.parse_pos_string(pos[1:], counts): 340 | first = border_l.setdefault(i, {}) 341 | first[j] = border 342 | 343 | def get_border(self, i: int, j: int, is_vert: bool) -> dict: 344 | """Method to get the border in the horizontal position ``i``, and 345 | vertical position ``j``. It takes a vertical border if ``is_vert`` is 346 | ``true``, and a horizontal border if ``is_vert`` is ``false`` 347 | Args: 348 | i (int): horizontal position. 349 | j (int): vertical position. 350 | is_vert (bool): vertical (True) or horizontal (False) border. 351 | 352 | Returns: 353 | dict: dict with description of the border in position ``i``, ``j``. 354 | """ 355 | border_l = self.borders_v if is_vert else self.borders_h 356 | if i in border_l and j in border_l[i]: 357 | return copy(border_l[i][j]) 358 | else: 359 | return copy(self.default_border) 360 | 361 | def setup_fills(self, fills: Iterable) -> None: 362 | """Method to process the ``fills`` argument passed to this class, and 363 | populate attribute ``fills_defs``. 364 | 365 | Args: 366 | fills (iterable): the ``fills`` argument passed to this class. 367 | """ 368 | v_count = len(self.content) 369 | h_count = self.cols_count 370 | self.fills_defs = {} 371 | for f in fills: 372 | fill = f['color'] 373 | pos = f.get('pos', None) 374 | if pos is None: 375 | continue 376 | for i, j in self.parse_pos_string(pos, (v_count, h_count)): 377 | first = self.fills_defs.setdefault(i, {}) 378 | first[j] = fill 379 | 380 | def compare_borders(self, a: dict, b: dict) -> bool: 381 | """Method that compares border dicts ``a`` and ``b`` and returns if they 382 | are equal (``True``) or not (``False``) 383 | 384 | Args: 385 | a (dict): first border. 386 | b (dict): second border. 387 | 388 | Returns: 389 | bool: if ``a`` and ``b`` are equal (``True``) or not (``False``). 390 | """ 391 | return all(a[attr] == b[attr] for attr in ['width', 'color', 'style']) 392 | 393 | def process_borders( 394 | self, col: int, border_left: dict, border_top: dict 395 | ) -> None: 396 | """Method to setup the top and left borders of each cell 397 | 398 | Args: 399 | col (int): the columns number. 400 | border_left (dict): the left border dict. 401 | border_top (dict): the top border dict. 402 | """ 403 | vert_correction = border_top.get('width', 0)/2 404 | if not self.top_lines_interrupted: 405 | aux = self.top_lines[-1].get('width', 0)/2 406 | if aux > vert_correction: 407 | vert_correction = aux 408 | 409 | v_line = self.vert_lines[col] 410 | horiz_correction = border_left.get('width', 0)/2 411 | if not v_line['interrupted']: 412 | v_line['list'][-1]['y2'] = self.y_abs - vert_correction 413 | aux = v_line['list'][-1].get('width', 0)/2 414 | if aux > horiz_correction: 415 | horiz_correction = aux 416 | 417 | if not self.top_lines_interrupted: 418 | self.top_lines[-1]['x2'] += horiz_correction 419 | 420 | if border_top.get('width', 0) > 0: 421 | x2 = self.x_abs + self.widths[col] * self.width 422 | if ( 423 | not self.top_lines_interrupted and 424 | self.compare_borders(self.top_lines[-1], border_top) 425 | ): 426 | self.top_lines[-1]['x2'] = x2 427 | else: 428 | border_top.update(dict( 429 | type='line', y1=self.y_abs, 430 | y2=self.y_abs, x2=x2, x1=self.x_abs - horiz_correction 431 | )) 432 | self.top_lines.append(border_top) 433 | self.top_lines_interrupted = False 434 | else: 435 | self.top_lines_interrupted = True 436 | 437 | if border_left.get('width', 0) > 0: 438 | if not ( 439 | not v_line['interrupted'] and 440 | self.compare_borders(v_line['list'][-1], border_left) 441 | ): 442 | border_left.update(dict( 443 | type='line', x1=self.x_abs, 444 | x2=self.x_abs, y1=self.y_abs + vert_correction 445 | )) 446 | v_line['list'].append(border_left) 447 | v_line['interrupted'] = False 448 | else: 449 | v_line['interrupted'] = True 450 | 451 | def run( 452 | self, x: Number=None, y: Number=None, 453 | width: Number=None, height: Number=None 454 | ) -> None: 455 | """Method to add as many rows as possible to the rectangle defined by 456 | ``x``, ``y```, ``width`` and ``height`` attributes. 457 | 458 | More information about this method in this class definition. 459 | 460 | Args: 461 | x (int, float, optional): the x position of the left of the table. 462 | y (int, float, optional): the y position of the top of the table. 463 | width (int, float, optional): the width of the table. 464 | height (int, float, optional): the height of the table. 465 | """ 466 | 467 | self.setup(x, y, width, height) 468 | self.parts = [] 469 | self.lines = [] 470 | self.fills = [] 471 | self.fills_mem = {} 472 | self.heights_mem = {} 473 | self.rowspan = {} 474 | self.vert_lines = [ 475 | {'list': [], 'interrupted': True} 476 | for i in range(self.cols_count + 1) 477 | ] 478 | self.current_height = 0 479 | 480 | can_continue = True 481 | if len(self.delayed) > 0: 482 | can_continue = False 483 | row = [self.delayed.get(i) for i in range(self.cols_count)] 484 | action = self.add_row(row, True) 485 | if action == 'continue': 486 | self.current_index += 1 487 | can_continue = True 488 | 489 | cancel = False 490 | if can_continue: 491 | while self.current_index < len(self.content): 492 | action = self.add_row(self.content[self.current_index]) 493 | if action == 'cancel': 494 | cancel = True 495 | break 496 | elif action == 'continue': 497 | self.current_index += 1 498 | else: 499 | break 500 | 501 | if cancel: 502 | self.parts = [] 503 | self.lines = [] 504 | self.fills = [] 505 | return 506 | 507 | self.top_lines = [] 508 | self.top_lines_interrupted = True 509 | self.y_abs = self.y - self.current_height 510 | for col in range(self.cols_count): 511 | self.x_abs = self.x + sum(self.widths[0:col]) * self.width 512 | border_top = self.get_border(self.current_index, col, False) 513 | self.process_borders(col, {}, border_top) 514 | 515 | self.x_abs = self.x + self.width 516 | self.process_borders(self.cols_count, {}, {}) 517 | self.lines.extend(self.top_lines) 518 | self.lines.extend( 519 | line for vert_line in self.vert_lines for line in vert_line['list'] 520 | ) 521 | 522 | if self.current_index >= len(self.content) and len(self.delayed) == 0: 523 | self.finished = True 524 | 525 | def add_row(self, row: Iterable, is_delayed: bool=False) -> str: 526 | """Method to add a row to this table. 527 | 528 | Args: 529 | row (iterable): the row iterable. 530 | is_delayed (bool, optional): whether this row is being added in 531 | delayed mode (``True``) or not (``False``). 532 | 533 | Returns: 534 | str: string with the action that should be performed after this row 535 | is added. 536 | """ 537 | if not len(row) == self.cols_count: 538 | error = 'row {} should be of length {}.' 539 | raise Exception(error.format(self.current_index+1, self.cols_count)) 540 | 541 | self.max_height = 0 542 | self.row_max_height = self.height - self.current_height 543 | self.colspan = 0 544 | self.top_lines = [] 545 | self.top_lines_interrupted = True 546 | self.is_rowspan = False 547 | self.y_abs = self.y - self.current_height 548 | for col, element in enumerate(row): 549 | move_next = self.add_cell(col, element, is_delayed) 550 | if move_next: 551 | continue 552 | 553 | if self.first_row: 554 | self.first_row = False 555 | if self.max_height == 0: 556 | return 'cancel' 557 | 558 | ret = True 559 | if sum(v['colspan'] for v in self.delayed.values()) == self.cols_count: 560 | ret = False 561 | for col in self.delayed: 562 | if not col in self.rowspan: 563 | ret = False 564 | break 565 | 566 | self.x_abs = self.x + self.width 567 | last_border = self.get_border(self.current_index, len(row), True) 568 | self.process_borders(len(row), last_border, {}) 569 | 570 | self.lines.extend(self.top_lines) 571 | 572 | for col in self.heights_mem: 573 | self.heights_mem[col] -= self.max_height 574 | 575 | for col, fill in list(self.fills_mem.items()): 576 | fill['height'] += self.max_height 577 | if not fill.get('add_later', False) or not ret: 578 | fill['y'] -= fill['height'] 579 | self.fills.append(fill) 580 | self.fills_mem.pop(col) 581 | 582 | self.current_height += self.max_height 583 | 584 | return 'continue' if ret else 'interrupt' 585 | 586 | def get_cell_dimensions( 587 | self, col: int, border_left: dict, border_top: dict, 588 | cell_style: dict, rowspan: int, colspan: int 589 | ) -> tuple: 590 | """Method to get the cell dimensions at column ``col``, taking into 591 | account the cell borders, and the column and row spans. 592 | 593 | Args: 594 | col (int): the column of the cell. 595 | border_left (dict): left border dict. 596 | border_top (dict): top border dict. 597 | cell_style (dict): cell style dict. 598 | rowspan (int): the row span. 599 | colspan (int): the column span. 600 | 601 | Returns: 602 | tuple: tuple with position ``(x, y)``, size ``(width, height)``, 603 | and padding ``(left, top)`` for this cell. 604 | """ 605 | border_right = self.get_border( 606 | self.current_index, col + colspan + 1, True 607 | ) 608 | border_bottom = self.get_border( 609 | self.current_index + rowspan + 1, col, False 610 | ) 611 | 612 | full_width = sum(self.widths[col:col + colspan + 1]) * self.width 613 | padd_x_left = border_left.get('width', 0) / 2 + \ 614 | cell_style['cell_margin_left'] 615 | padd_x_right = border_right.get('width', 0) / 2 + \ 616 | cell_style['cell_margin_right'] 617 | padd_x = padd_x_left + padd_x_right 618 | padd_y_top = border_top.get('width', 0) / 2 + \ 619 | cell_style['cell_margin_top'] 620 | padd_y_bottom = border_bottom.get('width', 0) / 2 + \ 621 | cell_style['cell_margin_bottom'] 622 | padd_y = padd_y_top + padd_y_bottom 623 | x = self.x_abs + padd_x_left 624 | y = self.y_abs - padd_y_top 625 | width = full_width - padd_x 626 | height = self.row_max_height - padd_y 627 | return x, y, width, height, padd_x, padd_y 628 | 629 | def is_span( 630 | self, col: int, border_left: dict, border_top: dict, is_delayed: bool 631 | ) -> bool: 632 | """Method to check if cell at column ``col`` is part of a spanned cell 633 | (``True``) or not (``False``). 634 | 635 | Args: 636 | col (int): the column of the cell. 637 | border_left (dict): left border dict. 638 | border_top (dict): top border dict. 639 | is_delayed (bool, optional): whether this row is being added in 640 | delayed mode (``True``) or not (``False``). 641 | 642 | Returns: 643 | bool: whether ``col`` is part of a spanned cell (``True``) or not 644 | (``False``). 645 | """ 646 | rowspan_memory = self.rowspan.get(col, None) 647 | can_continue = False 648 | if rowspan_memory is None: 649 | if self.colspan > 0: 650 | self.colspan -= 1 651 | border_left['width'] = 0 652 | if self.is_rowspan: 653 | border_top['width'] = 0 654 | if self.colspan == 0: 655 | self.is_rowspan = False 656 | can_continue = True 657 | elif not is_delayed: 658 | rowspan_memory['rows'] -= 1 659 | self.colspan = rowspan_memory['cols'] 660 | self.is_rowspan = True 661 | border_top['width'] = 0 662 | 663 | if rowspan_memory['rows'] == 0: 664 | self.rowspan.pop(col) 665 | if col in self.fills_mem: 666 | self.fills_mem[col]['add_later'] = False 667 | if self.heights_mem.get(col, 0) > self.max_height: 668 | self.max_height = self.heights_mem[col] 669 | if self.colspan == 0: 670 | self.is_rowspan = False 671 | can_continue = True 672 | return can_continue 673 | 674 | def get_cell_style(self, element: CellType) -> tuple: 675 | """Method to extract the cell style from a cell ``element``. 676 | 677 | Args: 678 | element (dict, str, list, tuple): the cell element to extract the 679 | cell style from. 680 | 681 | Returns: 682 | tuple: tuple with a copy of ``element``, the element ``style``, 683 | and the ``cell_style``. 684 | """ 685 | if isinstance(element, dict) and 'delayed' in element: 686 | cell_style = element['cell_style'] 687 | style = {} 688 | else: 689 | style = copy(self.style) 690 | 691 | if element is None: 692 | element = '' 693 | if not isinstance(element, (dict, str, list, tuple)): 694 | element = str(element) 695 | if isinstance(element, (str, list, tuple)): 696 | element = {'.': element} 697 | 698 | if not isinstance(element, dict): 699 | raise TypeError( 700 | 'Elements must be of type dict, str, list or tuple:' 701 | + str(element) 702 | ) 703 | 704 | keys = [key for key in element.keys() if key.startswith('.')] 705 | if len(keys) > 0: 706 | style.update(parse_style_str(keys[0][1:], self.fonts)) 707 | style.update(process_style(element.get('style'), self.pdf)) 708 | cell_style = {} 709 | attr = 'cell_margin' 710 | for side in ('top', 'right', 'bottom', 'left'): 711 | cell_style[attr + '_' + side] = style.pop( 712 | attr + '_' + side, style.get(attr, None) 713 | ) 714 | style.pop(attr, None) 715 | cell_style['cell_fill'] = style.pop('cell_fill', None) 716 | element = copy(element) 717 | return element, style, cell_style 718 | 719 | def _setup_cell_fill( 720 | self, col: int, cell_style: dict, width: Number, rowspan: int 721 | ) -> None: 722 | """Method to optionally add the fill color in ``cell`style`` to 723 | attribute ``fills_mem`` 724 | 725 | Args: 726 | col (int): the cell column index. 727 | cell_style (dict): the cell style. 728 | width (Number): the width of the cell. 729 | rowspan (int): the rowspan of the cell. 730 | """ 731 | fill_color = cell_style.get('cell_fill', None) 732 | 733 | if fill_color is None: 734 | fill_color = ( 735 | self.fills_defs.get(self.current_index, {}).get(col, None) 736 | ) 737 | 738 | if fill_color is not None: 739 | self.fills_mem[col] = { 'type': 'fill', 'x': self.x_abs, 740 | 'y': self.y_abs, 'width': width, 'height': 0, 741 | 'color': PDFColor(fill_color, stroke=False) 742 | } 743 | if rowspan > 0: 744 | self.fills_mem[col]['add_later'] = True 745 | 746 | def _is_delayed_type(self, el: dict, type_: str) -> bool: 747 | return 'delayed' in el and el['type'] == type_ 748 | 749 | def is_type(self, el, type_): 750 | return type_ in el or self._is_delayed_type(el, type_) 751 | 752 | def add_cell(self, col: int, element: CellType, is_delayed: bool) -> bool: 753 | """Method to add a cell to the current row. 754 | 755 | Args: 756 | col (int): the column index for the cell. 757 | element (dict, str, list, tuple): the cell element to be added. 758 | is_delayed (bool, optional): whether current row is being added in 759 | delayed mode (``True``) or not (``False``). 760 | 761 | Returns: 762 | bool: whether ``col`` is part of a spanned cell (``True``) or not 763 | (``False``). 764 | """ 765 | self.x_abs = self.x + sum(self.widths[0:col]) * self.width 766 | border_left = self.get_border(self.current_index, col, True) 767 | border_top = self.get_border(self.current_index, col, False) 768 | can_continue = self.is_span(col, border_left, border_top, is_delayed) 769 | self.process_borders(col, border_left, border_top) 770 | if can_continue: 771 | return True 772 | 773 | element, style, cell_style = self.get_cell_style(element) 774 | 775 | colspan_original = element.get('colspan', 1) 776 | self.colspan = colspan_original - 1 777 | row_added = element.get('row', self.current_index) 778 | rowspan = element.get('rowspan', 1) - 1 - self.current_index + \ 779 | row_added 780 | if rowspan > 0: 781 | self.rowspan[col] = {'rows': rowspan, 'cols': self.colspan} 782 | 783 | x, y, width, height, padd_x, padd_y = self.get_cell_dimensions( 784 | col, border_left, border_top, cell_style, rowspan, self.colspan 785 | ) 786 | 787 | delayed = { 788 | 'cell_style': cell_style, 'colspan': colspan_original, 789 | 'rowspan': element.get('rowspan', 1), 'row': row_added 790 | } 791 | 792 | self._setup_cell_fill(col, cell_style, width + padd_x, rowspan) 793 | 794 | keys = [key for key in element.keys() if key.startswith('.')] 795 | 796 | real_height = 0 797 | did_finished = False 798 | if len(keys) > 0 or self._is_delayed_type(element, 'text'): 799 | real_height, did_finished = self.process_text( 800 | element, x, y, width, height, style, delayed 801 | ) 802 | elif self.is_type(element, 'image'): 803 | real_height, did_finished = self.process_image( 804 | element, x, y, width, height, delayed 805 | ) 806 | elif self.is_type(element, 'content'): 807 | real_height, did_finished = self.process_content( 808 | element, x, y, width, height, style, delayed 809 | ) 810 | 811 | elif self.is_type(element, 'table'): 812 | real_height, did_finished = self.process_table( 813 | element, x, y, width, height, style, delayed 814 | ) 815 | 816 | if did_finished: 817 | self.delayed.pop(col, None) 818 | else: 819 | self.delayed[col] = delayed 820 | 821 | real_height += padd_y if real_height>0 else (0 if self.first_row else 4) 822 | if rowspan > 0: 823 | self.heights_mem[col] = real_height 824 | real_height = 0 825 | if real_height > self.max_height: 826 | self.max_height = real_height 827 | 828 | return False 829 | 830 | def process_text( 831 | self, element: dict, x: Number, y: Number, width: Number, 832 | height: Number, style: dict, delayed: dict 833 | ) -> float: 834 | """Method to add a paragraph to a cell. 835 | 836 | Args: 837 | col (int): the column index of the cell. 838 | element (dict): the paragraph element 839 | x (Number): the x coordinate of the paragraph. 840 | y (Number): the y coordinate of the paragraph. 841 | width (Number): the width of the paragraph. 842 | height (Number): the height of the paragraph. 843 | style (dict): the paragraph style. 844 | delayed (dict): the delayed element to add the current paragraph if 845 | it can not be added completely to the current cell. 846 | 847 | Returns: 848 | float: the height of the paragraph. 849 | """ 850 | if 'delayed' in element: 851 | pdf_text = element['delayed'] 852 | pdf_text.setup(x, y, width, height) 853 | pdf_text.set_state(**element['state']) 854 | pdf_text.finished = False 855 | else: 856 | par_style = { 857 | v: style.get(v) for v in PARAGRAPH_PROPS if v in style 858 | } 859 | element['style'] = style 860 | pdf_text = PDFText( 861 | element, width, height, x, y, fonts=self.fonts, pdf=self.pdf, 862 | **par_style 863 | ) 864 | 865 | result = pdf_text.run() 866 | result['type'] = 'paragraph' 867 | self.parts.append(result) 868 | 869 | if not pdf_text.finished: 870 | delayed.update({'delayed': pdf_text, 'type': 'text'}) 871 | delayed['state'] = pdf_text.get_state() 872 | return pdf_text.current_height, pdf_text.finished 873 | 874 | def process_image( 875 | self, element: dict, x: Number, y: Number, 876 | width: Number, height: Number, delayed: dict 877 | ) -> float: 878 | """Method to add an image to a cell. 879 | 880 | Args: 881 | col (int): the column index of the cell. 882 | element (dict): the image element 883 | x (Number): the x coordinate of the image. 884 | y (Number): the y coordinate of the image. 885 | width (Number): the width of the image. 886 | height (Number): the height of the image. 887 | delayed (dict): the delayed element to add the current image if 888 | it can not be added to the current cell. 889 | 890 | Returns: 891 | float: the height of the image. 892 | """ 893 | if 'delayed' in element: 894 | pdf_image = element['delayed'] 895 | else: 896 | pdf_image = PDFImage( 897 | element['image'], element.get('extension'), 898 | element.get('image_name') 899 | ) 900 | img_width = width 901 | img_height = img_width * pdf_image.height / pdf_image.width 902 | 903 | real_height = 0 904 | finished = False 905 | if img_height < height: 906 | real_height = img_height 907 | self.parts.append({ 908 | 'type': 'image', 'pdf_image': pdf_image, 909 | 'x': x, 'y': y - img_height, 910 | 'width': img_width, 'height': img_height 911 | }) 912 | finished = True 913 | else: 914 | delayed.update({'delayed': pdf_image, 'type': 'image'}) 915 | return real_height, finished 916 | 917 | def process_content( 918 | self, element: dict, x: Number, y: Number, 919 | width: Number, height: Number, style: dict, delayed: dict 920 | ) -> float: 921 | """Method to add a content box to a cell. 922 | 923 | Args: 924 | col (int): the column index of the cell. 925 | element (dict): the content box element 926 | x (Number): the x coordinate of the content box. 927 | y (Number): the y coordinate of the content box. 928 | width (Number): the width of the content box. 929 | height (Number): the height of the content box. 930 | style (dict): the content box style. 931 | delayed (dict): the delayed element to add the current content box 932 | if it can not be added completely to the current cell. 933 | 934 | Returns: 935 | float: the height of the content box. 936 | """ 937 | if 'delayed' in element and element['type'] == 'content': 938 | pdf_content = element['delayed'] 939 | pdf_content.setup(x, y, width, height) 940 | pdf_content.set_state(**element['state']) 941 | pdf_content.finished = False 942 | else: 943 | element['style'] = style 944 | pdf_content = PDFContent( 945 | element, self.fonts, x, y, width, height, self.pdf 946 | ) 947 | 948 | pdf_content.run() 949 | 950 | self.parts.extend(pdf_content.parts) 951 | self.lines.extend(pdf_content.lines) 952 | self.fills.extend(pdf_content.fills) 953 | 954 | if not pdf_content.finished: 955 | delayed.update({'delayed': pdf_content, 'type': 'content'}) 956 | delayed['state'] = pdf_content.get_state() 957 | return pdf_content.current_height, pdf_content.finished 958 | 959 | def process_table( 960 | self, element: dict, x: Number, y: Number, 961 | width: Number, height: Number, style: dict, delayed: dict 962 | ) -> float: 963 | """Method to add a table to a cell. 964 | 965 | Args: 966 | col (int): the column index of the cell. 967 | element (dict): the table element 968 | x (Number): the x coordinate of the table. 969 | y (Number): the y coordinate of the table. 970 | width (Number): the width of the table. 971 | height (Number): the height of the table. 972 | style (dict): the table style. 973 | delayed (dict): the delayed element to add the current table if 974 | it can not be added completely to the current cell. 975 | 976 | Returns: 977 | float: the height of the table. 978 | """ 979 | if 'delayed' in element and element['type'] == 'table': 980 | pdf_table = element['delayed'] 981 | pdf_table.setup(x, y, width, height) 982 | pdf_table.set_state(**element['state']) 983 | pdf_table.finished = False 984 | else: 985 | table_props = { 986 | v: element.get(v) for v in TABLE_PROPS if v in element 987 | } 988 | pdf_table = PDFTable( 989 | element['table'], self.fonts, x, y, width, height, 990 | style=style, pdf=self.pdf, **table_props 991 | ) 992 | 993 | pdf_table.run() 994 | 995 | self.parts.extend(pdf_table.parts) 996 | self.lines.extend(pdf_table.lines) 997 | self.fills.extend(pdf_table.fills) 998 | 999 | if not pdf_table.finished: 1000 | delayed.update({'delayed': pdf_table, 'type': 'table'}) 1001 | delayed['state'] = pdf_table.get_state() 1002 | return pdf_table.current_height, pdf_table.finished 1003 | 1004 | from .color import PDFColor 1005 | from .content import PDFContent 1006 | from .fonts import PDFFonts 1007 | from .image import PDFImage 1008 | from .pdf import PDF 1009 | from .text import PDFText 1010 | from .utils import parse_style_str, process_style, copy -------------------------------------------------------------------------------- /pdfme/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Iterable, Union 3 | 4 | page_sizes = { 5 | 'a5': (419.528, 595.276), 6 | 'a4': (595.276, 841.89), 7 | 'a3': (841.89, 1190.551), 8 | 'b5': (498.898, 708.661), 9 | 'b4': (708.661, 1000.63), 10 | 'jis-b5': (515.906, 728.504), 11 | 'jis-b4': (728.504, 1031.812), 12 | 'letter': (612, 792), 13 | 'legal': (612, 1008), 14 | 'ledger': (792, 1224) 15 | } 16 | 17 | Number = Union[int, float] 18 | MarginType = Union[int, float, Iterable[Number], dict] 19 | 20 | def subs(string: str, *args: tuple, **kwargs: dict) -> bytes: 21 | """Function to take ``string``, format it using ``args`` and ``kwargs`` and 22 | encode it into bytes. 23 | 24 | Args: 25 | string (str): string to be transformed. 26 | 27 | Returns: 28 | bytes: the resulting bytes. 29 | """ 30 | return string.format(*args, **kwargs).encode('latin') 31 | 32 | def process_style(style: Union[str, dict], pdf: 'PDF'=None) -> dict: 33 | """Function to use a named style from the PDF instance passed, if ``style`` 34 | is a string or ``style`` itself if this is a dict. 35 | 36 | Args: 37 | style (str, dict): a style name (str) or a style dict. 38 | pdf (PDF, optional): the PDF to extract the named style from. 39 | 40 | Returns: 41 | dict: a style dict. 42 | """ 43 | if style is None: 44 | return {} 45 | elif isinstance(style, str): 46 | if pdf is None: 47 | return {} 48 | return copy(pdf.formats[style]) 49 | elif isinstance(style, dict): 50 | return style 51 | else: 52 | raise Exception('style must be a str with the name of a style or dict') 53 | 54 | def get_page_size(size: Union[Number, str, Iterable]) -> tuple: 55 | """Function to get tuple with the width and height of a page, from the value 56 | in ``size``. 57 | 58 | If ``size`` is a str, it should be the name of a page size: ``a5``, ``a4``, 59 | ``a3``, ``b5``, ``b4``, ``jis-b5``, ``jis-b4``, ``letter``, ``legal`` and 60 | ``ledger``. 61 | 62 | If ``size`` is a int, the page will be a square of size ``(int, int)``. 63 | 64 | If ``size`` is a list or tuple, it will be converted to a tuple. 65 | 66 | Args: 67 | size (int, float, str, iterable): the page size. 68 | 69 | Returns: 70 | tuple: tuple with the page width and height. 71 | """ 72 | if isinstance(size, (int, float)): 73 | return (size, size) 74 | elif isinstance(size, str): 75 | return page_sizes[size] 76 | elif isinstance(size, (list, tuple)): 77 | return tuple(size) 78 | else: 79 | raise Exception('Page size must be a two numbers list or tuple, a' 80 | 'number (for a square page) or any of the following strings: {}' 81 | .format( 82 | ', '. join('"{}"'.format(name) for name in page_sizes.keys()) 83 | )) 84 | 85 | def parse_margin(margin: MarginType) -> dict: 86 | """Function to transform ``margin`` into a dict containing keys ``top``, 87 | ``left``, ``bottom`` and ``right`` with the margins. 88 | 89 | If ``margin`` is a dict, it is returned as it is. 90 | 91 | If ``margin`` is a string, it will be splitted using commas or spaces, and 92 | each substring will be converted into a number, and after this, the list 93 | obtained will have the same treatment of an iterable. 94 | 95 | If ``margin`` is an iterable of 1 element, its value will be the margin for 96 | the four sides. If it has 2 elements, the first one will be the ``top`` and 97 | ``bottom`` margin, and the second one will be the ``left`` and ``right`` 98 | margin. If it has 3 elements, these will be the ``top``, ``right`` and 99 | ``bottom`` margins, and the ``left`` margin will be the second number (the 100 | same as ``right``). If it has 4 elements, they will be the ``top``, 101 | ``right``, ``bottom`` and ``left`` margins respectively. 102 | 103 | Args: 104 | margin (str, int, float, tuple, list, dict): the margin element. 105 | 106 | Returns: 107 | dict: dict containing keys ``top``, ``left``, ``bottom`` and ``right`` 108 | with the margins. 109 | """ 110 | if isinstance(margin, dict): 111 | return margin 112 | 113 | if isinstance(margin, str): 114 | margin = re.split(',| ', margin) 115 | if len(margin) == 1: 116 | margin = float(margin) 117 | else: 118 | margin = [float(x) for x in margin] 119 | 120 | if isinstance(margin, (int, float)): 121 | margin = [margin] * 4 122 | 123 | if isinstance(margin, (list, tuple)): 124 | if len(margin) == 0: 125 | margin = [0] * 4 126 | elif len(margin) == 1: 127 | margin = margin * 4 128 | elif len(margin) == 2: 129 | margin = margin * 2 130 | elif len(margin) == 3: 131 | margin = margin + [margin[1]] 132 | elif len(margin) > 4: 133 | margin = margin[0:4] 134 | 135 | return {k: v for k, v in zip(['top', 'right', 'bottom', 'left'], margin)} 136 | else: 137 | raise TypeError('margin property must be of type str, int, list or dict') 138 | 139 | 140 | def parse_style_str(style_str: str, fonts: 'PDFFonts') -> dict: 141 | """Function to parse a style string into a style dict. 142 | 143 | It parses a string with a semi-colon separeted list of the style attributes 144 | you want to apply (for a list of the attributes you can use in this string 145 | see :class:`pdfme.text.PDFText`). For the ones that are of type bool, you 146 | just have to include the name and it will mean they are ``True``, 147 | and for the rest you need to include the name, a colon, and the value of the 148 | attribute. In case the value is a color, it can be any of the possible 149 | string inputs to function :func:`pdfme.color.parse_color`. 150 | Empty values mean ``None``, and ``"1" == True`` and ``"0" == False`` for 151 | bool attributes. 152 | 153 | This is an example of a valid style string: 154 | 155 | .. code-block:: 156 | 157 | ".b;s:10;c:1;u:0;bg:" 158 | 159 | Args: 160 | style_str (str): The string representing the text style. 161 | fonts (PDFFonts): If a font family is included, this is needed to check 162 | if it is among the fonts already added to the PDFFonts instance 163 | passed. 164 | 165 | Raises: 166 | ValueError: If the string format is not valid. 167 | 168 | Returns: 169 | dict: A style dict like the one described in :class:`pdfme.text.PDFText`. 170 | """ 171 | 172 | style = {} 173 | for attrs_str in style_str.split(';'): 174 | attrs = attrs_str.split(':') 175 | if len(attrs) == 1: 176 | if attrs[0] == '': 177 | continue 178 | attr = attrs[0].strip() 179 | if not attr in ['b', 'i', 'u']: 180 | raise ValueError( 181 | 'Style elements with no paramter must be whether "b" for ' 182 | 'bold, "i" for italics(Oblique) or "u" for underline.' 183 | ) 184 | style[attr] = True 185 | elif len(attrs) == 2: 186 | attr = attrs[0].strip() 187 | value = attrs[1].strip() 188 | 189 | if attr in ['b', 'i', 'u']: 190 | if value == '1': 191 | style[attr] = True 192 | elif value == '0': 193 | style[attr] = False 194 | else: 195 | raise ValueError( 196 | 'Style element "{}" must be 0 or 1: {}' 197 | .format(attr, value) 198 | ) 199 | if attr == "f": 200 | if value not in fonts.fonts: 201 | raise ValueError( 202 | 'Style element "f" must have the name of a font family' 203 | ' already added.' 204 | ) 205 | 206 | style['f'] = value 207 | elif attr == "c": 208 | style['c'] = PDFColor(value) 209 | elif attr == "bg": 210 | style['bg'] = PDFColor(value) 211 | elif attrs[0] == "s": 212 | try: 213 | v = float(value) 214 | if int(v) == v: 215 | v = int(v) 216 | style['s'] = v 217 | except: 218 | raise ValueError( 219 | 'Style element value for "s" is wrong:' 220 | ' {}'.format(value) 221 | ) 222 | elif attrs[0] == 'r': 223 | try: style['r'] = float(value) 224 | except: 225 | raise ValueError( 226 | 'Style element value for "r" is wrong:' 227 | ' {}'.format(value) 228 | ) 229 | else: 230 | raise ValueError( 231 | 'Style elements with arguments must be ' 232 | '"b", "u", "i", "f", "s", "c", "r"' 233 | ) 234 | 235 | else: 236 | raise ValueError('Invalid Style string: {}'.format(attrs_str)) 237 | 238 | return style 239 | 240 | def create_graphics(graphics: list) -> str: 241 | """Function to transform a list of graphics dicts (with lines and fill 242 | rectangles) into a PDF stream, ready to be added to a PDF page stream. 243 | 244 | Args: 245 | graphics (list): list of graphics dicts. 246 | 247 | Returns: 248 | str: a PDF stream containing the passed graphics. 249 | """ 250 | last_fill = last_color = last_line_width = last_line_style = None 251 | stream = '' 252 | for g in graphics: 253 | if g['type'] == 'fill': 254 | if g['color'] != last_fill: 255 | last_fill = g['color'] 256 | stream += ' ' + str(last_fill) 257 | 258 | stream += ' {} {} {} {} re F'.format( 259 | round(g['x'], 3), round(g['y'], 3), 260 | round(g['width'], 3), round(g['height'], 3) 261 | ) 262 | 263 | if g['type'] == 'line': 264 | if g['color'] != last_color: 265 | last_color = g['color'] 266 | stream += ' ' + str(last_color) 267 | 268 | if g['width'] != last_line_width: 269 | last_line_width = g['width'] 270 | stream += ' {} w'.format(round(g['width'], 3)) 271 | 272 | if g['style'] == 'dashed': 273 | line_style = ' 0 J [{} {}] 0 d'.format(round(g['width']*3, 3), 274 | round(g['width']*1.5, 3)) 275 | elif g['style'] == 'dotted': 276 | line_style = ' 1 J [0 {}] {} d'.format(round(g['width']*2, 3), 277 | round(g['width'], 3)*0.5) 278 | elif g['style'] == 'solid': 279 | line_style = ' 0 J [] 0 d' 280 | else: 281 | raise Exception( 282 | 'line style should be dotted, dashed or solid: {}' 283 | .format(g['style']) 284 | ) 285 | 286 | if line_style != last_line_style: 287 | last_line_style = line_style 288 | stream += line_style 289 | 290 | stream += ' {} {} m {} {} l S'.format( 291 | round(g['x1'], 3), round(g['y1'], 3), 292 | round(g['x2'], 3), round(g['y2'], 3), 293 | ) 294 | 295 | if stream != '': 296 | stream = ' q' + stream + ' Q' 297 | 298 | return stream 299 | 300 | def _roman_five(n, one, five): 301 | return one * n if n < 4 else (one + five if n == 4 else five) 302 | 303 | def _roman_ten(n, one, five, ten): 304 | return _roman_five(n, one, five) if n < 5 else ((five if n < 9 else '') 305 | + _roman_five(n - 5, one, ten) if n > 5 else five) 306 | 307 | def to_roman(n: int) -> str: 308 | """Function to transform ``n`` integer into a string with its corresponding 309 | Roman representation. 310 | 311 | Args: 312 | n (int): the number to be transformed. 313 | 314 | Returns: 315 | str: the Roman representation of the integer passed. 316 | """ 317 | if not (0 < n < 4000): 318 | raise Exception('0 < n < 4000') 319 | roman = '' 320 | n = str(int(n)) 321 | if len(n) > 0: 322 | roman = _roman_ten(int(n[-1]), 'I', 'V', 'X') 323 | if len(n) > 1: 324 | roman = _roman_ten(int(n[-2]), 'X', 'L', 'C') + roman 325 | if len(n) > 2: 326 | roman = _roman_ten(int(n[-3]), 'C', 'D', 'M') + roman 327 | if len(n) > 3: 328 | roman = _roman_ten(int(n[-4]), 'M', '', '') + roman 329 | return roman 330 | 331 | def get_paragraph_stream( 332 | x: Number, y: Number, text_stream: str, graphics_stream: str 333 | ) -> str: 334 | """Function to create a paragraph stream, in position ``x`` and ``y``, using 335 | stream information in ``text_stream`` and ``graphics_stream``. 336 | 337 | Args: 338 | x (int, float): the x coordinate of the paragraph. 339 | y (int, float): the y coordinate of the paragraph. 340 | text_stream (str): the text stream of the paragraph. 341 | graphics_stream (str): the graphics stream of the paragraph. 342 | 343 | Returns: 344 | str: the whole stream of the paragraph. 345 | """ 346 | 347 | stream = '' 348 | x, y = round(x, 3), round(y, 3) 349 | if graphics_stream != '': 350 | stream += ' q 1 0 0 1 {} {} cm{} Q'.format(x, y, graphics_stream) 351 | if text_stream != '': 352 | stream += ' BT 1 0 0 1 {} {} Tm{} ET'.format(x, y, text_stream) 353 | return stream 354 | 355 | def copy(obj: Any) -> Any: 356 | """Function to copy objects like the ones used in this project: dicts, 357 | lists, PDFText, PDFTable, PDFContent, etc. 358 | 359 | 360 | Args: 361 | obj (Any): the object to be copied. 362 | 363 | Returns: 364 | Any: the copy of the object passed as argument. 365 | """ 366 | if isinstance(obj, list): 367 | return [copy(el) for el in obj] 368 | elif isinstance(obj, dict): 369 | return {k: copy(v) for k, v in obj.items()} 370 | else: 371 | return obj 372 | 373 | class MuiltiRange: 374 | def __init__(self): 375 | self.ranges = [] 376 | 377 | def add(self, *range_args): 378 | self.ranges.append(range(*range_args)) 379 | 380 | def __contains__(self, number: Number): 381 | return any(number in range_ for range_ in self.ranges) 382 | 383 | 384 | 385 | def parse_range_string(range_str: str) -> MuiltiRange: 386 | """Function to convert a string of comma-separated integers and integer 387 | ranges into a set of all the integers included in those. 388 | 389 | Args: 390 | range_str (str): comma-separated list of integers and integer 391 | ranges. 392 | 393 | Returns: 394 | MuiltiRange: a set of integers. 395 | """ 396 | multi_range = MuiltiRange() 397 | for part in range_str.split(','): 398 | range_parts = part.split(':') 399 | if len(range_parts) == 1: 400 | index = int(range_parts[0].strip()) 401 | multi_range.add(index, index + 1) 402 | else: 403 | first = range_parts[0].strip() 404 | range_parts[0] = 0 if first == '' else int(first) 405 | if len(range_parts) > 1: 406 | range_parts[1] = int(range_parts[1].strip()) 407 | if len(range_parts) > 2: 408 | range_parts[2] = int(range_parts[2].strip()) 409 | multi_range.add(*range_parts) 410 | 411 | return multi_range 412 | 413 | from .color import PDFColor 414 | from .fonts import PDFFonts 415 | from .pdf import PDF 416 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pdfme 3 | version = 0.4.12 4 | author = Andrés Felipe Sierra Parra 5 | author_email = cepfelo@gmail.com 6 | description = Create PDFs easily 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/aFelipeSP/pdfme 10 | project_urls = 11 | Bug Tracker = https://github.com/aFelipeSP/pdfme/issues 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | License :: OSI Approved :: MIT License 15 | Operating System :: OS Independent 16 | Development Status :: 4 - Beta 17 | Intended Audience :: Developers 18 | Topic :: Software Development :: Libraries :: Python Modules 19 | Topic :: Multimedia :: Graphics 20 | [options] 21 | packages = find: 22 | python_requires = >=3.6 23 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import random 2 | import copy 3 | from argparse import ArgumentParser 4 | 5 | from tests import * 6 | 7 | 8 | if __name__ == '__main__': 9 | random.seed(20) 10 | 11 | parser = ArgumentParser() 12 | parser.add_argument('-s', '--single_test') 13 | parser.add_argument('-g', '--group_test', choices=['text', 'content', 'table']) 14 | args = parser.parse_args() 15 | 16 | if args.single_test: 17 | if args.single_test.startswith('test_content'): 18 | test_content(int(args.single_test[12:])) 19 | elif args.single_test.startswith('test_table'): 20 | test_table(int(args.single_test[10:])) 21 | else: 22 | globals()[args.single_test]() 23 | else: 24 | test_start = 'test' 25 | if args.group_test: 26 | test_start = 'test_' + args.group_test 27 | 28 | globals_ = list(globals().keys()) 29 | for key in globals_: 30 | val = globals()[key] 31 | if key.startswith(test_start) and callable(val): val() 32 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_text import * 2 | from .test_content import * 3 | from .test_table import * 4 | from .running_section_per_page import * 5 | from .group_element import * -------------------------------------------------------------------------------- /tests/group_element.py: -------------------------------------------------------------------------------- 1 | from pdfme import build_pdf 2 | 3 | from .utils import gen_text 4 | 5 | def test_group_element(): 6 | document = { 7 | "sections": [{"content": [ 8 | *[gen_text(50) for _ in range(4)], 9 | { 10 | "style": { 11 | "margin_left": 80, 12 | "margin_right": 80, 13 | # "shrink": 1 14 | }, 15 | "group": [ 16 | { 17 | "image": "tests/image_test.jpg", 18 | "style": { 19 | # "min_height": 50 20 | } 21 | }, 22 | gen_text(50), 23 | # {"image": "tests/image_test.jpg", "style": {"min_height": 100}}, 24 | ] 25 | } 26 | ]}] 27 | } 28 | 29 | with open('test_group_element.pdf', 'wb') as f: 30 | build_pdf(document, f) 31 | -------------------------------------------------------------------------------- /tests/image_test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aFelipeSP/pdfme/fac3ffd829440d18b92796d1b5ca4dbd7771b4b3/tests/image_test.jpg -------------------------------------------------------------------------------- /tests/image_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aFelipeSP/pdfme/fac3ffd829440d18b92796d1b5ca4dbd7771b4b3/tests/image_test.png -------------------------------------------------------------------------------- /tests/running_section_per_page.py: -------------------------------------------------------------------------------- 1 | from pdfme import build_pdf 2 | 3 | from .utils import gen_text 4 | 5 | def test_running_section_per_page(): 6 | document = { 7 | "running_sections": { 8 | "header": { 9 | "x": "left", "y": 20, "height": "top", "style": {"text_align": "r"}, 10 | "content": [{".b": "This is a header"}] 11 | } 12 | }, 13 | 'per_page': [ 14 | {'pages': '1:1000:2', 'style': {'margin': [60, 100, 60, 60]}}, 15 | {'pages': '0:1000:2', 'style': {'margin': [60, 60, 60, 100]}}, 16 | {'pages': '0,4:40:2', 'running_sections': {'include': ['header']}}, 17 | {'pages': '2', 'running_sections': {'exclude': ['header']}}, 18 | ], 19 | "sections": [ 20 | { 21 | "content": [gen_text(100) for _ in range(50)] 22 | }, 23 | ] 24 | } 25 | 26 | with open('test_running_section_per_page.pdf', 'wb') as f: 27 | build_pdf(document, f) 28 | -------------------------------------------------------------------------------- /tests/test_content.py: -------------------------------------------------------------------------------- 1 | from pdfme import PDF 2 | import json 3 | from pathlib import Path 4 | 5 | from .utils import gen_content 6 | 7 | def run_test(index): 8 | pdf = PDF() 9 | pdf.add_page() 10 | name = 'test_content{}'.format(index) 11 | input_file = Path(name + '.json') 12 | if input_file.exists(): 13 | with input_file.open(encoding='utf8') as f: 14 | content = json.load(f) 15 | else: 16 | content = gen_content(20) 17 | with input_file.open('w', encoding='utf8') as f: 18 | json.dump(content, f, ensure_ascii=False) 19 | 20 | pdf.content(content) 21 | with open('test_content{}.pdf'.format(index), 'wb') as f: 22 | pdf.output(f) 23 | 24 | def test_content(index=None): 25 | if index is not None: 26 | run_test(index) 27 | else: 28 | for i in range(6): 29 | run_test(i) 30 | -------------------------------------------------------------------------------- /tests/test_table.py: -------------------------------------------------------------------------------- 1 | from pdfme import PDF 2 | import json 3 | from pathlib import Path 4 | 5 | from .utils import gen_table 6 | 7 | def run_test(index): 8 | pdf = PDF() 9 | pdf.add_page() 10 | name = 'test_table{}'.format(index) 11 | input_file = Path(name + '.json') 12 | if input_file.exists(): 13 | with input_file.open(encoding='utf8') as f: 14 | content = json.load(f) 15 | else: 16 | content = gen_table(20) 17 | with input_file.open('w', encoding='utf8') as f: 18 | json.dump(content, f, ensure_ascii=False) 19 | 20 | pdf.table(**content) 21 | with open('test_table{}.pdf'.format(index), 'wb') as f: 22 | pdf.output(f) 23 | 24 | def test_table(index=None): 25 | if index is not None: 26 | run_test(index) 27 | else: 28 | for i in range(3): 29 | run_test(i) 30 | -------------------------------------------------------------------------------- /tests/test_text.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from .utils import gen_rich_text 5 | from pdfme import PDF 6 | 7 | def page_rect(pdf): 8 | pdf.page.add('q 0.9 g {} {} {} {} re F Q'.format( 9 | pdf.margin['left'], pdf.margin['bottom'], 10 | pdf.page.content_width, pdf.page.content_height 11 | )) 12 | 13 | def output(pdf, name): 14 | with open(name, 'wb') as f: 15 | pdf.output(f) 16 | 17 | def add_content(content, text_options, name): 18 | pdf = PDF() 19 | pdf.add_page() 20 | page_rect(pdf) 21 | pdf_text = pdf._text( 22 | content, x=pdf.page.margin_left, width=pdf.page.content_width, 23 | **text_options 24 | ) 25 | while not pdf_text.finished: 26 | pdf.add_page() 27 | page_rect(pdf) 28 | pdf_text = pdf._text(pdf_text) 29 | output(pdf, name + '.pdf') 30 | 31 | 32 | def base(text_options={}, name='test', words=5000): 33 | input_file = Path(name + '.json') 34 | if input_file.exists(): 35 | with input_file.open(encoding='utf8') as f: 36 | content = json.load(f) 37 | else: 38 | content = gen_rich_text(words) 39 | with input_file.open('w', encoding='utf8') as f: 40 | json.dump(content, f, ensure_ascii=False) 41 | add_content(content, text_options, name) 42 | 43 | def test_text_indent(): 44 | base({'indent': 20, 'text_align': 'j'}, 'test_text_indent') 45 | 46 | def test_text_line_height(): 47 | base({'line_height': 2}, 'test_text_line_height') 48 | 49 | def test_text_left(): 50 | base({}, 'test_text') 51 | 52 | def test_text_right(): 53 | base({'text_align': 'r'}, 'test_text_right') 54 | 55 | def test_text_center(): 56 | base({'text_align': 'c'}, 'test_text_center') 57 | 58 | def test_text_justify(): 59 | base({'text_align': 'j'}, 'test_text_justify') 60 | 61 | def test_text_list(): 62 | base({'list_text': '1. '}, 'test_text_list', 500) 63 | 64 | def test_text_list_style(): 65 | base({'list_text': chr(183) + ' ', 'list_style': {'f': 'Symbol'}}, 66 | 'test_text_list_style', 500) 67 | 68 | def test_text_list_style_indent(): 69 | base( 70 | { 71 | 'list_text': chr(183) + ' ', 'list_style': {'f': 'Symbol'}, 72 | 'list_indent': 40, 'indent': 20 73 | }, 74 | 'test_text_list_style_indent', 500 75 | ) 76 | 77 | def get_content_list(content): 78 | for key, val in content.items(): 79 | if key.startswith('.'): 80 | return val 81 | 82 | def append_text(content, new): 83 | for key, val in content.items(): 84 | if key.startswith('.'): 85 | val.extend(new) 86 | break 87 | 88 | def test_text_ref_label(): 89 | content = gen_rich_text(1) 90 | append_text(content, [{'ref': 'asdf', '.': 'asd fa sdf asdf asdfa sdfa'}]) 91 | append_text(content, get_content_list(gen_rich_text(500))) 92 | append_text(content, [{'label': 'asdf', '.': 'ertyer sdfgsd'}]) 93 | append_text(content, get_content_list(gen_rich_text(1000))) 94 | add_content(content, {}, 'test_text_ref_label') 95 | 96 | def test_text_link(): 97 | content = gen_rich_text(500) 98 | append_text(content, [{'uri': 'www.google.com', '.': 'its me google '*10}]) 99 | append_text(content, get_content_list(gen_rich_text(1000))) 100 | append_text(content, [{'uri': 'www.google.com', '.': 'its me google '*10}]) 101 | append_text(content, get_content_list(gen_rich_text(500))) 102 | add_content(content, {}, 'test_text_link') -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import json 3 | from pdfme import PDF 4 | 5 | abc = 'abcdefghijklmnñopqrstuvwxyzABCDEFGHIJKLMNÑOPQRSTUVWXYZáéíóúÁÉÍÓÚ' 6 | 7 | def gen_word(): 8 | return ''.join(random.choice(abc) for _ in range(random.randint(1, 10))) 9 | 10 | def gen_text(n): 11 | return random.choice(['',' ']) + (' '.join(gen_word() for _ in range(n))) + random.choice(['',' ']) 12 | 13 | def maybe(n=0.5): 14 | return random.choices([True, False], [n, 1 - n])[0] 15 | 16 | def color(min_=0.5, max_=1): 17 | return [random.uniform(min_, max_) for _ in range(3)] 18 | 19 | def gen_rich_text(n, size=7): 20 | 21 | style_ = { 22 | 'b': 1, 'i': 1, 's': random.triangular(size/2, size, size), 23 | 'f': random.choices(['Helvetica', 'Times', 'Courier'], [3, 1, 1])[0], 24 | 'c': color(), 'bg': color(), 'r': random.triangular(-0.4, 0.4), 'u': 1 25 | } 26 | 27 | obj = {} 28 | key = '.' 29 | 30 | if maybe(): 31 | obj['style'] = {k:v for k, v in style_.items() if maybe(.1)} 32 | else: 33 | style = [] 34 | for k, v in style_.items(): 35 | if maybe(): 36 | if k in ['b', 'i', 'u']: 37 | style.append(k) 38 | elif k == 'r' and v != 0: 39 | style.append(k+':'+str(v)) 40 | elif k in ['bg', 'c']: 41 | style.append(k+':'+ (' '.join(str(t) for t in v))) 42 | else: 43 | style.append(k+':'+str(v)) 44 | key += ';'.join(style) 45 | 46 | obj[key] = [] 47 | i = 1 48 | while n > 0: 49 | words = min(int(n / 3), random.randint(1, 40)) 50 | if words == 0: 51 | break 52 | n -= words 53 | if i % 2 == 0 and words > 1: 54 | ans = gen_rich_text(words, size) 55 | v = [v for k,v in ans.items() if k.startswith('.')][0] 56 | if ans is not None and len(v) > 0: 57 | obj[key].append(ans) 58 | else: 59 | if len(obj[key]) > 0 and isinstance(obj[key][-1], str): 60 | obj[key][-1] += gen_text(words) 61 | else: 62 | obj[key].append(gen_text(words)) 63 | i += 1 64 | 65 | return obj 66 | 67 | def gen_content(size, font_size=4, level=1, max_level=3): 68 | font_size = max(2.5, font_size) 69 | cols = random.randint(2,3) 70 | style = { 71 | 'b': 1, 'i': 1, 'u': 1, 'line_height': random.triangular(1, 1.5), 72 | 'f': random.choices(['Helvetica', 'Times', 'Courier'], [3, 1, 1])[0], 73 | 'text_align': random.choices(['j', 'c', 'l', 'r'], [4, 2, 2, 2])[0], 74 | 'r': random.triangular(-0.4, 0.4), 'c': color(), 'bg': color(), 75 | 'indent': random.triangular(0, 20, 0), 76 | 'margin-left': random.triangular(0, 10, 0), 77 | 'margin-right': random.triangular(0, 10, 0), 78 | 'margin-top': random.triangular(0, 10, 0), 79 | 'margin-bottom': random.triangular(0, 10, 0), 80 | } 81 | style = {k:v for k, v in style.items() if maybe(.1)} 82 | if level == 1 or maybe(0.1): 83 | style['s'] = -1.6 * level + font_size + 6 - cols 84 | c = [] 85 | obj = {'style': style, 'content': c, 'cols': {"count": cols}} 86 | n = int(random.triangular(3, (0.15 * (1 - level) + 1) * size)) 87 | l = random.choice([0,1]) 88 | 89 | if level == max_level: 90 | c.append(gen_text(random.randint(50, 300))) 91 | return obj 92 | 93 | for i in range(n): 94 | if i%2 == l: 95 | ans = gen_content(size, font_size, level + 1, max_level) 96 | c.append(ans) 97 | else: 98 | if maybe(0.5): 99 | c.append(gen_text(random.randint(50, 300))) 100 | else: 101 | c.append({'image': 'tests/image_test.jpg', 'style': 102 | {'image_place': 'flow' if maybe(0.7) else 'normal'} 103 | }) 104 | 105 | return obj 106 | 107 | 108 | def gen_table(rows=None, cols=None): 109 | rows = 10 if rows is None else rows 110 | cols = int(random.triangular(1, 7, 2)) if cols is None else cols 111 | 112 | obj = {'content': []} 113 | obj['widths'] = [random.triangular(3, 6) for _ in range(cols)] 114 | style_ = { 115 | 'cell_fill': color(), 'cell_margin': random.triangular(5, 20, 5), 116 | 'border_width': random.triangular(1, 3), 'border_color': color(0, 0.6), 117 | 'border_style': random.choice(['solid', 'dotted', 'dashed']), 118 | } 119 | obj['style'] = {k:v for k, v in style_.items() if maybe(.15)} 120 | 121 | obj['borders'] = [ 122 | {'pos': 'h::2;', 'width': 1.5, 'color': 'green', 'style': 'dotted'}, 123 | {'pos': 'v;1::2', 'width': 2, 'color': 'red', 'style': 'dashed'}, 124 | {'pos': 'h0,1,-1;', 'width': 2.5, 'color': 'blue', 'style': 'solid'}, 125 | ] 126 | 127 | obj['fills'] = [ 128 | {'pos': '::2;::2', 'color': 0.8}, 129 | {'pos': '1::2;::2', 'color': 0.7}, 130 | {'pos': '::2;1::2', 'color': 0.8}, 131 | {'pos': '1::2;1::2', 'color': 0.7}, 132 | ] 133 | 134 | row_spans = {} 135 | for i in range(rows): 136 | row = [] 137 | col_spans = 0 138 | for j in range(cols): 139 | if col_spans > 0: 140 | col_spans -= 1 141 | row.append(None) 142 | elif row_spans.get(j, {}).get('rows', 0) > 0: 143 | row_spans[j]['rows'] -= 1 144 | col_spans = row_spans[j]['cols'] 145 | row.append(None) 146 | else: 147 | prob = random.random() 148 | if prob < 0.3: 149 | element = gen_content(1, max_level=1) 150 | elif prob < 0.7: 151 | element = gen_rich_text(random.triangular(2, 200), 5) 152 | else: 153 | element = {'image': 'tests/image_test.jpg'} 154 | rowspan = max(1, int(random.triangular(1, min(3, rows - i), 1))) 155 | colspan = max(1, int(random.triangular(1, min(3, cols - j), 1))) 156 | col_spans = colspan - 1 157 | element['rowspan'] = rowspan 158 | element['colspan'] = colspan 159 | style = {} 160 | if maybe(0.1): style['cell_fill'] = color() 161 | if maybe(0.1): style['cell_margin'] = random.triangular(5, 20, 5) 162 | element['style'] = style 163 | row_spans[j] = {'rows': rowspan - 1, 'cols': colspan - 1} 164 | row.append(element) 165 | obj['content'].append(row) 166 | 167 | return obj 168 | -------------------------------------------------------------------------------- /tutorial.py: -------------------------------------------------------------------------------- 1 | from pdfme import build_pdf 2 | 3 | 4 | 5 | document = {} 6 | 7 | document['style'] = { 8 | 'margin_bottom': 15, 9 | 'text_align': 'j' 10 | } 11 | 12 | document['formats'] = { 13 | 'url': {'c': 'blue', 'u': 1}, 14 | 'title': {'b': 1, 's': 13} 15 | } 16 | 17 | document['running_sections'] = { 18 | 'header': { 19 | 'x': 'left', 'y': 20, 'height': 'top', 20 | 'style': {'text_align': 'r'}, 21 | 'content': [{'.b': 'This is a header'}] 22 | }, 23 | 'footer': { 24 | 'x': 'left', 'y': 800, 'height': 'bottom', 25 | 'style': {'text_align': 'c'}, 26 | 'content': [{'.': ['Page ', {'var': '$page'}]}] 27 | } 28 | } 29 | 30 | document['per_page'] = [ 31 | {'pages': '1:1000:2', 'style': {'margin': [60, 100, 60, 60]}}, 32 | {'pages': '0:1000:2', 'style': {'margin': [60, 60, 60, 100]}}, 33 | {'pages': '0:4:2', 'running_sections': {'include': ['header']}}, 34 | ] 35 | 36 | document['sections'] = [] 37 | section1 = {} 38 | document['sections'].append(section1) 39 | 40 | section1['style'] = { 41 | 'page_numbering_style': 'roman' 42 | } 43 | 44 | section1['running_sections'] = ['footer'] 45 | 46 | section1['content'] = content1 = [] 47 | 48 | content1.append({ 49 | '.': 'A Title', 'style': 'title', 'label': 'title1', 50 | 'outline': {'level': 1, 'text': 'A different title 1'} 51 | }) 52 | 53 | content1.append( 54 | ['This is a paragraph with a ', {'.b;c:green': 'bold green part'}, ', a ', 55 | {'.': 'link', 'style': 'url', 'uri': 'https://some.url.com'}, 56 | ', a footnote', {'footnote': 'description of the footnote'}, 57 | ' and a reference to ', 58 | {'.': 'Title 2.', 'style': 'url', 'ref': 'title2'}] 59 | ) 60 | 61 | content1.append({ 62 | 'image': "tests/image_test.jpg", 63 | 'style': {'margin_left': 100, 'margin_right': 100} 64 | }) 65 | 66 | content1.append({ 67 | "style": {"margin_left": 160, "margin_right": 160}, 68 | "group": [ 69 | {"image": "tests/image_test.png"}, 70 | {".": "Figure 1: Description of figure 1"} 71 | ] 72 | }) 73 | 74 | table_def1 = { 75 | 'widths': [1.5, 1, 1, 1], 76 | 'style': {'border_width': 0, 'margin_left': 70, 'margin_right': 70}, 77 | 'fills': [{'pos': '1::2;:', 'color': 0.7}], 78 | 'borders': [{'pos': 'h0,1,-1;:', 'width': 0.5}], 79 | 'table': [ 80 | ['', 'column 1', 'column 2', 'column 3'], 81 | ['count', '2000', '2000', '2000'], 82 | ['mean', '28.58', '2643.66', '539.41'], 83 | ['std', '12.58', '2179.94', '421.49'], 84 | ['min', '1.00', '2.00', '1.00'], 85 | ['25%', '18.00', '1462.00', '297.00'], 86 | ['50%', '29.00', '2127.00', '434.00'], 87 | ['75%', '37.00', '3151.25', '648.25'], 88 | ['max', '52.00', '37937.00', '6445.00'] 89 | ] 90 | } 91 | 92 | content1.append(table_def1) 93 | 94 | table_def2 = { 95 | 'widths': [1.2, .8, 1, 1], 96 | 'table': [ 97 | [ 98 | { 99 | 'colspan': 4, 100 | 'style': { 101 | 'cell_fill': [0.8, 0.53, 0.3], 102 | 'text_align': 'c' 103 | }, 104 | '.b;c:1;s:12': 'Fake Form' 105 | },None, None, None 106 | ], 107 | [ 108 | {'colspan': 2, '.': [{'.b': 'First Name\n'}, 'Fakechael']}, None, 109 | {'colspan': 2, '.': [{'.b': 'Last Name\n'}, 'Fakinson Faker']}, None 110 | ], 111 | [ 112 | [{'.b': 'Email\n'}, 'fakeuser@fakemail.com'], 113 | [{'.b': 'Age\n'}, '35'], 114 | [{'.b': 'City of Residence\n'}, 'Fake City'], 115 | [{'.b': 'Cell Number\n'}, '33333333333'], 116 | ] 117 | ] 118 | } 119 | 120 | content1.append(table_def2) 121 | 122 | document['sections'].append({ 123 | 'style': { 124 | 'page_numbering_reset': True, 'page_numbering_style': 'arabic' 125 | }, 126 | 'running_sections': ['footer'], 127 | 'content': [ 128 | 129 | { 130 | '.': 'Title 2', 'style': 'title', 'label': 'title2', 131 | 'outline': {} 132 | }, 133 | 134 | { 135 | 'style': {'list_text': '1. '}, 136 | '.': ['This is a list paragraph with a reference to ', 137 | {'.': 'Title 1.', 'style': 'url', 'ref': 'title1'}] 138 | } 139 | ] 140 | }) 141 | 142 | with open('test_tutorial.pdf', 'wb') as f: 143 | build_pdf(document, f) 144 | --------------------------------------------------------------------------------