diff options
-rw-r--r-- | LICENSE | 29 | ||||
-rw-r--r-- | PKG-INFO | 57 | ||||
-rw-r--r-- | README.rst | 17 | ||||
-rw-r--r-- | docs/api_reference.rst | 19 | ||||
-rw-r--r-- | docs/changelog.rst | 167 | ||||
-rw-r--r-- | docs/common_use_cases.rst | 182 | ||||
-rw-r--r-- | docs/conf.py | 90 | ||||
-rw-r--r-- | docs/contribute.rst | 70 | ||||
-rw-r--r-- | docs/first_steps.rst | 36 | ||||
-rw-r--r-- | docs/going_further.rst | 32 | ||||
-rw-r--r-- | docs/index.rst | 23 | ||||
-rw-r--r-- | docs/support.rst | 28 | ||||
-rwxr-xr-x | pydyf/__init__.py | 502 | ||||
-rw-r--r-- | pyproject.toml | 59 | ||||
-rw-r--r-- | setup.py | 31 | ||||
-rw-r--r-- | tests/__init__.py | 80 | ||||
-rw-r--r-- | tests/test_pydyf.py | 708 |
17 files changed, 2130 insertions, 0 deletions
@@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2020, CourtBouillon +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..fe00eef --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,57 @@ +Metadata-Version: 2.1 +Name: pydyf +Version: 0.1.1 +Summary: A low-level PDF generator. +Keywords: pdf,generator +Author-email: CourtBouillon <contact@courtbouillon.org> +Maintainer-email: CourtBouillon <contact@courtbouillon.org> +Requires-Python: >=3.6 +Description-Content-Type: text/x-rst +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Requires-Dist: sphinx ; extra == "doc" +Requires-Dist: sphinx_rtd_theme ; extra == "doc" +Requires-Dist: pytest ; extra == "test" +Requires-Dist: pytest-cov ; extra == "test" +Requires-Dist: pytest-flake8 ; extra == "test" +Requires-Dist: pytest-isort ; extra == "test" +Requires-Dist: coverage[toml] ; extra == "test" +Requires-Dist: pillow ; extra == "test" +Project-URL: Changelog, https://github.com/CourtBouillon/pydyf/releases +Project-URL: Code, https://github.com/CourtBouillon/pydyf +Project-URL: Documentation, https://doc.courtbouillon.org/pydyf/ +Project-URL: Donation, https://opencollective.com/courtbouillon +Project-URL: Homepage, https://www.courtbouillon.org/pydyf +Project-URL: Issues, https://github.com/CourtBouillon/pydyf/issues +Provides-Extra: doc +Provides-Extra: test + +pydyf is a low-level PDF generator written in Python and based on PDF +specification 1.7. + +* Free software: BSD license +* For Python 3.6+, tested on CPython and PyPy +* Documentation: https://doc.courtbouillon.org/pydyf +* Changelog: https://github.com/CourtBouillon/pydyf/releases +* Code, issues, tests: https://github.com/CourtBouillon/pydyf +* Code of conduct: https://www.courtbouillon.org/code-of-conduct +* Professional support: https://www.courtbouillon.org +* Donation: https://opencollective.com/courtbouillon + +Copyrights are retained by their contributors, no copyright assignment is +required to contribute to pydyf. Unless explicitly stated otherwise, any +contribution intentionally submitted for inclusion is licensed under the BSD +3-clause license, without any additional terms or conditions. For full +authorship information, see the version control history. + diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..6c37a4a --- /dev/null +++ b/README.rst @@ -0,0 +1,17 @@ +pydyf is a low-level PDF generator written in Python and based on PDF +specification 1.7. + +* Free software: BSD license +* For Python 3.6+, tested on CPython and PyPy +* Documentation: https://doc.courtbouillon.org/pydyf +* Changelog: https://github.com/CourtBouillon/pydyf/releases +* Code, issues, tests: https://github.com/CourtBouillon/pydyf +* Code of conduct: https://www.courtbouillon.org/code-of-conduct +* Professional support: https://www.courtbouillon.org +* Donation: https://opencollective.com/courtbouillon + +Copyrights are retained by their contributors, no copyright assignment is +required to contribute to pydyf. Unless explicitly stated otherwise, any +contribution intentionally submitted for inclusion is licensed under the BSD +3-clause license, without any additional terms or conditions. For full +authorship information, see the version control history. diff --git a/docs/api_reference.rst b/docs/api_reference.rst new file mode 100644 index 0000000..80bf16c --- /dev/null +++ b/docs/api_reference.rst @@ -0,0 +1,19 @@ +API Reference +============= + +.. module:: pydyf + +.. autoclass:: Object + :members: + +.. autoclass:: Dictionary + +.. autoclass:: Stream + :members: + +.. autoclass:: String + +.. autoclass:: Array + +.. autoclass:: PDF + :members: diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..cbebc90 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,167 @@ +Changelog +========= + + +Version 0.1.1 +------------- + +Released on 2021-08-22. + +Bug fixes: + +* `0f7c8e9 <https://github.com/CourtBouillon/pydyf/commit/0f7c8e9>`_: + Fix string encoding + +Contributors: + +* Guillaume Ayoub + +Backers and sponsors: + +* Grip Angebotssoftware +* PDF Blocks +* SimonSoft +* Menutech +* Manuel Barkhau +* Simon Sapin +* KontextWork +* René Fritz +* Maykin Media +* NCC Group +* Des images et des mots +* Andreas Zettl +* Nathalie Gutton +* Tom Pohl +* Moritz Mahringer +* Florian Demmer +* Yanal-Yvez Fargialla + + +Version 0.1.0 +------------- + +Released on 2021-08-21. + +Bug fixes: + +* `#8 <https://github.com/CourtBouillon/pydyf/issues/8>`_: + Don’t use sys.stdout.buffer as default write object + +Contributors: + +* Guillaume Ayoub + +Backers and sponsors: + +* Grip Angebotssoftware +* PDF Blocks +* SimonSoft +* Menutech +* Manuel Barkhau +* Simon Sapin +* KontextWork +* René Fritz +* Maykin Media +* NCC Group +* Des images et des mots +* Andreas Zettl +* Nathalie Gutton +* Tom Pohl +* Moritz Mahringer +* Florian Demmer +* Yanal-Yvez Fargialla + + +Version 0.0.3 +------------- + +Released on 2021-04-22. + +New features: + +* Support text rendering + +Contributors: + +* Guillaume Ayoub + +Backers and sponsors: + +* PDF Blocks +* SimonSoft +* Menutech +* Simon Sapin +* Manuel Barkhau +* Andreas Zettl +* Nathalie Gutton +* Tom Pohl +* René Fritz +* Moritz Mahringer +* Florian Demmer +* KontextWork +* Michele Mostarda + + +Version 0.0.2 +------------- + +Released on 2021-03-13. + +New features: + +* Support linecap style +* Support line join et miter limit +* Add more cubic Bézier curve options + +Bug fixes: + +* Don’t include EOL in dictionary length +* Add a second binary line in PDF + +Contributors: + +* Guillaume Ayoub +* Lucie Anglade +* Alexander Schrijver +* Kees Cook + +Backers and sponsors: + +* PDF Blocks +* SimonSoft +* Menutech +* Simon Sapin +* Manuel Barkhau +* Andreas Zettl +* Nathalie Gutton +* Tom Pohl +* René Fritz +* Moritz Mahringer +* Florian Demmer +* KontextWork +* Michele Mostarda + + +Version 0.0.1 +------------- + +Released on 2020-12-06. + +Initial release. + +Contributors: + +* Guillaume Ayoub +* Lucie Anglade + +Backers and sponsors: + +* PDF Blocks +* SimonSoft +* Menutech +* Simon Sapin +* Nathalie Gutton +* Andreas Zetti +* Tom Pohl +* Florian Demmer +* Moritz Mahringer diff --git a/docs/common_use_cases.rst b/docs/common_use_cases.rst new file mode 100644 index 0000000..45e2d3d --- /dev/null +++ b/docs/common_use_cases.rst @@ -0,0 +1,182 @@ +Common Use Cases +================ + +pydyf has been created for WeasyPrint and many common use cases can thus be +found in `its repository`_. + +.. _its repository: https://github.com/Kozea/WeasyPrint + + +Draw rectangles and lines +------------------------- + +.. code-block:: python + + import pydyf + + document = pydyf.PDF() + + draw = pydyf.Stream() + + # Draw a first rectangle + # With the border in dash style + # The dash line is 2 points full, 1 point empty + # And the dash line begins with 2 full points + draw.rectangle(100, 100, 50, 70) + draw.set_dash([2, 1], 0) + draw.stroke() + + # Draw a second rectangle + # The dash is reset to a full line + # The line width is set + # Move the bottom-left corner to (80, 80) + # Fill the rectangle + draw.rectangle(50, 50, 20, 40) + draw.set_dash([], 0) + draw.set_line_width(10) + draw.transform(1, 0, 0, 1, 80, 80) + draw.fill() + + # Add the stream with the two rectangles into the document + document.add_object(draw) + + # Add a page to the document containing the draw + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 200, 200]), + })) + + # Write to document.pdf + with open('document.pdf', 'wb') as f: + document.write(f) + +Add some color +-------------- + +.. code-block:: python + + import pydyf + + document = pydyf.PDF() + + draw = pydyf.Stream() + + # Set the color for nonstroking and stroking operations + # Red for nonstroking an green for stroking + draw.set_color_rgb(1.0, 0.0, 0.0) + draw.set_color_rgb(0.0, 1.0, 0.0, stroke=True) + draw.rectangle(100, 100, 50, 70) + draw.set_dash([2, 1], 0) + draw.stroke() + draw.rectangle(50, 50, 20, 40) + draw.set_dash([], 0) + draw.set_line_width(10) + draw.transform(1, 0, 0, 1, 80, 80) + draw.fill() + + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 200, 200]), + })) + + with open('document.pdf', 'wb') as f: + document.write(f) + +Display image +------------- + +.. code-block:: python + + import pydyf + + document = pydyf.PDF() + + extra = Dictionary({ + 'Type': '/XObject', + 'Subtype': '/Image', + 'Width': 197, + 'Height': 101, + 'ColorSpace': '/DeviceRGB', + 'BitsPerComponent': 8, + 'Filter': '/DCTDecode', + }) + + image = open('logo.jpg', 'rb').read() + xobject = pydyf.Stream([image], extra=extra) + document.add_object(xobject) + + image = pydyf.Stream() + image.push_state() + image.transform(100, 0, 0, 100, 100, 100) + image.draw_x_object('Im1') + image.pop_state() + document.add_object(image) + + # Put the image in the resources of the PDF + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'MediaBox': pydyf.Array([0, 0, 200, 200]), + 'Resources': pydyf.Dictionary({ + 'ProcSet': pydyf.Array(['/PDF', '/ImageB']), + 'XObject': pydyf.Dictionary({'Im1': xobject.reference}), + }), + 'Contents': image.reference, + })) + + with open('document.pdf', 'wb') as f: + document.write(f) + +Display text +------------ + +.. code-block:: python + + import pydyf + + document = pydyf.PDF() + + # Define the font + font = pydyf.Dictionary({ + 'Type': '/Font', + 'Subtype': '/Type1', + 'Name': '/F1', + 'BaseFont': '/Helvetica', + 'Encoding': '/MacRomanEncoding', + }) + + document.add_object(font) + + # Set the font use for the text + # Move to where to display the text + # And display it + text = pydyf.Stream() + text.begin_text() + text.set_font_size('F1', 24) + text.text_matrix(1, 0, 0, 1, 10, 90) + text.show_text(pydyf.String('Hello World')) + text.end_text() + + document.add_object(text) + + # Put the font in the resources of the PDF + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'MediaBox': pydyf.Array([0, 0, 200, 200]), + 'Contents': text.reference, + 'Resources': pydyf.Dictionary({ + 'ProcSet': pydyf.Array(['/PDF', '/Text']), + 'Font': pydyf.Dictionary({'F1': font.reference}), + }) + })) + + with open('document.pdf', 'wb') as f: + document.write(f) + diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..585afd0 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,90 @@ +# pydyf documentation build configuration file. + +import sys +from pathlib import Path + +import pydyf + +# Add current path for css_diagram_role +sys.path.append(str(Path(__file__).parent)) + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [ + 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', + 'sphinx.ext.autosectionlabel'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'pydyf' +copyright = 'CourtBouillon and contributors' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The full version, including alpha/beta/rc tags. +release = pydyf.__version__ + +# The short X.Y version. +version = '.'.join(release.split('.')[:2]) + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'monokai' + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'sphinx_rtd_theme' + +html_theme_options = { + 'collapse_navigation': False, +} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = [] + +# These paths are either relative to html_static_path +# or fully qualified paths (eg. https://...) +html_css_files = [ + 'https://www.courtbouillon.org/static/docs.css', +] + +# Output file base name for HTML help builder. +htmlhelp_basename = 'pydyf2doc' + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'pydyf', 'pydyf Documentation', + ['CourtBouillon and contributors'], 1) +] + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'pydyf', 'pydyf Documentation', + 'CourtBouillon', 'pydyf', + 'A low-level PDF creator', + 'Miscellaneous'), +] + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = { + 'python': ('https://docs.python.org/3/', None), + 'webencodings': ('https://pythonhosted.org/webencodings/', None), +} diff --git a/docs/contribute.rst b/docs/contribute.rst new file mode 100644 index 0000000..adf9024 --- /dev/null +++ b/docs/contribute.rst @@ -0,0 +1,70 @@ +Contribute +========== + +You want to add some code to pydyf, launch its tests or improve its +documentation? Thank you very much! Here are some tips to help you play with +pydyf in good conditions. + +The first step is to clone the repository, create a virtual environment and +install pydyf dependencies. + +.. code-block:: shell + + git clone https://github.com/CourtBouillon/pydyf.git + cd pydyf + python -m venv venv + venv/bin/pip install .[doc,test] + +You can then let your terminal in the current directory and launch Python to +test your changes. ``import pydyf`` will then import the working directory +code, so that you can modify it and test your changes. + +.. code-block:: shell + + venv/bin/python + + +Code & Issues +------------- + +If you’ve found a bug in pydyf, it’s time to report it, and to fix it if you +can! + +You can report bugs and feature requests on GitHub_. If you want to add or +fix some code, please fork the repository and create a pull request, we’ll be +happy to review your work. + +.. _GitHub: https://github.com/CourtBouillon/pydyf + + +Tests +----- + +Tests are stored in the ``tests`` folder at the top of the repository. They use +the pytest_ library. + +Launching tests require to have Ghostscript_ installed and available in +``PATH``. + +You can launch tests (with code coverage and lint) using the following command:: + + venv/bin/pytest + +.. _pytest: https://docs.pytest.org/ +.. _Ghostscript: https://www.ghostscript.com/ + + +Documentation +------------- + +Documentation is stored in the ``docs`` folder at the top of the repository. It +relies on the Sphinx_ library. + +You can build the documentation using the following command:: + + venv/bin/sphinx-build docs docs/_build + +The documentation home page can now be found in the ``docs/_build/index.html`` +file. You can open this file in a browser to see the final rendering. + +.. _Sphinx: https://www.sphinx-doc.org/ diff --git a/docs/first_steps.rst b/docs/first_steps.rst new file mode 100644 index 0000000..61d967f --- /dev/null +++ b/docs/first_steps.rst @@ -0,0 +1,36 @@ +First Steps +=========== + + +Installation +------------ + +The easiest way to use pydyf is to install it in a Python `virtual +environment`_. When your virtual environment is activated, you can then install +pydyf with pip_:: + + pip install pydyf + +.. _virtual environment: https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/ +.. _pip: https://pip.pypa.io/ + + +Create a PDF +------------ + +.. code-block:: python + + import pydyf + + document = pydyf.PDF() + + # Add an empty page + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'MediaBox': pydyf.Array([0, 0, 200, 200]), + })) + + # Write to document.pdf + with open('document.pdf', 'wb') as f: + document.write(f) diff --git a/docs/going_further.rst b/docs/going_further.rst new file mode 100644 index 0000000..ce9a8f3 --- /dev/null +++ b/docs/going_further.rst @@ -0,0 +1,32 @@ +Going Further +============= + + +Why pydyf? +------------- + +pydyf has been created to replace Cairo PDF generation in WeasyPrint_. + +Indeed, there are some bugs in WeasyPrint caused by Cairo_ and Cairo has some +difficulties to make releases. +Also there are features which will be easier to implement while having more +control on the PDF generation. + +So we created pydyf. + +.. _WeasyPrint: https://www.courtbouillon.org/weasyprint +.. _Cairo: https://www.cairographics.org/ + +Why Python? +----------- + +Python is a really good language to design a small, OS-agnostic parser. As it +is object-oriented, it gives the possibility to follow the specification with +high-level classes and a small amount of very simple code. + +And of course, WeasyPrint is written in Python too, giving an obvious reason +for this choice. + +Speed is not pydyf’s main goal. Code simplicity, maintainability and +flexibility are more important goals for this library, as they give the +ability to stay really close to the specification and to fix bugs easily. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..af30f00 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,23 @@ +pydyf +======== + +.. currentmodule:: pydyf + +.. include:: ../README.rst + +.. toctree:: + :caption: Documentation + :maxdepth: 3 + + first_steps + common_use_cases + api_reference + going_further + +.. toctree:: + :caption: Extra Information + :maxdepth: 3 + + changelog + contribute + support diff --git a/docs/support.rst b/docs/support.rst new file mode 100644 index 0000000..fd4452d --- /dev/null +++ b/docs/support.rst @@ -0,0 +1,28 @@ +Support +======= + + +Sponsorship +----------- + +With `donations and sponsorship`_, you help the projects to be +better. Donations allow the CourtBouillon team to have more time dedicated to +add new features, fix bugs, and improve documentation. + +.. _donations and sponsorship: https://opencollective.com/courtbouillon + + +Professionnal Support +--------------------- + +You can improve your experience with CourtBouillon’s tools thanks to our +professional support. You want bugs fixed as soon as possible? You projects +would highly benefit from some new features? You or your team would like to get +new skills with one of the technologies we master? + +Please contact us by mail_, by chat_ or by tweet_ to get in touch and find the +best way we can help you. + +.. _mail: mailto:contact@courtbouillon.org +.. _chat: https://gitter.im/CourtBouillon/pydyf +.. _tweet: https://twitter.com/BouillonCourt diff --git a/pydyf/__init__.py b/pydyf/__init__.py new file mode 100755 index 0000000..4ad0bde --- /dev/null +++ b/pydyf/__init__.py @@ -0,0 +1,502 @@ +""" +A low-level PDF generator. + +""" + +import zlib +from codecs import BOM_UTF16_BE + +VERSION = __version__ = '0.1.1' + + +def _to_bytes(item): + """Convert item to bytes.""" + if isinstance(item, bytes): + return item + elif isinstance(item, Object): + return item.data + elif isinstance(item, float): + if item.is_integer(): + return f'{int(item):d}'.encode('ascii') + else: + return f'{item:f}'.encode('ascii') + elif isinstance(item, int): + return f'{item:d}'.encode('ascii') + return str(item).encode('ascii') + + +class Object: + """Base class for PDF objects.""" + def __init__(self): + #: Number of the object. + self.number = None + #: Position in the PDF of the object. + self.offset = 0 + #: Version number of the object, non-negative. + self.generation = 0 + #: Indicate if an object is used (``'n'``), or has been deleted + #: and therefore is free (``'f'``). + self.free = 'n' + + @property + def indirect(self): + """Indirect representation of an object.""" + return b'\n'.join(( + str(self.number).encode() + b' ' + + str(self.generation).encode() + b' obj', + self.data, + b'endobj', + )) + + @property + def reference(self): + """Object identifier.""" + return ( + str(self.number).encode() + b' ' + + str(self.generation).encode() + b' R') + + @property + def data(self): + """Data contained in the object. Shall be defined in each subclass.""" + raise NotImplementedError() + + +class Dictionary(Object, dict): + """PDF Dictionary object. + + Inherits from :class:`Object` and Python :obj:`dict`. + + """ + def __init__(self, values=None): + Object.__init__(self) + dict.__init__(self, values or {}) + + @property + def data(self): + result = [b'<<'] + for key, value in self.items(): + result.append(b'/' + _to_bytes(key) + b' ' + _to_bytes(value)) + result.append(b'>>') + return b'\n'.join(result) + + +class Stream(Object): + """PDF Stream object. + + Inherits from :class:`Object`. + + """ + def __init__(self, stream=None, extra=None, compress=False): + super().__init__() + #: Python array of data composing stream. + self.stream = stream or [] + #: Metadata containing at least the length of the Stream. + self.extra = extra or {} + #: Compress the stream data if set to ``True``. Default is ``False``. + self.compress = compress + + def begin_text(self): + """Begin a text object.""" + self.stream.append(b'BT') + + def clip(self, even_odd=False): + """Modify current clipping path by intersecting it with current path. + + Use the nonzero winding number rule to determine which regions lie + inside the clipping path by default. + + Use the even-odd rule if ``even_odd`` set to ``True``. + + """ + self.stream.append(b'W*' if even_odd else b'W') + + def close(self): + """Close current subpath. + + Append a straight line segment from the current point to the starting + point of the subpath. + + """ + self.stream.append(b'h') + + def color_space(self, space, stroke=False): + """Set the nonstroking color space. + + If stroke is set to ``True``, set the stroking color space instead. + + """ + self.stream.append( + b'/' + _to_bytes(space) + b' ' + (b'CS' if stroke else b'cs')) + + def curve_to(self, x1, y1, x2, y2, x3, y3): + """Add cubic Bézier curve to current path. + + The curve shall extend from ``(x3, y3)`` using ``(x1, y1)`` and ``(x2, + y2)`` as the Bézier control points. + + """ + self.stream.append(b' '.join(( + _to_bytes(x1), _to_bytes(y1), + _to_bytes(x2), _to_bytes(y2), + _to_bytes(x3), _to_bytes(y3), b'c'))) + + def curve_start_to(self, x2, y2, x3, y3): + """Add cubic Bézier curve to current path + + The curve shall extend to ``(x3, y3)`` using the current point and + ``(x2, y2)`` as the Bézier control points. + + """ + self.stream.append(b' '.join(( + _to_bytes(x2), _to_bytes(y2), + _to_bytes(x3), _to_bytes(y3), b'v'))) + + def curve_end_to(self, x1, y1, x3, y3): + """Add cubic Bézier curve to current path + + The curve shall extend to ``(x3, y3)`` using `(x1, y1)`` and ``(x3, + y3)`` as the Bézier control points. + + """ + self.stream.append(b' '.join(( + _to_bytes(x1), _to_bytes(y1), + _to_bytes(x3), _to_bytes(y3), b'y'))) + + def draw_x_object(self, reference): + """Draw object given by reference.""" + self.stream.append(b'/' + _to_bytes(reference) + b' Do') + + def end(self): + """End path without filling or stroking.""" + self.stream.append(b'n') + + def end_text(self): + """End text object.""" + self.stream.append(b'ET') + + def fill(self, even_odd=False): + """Fill path using nonzero winding rule. + + Use even-odd rule if ``even_odd`` is set to ``True``. + + """ + self.stream.append(b'f*' if even_odd else b'f') + + def fill_and_stroke(self, even_odd=False): + """Fill and stroke path usign nonzero winding rule. + + Use even-odd rule if ``even_odd`` is set to ``True``. + + """ + self.stream.append(b'B*' if even_odd else b'B') + + def fill_stroke_and_close(self, even_odd=False): + """Fill, stroke and close path using nonzero winding rule. + + Use even-odd rule if ``even_odd`` is set to ``True``. + + """ + self.stream.append(b'b*' if even_odd else b'b') + + def line_to(self, x, y): + """Add line from current point to point ``(x, y)``.""" + self.stream.append(b' '.join((_to_bytes(x), _to_bytes(y), b'l'))) + + def move_to(self, x, y): + """Begin new subpath by moving current point to ``(x, y)``.""" + self.stream.append(b' '.join((_to_bytes(x), _to_bytes(y), b'm'))) + + def shading(self, name): + """Paint shape and color shading using shading dictionary ``name``.""" + self.stream.append(b'/' + _to_bytes(name) + b' sh') + + def pop_state(self): + """Restore graphic state.""" + self.stream.append(b'Q') + + def push_state(self): + """Save graphic state.""" + self.stream.append(b'q') + + def rectangle(self, x, y, width, height): + """Add rectangle to current path as complete subpath. + + ``(x, y)`` is the lower-left corner and width and height the + dimensions. + + """ + self.stream.append(b' '.join(( + _to_bytes(x), _to_bytes(y), + _to_bytes(width), _to_bytes(height), b're'))) + + def set_color_rgb(self, r, g, b, stroke=False): + """Set RGB color for nonstroking operations. + + Set RGB color for stroking operations instead if ``stroke`` is set to + ``True``. + + """ + self.stream.append(b' '.join(( + _to_bytes(r), _to_bytes(g), _to_bytes(b), + (b'RG' if stroke else b'rg')))) + + def set_color_special(self, name, stroke=False): + """Set color for nonstroking operations. + + Set color for stroking operation if ``stroke`` is set to ``True``. + + """ + self.stream.append( + b'/' + _to_bytes(name) + b' ' + (b'SCN' if stroke else b'scn')) + + def set_dash(self, dash_array, dash_phase): + """Set dash line pattern. + + :param dash_array: Dash pattern. + :type dash_array: :term:`iterable` + :param dash_phase: Start of dash phase. + :type dash_phase: :obj:`int` + + """ + self.stream.append(b' '.join(( + Array(dash_array).data, _to_bytes(dash_phase), b'd'))) + + def set_font_size(self, font, size): + """Set font name and size.""" + self.stream.append( + b'/' + _to_bytes(font) + b' ' + _to_bytes(size) + b' Tf') + + def set_text_rendering(self, mode): + """Set text rendering mode.""" + self.stream.append(_to_bytes(mode) + b' Tr') + + def set_line_cap(self, line_cap): + """Set line cap style.""" + self.stream.append(_to_bytes(line_cap) + b' J') + + def set_line_join(self, line_join): + """Set line join style.""" + self.stream.append(_to_bytes(line_join) + b' j') + + def set_line_width(self, width): + """Set line width.""" + self.stream.append(_to_bytes(width) + b' w') + + def set_miter_limit(self, miter_limit): + """Set miter limit.""" + self.stream.append(_to_bytes(miter_limit) + b' M') + + def set_state(self, state_name): + """Set specified parameters in graphic state. + + :param state_name: Name of the graphic state. + + """ + self.stream.append(b'/' + _to_bytes(state_name) + b' gs') + + def show_text(self, text): + """Show text.""" + self.stream.append(b'[' + _to_bytes(text) + b'] TJ') + + def stroke(self): + """Stroke path.""" + self.stream.append(b'S') + + def stroke_and_close(self): + """Stroke and close path.""" + self.stream.append(b's') + + def text_matrix(self, a, b, c, d, e, f): + """Set text matrix and text line matrix. + + :param a: Top left number in the matrix. + :type a: :obj:`int` or :obj:`float` + :param b: Top middle number in the matrix. + :type b: :obj:`int` or :obj:`float` + :param c: Middle left number in the matrix. + :type c: :obj:`int` or :obj:`float` + :param d: Middle middle number in the matrix. + :type d: :obj:`int` or :obj:`float` + :param e: Bottom left number in the matrix. + :type e: :obj:`int` or :obj:`float` + :param f: Bottom middle number in the matrix. + :type f: :obj:`int` or :obj:`float` + + """ + self.stream.append(b' '.join(( + _to_bytes(a), _to_bytes(b), _to_bytes(c), + _to_bytes(d), _to_bytes(e), _to_bytes(f), b'Tm'))) + + def transform(self, a, b, c, d, e, f): + """Modify current transformation matrix. + + :param a: Top left number in the matrix. + :type a: :obj:`int` or :obj:`float` + :param b: Top middle number in the matrix. + :type b: :obj:`int` or :obj:`float` + :param c: Middle left number in the matrix. + :type c: :obj:`int` or :obj:`float` + :param d: Middle middle number in the matrix. + :type d: :obj:`int` or :obj:`float` + :param e: Bottom left number in the matrix. + :type e: :obj:`int` or :obj:`float` + :param f: Bottom middle number in the matrix. + :type f: :obj:`int` or :obj:`float` + + """ + self.stream.append(b' '.join(( + _to_bytes(a), _to_bytes(b), _to_bytes(c), + _to_bytes(d), _to_bytes(e), _to_bytes(f), b'cm'))) + + @property + def data(self): + stream = b'\n'.join(_to_bytes(item) for item in self.stream) + extra = Dictionary(self.extra.copy()) + if self.compress: + extra['Filter'] = '/FlateDecode' + compressobj = zlib.compressobj() + stream = compressobj.compress(stream) + stream += compressobj.flush() + extra['Length'] = len(stream) + return b'\n'.join((extra.data, b'stream', stream, b'endstream')) + + +class String(Object): + """PDF String object. + + Inherits from :class:`Object`. + + """ + def __init__(self, string=''): + super().__init__() + #: Unicode string. + self.string = string + + @property + def data(self): + try: + return b'(' + _to_bytes(self.string) + b')' + except UnicodeEncodeError: + encoded = BOM_UTF16_BE + str(self.string).encode('utf-16-be') + return b'<' + encoded.hex().encode() + b'>' + + +class Array(Object, list): + """PDF Array object. + + Inherits from :class:`Object` and Python :obj:`list`. + + """ + def __init__(self, array=None): + Object.__init__(self) + list.__init__(self, array or []) + + @property + def data(self): + result = [b'['] + for child in self: + result.append(_to_bytes(child)) + result.append(b']') + return b' '.join(result) + + +class PDF: + """PDF document.""" + def __init__(self): + #: Python :obj:`list` containing the PDF’s objects. + self.objects = [] + + zero_object = Object() + zero_object.generation = 65535 + zero_object.free = 'f' + self.add_object(zero_object) + + #: PDF :class:`Dictionary` containing the PDF’s pages. + self.pages = Dictionary({ + 'Type': '/Pages', + 'Kids': Array([]), + 'Count': 0, + }) + self.add_object(self.pages) + + #: PDF :class:`Dictionary` containing the PDF’s metadata. + self.info = Dictionary({}) + self.add_object(self.info) + + #: PDF :class:`Dictionary` containing references to the other objects. + self.catalog = Dictionary({ + 'Type': '/Catalog', + 'Pages': self.pages.reference, + }) + self.add_object(self.catalog) + + #: Current position in the PDF. + self.current_position = 0 + #: Position of the cross reference table. + self.xref_position = None + + def add_page(self, page): + """Add page to the PDF. + + :param page: New page. + :type page: :class:`Dictionary` + + """ + self.pages['Count'] += 1 + self.add_object(page) + self.pages['Kids'].extend([page.number, 0, 'R']) + + def add_object(self, object_): + """Add object to the PDF.""" + object_.number = len(self.objects) + self.objects.append(object_) + + def write_line(self, content, output): + """Write line to output. + + :param content: Content to write. + :type content: :obj:`bytes` + :param output: Output stream. + :type output: binary :term:`file object` + + """ + self.current_position += len(content) + 1 + output.write(content + b'\n') + + def write(self, output): + """Write PDF to output. + + :param output: Output stream. + :type output: binary :term:`file object` + + """ + # Write header + self.write_line(b'%PDF-1.7', output) + self.write_line(b'%\xf0\x9f\x96\xa4', output) + + # Write all non-free PDF objects + for object_ in self.objects: + if object_.free == 'f': + continue + object_.offset = self.current_position + self.write_line(object_.indirect, output) + + # Write cross reference table + self.xref_position = self.current_position + self.write_line(b'xref', output) + self.write_line(f'0 {len(self.objects)}'.encode(), output) + for object_ in self.objects: + self.write_line( + (f'{object_.offset:010} {object_.generation:05} ' + f'{object_.free} ').encode(), output) + + # Write trailer + self.write_line(b'trailer', output) + self.write_line(b'<<', output) + self.write_line(f'/Size {len(self.objects)}'.encode(), output) + self.write_line(b'/Root ' + self.catalog.reference, output) + self.write_line(b'/Info ' + self.info.reference, output) + self.write_line(b'>>', output) + self.write_line(b'startxref', output) + self.write_line(f'{self.xref_position}'.encode(), output) + self.write_line(b'%%EOF', output) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5a88369 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ['flit_core >=3.2,<4'] +build-backend = 'flit_core.buildapi' + +[project] +name = 'pydyf' +description = 'A low-level PDF generator.' +keywords = ['pdf', 'generator'] +authors = [{name = 'CourtBouillon', email = 'contact@courtbouillon.org'}] +maintainers = [{name = 'CourtBouillon', email = 'contact@courtbouillon.org'}] +requires-python = '>=3.6' +readme = {file = 'README.rst', content-type = 'text/x-rst'} +license = {file = 'LICENSE'} +classifiers = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', +] +dynamic = ['version'] + +[project.urls] +Homepage = 'https://www.courtbouillon.org/pydyf' +Documentation = 'https://doc.courtbouillon.org/pydyf/' +Code = 'https://github.com/CourtBouillon/pydyf' +Issues = 'https://github.com/CourtBouillon/pydyf/issues' +Changelog = 'https://github.com/CourtBouillon/pydyf/releases' +Donation = 'https://opencollective.com/courtbouillon' + +[project.optional-dependencies] +doc = ['sphinx', 'sphinx_rtd_theme'] +test = ['pytest', 'pytest-cov', 'pytest-flake8', 'pytest-isort', 'coverage[toml]', 'pillow'] + +[tool.flit.sdist] +exclude = ['.*'] + +[tool.pytest.ini_options] +addopts = '--isort --flake8 --cov --no-cov-on-fail' + +[tool.coverage.run] +branch = true +include = ['tests/*', 'pydyf/*'] + +[tool.coverage.report] +exclude_lines = ['pragma: no cover', 'def __repr__', 'raise NotImplementedError'] +omit = ['.*'] + +[tool.isort] +default_section = 'FIRSTPARTY' +multi_line_output = 4 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..45b7533 --- /dev/null +++ b/setup.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# setup.py generated by flit for tools that don't yet use PEP 517 + +from distutils.core import setup + +packages = \ +['pydyf'] + +package_data = \ +{'': ['*']} + +extras_require = \ +{'doc': ['sphinx', 'sphinx_rtd_theme'], + 'test': ['pytest', + 'pytest-cov', + 'pytest-flake8', + 'pytest-isort', + 'coverage[toml]', + 'pillow']} + +setup(name='pydyf', + version='0.1.1', + description='A low-level PDF generator.', + author=None, + author_email='CourtBouillon <contact@courtbouillon.org>', + url=None, + packages=packages, + package_data=package_data, + extras_require=extras_require, + python_requires='>=3.6', + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..ae5cf6a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,80 @@ +""" +Test suite for pydyf. + +This module adds a PNG export based on GhostScript, that is released under +AGPL. As "the end user has the ability to opt out of installing the AGPL +version of [Ghostscript] during the install process", and with explicit +aggreement from Artifex, it is OK to distribute this code under BSD. + +See https://www.ghostscript.com/license.html. + +""" + +import io +import os +from pathlib import Path +from subprocess import PIPE, run + +from PIL import Image + +PIXELS_BY_CHAR = dict( + _=(255, 255, 255), # white + R=(255, 0, 0), # red + B=(0, 0, 255), # blue + G=(0, 255, 0), # lime green + K=(0, 0, 0), # black + z=None, # any color +) + + +def assert_pixels(document, reference_pixels): + """Test that the rendered document matches the reference pixels.""" + + # Transform the PDF document into a list of RGB tuples + pdf = io.BytesIO() + document.write(pdf) + command = [ + 'gs', '-q', '-dNOPAUSE', '-dSAFER', '-sDEVICE=png16m', + '-r576', '-dDownScaleFactor=8', '-sOutputFile=-', '-'] + png = run(command, input=pdf.getvalue(), stdout=PIPE).stdout + image = Image.open(io.BytesIO(png)) + pixels = image.getdata() + + # Transform reference drawings into a list of RGB tuples + lines = tuple( + line.strip() for line in reference_pixels.splitlines() if line.strip()) + assert len({len(line) for line in lines}) == 1, ( + 'The lines of reference pixels don’t have the same length') + width, height = len(lines[0]), len(lines) + assert (width, height) == image.size, ( + f'Reference size is {width}×{height}, ' + f'output size is {image.width}×{image.height}') + reference_pixels = tuple( + PIXELS_BY_CHAR[char] for line in lines for char in line) + + # Compare pixels + if pixels != reference_pixels: # pragma: no cover + for i, (value, reference) in enumerate(zip(pixels, reference_pixels)): + if reference is None: + continue + if any(value != reference + for value, reference in zip(value, reference)): + name = os.environ.get('PYTEST_CURRENT_TEST') + name = name.split(':')[-1].split(' ')[0] + write_png(f'{name}', pixels, width, height) + reference_pixels = [ + pixel or (255, 255, 255) for pixel in reference_pixels] + write_png(f'{name}-reference', reference_pixels, width, height) + x, y = i % width, i // width + assert 0, ( + f'Pixel ({x}, {y}) in {name}: ' + f'reference rgba{reference}, got rgba{value}') + + +def write_png(name, pixels, width, height): # pragma: no cover + """Take a pixel matrix and write a PNG file.""" + directory = Path(__file__).parent / 'results' + directory.mkdir(exist_ok=True) + image = Image.new('RGB', (width, height)) + image.putdata(pixels) + image.save(directory / f'{name}.png') diff --git a/tests/test_pydyf.py b/tests/test_pydyf.py new file mode 100644 index 0000000..87d9763 --- /dev/null +++ b/tests/test_pydyf.py @@ -0,0 +1,708 @@ +import pydyf + +from . import assert_pixels + + +def test_fill(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.rectangle(2, 2, 5, 6) + draw.fill() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __________ + __________ + ''') + + +def test_stroke(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.rectangle(2, 2, 5, 6) + draw.set_line_width(2) + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + _KKKKKKK__ + _KKKKKKK__ + _KK___KK__ + _KK___KK__ + _KK___KK__ + _KK___KK__ + _KKKKKKK__ + _KKKKKKK__ + __________ + ''') + + +def test_line_to(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.move_to(2, 2) + draw.set_line_width(2) + draw.line_to(2, 5) + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __________ + __________ + __________ + _KK_______ + _KK_______ + _KK_______ + __________ + __________ + ''') + + +def test_set_color_rgb_stroke(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.rectangle(2, 2, 5, 6) + draw.set_line_width(2) + draw.set_color_rgb(0, 0, 255, stroke=True) + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + _BBBBBBB__ + _BBBBBBB__ + _BB___BB__ + _BB___BB__ + _BB___BB__ + _BB___BB__ + _BBBBBBB__ + _BBBBBBB__ + __________ + ''') + + +def test_set_color_rgb_fill(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.rectangle(2, 2, 5, 6) + draw.set_color_rgb(255, 0, 0) + draw.fill() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __RRRRR___ + __RRRRR___ + __RRRRR___ + __RRRRR___ + __RRRRR___ + __RRRRR___ + __________ + __________ + ''') + + +def test_set_dash(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.move_to(2, 2) + draw.set_line_width(2) + draw.line_to(2, 6) + draw.set_dash([2, 1], 0) + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __________ + __________ + _KK_______ + __________ + _KK_______ + _KK_______ + __________ + __________ + ''') + + +def test_curve_to(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.move_to(2, 5) + draw.set_line_width(2) + draw.curve_to(2, 5, 3, 5, 5, 5) + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __________ + __________ + __KKK_____ + __KKK_____ + __________ + __________ + __________ + __________ + ''') + + +def test_curve_start_to(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.move_to(2, 5) + draw.set_line_width(2) + draw.curve_start_to(3, 5, 5, 5) + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __________ + __________ + __KKK_____ + __KKK_____ + __________ + __________ + __________ + __________ + ''') + + +def test_curve_end_to(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.move_to(2, 5) + draw.set_line_width(2) + draw.curve_end_to(3, 5, 5, 5) + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __________ + __________ + __KKK_____ + __KKK_____ + __________ + __________ + __________ + __________ + ''') + + +def test_transform(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.move_to(2, 2) + draw.set_line_width(2) + draw.line_to(2, 5) + draw.transform(1, 0, 0, 1, 1, 1) + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __________ + __________ + __KK______ + __KK______ + __KK______ + __________ + __________ + __________ + ''') + + +def test_set_state(): + document = pydyf.PDF() + + graphic_state = pydyf.Dictionary({ + 'Type': '/ExtGState', + 'LW': 2, + }) + document.add_object(graphic_state) + + draw = pydyf.Stream() + draw.rectangle(2, 2, 5, 6) + draw.set_state('GS') + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + 'Resources': pydyf.Dictionary({ + 'ExtGState': pydyf.Dictionary({'GS': graphic_state.reference}), + }), + })) + + assert_pixels(document, ''' + __________ + _KKKKKKK__ + _KKKKKKK__ + _KK___KK__ + _KK___KK__ + _KK___KK__ + _KK___KK__ + _KKKKKKK__ + _KKKKKKK__ + __________ + ''') + + +def test_fill_and_stroke(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.rectangle(2, 2, 5, 6) + draw.set_line_width(2) + draw.set_color_rgb(0, 0, 255, stroke=True) + draw.fill_and_stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + _BBBBBBB__ + _BBBBBBB__ + _BBKKKBB__ + _BBKKKBB__ + _BBKKKBB__ + _BBKKKBB__ + _BBBBBBB__ + _BBBBBBB__ + __________ + ''') + + +def test_clip(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.rectangle(3, 3, 5, 6) + draw.rectangle(4, 3, 2, 6) + draw.clip() + draw.end() + draw.move_to(0, 5) + draw.line_to(10, 5) + draw.set_color_rgb(255, 0, 0, stroke=True) + draw.set_line_width(2) + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __________ + __________ + ___RRRRR__ + ___RRRRR__ + __________ + __________ + __________ + __________ + ''') + + +def test_clip_even_odd(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.rectangle(3, 3, 5, 6) + draw.rectangle(4, 3, 2, 6) + draw.clip(even_odd=True) + draw.end() + draw.move_to(0, 5) + draw.line_to(10, 5) + draw.set_color_rgb(255, 0, 0, stroke=True) + draw.set_line_width(2) + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __________ + __________ + ___R__RR__ + ___R__RR__ + __________ + __________ + __________ + __________ + ''') + + +def test_close(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.move_to(2, 2) + draw.line_to(2, 8) + draw.line_to(7, 8) + draw.line_to(7, 2) + draw.close() + draw.set_color_rgb(0, 0, 255, stroke=True) + draw.set_line_width(2) + draw.stroke() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + _BBBBBBB__ + _BBBBBBB__ + _BB___BB__ + _BB___BB__ + _BB___BB__ + _BB___BB__ + _BBBBBBB__ + _BBBBBBB__ + __________ + ''') + + +def test_stroke_and_close(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.move_to(2, 2) + draw.line_to(2, 8) + draw.line_to(7, 8) + draw.line_to(7, 2) + draw.set_color_rgb(0, 0, 255, stroke=True) + draw.set_line_width(2) + draw.stroke_and_close() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + _BBBBBBB__ + _BBBBBBB__ + _BB___BB__ + _BB___BB__ + _BB___BB__ + _BB___BB__ + _BBBBBBB__ + _BBBBBBB__ + __________ + ''') + + +def test_fill_stroke_and_close(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.move_to(2, 2) + draw.line_to(2, 8) + draw.line_to(7, 8) + draw.line_to(7, 2) + draw.set_color_rgb(255, 0, 0) + draw.set_color_rgb(0, 0, 255, stroke=True) + draw.set_line_width(2) + draw.fill_stroke_and_close() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + _BBBBBBB__ + _BBBBBBB__ + _BBRRRBB__ + _BBRRRBB__ + _BBRRRBB__ + _BBRRRBB__ + _BBBBBBB__ + _BBBBBBB__ + __________ + ''') + + +def test_push_pop_state(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.rectangle(2, 2, 5, 6) + draw.push_state() + draw.rectangle(4, 4, 2, 2) + draw.set_color_rgb(255, 0, 0) + draw.pop_state() + draw.fill() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __________ + __________ + ''') + + +def test_types(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.rectangle(2, 2.0, '5', b'6') + draw.set_line_width(2.3456) + draw.fill() + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __________ + __________ + ''') + + +def test_compress(): + document = pydyf.PDF() + + draw = pydyf.Stream() + draw.rectangle(2, 2, 5, 6) + draw.fill() + assert b'2 2 5 6' in draw.data + + draw = pydyf.Stream(compress=True) + draw.rectangle(2, 2, 5, 6) + draw.fill() + assert b'2 2 5 6' not in draw.data + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + })) + + assert_pixels(document, ''' + __________ + __________ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __KKKKK___ + __________ + __________ + ''') + + +def test_text(): + document = pydyf.PDF() + + font = pydyf.Dictionary({ + 'Type': '/Font', + 'Subtype': '/Type1', + 'Name': '/F1', + 'BaseFont': '/Helvetica', + 'Encoding': '/MacRomanEncoding', + }) + document.add_object(font) + + draw = pydyf.Stream() + draw.begin_text() + draw.set_font_size('F1', 200) + draw.text_matrix(1, 0, 0, 1, -20, 5) + draw.show_text(pydyf.String('l')) + draw.show_text(pydyf.String('É')) + draw.end_text() + + document.add_object(draw) + + document.add_page(pydyf.Dictionary({ + 'Type': '/Page', + 'Parent': document.pages.reference, + 'Contents': draw.reference, + 'MediaBox': pydyf.Array([0, 0, 10, 10]), + 'Resources': pydyf.Dictionary({ + 'ProcSet': pydyf.Array(['/PDF', '/Text']), + 'Font': pydyf.Dictionary({'F1': font.reference}), + }), + })) + + assert_pixels(document, ''' + KKKKKKKKKK + KKKKKKKKKK + KKKKKKKKKK + KKKKKKKKKK + KKKKKKKKKK + __________ + __________ + __________ + __________ + __________ + ''') + + +def test_string_encoding(): + assert pydyf.String('abc').data == b'(abc)' + assert pydyf.String('déf').data == b'<feff006400e90066>' + assert pydyf.String('♡').data == b'<feff2661>' |