summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--LICENSE29
-rw-r--r--PKG-INFO57
-rw-r--r--README.rst17
-rw-r--r--docs/api_reference.rst19
-rw-r--r--docs/changelog.rst167
-rw-r--r--docs/common_use_cases.rst182
-rw-r--r--docs/conf.py90
-rw-r--r--docs/contribute.rst70
-rw-r--r--docs/first_steps.rst36
-rw-r--r--docs/going_further.rst32
-rw-r--r--docs/index.rst23
-rw-r--r--docs/support.rst28
-rwxr-xr-xpydyf/__init__.py502
-rw-r--r--pyproject.toml59
-rw-r--r--setup.py31
-rw-r--r--tests/__init__.py80
-rw-r--r--tests/test_pydyf.py708
17 files changed, 2130 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8271151
--- /dev/null
+++ b/LICENSE
@@ -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>'