├── .github ├── FUNDING.yml └── workflows │ ├── doconfly.yml │ ├── exe.yml │ ├── release.yml │ ├── test_samples.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.rst ├── docs ├── api_reference.rst ├── changelog.rst ├── common_use_cases.rst ├── conf.py ├── contribute.rst ├── first_steps.rst ├── going_further.rst ├── index.rst ├── manpage.rst └── support.rst ├── pyproject.toml ├── tests ├── __init__.py ├── conftest.py ├── css │ ├── __init__.py │ ├── test_common.py │ ├── test_counters.py │ ├── test_descriptors.py │ ├── test_errors.py │ ├── test_expanders.py │ ├── test_fonts.py │ ├── test_nesting.py │ ├── test_pages.py │ ├── test_target.py │ ├── test_ua.py │ ├── test_validation.py │ └── test_variables.py ├── draw │ ├── __init__.py │ ├── svg │ │ ├── __init__.py │ │ ├── test_bounding_box.py │ │ ├── test_clip.py │ │ ├── test_defs.py │ │ ├── test_gradients.py │ │ ├── test_images.py │ │ ├── test_markers.py │ │ ├── test_opacity.py │ │ ├── test_paths.py │ │ ├── test_patterns.py │ │ ├── test_shapes.py │ │ ├── test_text.py │ │ ├── test_transform.py │ │ ├── test_units.py │ │ └── test_visibility.py │ ├── test_absolute.py │ ├── test_background.py │ ├── test_before_after.py │ ├── test_box.py │ ├── test_column.py │ ├── test_current_color.py │ ├── test_float.py │ ├── test_footnote.py │ ├── test_footnote_column.py │ ├── test_gradient.py │ ├── test_image.py │ ├── test_leader.py │ ├── test_list.py │ ├── test_opacity.py │ ├── test_overflow.py │ ├── test_page.py │ ├── test_table.py │ ├── test_text.py │ ├── test_transform.py │ ├── test_visibility.py │ └── test_whitespace.py ├── layout │ ├── __init__.py │ ├── test_block.py │ ├── test_column.py │ ├── test_flex.py │ ├── test_float.py │ ├── test_footnotes.py │ ├── test_grid.py │ ├── test_image.py │ ├── test_inline.py │ ├── test_inline_block.py │ ├── test_list.py │ ├── test_page.py │ ├── test_position.py │ ├── test_preferred.py │ └── test_table.py ├── resources │ ├── acid2-reference.html │ ├── acid2-test.html │ ├── blue.jpg │ ├── border.svg │ ├── border2.svg │ ├── doc1.html │ ├── doc1_UTF-16BE.html │ ├── icon.png │ ├── latin1-test.css │ ├── logo_small.png │ ├── mask.svg │ ├── mini_ua.css │ ├── not-optimized-exif.jpg │ ├── not-optimized.jpg │ ├── pattern-transparent.svg │ ├── pattern.gif │ ├── pattern.palette.png │ ├── pattern.png │ ├── pattern.svg │ ├── really-a-png.svg │ ├── really-a-svg.png │ ├── sheet2.css │ ├── sub_directory │ │ └── sheet1.css │ ├── tests_ua.css │ ├── user.css │ ├── utf8-test.css │ ├── weasyprint.otb │ ├── weasyprint.otf │ └── weasyprint.woff ├── test_acid2.py ├── test_api.py ├── test_boxes.py ├── test_fonts.py ├── test_pdf.py ├── test_presentational_hints.py ├── test_stacking.py ├── test_text.py ├── test_unicode.py ├── test_url.py └── testing_utils.py └── weasyprint ├── __init__.py ├── __main__.py ├── anchors.py ├── css ├── __init__.py ├── computed_values.py ├── counters.py ├── html5_ph.css ├── html5_ua.css ├── html5_ua_form.css ├── media_queries.py ├── properties.py ├── targets.py ├── utils.py └── validation │ ├── __init__.py │ ├── descriptors.py │ ├── expanders.py │ └── properties.py ├── document.py ├── draw ├── __init__.py ├── border.py ├── color.py ├── stack.py └── text.py ├── formatting_structure ├── boxes.py └── build.py ├── html.py ├── images.py ├── layout ├── __init__.py ├── absolute.py ├── background.py ├── block.py ├── column.py ├── flex.py ├── float.py ├── grid.py ├── inline.py ├── leader.py ├── min_max.py ├── page.py ├── percent.py ├── preferred.py ├── replaced.py └── table.py ├── logger.py ├── matrix.py ├── pdf ├── __init__.py ├── anchors.py ├── debug.py ├── fonts.py ├── metadata.py ├── pdfa.py ├── pdfua.py ├── sRGB2014.icc └── stream.py ├── stacking.py ├── svg ├── __init__.py ├── bounding_box.py ├── css.py ├── defs.py ├── images.py ├── path.py ├── shapes.py ├── text.py └── utils.py ├── text ├── constants.py ├── ffi.py ├── fonts.py └── line_break.py └── urls.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | open_collective: courtbouillon 4 | -------------------------------------------------------------------------------- /.github/workflows/doconfly.yml: -------------------------------------------------------------------------------- 1 | name: doconfly 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - "*" 8 | 9 | jobs: 10 | doconfly: 11 | name: doconfly job 12 | runs-on: ubuntu-latest 13 | env: 14 | PORT: ${{ secrets.PORT }} 15 | SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} 16 | TAKOYAKI: ${{ secrets.TAKOYAKI }} 17 | USER: ${{ secrets.USER }} 18 | DOCUMENTATION_PATH: ${{ secrets.DOCUMENTATION_PATH }} 19 | DOCUMENTATION_URL: ${{ secrets.DOCUMENTATION_URL }} 20 | steps: 21 | - run: | 22 | which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y ) 23 | eval $(ssh-agent -s) 24 | echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - 25 | mkdir -p ~/.ssh 26 | chmod 700 ~/.ssh 27 | ssh-keyscan -p $PORT $TAKOYAKI >> ~/.ssh/known_hosts 28 | chmod 644 ~/.ssh/known_hosts 29 | ssh $USER@$TAKOYAKI -p $PORT "doconfly/doconfly.sh $GITHUB_REPOSITORY $GITHUB_REF $DOCUMENTATION_PATH $DOCUMENTATION_URL" 30 | -------------------------------------------------------------------------------- /.github/workflows/exe.yml: -------------------------------------------------------------------------------- 1 | name: WeasyPrint’s exe generation 2 | on: [push] 3 | 4 | jobs: 5 | generate: 6 | name: ${{ matrix.os }} 7 | runs-on: 'windows-latest' 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-python@v5 11 | with: 12 | python-version: '3.13' 13 | - name: Use absolute imports and install Pango (Windows) 14 | run: | 15 | C:\msys64\usr\bin\bash -lc 'pacman -S mingw-w64-x86_64-pango mingw-w64-x86_64-sed --noconfirm' 16 | C:\msys64\mingw64\bin\sed -i 's/^from \. /from weasyprint /' weasyprint/__main__.py 17 | C:\msys64\mingw64\bin\sed -i 's/^from \./from weasyprint\./' weasyprint/__main__.py 18 | echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH 19 | rm C:\msys64\mingw64\bin\python.exe 20 | - name: Install requirements 21 | run: python -m pip install . pyinstaller 22 | - name: Generate executable 23 | run: python -m PyInstaller weasyprint/__main__.py -n weasyprint -F 24 | - name: Test executable 25 | run: dist/weasyprint --info 26 | - name: Store executable 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: weasyprint-windows 30 | path: | 31 | dist/weasyprint 32 | dist/weasyprint.exe 33 | README.rst 34 | LICENSE 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release new version 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | 7 | jobs: 8 | pypi-publish: 9 | name: Upload release to PyPI 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: pypi 13 | url: https://pypi.org/p/weasyprint 14 | permissions: 15 | id-token: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-python@v5 19 | - name: Install requirements 20 | run: python -m pip install flit 21 | - name: Build packages 22 | run: flit build 23 | - name: Publish package distributions to PyPI 24 | uses: pypa/gh-action-pypi-publish@release/v1 25 | add-version: 26 | name: Add version to GitHub 27 | runs-on: ubuntu-latest 28 | permissions: 29 | contents: write 30 | steps: 31 | - name: Checkout code 32 | uses: actions/checkout@v4 33 | - name: Install requirements 34 | run: sudo apt-get install pandoc 35 | - name: Generate content 36 | run: | 37 | pandoc docs/changelog.rst -f rst -t gfm | csplit - /##/ "{1}" -f .part 38 | sed -r "s/^([A-Z].*)\:\$/## \1/" .part01 | sed -r "s/^ *//" | sed -rz "s/([^\n])\n([^\n^-])/\1 \2/g" | tail -n +5 > .body 39 | - name: Create Release 40 | uses: softprops/action-gh-release@v2 41 | with: 42 | body_path: .body 43 | -------------------------------------------------------------------------------- /.github/workflows/test_samples.yml: -------------------------------------------------------------------------------- 1 | name: WeasyPrint's samples tests 2 | on: [push, pull_request] 3 | 4 | env: 5 | REPORTS_FOLDER: 'report' 6 | 7 | jobs: 8 | samples: 9 | name: Generate samples 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: ${{ matrix.python-version }} 16 | - name: Upgrade pip and setuptools 17 | run: python -m pip install --upgrade pip setuptools 18 | - name: Install requirements 19 | run: python -m pip install . 20 | - name: Clone samples repository 21 | run: git clone https://github.com/CourtBouillon/weasyprint-samples.git 22 | - name: Create output folder 23 | run: mkdir ${{env.REPORTS_FOLDER}} 24 | - name: Book classical 25 | run: python -m weasyprint weasyprint-samples/book/book.html -s weasyprint-samples/book/book-classical.css ${{env.REPORTS_FOLDER}}/book-classical.pdf 26 | - name: Book fancy 27 | run: python -m weasyprint weasyprint-samples/book/book.html -s weasyprint-samples/book/book.css ${{env.REPORTS_FOLDER}}/book-fancy.pdf 28 | - name: Invoice 29 | run: python -m weasyprint weasyprint-samples/invoice/invoice.html ${{env.REPORTS_FOLDER}}/invoice.pdf 30 | - name: Letter 31 | run: python -m weasyprint weasyprint-samples/letter/letter.html ${{env.REPORTS_FOLDER}}/letter.pdf 32 | - name: Poster 33 | run: python -m weasyprint weasyprint-samples/poster/poster.html -s weasyprint-samples/poster/poster.css ${{env.REPORTS_FOLDER}}/poster.pdf 34 | - name: Flyer 35 | run: python -m weasyprint weasyprint-samples/poster/poster.html -s weasyprint-samples/poster/flyer.css ${{env.REPORTS_FOLDER}}/flyer.pdf 36 | - name: Report 37 | run: python -m weasyprint weasyprint-samples/report/report.html ${{env.REPORTS_FOLDER}}/report.pdf 38 | - name: Ticket 39 | run: python -m weasyprint weasyprint-samples/ticket/ticket.html ${{env.REPORTS_FOLDER}}/ticket.pdf 40 | - name: Archive generated PDFs 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: generated-documents 44 | path: ${{env.REPORTS_FOLDER}} 45 | retention-days: 1 46 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: WeasyPrint's tests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | tests: 6 | name: ${{ matrix.os }} - ${{ matrix.python-version }} 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | python-version: ['3.13'] 12 | include: 13 | - os: ubuntu-latest 14 | python-version: '3.9' 15 | - os: ubuntu-latest 16 | python-version: 'pypy-3.10' 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install DejaVu and Ghostscript (Ubuntu) 23 | if: matrix.os == 'ubuntu-latest' 24 | run: sudo apt-get update -y && sudo apt-get install fonts-dejavu ghostscript -y 25 | - name: Install DejaVu, Pango and Ghostscript (MacOS) 26 | if: matrix.os == 'macos-latest' 27 | run: | 28 | brew update 29 | brew install --cask font-dejavu 30 | brew install pango ghostscript 31 | - name: Install DejaVu, Pango and Ghostscript (Windows) 32 | if: matrix.os == 'windows-latest' 33 | run: | 34 | C:\msys64\usr\bin\bash -lc 'pacman -S mingw-w64-x86_64-ttf-dejavu mingw-w64-x86_64-pango mingw-w64-x86_64-ghostscript --noconfirm' 35 | xcopy "C:\msys64\mingw64\share\fonts\TTF" "C:\Users\runneradmin\.fonts" /e /i 36 | echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH 37 | rm C:\msys64\mingw64\bin\python.exe 38 | - name: Upgrade pip and setuptools 39 | run: python -m pip install --upgrade pip setuptools 40 | - name: Install tests’ requirements 41 | run: python -m pip install .[test] pytest-xdist 42 | - name: Launch tests 43 | run: python -m pytest -n auto 44 | env: 45 | DYLD_FALLBACK_LIBRARY_PATH: /opt/homebrew/lib 46 | - name: Check coding style 47 | run: python -m ruff check 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .cache 3 | /.coverage 4 | /build 5 | /dist 6 | /docs/_build 7 | /pytest_cache 8 | /tests/draw/results 9 | /venv 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2011-2021, Simon Sapin and contributors. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | **The Awesome Document Factory** 2 | 3 | WeasyPrint is a smart solution helping web developers to create PDF 4 | documents. It turns simple HTML pages into gorgeous statistical reports, 5 | invoices, tickets… 6 | 7 | From a technical point of view, WeasyPrint is a visual rendering engine for 8 | HTML and CSS that can export to PDF. It aims to support web standards for 9 | printing. WeasyPrint is free software made available under a BSD license. 10 | 11 | It is based on various libraries but *not* on a full rendering engine like 12 | WebKit or Gecko. The CSS layout engine is written in Python, designed for 13 | pagination, and meant to be easy to hack on. 14 | 15 | * Free software: BSD license 16 | * For Python 3.9+, tested on CPython and PyPy 17 | * Documentation: https://doc.courtbouillon.org/weasyprint 18 | * Examples: https://weasyprint.org/#samples 19 | * Changelog: https://github.com/Kozea/WeasyPrint/releases 20 | * Code, issues, tests: https://github.com/Kozea/WeasyPrint 21 | * Code of conduct: https://www.courtbouillon.org/code-of-conduct 22 | * Professional support: https://www.courtbouillon.org 23 | * Donation: https://opencollective.com/courtbouillon 24 | 25 | WeasyPrint has been created and developed by Kozea (https://kozea.fr/). 26 | Professional support, maintenance and community management is provided by 27 | CourtBouillon (https://www.courtbouillon.org/). 28 | 29 | Copyrights are retained by their contributors, no copyright assignment is 30 | required to contribute to WeasyPrint. Unless explicitly stated otherwise, any 31 | contribution intentionally submitted for inclusion is licensed under the BSD 32 | 3-clause license, without any additional terms or conditions. For full 33 | authorship information, see the version control history. 34 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # WeasyPrint documentation build configuration file. 2 | 3 | import weasyprint 4 | 5 | # Add any Sphinx extension module names here, as strings. They can be 6 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 7 | extensions = [ 8 | 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 9 | 'sphinx.ext.autosectionlabel'] 10 | 11 | # Add any paths that contain templates here, relative to this directory. 12 | templates_path = ['_templates'] 13 | 14 | # The suffix of source filenames. 15 | source_suffix = '.rst' 16 | 17 | # The master toctree document. 18 | master_doc = 'index' 19 | 20 | # General information about the project. 21 | project = 'WeasyPrint' 22 | copyright = 'Simon Sapin and contributors' 23 | 24 | # The version info for the project you're documenting, acts as replacement for 25 | # |version| and |release|, also used in various other places throughout the 26 | # built documents. 27 | # 28 | # The full version, including alpha/beta/rc tags. 29 | release = weasyprint.__version__ 30 | 31 | # The short X.Y version. 32 | version = release 33 | 34 | # List of patterns, relative to source directory, that match files and 35 | # directories to ignore when looking for source files. 36 | exclude_patterns = ['_build'] 37 | 38 | # The name of the Pygments (syntax highlighting) style to use. 39 | pygments_style = 'monokai' 40 | 41 | # The theme to use for HTML and HTML Help pages. See the documentation for 42 | # a list of builtin themes. 43 | html_theme = 'furo' 44 | 45 | html_theme_options = { 46 | 'top_of_page_buttons': ['edit'], 47 | 'source_edit_link': 48 | 'https://github.com/Kozea/WeasyPrint/edit/main/docs/{filename}', 49 | } 50 | 51 | # Favicon URL 52 | html_favicon = 'https://www.courtbouillon.org/static/images/favicon.png' 53 | 54 | # Add any paths that contain custom static files (such as style sheets) here, 55 | # relative to this directory. They are copied after the builtin static files, 56 | # so a file named "default.css" will overwrite the builtin "default.css". 57 | html_static_path = [] 58 | 59 | # These paths are either relative to html_static_path 60 | # or fully qualified paths (eg. https://...) 61 | html_css_files = [ 62 | 'https://www.courtbouillon.org/static/docs-furo.css', 63 | ] 64 | 65 | # Output file base name for HTML help builder. 66 | htmlhelp_basename = 'weasyprintdoc' 67 | 68 | # One entry per manual page. List of tuples 69 | # (source start file, name, description, authors, manual section). 70 | man_pages = [ 71 | ('manpage', 'weasyprint', 'The Awesome Document Factory', 72 | ['Simon Sapin and contributors'], 1) 73 | ] 74 | 75 | # Grouping the document tree into Texinfo files. List of tuples 76 | # (source start file, target name, title, author, 77 | # dir menu entry, description, category) 78 | texinfo_documents = [( 79 | 'index', 'WeasyPrint', 'WeasyPrint Documentation', 80 | 'Simon Sapin and contributors', 'WeasyPrint', 81 | 'The Awesome Document Factory', 'Miscellaneous'), 82 | ] 83 | 84 | # Example configuration for intersphinx: refer to the Python standard library. 85 | intersphinx_mapping = { 86 | 'python': ('https://docs.python.org/3/', None), 87 | 'pydyf': ('https://doc.courtbouillon.org/pydyf/stable/', None), 88 | } 89 | -------------------------------------------------------------------------------- /docs/contribute.rst: -------------------------------------------------------------------------------- 1 | Contribute 2 | ========== 3 | 4 | You want to add some code to WeasyPrint, launch its tests or improve its 5 | documentation? Thank you very much! Here are some tips to help you play with 6 | WeasyPrint in good conditions. 7 | 8 | The first step is to clone the repository, create a virtual environment and 9 | install WeasyPrint dependencies: 10 | 11 | .. code-block:: shell 12 | 13 | git clone https://github.com/Kozea/WeasyPrint.git 14 | cd WeasyPrint 15 | python -m venv venv 16 | venv/bin/pip install -e '.[doc,test]' 17 | 18 | You can then launch Python to test your changes: 19 | 20 | .. code-block:: shell 21 | 22 | venv/bin/python 23 | 24 | Running WeasyPrint might look something like this: 25 | 26 | .. code-block:: shell 27 | 28 | venv/bin/python -m weasyprint example.html example.pdf 29 | 30 | 31 | Code & Issues 32 | ------------- 33 | 34 | If you’ve found a bug in WeasyPrint, it’s time to report it, and to fix it if you 35 | can! 36 | 37 | You can report bugs and feature requests on `GitHub`_. If you want to add or 38 | fix some code, please fork the repository and create a pull request, we’ll be 39 | happy to review your work. 40 | 41 | You can find more information about the code architecture in the :ref:`Dive 42 | into the Source` section. 43 | 44 | .. _GitHub: https://github.com/Kozea/WeasyPrint 45 | 46 | 47 | Tests 48 | ----- 49 | 50 | Tests are stored in the ``tests`` folder at the top of the repository. They use 51 | the pytest_ library. 52 | 53 | Tests require Ghostscript_ to be installed and available on the local path. You 54 | should also install all the `DejaVu fonts`_ if you’re on Linux. 55 | 56 | You can launch tests using the following command:: 57 | 58 | venv/bin/python -m pytest 59 | 60 | WeasyPrint also uses ruff_ to check the coding style:: 61 | 62 | venv/bin/python -m ruff check 63 | 64 | .. _pytest: https://docs.pytest.org/ 65 | .. _Ghostscript: https://www.ghostscript.com/ 66 | .. _DejaVu fonts: https://dejavu-fonts.github.io/ 67 | .. _ruff: https://docs.astral.sh/ruff/ 68 | 69 | 70 | Documentation 71 | ------------- 72 | 73 | Documentation is stored in the ``docs`` folder at the top of the repository. It 74 | relies on the `Sphinx`_ library. 75 | 76 | You can build the documentation using the following command:: 77 | 78 | venv/bin/sphinx-build docs docs/_build 79 | 80 | The documentation home page can now be found in the 81 | ``/path/to/weasyprint/docs/_build/index.html`` file. You can open this file in a 82 | browser to see the final rendering. 83 | 84 | .. _Sphinx: https://www.sphinx-doc.org/ 85 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | WeasyPrint 2 | ========== 3 | 4 | .. currentmodule:: weasyprint 5 | 6 | .. include:: ../README.rst 7 | 8 | .. toctree:: 9 | :caption: Documentation 10 | :maxdepth: 2 11 | 12 | first_steps 13 | common_use_cases 14 | api_reference 15 | going_further 16 | 17 | .. toctree:: 18 | :caption: Extra Information 19 | :maxdepth: 2 20 | 21 | changelog 22 | contribute 23 | support 24 | -------------------------------------------------------------------------------- /docs/manpage.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. currentmodule:: weasyprint 4 | 5 | 6 | Description 7 | ----------- 8 | 9 | .. autofunction:: weasyprint.__main__.main(argv=sys.argv) 10 | :noindex: 11 | 12 | 13 | About 14 | ----- 15 | 16 | .. include:: ../README.rst 17 | -------------------------------------------------------------------------------- /docs/support.rst: -------------------------------------------------------------------------------- 1 | Support 2 | ======= 3 | 4 | 5 | Sponsorship 6 | ----------- 7 | 8 | With `donations and sponsorship`, you help make the projects 9 | better. Donations allow the CourtBouillon team to have more time dedicated to 10 | add new features, fix bugs, and improve documentation. 11 | 12 | .. _donations and sponsorship: https://opencollective.com/courtbouillon 13 | 14 | 15 | Professional Support 16 | -------------------- 17 | 18 | You can improve your experience with CourtBouillon’s tools thanks to our 19 | professional support. You want bugs fixed as soon as possible? Your projects 20 | would highly benefit from some new features? You or your team would like to get 21 | new skills with one of the technologies we master? 22 | 23 | Please contact us by mail, by chat, or by tweet to get in touch and find the 24 | best way we can help you. 25 | 26 | .. _mail: mailto:contact@courtbouillon.org 27 | .. _chat: https://gitter.im/CourtBouillon/tinycss2 28 | .. _tweet: https://twitter.com/BouillonCourt 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['flit_core >=3.2,<4'] 3 | build-backend = 'flit_core.buildapi' 4 | 5 | [project] 6 | name = 'weasyprint' 7 | description = 'The Awesome Document Factory' 8 | keywords = ['html', 'css', 'pdf', 'converter'] 9 | authors = [{name = 'Simon Sapin', email = 'simon.sapin@exyr.org'}] 10 | maintainers = [{name = 'CourtBouillon', email = 'contact@courtbouillon.org'}] 11 | requires-python = '>=3.9' 12 | readme = {file = 'README.rst', content-type = 'text/x-rst'} 13 | license = {file = 'LICENSE'} 14 | dependencies = [ 15 | 'pydyf >=0.11.0', 16 | 'cffi >=0.6', 17 | 'tinyhtml5 >=2.0.0b1', 18 | 'tinycss2 >=1.4.0', 19 | 'cssselect2 >=0.8.0', 20 | 'Pyphen >=0.9.1', 21 | 'Pillow >=9.1.0', 22 | 'fonttools[woff] >=4.0.0', 23 | ] 24 | classifiers = [ 25 | 'Development Status :: 5 - Production/Stable', 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: BSD License', 28 | 'Operating System :: OS Independent', 29 | 'Programming Language :: Python', 30 | 'Programming Language :: Python :: 3', 31 | 'Programming Language :: Python :: 3 :: Only', 32 | 'Programming Language :: Python :: 3.9', 33 | 'Programming Language :: Python :: 3.10', 34 | 'Programming Language :: Python :: 3.11', 35 | 'Programming Language :: Python :: 3.12', 36 | 'Programming Language :: Python :: 3.13', 37 | 'Programming Language :: Python :: Implementation :: CPython', 38 | 'Programming Language :: Python :: Implementation :: PyPy', 39 | 'Topic :: Internet :: WWW/HTTP', 40 | 'Topic :: Text Processing :: Markup :: HTML', 41 | 'Topic :: Multimedia :: Graphics :: Graphics Conversion', 42 | 'Topic :: Printing', 43 | ] 44 | dynamic = ['version'] 45 | 46 | [project.urls] 47 | Homepage = 'https://weasyprint.org/' 48 | Documentation = 'https://doc.courtbouillon.org/weasyprint/' 49 | Code = 'https://github.com/Kozea/WeasyPrint' 50 | Issues = 'https://github.com/Kozea/WeasyPrint/issues' 51 | Changelog = 'https://github.com/Kozea/WeasyPrint/releases' 52 | Donation = 'https://opencollective.com/courtbouillon' 53 | 54 | [project.optional-dependencies] 55 | doc = ['sphinx', 'furo'] 56 | test = ['pytest', 'ruff'] 57 | 58 | [project.scripts] 59 | weasyprint = 'weasyprint.__main__:main' 60 | 61 | [tool.flit.sdist] 62 | exclude = ['.*'] 63 | 64 | [tool.coverage.run] 65 | branch = true 66 | include = ['tests/*', 'weasyprint/*'] 67 | 68 | [tool.coverage.report] 69 | exclude_lines = ['pragma: no cover', 'def __repr__', 'raise NotImplementedError'] 70 | 71 | [tool.ruff.lint] 72 | select = ['E', 'W', 'F', 'I', 'N', 'RUF'] 73 | ignore = ['RUF001', 'RUF002', 'RUF003'] 74 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """The Weasyprint test suite.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Configuration for WeasyPrint tests. 2 | 3 | This module adds a PNG export based on Ghostscript. 4 | 5 | Note that Ghostscript is released under AGPL. 6 | 7 | """ 8 | 9 | import io 10 | import os 11 | import shutil 12 | from subprocess import PIPE, run 13 | from tempfile import NamedTemporaryFile 14 | 15 | import pytest 16 | from PIL import Image 17 | 18 | from weasyprint import HTML 19 | from weasyprint.document import Document 20 | 21 | from . import draw 22 | 23 | MAGIC_NUMBER = b'\x89\x50\x4e\x47\x0d\x0a\x1a\x0a' 24 | 25 | 26 | def document_write_png(document, target=None, resolution=96, antialiasing=1, 27 | zoom=4/30, split_images=False): 28 | # Use temporary files because gs on Windows doesn’t accept binary on stdin 29 | with NamedTemporaryFile(delete=False) as pdf: 30 | document.write_pdf(pdf, zoom=zoom) 31 | command = ( 32 | 'gs', '-q', '-sDEVICE=png16m', f'-dTextAlphaBits={antialiasing}', 33 | f'-dGraphicsAlphaBits={antialiasing}', '-dBATCH', '-dNOPAUSE', 34 | '-dPDFSTOPONERROR', f'-r{resolution / zoom}', '-sOutputFile=-', 35 | pdf.name) 36 | pngs = run(command, stdout=PIPE).stdout 37 | os.remove(pdf.name) 38 | 39 | error = pngs.split(MAGIC_NUMBER)[0].decode().strip() or 'no output' 40 | assert pngs.startswith(MAGIC_NUMBER), f'Ghostscript error: {error}' 41 | 42 | if split_images: 43 | assert target is None 44 | 45 | # TODO: use a different way to find PNG files in stream 46 | magic_numbers = pngs.count(MAGIC_NUMBER) 47 | if magic_numbers == 1: 48 | if target is None: 49 | return [pngs] if split_images else pngs 50 | png = io.BytesIO(pngs) 51 | else: 52 | images = [MAGIC_NUMBER + png for png in pngs[8:].split(MAGIC_NUMBER)] 53 | if split_images: 54 | return images 55 | images = [Image.open(io.BytesIO(image)) for image in images] 56 | width = max(image.width for image in images) 57 | height = sum(image.height for image in images) 58 | output_image = Image.new('RGBA', (width, height)) 59 | top = 0 60 | for image in images: 61 | output_image.paste(image, (int((width - image.width) / 2), top)) 62 | top += image.height 63 | png = io.BytesIO() 64 | output_image.save(png, format='png') 65 | 66 | png.seek(0) 67 | 68 | if target is None: 69 | return png.read() 70 | 71 | if hasattr(target, 'write'): 72 | shutil.copyfileobj(png, target) 73 | else: 74 | with open(target, 'wb') as fd: 75 | shutil.copyfileobj(png, fd) 76 | 77 | 78 | def html_write_png(document, target=None, font_config=None, counter_style=None, 79 | resolution=96, **options): 80 | document = document.render(font_config, counter_style, **options) 81 | return document.write_png(target, resolution) 82 | 83 | 84 | Document.write_png = document_write_png 85 | HTML.write_png = html_write_png 86 | 87 | 88 | def test_filename(filename): 89 | return ''.join( 90 | character if character.isalnum() else '_' 91 | for character in filename[5:50]).rstrip('_') 92 | 93 | 94 | @pytest.fixture 95 | def assert_pixels(request, *args, **kwargs): 96 | return lambda *args, **kwargs: draw.assert_pixels( 97 | test_filename(request.node.name), *args, **kwargs) 98 | 99 | 100 | @pytest.fixture 101 | def assert_same_renderings(request, *args, **kwargs): 102 | return lambda *args, **kwargs: draw.assert_same_renderings( 103 | test_filename(request.node.name), *args, **kwargs) 104 | 105 | 106 | @pytest.fixture 107 | def assert_different_renderings(request, *args, **kwargs): 108 | return lambda *args, **kwargs: draw.assert_different_renderings( 109 | test_filename(request.node.name), *args, **kwargs) 110 | 111 | 112 | @pytest.fixture 113 | def assert_pixels_equal(request, *args, **kwargs): 114 | return lambda *args, **kwargs: draw.assert_pixels_equal( 115 | test_filename(request.node.name), *args, **kwargs) 116 | -------------------------------------------------------------------------------- /tests/css/__init__.py: -------------------------------------------------------------------------------- 1 | """Test CSS features.""" 2 | -------------------------------------------------------------------------------- /tests/css/test_common.py: -------------------------------------------------------------------------------- 1 | """Test the CSS parsing, cascade, inherited and computed values.""" 2 | 3 | import pytest 4 | 5 | from weasyprint import CSS, default_url_fetcher 6 | from weasyprint.css import find_stylesheets, get_all_computed_styles 7 | from weasyprint.urls import path2url 8 | 9 | from ..testing_utils import ( # isort:skip 10 | BASE_URL, FakeHTML, assert_no_logs, capture_logs, resource_path) 11 | 12 | 13 | @assert_no_logs 14 | def test_find_stylesheets(): 15 | html = FakeHTML(resource_path('doc1.html')) 16 | 17 | sheets = list(find_stylesheets( 18 | html.wrapper_element, 'print', default_url_fetcher, html.base_url, 19 | font_config=None, counter_style=None, page_rules=None)) 20 | assert len(sheets) == 2 21 | # Also test that stylesheets are in tree order. 22 | sheet_names = [ 23 | sheet.base_url.rsplit('/', 1)[-1].rsplit(',', 1)[-1] 24 | for sheet in sheets] 25 | assert sheet_names == ['a%7Bcolor%3AcurrentColor%7D', 'doc1.html'] 26 | 27 | rules = [] 28 | for sheet in sheets: 29 | for sheet_rules in sheet.matcher.lower_local_name_selectors.values(): 30 | for rule in sheet_rules: 31 | rules.append(rule) 32 | for rule in sheet.page_rules: 33 | rules.append(rule) 34 | assert len(rules) == 10 35 | 36 | # TODO: Test that the values are correct too. 37 | 38 | 39 | @assert_no_logs 40 | def test_annotate_document(): 41 | document = FakeHTML(resource_path('doc1.html')) 42 | document._ua_stylesheets = ( 43 | lambda *_, **__: [CSS(resource_path('mini_ua.css'))]) 44 | style_for = get_all_computed_styles( 45 | document, user_stylesheets=[CSS(resource_path('user.css'))]) 46 | 47 | # Element objects behave as lists of their children. 48 | _head, body = document.etree_element 49 | h1, p, ul, div = body 50 | li_0, _li_1 = ul 51 | a, = li_0 52 | span1, = div 53 | span2, = span1 54 | 55 | h1 = style_for(h1) 56 | p = style_for(p) 57 | ul = style_for(ul) 58 | li_0 = style_for(li_0) 59 | div = style_for(div) 60 | after = style_for(a, 'after') 61 | a = style_for(a) 62 | span1 = style_for(span1) 63 | span2 = style_for(span2) 64 | 65 | assert h1['background_image'] == ( 66 | ('url', path2url(resource_path('logo_small.png'))),) 67 | 68 | assert h1['font_weight'] == 700 69 | assert h1['font_size'] == 40 # 2em 70 | 71 | # x-large * initial = 3/2 * 16 = 24 72 | assert p['margin_top'] == (24, 'px') 73 | assert p['margin_right'] == (0, 'px') 74 | assert p['margin_bottom'] == (24, 'px') 75 | assert p['margin_left'] == (0, 'px') 76 | assert p['background_color'] == 'currentcolor' 77 | 78 | # 2em * 1.25ex = 2 * 20 * 1.25 * 0.8 = 40 79 | # 2.5ex * 1.25ex = 2.5 * 0.8 * 20 * 1.25 * 0.8 = 40 80 | # TODO: ex unit doesn't work with @font-face fonts, see computed_values.py 81 | # assert ul['margin_top'] == (40, 'px') 82 | # assert ul['margin_right'] == (40, 'px') 83 | # assert ul['margin_bottom'] == (40, 'px') 84 | # assert ul['margin_left'] == (40, 'px') 85 | 86 | assert ul['font_weight'] == 400 87 | # thick = 5px, 0.25 inches = 96*.25 = 24px 88 | assert ul['border_top_width'] == 0 89 | assert ul['border_right_width'] == 5 90 | assert ul['border_bottom_width'] == 0 91 | assert ul['border_left_width'] == 24 92 | 93 | assert li_0['font_weight'] == 700 94 | assert li_0['font_size'] == 8 # 6pt 95 | assert li_0['margin_top'] == (16, 'px') # 2em 96 | assert li_0['margin_right'] == (0, 'px') 97 | assert li_0['margin_bottom'] == (16, 'px') 98 | assert li_0['margin_left'] == (32, 'px') # 4em 99 | 100 | assert a['text_decoration_line'] == {'underline'} 101 | assert a['font_weight'] == 900 102 | assert a['font_size'] == 24 # 300% of 8px 103 | assert a['padding_top'] == (1, 'px') 104 | assert a['padding_right'] == (2, 'px') 105 | assert a['padding_bottom'] == (3, 'px') 106 | assert a['padding_left'] == (4, 'px') 107 | assert a['border_top_width'] == 42 108 | assert a['border_bottom_width'] == 42 109 | 110 | assert a['color'] == (1, 0, 0, 1) 111 | assert a['border_top_color'] == 'currentcolor' 112 | 113 | assert div['font_size'] == 40 # 2 * 20px 114 | assert span1['width'] == (160, 'px') # 10 * 16px (root default is 16px) 115 | assert span1['height'] == (400, 'px') # 10 * (2 * 20px) 116 | assert span2['font_size'] == 32 117 | 118 | # The href attr should be as in the source, not made absolute. 119 | assert after['content'] == ( 120 | ('string', ' ['), ('string', 'home.html'), ('string', ']')) 121 | assert after['background_color'] == (1, 0, 0, 1) 122 | assert after['border_top_width'] == 42 123 | assert after['border_bottom_width'] == 3 124 | 125 | # TODO: much more tests here: test that origin and selector precedence 126 | # and inheritance are correct… 127 | 128 | 129 | @assert_no_logs 130 | def test_important(): 131 | document = FakeHTML(string=''' 132 | 145 |

146 |

147 |

148 |

149 |

150 |

151 | ''') 152 | page, = document.render(stylesheets=[CSS(string=''' 153 | body p:nth-child(1) { color: red } 154 | p:nth-child(2) { color: lime !important } 155 | 156 | p:nth-child(4) { color: lime !important } 157 | body p:nth-child(4) { color: red } 158 | ''')]).pages 159 | html, = page._page_box.children 160 | body, = html.children 161 | for paragraph in body.children: 162 | assert paragraph.style['color'] == (0, 1, 0, 1) # lime (light green) 163 | 164 | 165 | @assert_no_logs 166 | @pytest.mark.parametrize('value, width', ( 167 | ('96px', 96), 168 | ('1in', 96), 169 | ('72pt', 96), 170 | ('6pc', 96), 171 | ('2.54cm', 96), 172 | ('25.4mm', 96), 173 | ('101.6q', 96), 174 | ('1.1em', 11), 175 | ('1.1rem', 17.6), 176 | # TODO: ch and ex units don't work with font-face, see computed_values.py. 177 | # ('1.1ch', 11), 178 | # ('1.5ex', 12), 179 | )) 180 | def test_units(value, width): 181 | document = FakeHTML(base_url=BASE_URL, string=''' 182 | 183 |

''' % value) 184 | page, = document.render().pages 185 | html, = page._page_box.children 186 | body, = html.children 187 | p, = body.children 188 | assert p.margin_left == width 189 | 190 | 191 | @pytest.mark.parametrize('media, width, warning', ( 192 | ('@media screen { @page { size: 10px } }', 20, False), 193 | ('@media print { @page { size: 10px } }', 10, False), 194 | ('@media ("unknown content") { @page { size: 10px } }', 20, True), 195 | )) 196 | def test_media_queries(media, width, warning): 197 | document = FakeHTML(string='

ab') 198 | with capture_logs() as logs: 199 | page, = document.render( 200 | stylesheets=[CSS(string='@page{size:20px}%s' % media)]).pages 201 | html, = page._page_box.children 202 | assert html.width == width 203 | assert (logs if warning else not logs) 204 | -------------------------------------------------------------------------------- /tests/css/test_errors.py: -------------------------------------------------------------------------------- 1 | """Test CSS errors and warnings.""" 2 | 3 | import pytest 4 | 5 | from weasyprint import CSS 6 | 7 | from ..testing_utils import assert_no_logs, capture_logs, render_pages 8 | 9 | 10 | @assert_no_logs 11 | @pytest.mark.parametrize('source, messages', ( 12 | (':lipsum { margin: 2cm', ['WARNING: Invalid or unsupported selector']), 13 | ('::lipsum { margin: 2cm', ['WARNING: Invalid or unsupported selector']), 14 | ('foo { margin-color: red', ['WARNING: Ignored', 'unknown property']), 15 | ('foo { margin-top: red', ['WARNING: Ignored', 'invalid value']), 16 | ('@import "relative-uri.css"', 17 | ['ERROR: Relative URI reference without a base URI']), 18 | ('@import "invalid-protocol://absolute-URL"', 19 | ['ERROR: Failed to load stylesheet at']), 20 | ('test', ['WARNING: Parse error']), 21 | ('@test', ['WARNING: Unknown empty rule']), 22 | ('@test {}', ['WARNING: Unknown rule']), 23 | )) 24 | def test_warnings(source, messages): 25 | with capture_logs() as logs: 26 | CSS(string=source) 27 | assert len(logs) == 1, source 28 | for message in messages: 29 | assert message in logs[0] 30 | 31 | 32 | @assert_no_logs 33 | def test_warnings_stylesheet(): 34 | with capture_logs() as logs: 35 | render_pages('') 36 | assert len(logs) == 1 37 | assert 'ERROR: Failed to load stylesheet at' in logs[0] 38 | 39 | 40 | @assert_no_logs 41 | @pytest.mark.parametrize('style', ( 42 | ' 57 |

58 | ''') 59 | html, = page.children 60 | body, = html.children 61 | div, = body.children 62 | section, = div.children 63 | paragraph, = section.children 64 | assert html.style['font_size'] == 10 65 | assert div.style['font_size'] == 20 66 | # 140% of 10px = 14px is inherited from html 67 | assert strut_layout(div.style)[0] == 14 68 | assert div.style['vertical_align'] == 7 # 50 % of 14px 69 | 70 | assert paragraph.style['font_size'] == 20 71 | # 1.4 is inherited from p, 1.4 * 20px on em = 28px 72 | assert strut_layout(paragraph.style)[0] == 28 73 | assert paragraph.style['vertical_align'] == 14 # 50% of 28px 74 | -------------------------------------------------------------------------------- /tests/css/test_nesting.py: -------------------------------------------------------------------------------- 1 | """Test CSS nesting.""" 2 | 3 | import pytest 4 | 5 | from ..testing_utils import assert_no_logs, render_pages 6 | 7 | 8 | @assert_no_logs 9 | @pytest.mark.parametrize('style', ( 10 | 'div { p { width: 10px } }', 11 | 'p { div & { width: 10px } }', 12 | 'p { width: 20px; div & { width: 10px } }', 13 | 'p { div & { width: 10px } width: 20px }', 14 | 'div { & { & { p { & { width: 10px } } } } }', 15 | '@media print { div { p { width: 10px } } }', 16 | 'div { em, p { width: 10px } }', 17 | 'p { a, div & { width: 10px } }', 18 | )) 19 | def test_nesting_block(style): 20 | page, = render_pages(''' 21 | 22 |

23 | ''' % style) 24 | html, = page.children 25 | body, = html.children 26 | div, p = body.children 27 | div_p, = div.children 28 | assert div_p.width == 10 29 | assert p.width != 10 30 | -------------------------------------------------------------------------------- /tests/css/test_pages.py: -------------------------------------------------------------------------------- 1 | """Test CSS pages.""" 2 | 3 | import pytest 4 | import tinycss2 5 | 6 | from weasyprint import CSS 7 | from weasyprint.css import get_all_computed_styles, parse_page_selectors 8 | from weasyprint.layout.page import PageType, set_page_type_computed_styles 9 | 10 | from ..testing_utils import FakeHTML, assert_no_logs, render_pages, resource_path 11 | 12 | 13 | @assert_no_logs 14 | def test_page(): 15 | document = FakeHTML(resource_path('doc1.html')) 16 | style_for = get_all_computed_styles( 17 | document, user_stylesheets=[CSS(string=''' 18 | html { color: red } 19 | @page { margin: 10px } 20 | @page :right { 21 | color: blue; 22 | margin-bottom: 12pt; 23 | font-size: 20px; 24 | @top-left { width: 10em } 25 | @top-right { font-size: 10px} 26 | } 27 | ''')]) 28 | 29 | page_type = PageType(side='left', blank=False, name='', index=0, groups=()) 30 | set_page_type_computed_styles(page_type, document, style_for) 31 | style = style_for(page_type) 32 | assert style['margin_top'] == (5, 'px') 33 | assert style['margin_left'] == (10, 'px') 34 | assert style['margin_bottom'] == (10, 'px') 35 | assert style['color'] == (1, 0, 0, 1) # red, inherited from html 36 | 37 | page_type = PageType(side='right', blank=False, name='', index=0, groups=()) 38 | set_page_type_computed_styles(page_type, document, style_for) 39 | style = style_for(page_type) 40 | assert style['margin_top'] == (5, 'px') 41 | assert style['margin_left'] == (10, 'px') 42 | assert style['margin_bottom'] == (16, 'px') 43 | assert style['color'] == (0, 0, 1, 1) # blue 44 | 45 | page_type = PageType(side='left', blank=False, name='', index=1, groups=()) 46 | set_page_type_computed_styles(page_type, document, style_for) 47 | style = style_for(page_type) 48 | assert style['margin_top'] == (10, 'px') 49 | assert style['margin_left'] == (10, 'px') 50 | assert style['margin_bottom'] == (10, 'px') 51 | assert style['color'] == (1, 0, 0, 1) # red, inherited from html 52 | 53 | page_type = PageType(side='right', blank=False, name='', index=1, groups=()) 54 | set_page_type_computed_styles(page_type, document, style_for) 55 | style = style_for(page_type) 56 | assert style['margin_top'] == (10, 'px') 57 | assert style['margin_left'] == (10, 'px') 58 | assert style['margin_bottom'] == (16, 'px') 59 | assert style['color'] == (0, 0, 1, 1) # blue 60 | 61 | page_type = PageType(side='left', blank=False, name='', index=0, groups=()) 62 | set_page_type_computed_styles(page_type, document, style_for) 63 | style = style_for(page_type, '@top-left') 64 | assert style is None 65 | 66 | page_type = PageType(side='right', blank=False, name='', index=0, groups=()) 67 | set_page_type_computed_styles(page_type, document, style_for) 68 | style = style_for(page_type, '@top-left') 69 | assert style['font_size'] == 20 # inherited from @page 70 | assert style['width'] == (200, 'px') 71 | 72 | page_type = PageType(side='right', blank=False, name='', index=0, groups=()) 73 | set_page_type_computed_styles(page_type, document, style_for) 74 | style = style_for(page_type, '@top-right') 75 | assert style['font_size'] == 10 76 | 77 | 78 | @assert_no_logs 79 | @pytest.mark.parametrize('style, selectors', ( 80 | ('@page {}', [{ 81 | 'side': None, 'blank': None, 'first': None, 'name': None, 82 | 'index': None, 'specificity': [0, 0, 0]}]), 83 | ('@page :left {}', [{ 84 | 'side': 'left', 'blank': None, 'first': None, 'name': None, 85 | 'index': None, 'specificity': [0, 0, 1]}]), 86 | ('@page:first:left {}', [{ 87 | 'side': 'left', 'blank': None, 'first': True, 'name': None, 88 | 'index': None, 'specificity': [0, 1, 1]}]), 89 | ('@page pagename {}', [{ 90 | 'side': None, 'blank': None, 'first': None, 'name': 'pagename', 91 | 'index': None, 'specificity': [1, 0, 0]}]), 92 | ('@page pagename:first:right:blank {}', [{ 93 | 'side': 'right', 'blank': True, 'first': True, 'name': 'pagename', 94 | 'index': None, 'specificity': [1, 2, 1]}]), 95 | ('@page pagename, :first {}', [ 96 | {'side': None, 'blank': None, 'first': None, 'name': 'pagename', 97 | 'index': None, 'specificity': [1, 0, 0]}, 98 | {'side': None, 'blank': None, 'first': True, 'name': None, 99 | 'index': None, 'specificity': [0, 1, 0]}]), 100 | ('@page :first:first {}', [{ 101 | 'side': None, 'blank': None, 'first': True, 'name': None, 102 | 'index': None, 'specificity': [0, 2, 0]}]), 103 | ('@page :left:left {}', [{ 104 | 'side': 'left', 'blank': None, 'first': None, 'name': None, 105 | 'index': None, 'specificity': [0, 0, 2]}]), 106 | ('@page :nth(2) {}', [{ 107 | 'side': None, 'blank': None, 'first': None, 'name': None, 108 | 'index': (0, 2, None), 'specificity': [0, 1, 0]}]), 109 | ('@page :nth(2n + 4) {}', [{ 110 | 'side': None, 'blank': None, 'first': None, 'name': None, 111 | 'index': (2, 4, None), 'specificity': [0, 1, 0]}]), 112 | ('@page :nth(3n) {}', [{ 113 | 'side': None, 'blank': None, 'first': None, 'name': None, 114 | 'index': (3, 0, None), 'specificity': [0, 1, 0]}]), 115 | ('@page :nth( n+2 ) {}', [{ 116 | 'side': None, 'blank': None, 'first': None, 'name': None, 117 | 'index': (1, 2, None), 'specificity': [0, 1, 0]}]), 118 | ('@page :nth(even) {}', [{ 119 | 'side': None, 'blank': None, 'first': None, 'name': None, 120 | 'index': (2, 0, None), 'specificity': [0, 1, 0]}]), 121 | ('@page pagename:nth(2) {}', [{ 122 | 'side': None, 'blank': None, 'first': None, 'name': 'pagename', 123 | 'index': (0, 2, None), 'specificity': [1, 1, 0]}]), 124 | ('@page page page {}', None), 125 | ('@page :left page {}', None), 126 | ('@page :left, {}', None), 127 | ('@page , {}', None), 128 | ('@page :left, test, {}', None), 129 | ('@page :wrong {}', None), 130 | ('@page :left:wrong {}', None), 131 | ('@page :left:right {}', None), 132 | )) 133 | def test_page_selectors(style, selectors): 134 | at_rule, = tinycss2.parse_stylesheet(style) 135 | assert parse_page_selectors(at_rule) == selectors 136 | 137 | 138 | @assert_no_logs 139 | def test_named_pages(): 140 | page, = render_pages(''' 141 | 146 |

a

147 | ''') 148 | html, = page.children 149 | body, = html.children 150 | div, = body.children 151 | p, = div.children 152 | span, = p.children 153 | assert html.style['page'] == '' 154 | assert body.style['page'] == '' 155 | assert div.style['page'] == '' 156 | assert p.style['page'] == 'NARRow' 157 | assert span.style['page'] == 'NARRow' 158 | -------------------------------------------------------------------------------- /tests/css/test_target.py: -------------------------------------------------------------------------------- 1 | """Test the CSS cross references using target-*() functions.""" 2 | 3 | from ..testing_utils import assert_no_logs, render_pages 4 | 5 | 6 | @assert_no_logs 7 | def test_target_counter(): 8 | page, = render_pages(''' 9 | 17 | 18 |
19 |
20 |
21 |
22 | ''') 23 | html, = page.children 24 | body, = html.children 25 | div1, div2, div3, div4 = body.children 26 | before = div1.children[0].children[0].children[0] 27 | assert before.text == '4' 28 | before = div2.children[0].children[0].children[0] 29 | assert before.text == 'test 1' 30 | before = div3.children[0].children[0].children[0] 31 | assert before.text == 'iv' 32 | before = div4.children[0].children[0].children[0] 33 | assert before.text == '3' 34 | 35 | 36 | @assert_no_logs 37 | def test_target_counter_attr(): 38 | page, = render_pages(''' 39 | 47 | 48 |
49 |
50 |
51 |
52 | ''') 53 | html, = page.children 54 | body, = html.children 55 | div1, div2, div3, div4 = body.children 56 | before = div1.children[0].children[0].children[0] 57 | assert before.text == '4' 58 | before = div2.children[0].children[0].children[0] 59 | assert before.text == '1' 60 | before = div3.children[0].children[0].children[0] 61 | assert before.text == '2' 62 | before = div4.children[0].children[0].children[0] 63 | assert before.text == 'c' 64 | 65 | 66 | @assert_no_logs 67 | def test_target_counters(): 68 | page, = render_pages(''' 69 | 79 | 80 |
81 |
82 |
83 |
84 |
85 |
86 | ''') 87 | html, = page.children 88 | body, = html.children 89 | div1, div2, div3, div4 = body.children 90 | before = div1.children[1].children[0].children[0].children[0] 91 | assert before.text == '4.2' 92 | before = div2.children[0].children[0].children[0].children[0] 93 | assert before.text == '3' 94 | before = div3.children[0].children[0].children[0] 95 | assert before.text == 'b.a' 96 | before = div4.children[1].children[0].children[0].children[0] 97 | assert before.text == '12' 98 | 99 | 100 | @assert_no_logs 101 | def test_target_text(): 102 | page, = render_pages(''' 103 | 113 | 114 | 115 |
1 Chapter 1
116 | 117 |
2 Chapter 2
118 |
3 Chapter 3
119 | 120 |
4 Chapter 4
121 | 122 | ''') 123 | html, = page.children 124 | body, = html.children 125 | a1, div1, a2, div2, div3, a3, div4, a4 = body.children 126 | before = a1.children[0].children[0].children[0] 127 | assert before.text == 'test 4 Chapter 4' 128 | before = a2.children[0].children[0].children[0] 129 | assert before.text == 'wow' 130 | assert len(a3.children[0].children[0].children) == 0 131 | before = a4.children[0].children[0].children[0] 132 | assert before.text == '1' 133 | 134 | 135 | @assert_no_logs 136 | def test_target_float(): 137 | page, = render_pages(''' 138 | 144 |
link
145 |

abc

146 | ''') 147 | html, = page.children 148 | body, = html.children 149 | div, h1 = body.children 150 | line, = div.children 151 | inline, = line.children 152 | text_box, after = inline.children 153 | assert text_box.text == 'link' 154 | assert after.children[0].children[0].text == '1' 155 | 156 | 157 | @assert_no_logs 158 | def test_target_absolute(): 159 | page, = render_pages(''' 160 | 168 |
link
169 |

abc

170 | ''') 171 | html, = page.children 172 | body, = html.children 173 | div, h1 = body.children 174 | line, = div.children 175 | inline, = line.children 176 | text_box, after = inline.children 177 | assert text_box.text == 'link' 178 | assert after.children[0].text == '1' 179 | 180 | 181 | @assert_no_logs 182 | def test_target_absolute_non_root(): 183 | page, = render_pages(''' 184 | 195 |
link
196 |

abc

197 | ''') 198 | html, = page.children 199 | body, = html.children 200 | section, h1 = body.children 201 | div, = section.children 202 | line, = div.children 203 | inline, = line.children 204 | text_box, after = inline.children 205 | assert text_box.text == 'link' 206 | assert after.children[0].text == '1' 207 | -------------------------------------------------------------------------------- /tests/css/test_ua.py: -------------------------------------------------------------------------------- 1 | """Test the user-agent stylesheet.""" 2 | 3 | import pytest 4 | 5 | from weasyprint.html import CSS, HTML5_PH, HTML5_UA, HTML5_UA_FORM 6 | 7 | from ..testing_utils import assert_no_logs 8 | 9 | 10 | @assert_no_logs 11 | @pytest.mark.parametrize('css', (HTML5_UA, HTML5_UA_FORM, HTML5_PH)) 12 | def test_ua_stylesheets(css): 13 | CSS(string=css) 14 | -------------------------------------------------------------------------------- /tests/draw/__init__.py: -------------------------------------------------------------------------------- 1 | """Test the final, drawn results and compare PNG images pixel per pixel.""" 2 | 3 | import io 4 | from itertools import zip_longest 5 | from pathlib import Path 6 | 7 | from PIL import Image 8 | 9 | from ..testing_utils import FakeHTML, resource_path 10 | 11 | # NOTE: "r" is not half red on purpose. In the pixel strings it has 12 | # better contrast with "B" than does "R". eg. "rBBBrrBrB" vs "RBBBRRBRB". 13 | PIXELS_BY_CHAR = { 14 | '_': (255, 255, 255), # white 15 | 'R': (255, 0, 0), # red 16 | 'B': (0, 0, 255), # blue 17 | 'G': (0, 255, 0), # lime green 18 | 'V': (191, 0, 64), # average of 1*B and 3*R 19 | 'S': (255, 63, 63), # R above R above _ 20 | 'C': (0, 255, 255), # cyan 21 | 'M': (255, 0, 255), # magenta 22 | 'Y': (255, 255, 0), # yellow 23 | 'K': (0, 0, 0), # black 24 | 'r': (255, 0, 0), # red 25 | 'g': (0, 128, 0), # half green 26 | 'b': (0, 0, 128), # half blue 27 | 'v': (128, 0, 128), # average of B and R 28 | 's': (255, 127, 127), # R above _ 29 | 't': (127, 255, 127), # G above _ 30 | 'u': (128, 0, 127), # r above B above _ 31 | 'h': (64, 0, 64), # half average of B and R 32 | 'a': (0, 0, 254), # R in lossy JPG 33 | 'p': (192, 0, 63), # R above R above B above _ 34 | 'z': None, 35 | } 36 | 37 | 38 | def parse_pixels(pixels): 39 | lines = (line.split('#')[0].strip() for line in pixels.splitlines()) 40 | lines = tuple(line for line in lines if line) 41 | widths = {len(line) for line in lines} 42 | assert len(widths) == 1, 'All lines of pixels must have the same width' 43 | width = widths.pop() 44 | height = len(lines) 45 | pixels = tuple(PIXELS_BY_CHAR[char] for line in lines for char in line) 46 | return width, height, pixels 47 | 48 | 49 | def assert_pixels(name, expected_pixels, html): 50 | """Helper testing the size of the image and the pixels values.""" 51 | expected_width, expected_height, expected_pixels = parse_pixels( 52 | expected_pixels) 53 | width, height, pixels = html_to_pixels(html) 54 | assert (expected_width, expected_height) == (width, height), ( 55 | 'Images do not have the same sizes:\n' 56 | f'- expected: {expected_width} × {expected_height}\n' 57 | f'- result: {width} × {height}') 58 | assert_pixels_equal(name, width, height, pixels, expected_pixels) 59 | 60 | 61 | def assert_same_renderings(name, *documents, tolerance=0): 62 | """Render HTML documents to PNG and check that they're the same.""" 63 | pixels_list = [] 64 | 65 | for html in documents: 66 | width, height, pixels = html_to_pixels(html) 67 | pixels_list.append(pixels) 68 | 69 | reference = pixels_list[0] 70 | for i, pixels in enumerate(pixels_list[1:], start=1): 71 | assert_pixels_equal( 72 | f'{name}_{i}', width, height, pixels, reference, tolerance) 73 | 74 | 75 | def assert_different_renderings(name, *documents): 76 | """Render HTML documents to PNG and check that they’re different.""" 77 | pixels_list = [] 78 | 79 | for html in documents: 80 | width, height, pixels = html_to_pixels(html) 81 | pixels_list.append(pixels) 82 | 83 | for i, pixels_1 in enumerate(pixels_list, start=1): 84 | for j, pixels_2 in enumerate(pixels_list[i:], start=i+1): 85 | if tuple(pixels_1) == tuple(pixels_2): # pragma: no cover 86 | name_1, name_2 = f'{name}_{i}', f'{name}_{j}' 87 | write_png(name_1, pixels_1, width, height) 88 | assert False, f'{name_1} and {name_2} are the same' 89 | 90 | 91 | def assert_pixels_equal(name, width, height, raw, expected_raw, tolerance=0): 92 | """Take 2 matrices of pixels and assert that they are the same.""" 93 | if raw != expected_raw: # pragma: no cover 94 | pixels = zip_longest(raw, expected_raw, fillvalue=(-1, -1, -1)) 95 | for i, (value, expected) in enumerate(pixels): 96 | if expected is None: 97 | continue 98 | if any(abs(value - expected) > tolerance 99 | for value, expected in zip(value, expected)): 100 | actual_height = len(raw) // width 101 | write_png(name, raw, width, actual_height) 102 | expected_raw = [ 103 | pixel or (255, 255, 255) for pixel in expected_raw] 104 | write_png(f'{name}.expected', expected_raw, width, height) 105 | x = i % width 106 | y = i // width 107 | assert 0, ( 108 | f'Pixel ({x}, {y}) in {name}: ' 109 | f'expected rgba{expected}, got rgba{value}') 110 | 111 | 112 | def write_png(name, pixels, width, height): # pragma: no cover 113 | """Take a pixel matrix and write a PNG file.""" 114 | directory = Path(__file__).parent / 'results' 115 | directory.mkdir(exist_ok=True) 116 | image = Image.new('RGB', (width, height)) 117 | image.putdata(pixels) 118 | image.save(directory / f'{name}.png') 119 | 120 | 121 | def html_to_pixels(html): 122 | """Render an HTML document to PNG, checks its size and return pixel data. 123 | 124 | Also return the document to aid debugging. 125 | 126 | """ 127 | document = FakeHTML(string=html, base_url=resource_path('')) 128 | return document_to_pixels(document) 129 | 130 | 131 | def document_to_pixels(document): 132 | """Render an HTML document to PNG, check its size and return pixel data.""" 133 | image = Image.open(io.BytesIO(document.write_png())) 134 | return image.width, image.height, image.getdata() 135 | -------------------------------------------------------------------------------- /tests/draw/svg/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/draw/svg/__init__.py -------------------------------------------------------------------------------- /tests/draw/svg/test_clip.py: -------------------------------------------------------------------------------- 1 | """Test clip-path attribute.""" 2 | 3 | import pytest 4 | 5 | from ...testing_utils import assert_no_logs 6 | 7 | 8 | @assert_no_logs 9 | def test_clip_path(assert_pixels): 10 | assert_pixels(''' 11 | _________ 12 | _________ 13 | __RRRRR__ 14 | __RBBBR__ 15 | __RBBBR__ 16 | __RBBBR__ 17 | __RRRRR__ 18 | _________ 19 | _________ 20 | ''', ''' 21 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | ''') 35 | 36 | 37 | @assert_no_logs 38 | def test_clip_path_on_group(assert_pixels): 39 | assert_pixels(''' 40 | _________ 41 | _________ 42 | __BBBB___ 43 | __BRRRR__ 44 | __BRRRR__ 45 | __BRRRR__ 46 | ___RRRR__ 47 | _________ 48 | _________ 49 | ''', ''' 50 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ''') 66 | 67 | 68 | @pytest.mark.xfail 69 | @assert_no_logs 70 | def test_clip_path_group_on_group(assert_pixels): 71 | assert_pixels(9, 9, ''' 72 | _________ 73 | _________ 74 | __BB_____ 75 | __BR_____ 76 | _________ 77 | _____RR__ 78 | _____RR__ 79 | _________ 80 | _________ 81 | ''', ''' 82 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | ''') 99 | -------------------------------------------------------------------------------- /tests/draw/svg/test_defs.py: -------------------------------------------------------------------------------- 1 | """Test how SVG definitions are drawn.""" 2 | 3 | from base64 import b64encode 4 | 5 | from ...testing_utils import assert_no_logs 6 | 7 | SVG = ''' 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ''' 20 | 21 | RESULT = ''' 22 | RRRRR_____ 23 | RRRRR_____ 24 | __________ 25 | ___BB_____ 26 | ___BB_____ 27 | __________ 28 | _____RRRRR 29 | _____RRRRR 30 | __________ 31 | __________ 32 | ''' 33 | 34 | 35 | @assert_no_logs 36 | def test_use(assert_pixels): 37 | assert_pixels(RESULT, ''' 38 | 42 | ''' + SVG) 43 | 44 | 45 | @assert_no_logs 46 | def test_use_base64(assert_pixels): 47 | base64_svg = b64encode(SVG.encode()).decode() 48 | assert_pixels(RESULT, ''' 49 | 53 | ') 54 | -------------------------------------------------------------------------------- /tests/draw/svg/test_opacity.py: -------------------------------------------------------------------------------- 1 | """Test how opacity is handled for SVG.""" 2 | 3 | import pytest 4 | 5 | from ...testing_utils import assert_no_logs 6 | 7 | opacity_source = ''' 8 | 12 | %s''' 13 | 14 | 15 | @assert_no_logs 16 | def test_opacity(assert_same_renderings): 17 | assert_same_renderings( 18 | opacity_source % ''' 19 | 21 | ''', 22 | opacity_source % ''' 23 | 25 | ''', 26 | ) 27 | 28 | 29 | @assert_no_logs 30 | def test_fill_opacity(assert_same_renderings): 31 | assert_same_renderings( 32 | opacity_source % ''' 33 | 35 | 37 | ''', 38 | opacity_source % ''' 39 | 41 | ''', 42 | ) 43 | 44 | 45 | @pytest.mark.xfail 46 | @assert_no_logs 47 | def test_stroke_opacity(assert_same_renderings): 48 | # TODO: This test (and the other ones) fail because of a difference between 49 | # the PDF and the SVG specifications: transparent borders have to be drawn 50 | # on top of the shape filling in SVG but not in PDF. See: 51 | # - PDF-1.7 11.7.4.4 Note 2 52 | # - https://www.w3.org/TR/SVG2/render.html#PaintingShapesAndText 53 | assert_same_renderings( 54 | ''' 55 | 57 | 59 | ''', 60 | opacity_source % ''' 61 | 63 | ''', 64 | ) 65 | 66 | 67 | @pytest.mark.xfail 68 | @assert_no_logs 69 | def test_stroke_fill_opacity(assert_same_renderings): 70 | assert_same_renderings( 71 | opacity_source % ''' 72 | 74 | 76 | ''', 77 | opacity_source % ''' 78 | 81 | ''', 82 | ) 83 | 84 | 85 | @assert_no_logs 86 | def test_pattern_gradient_stroke_fill_opacity(assert_same_renderings): 87 | assert_same_renderings( 88 | opacity_source % ''' 89 | 90 | 92 | 93 | 94 | 95 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 106 | 108 | ''', 109 | opacity_source % ''' 110 | 111 | 113 | 114 | 115 | 116 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 128 | ''', 129 | tolerance=1, 130 | ) 131 | 132 | 133 | @assert_no_logs 134 | def test_translate_opacity(assert_same_renderings): 135 | # Regression test for #1976. 136 | assert_same_renderings( 137 | opacity_source % ''' 138 | 140 | ''', 141 | opacity_source % ''' 142 | 144 | ''', 145 | ) 146 | 147 | 148 | @assert_no_logs 149 | def test_translate_use_opacity(assert_same_renderings): 150 | # Regression test for #1976. 151 | assert_same_renderings( 152 | opacity_source % ''' 153 | 154 | 155 | 156 | 157 | ''', 158 | opacity_source % ''' 159 | 160 | ''', 161 | ) 162 | -------------------------------------------------------------------------------- /tests/draw/svg/test_units.py: -------------------------------------------------------------------------------- 1 | """Test SVG units.""" 2 | 3 | from ...testing_utils import assert_no_logs 4 | 5 | 6 | @assert_no_logs 7 | def test_units_px(assert_pixels): 8 | assert_pixels(''' 9 | _________ 10 | _RRRRRRR_ 11 | _RRRRRRR_ 12 | _RR___RR_ 13 | _RR___RR_ 14 | _RR___RR_ 15 | _RRRRRRR_ 16 | _RRRRRRR_ 17 | _________ 18 | ''', ''' 19 | 23 | 24 | 26 | 27 | ''') 28 | 29 | 30 | @assert_no_logs 31 | def test_units_em(assert_pixels): 32 | assert_pixels(''' 33 | _________ 34 | _RRRRRRR_ 35 | _RRRRRRR_ 36 | _RR___RR_ 37 | _RR___RR_ 38 | _RR___RR_ 39 | _RRRRRRR_ 40 | _RRRRRRR_ 41 | _________ 42 | ''', ''' 43 | 47 | 49 | 51 | 52 | ''') 53 | 54 | 55 | @assert_no_logs 56 | def test_units_ex(assert_pixels): 57 | assert_pixels(''' 58 | _________ 59 | _RRRRRRR_ 60 | _RRRRRRR_ 61 | _RR___RR_ 62 | _RR___RR_ 63 | _RR___RR_ 64 | _RRRRRRR_ 65 | _RRRRRRR_ 66 | _________ 67 | ''', ''' 68 | 72 | 74 | 76 | 77 | ''') 78 | 79 | 80 | @assert_no_logs 81 | def test_units_unknown(assert_pixels): 82 | assert_pixels(''' 83 | _RRRRRRR_ 84 | _RR___RR_ 85 | _RR___RR_ 86 | _RR___RR_ 87 | _RRRRRRR_ 88 | _RRRRRRR_ 89 | _________ 90 | _________ 91 | _________ 92 | ''', ''' 93 | 97 | 98 | 100 | 101 | ''') 102 | -------------------------------------------------------------------------------- /tests/draw/svg/test_visibility.py: -------------------------------------------------------------------------------- 1 | """Test how the visibility is controlled with "visibility" and "display".""" 2 | 3 | from ...testing_utils import assert_no_logs 4 | 5 | 6 | @assert_no_logs 7 | def test_visibility_visible(assert_pixels): 8 | assert_pixels(''' 9 | _________ 10 | _________ 11 | __RRRRR__ 12 | __RRRRR__ 13 | __RRRRR__ 14 | __RRRRR__ 15 | __RRRRR__ 16 | _________ 17 | _________ 18 | ''', ''' 19 | 23 | 24 | 26 | 27 | ''') 28 | 29 | 30 | @assert_no_logs 31 | def test_visibility_hidden(assert_pixels): 32 | assert_pixels(''' 33 | _________ 34 | _________ 35 | _________ 36 | _________ 37 | _________ 38 | _________ 39 | _________ 40 | _________ 41 | _________ 42 | ''', ''' 43 | 47 | 48 | 50 | 51 | ''') 52 | 53 | 54 | @assert_no_logs 55 | def test_visibility_inherit_hidden(assert_pixels): 56 | assert_pixels(''' 57 | _________ 58 | _________ 59 | _________ 60 | _________ 61 | _________ 62 | _________ 63 | _________ 64 | _________ 65 | _________ 66 | ''', ''' 67 | 71 | 72 | 73 | 74 | 75 | 76 | ''') 77 | 78 | 79 | @assert_no_logs 80 | def test_visibility_inherit_visible(assert_pixels): 81 | assert_pixels(''' 82 | _________ 83 | _________ 84 | __RRRRR__ 85 | __RRRRR__ 86 | __RRRRR__ 87 | __RRRRR__ 88 | __RRRRR__ 89 | _________ 90 | _________ 91 | ''', ''' 92 | 96 | 97 | 98 | 100 | 101 | 102 | ''') 103 | 104 | 105 | @assert_no_logs 106 | def test_display_inline(assert_pixels): 107 | assert_pixels(''' 108 | _________ 109 | _________ 110 | __RRRRR__ 111 | __RRRRR__ 112 | __RRRRR__ 113 | __RRRRR__ 114 | __RRRRR__ 115 | _________ 116 | _________ 117 | ''', ''' 118 | 122 | 123 | 125 | 126 | ''') 127 | 128 | 129 | @assert_no_logs 130 | def test_display_none(assert_pixels): 131 | assert_pixels(''' 132 | _________ 133 | _________ 134 | _________ 135 | _________ 136 | _________ 137 | _________ 138 | _________ 139 | _________ 140 | _________ 141 | ''', ''' 142 | 146 | 147 | 149 | 150 | ''') 151 | 152 | 153 | @assert_no_logs 154 | def test_display_inherit_none(assert_pixels): 155 | assert_pixels(''' 156 | _________ 157 | _________ 158 | _________ 159 | _________ 160 | _________ 161 | _________ 162 | _________ 163 | _________ 164 | _________ 165 | ''', ''' 166 | 170 | 171 | 172 | 173 | 174 | 175 | ''') 176 | 177 | 178 | @assert_no_logs 179 | def test_display_inherit_inline(assert_pixels): 180 | assert_pixels(''' 181 | _________ 182 | _________ 183 | _________ 184 | _________ 185 | _________ 186 | _________ 187 | _________ 188 | _________ 189 | _________ 190 | ''', ''' 191 | 195 | 196 | 197 | 199 | 200 | 201 | ''') 202 | -------------------------------------------------------------------------------- /tests/draw/test_before_after.py: -------------------------------------------------------------------------------- 1 | """Test how before and after pseudo elements are drawn.""" 2 | 3 | from ..testing_utils import assert_no_logs 4 | 5 | 6 | @assert_no_logs 7 | def test_before_after_1(assert_same_renderings): 8 | assert_same_renderings( 9 | ''' 10 | 15 |

some content

16 | ''', 17 | ''' 18 | 22 |

[some url] some content

23 | ''', tolerance=10) 24 | 25 | 26 | @assert_no_logs 27 | def test_before_after_2(assert_same_renderings): 28 | assert_same_renderings( 29 | ''' 30 | 36 |

Lorem ipsum dolor sit amet

37 | ''', 38 | ''' 39 | 44 |

« Lorem ipsum 45 | “ dolor ” 46 | sit amet »

47 | ''', tolerance=10) 48 | 49 | 50 | @assert_no_logs 51 | def test_before_after_3(assert_same_renderings): 52 | assert_same_renderings( 53 | ''' 54 | 59 |

c

60 | ''', 61 | ''' 62 | 66 |

aMissing imagebc

67 | ''', tolerance=10) 68 | -------------------------------------------------------------------------------- /tests/draw/test_column.py: -------------------------------------------------------------------------------- 1 | """Test how columns are drawn.""" 2 | 3 | from ..testing_utils import assert_no_logs 4 | 5 | 6 | @assert_no_logs 7 | def test_column_rule_1(assert_pixels): 8 | assert_pixels(''' 9 | a_r_a 10 | a_r_a 11 | _____ 12 | ''', ''' 13 | 21 |
22 | 23 | 24 | 25 | 26 |
''') 27 | 28 | 29 | @assert_no_logs 30 | def test_column_rule_2(assert_pixels): 31 | assert_pixels(''' 32 | a_r_a 33 | a___a 34 | a_r_a 35 | ''', ''' 36 | 44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 |
''') 52 | 53 | 54 | @assert_no_logs 55 | def test_column_rule_span(assert_pixels): 56 | assert_pixels(''' 57 | ___________ 58 | ___________ 59 | ___________ 60 | ___a_______ 61 | ___a_r_a___ 62 | ___a_r_a___ 63 | ___________ 64 | ___________ 65 | ___________ 66 | ''', ''' 67 | 74 |
75 |
76 | 77 |
78 | 79 | 80 | 81 | 82 |
''') 83 | 84 | 85 | @assert_no_logs 86 | def test_column_rule_normal(assert_pixels): 87 | # Regression test for #2217. 88 | assert_pixels(''' 89 | a___a 90 | a___a 91 | _____ 92 | ''', ''' 93 | 99 |
100 | 101 | 102 | 103 | 104 |
''') 105 | -------------------------------------------------------------------------------- /tests/draw/test_current_color.py: -------------------------------------------------------------------------------- 1 | """Test the currentColor value.""" 2 | 3 | import pytest 4 | 5 | from ..testing_utils import assert_no_logs 6 | 7 | 8 | @assert_no_logs 9 | def test_current_color_1(assert_pixels): 10 | assert_pixels('GG\nGG', ''' 11 | 17 | ''') 18 | 19 | 20 | @assert_no_logs 21 | def test_current_color_2(assert_pixels): 22 | assert_pixels('GG\nGG', ''' 23 | 29 | ''') 30 | 31 | 32 | @assert_no_logs 33 | def test_current_color_3(assert_pixels): 34 | assert_pixels('GG\nGG', ''' 35 | 41 | ''') 42 | 43 | 44 | @assert_no_logs 45 | def test_current_color_4(assert_pixels): 46 | assert_pixels('GG\nGG', ''' 47 | 54 |
''') 55 | 56 | 57 | @assert_no_logs 58 | def test_current_color_svg_1(assert_pixels): 59 | assert_pixels('KK\nKK', ''' 60 | 64 | 66 | 67 | ''') 68 | 69 | 70 | @pytest.mark.xfail 71 | @assert_no_logs 72 | def test_current_color_svg_2(assert_pixels): 73 | assert_pixels('GG\nGG', ''' 74 | 79 | 81 | 82 | ''') 83 | 84 | 85 | @assert_no_logs 86 | def test_current_color_variable(assert_pixels): 87 | # Regression test for #2010. 88 | assert_pixels('GG\nGG', ''' 89 | 94 |
aa''') 95 | 96 | 97 | @assert_no_logs 98 | def test_current_color_variable_border(assert_pixels): 99 | # Regression test for #2010. 100 | assert_pixels('GG\nGG', ''' 101 | 106 |
''') 107 | -------------------------------------------------------------------------------- /tests/draw/test_list.py: -------------------------------------------------------------------------------- 1 | """Test how lists are drawn.""" 2 | 3 | import pytest 4 | 5 | from ..testing_utils import SANS_FONTS, assert_no_logs 6 | 7 | 8 | @assert_no_logs 9 | @pytest.mark.parametrize('position, pixels', ( 10 | ('outside', 11 | # ++++++++++++++ ++++
  • horizontal margins: 7px 2px 12 | # ######
  • width: 12 - 7 - 2 = 3px 13 | # -- list marker margin: 0.5em = 2px 14 | # ******** list marker image is 4px wide 15 | ''' 16 | ____________ 17 | ____________ 18 | ___rBBB_____ 19 | ___BBBB_____ 20 | ___BBBB_____ 21 | ___BBBB_____ 22 | ____________ 23 | ____________ 24 | ____________ 25 | ____________ 26 | '''), 27 | ('inside', 28 | # ++++++++++++++ ++++
  • horizontal margins: 7px 2px 29 | # ######
  • width: 12 - 7 - 2 = 3px 30 | # ******** list marker image is 4px wide: overflow 31 | ''' 32 | ____________ 33 | ____________ 34 | _______rBBB_ 35 | _______BBBB_ 36 | _______BBBB_ 37 | _______BBBB_ 38 | ____________ 39 | ____________ 40 | ____________ 41 | ____________ 42 | ''') 43 | )) 44 | def test_list_style_image(assert_pixels, position, pixels): 45 | assert_pixels(pixels, ''' 46 | 52 |
    ''' % (SANS_FONTS, position)) 53 | 54 | 55 | @assert_no_logs 56 | def test_list_style_image_none(assert_pixels): 57 | assert_pixels(''' 58 | __________ 59 | __________ 60 | __________ 61 | __________ 62 | __________ 63 | __________ 64 | __________ 65 | __________ 66 | __________ 67 | __________ 68 | ''', ''' 69 | 74 |
    • ''' % (SANS_FONTS,)) 75 | -------------------------------------------------------------------------------- /tests/draw/test_opacity.py: -------------------------------------------------------------------------------- 1 | """Test opacity.""" 2 | 3 | from ..testing_utils import assert_no_logs 4 | 5 | opacity_source = ''' 6 | 10 | %s''' 11 | 12 | 13 | @assert_no_logs 14 | def test_opacity_zero(assert_same_renderings): 15 | assert_same_renderings( 16 | opacity_source % '
      ', 17 | opacity_source % '
      ', 18 | opacity_source % '
      ', 19 | ) 20 | 21 | 22 | @assert_no_logs 23 | def test_opacity_normal_range(assert_same_renderings): 24 | assert_same_renderings( 25 | opacity_source % '
      ', 26 | opacity_source % '
      ', 27 | opacity_source % '
      ', 28 | opacity_source % '
      ', 29 | ) 30 | 31 | 32 | @assert_no_logs 33 | def test_opacity_nested(assert_same_renderings): 34 | assert_same_renderings( 35 | opacity_source % '
      ', 36 | opacity_source % '
      ', 37 | opacity_source % ''' 38 |
      39 |
      40 |
      41 | ''', # 0.9 * 0.666666 == 0.6 42 | ) 43 | 44 | 45 | @assert_no_logs 46 | def test_opacity_percent_clamp_down(assert_same_renderings): 47 | assert_same_renderings( 48 | opacity_source % '
      ', 49 | opacity_source % '
      ', 50 | opacity_source % '
      ', 51 | ) 52 | 53 | 54 | @assert_no_logs 55 | def test_opacity_percent_clamp_up(assert_same_renderings): 56 | assert_same_renderings( 57 | opacity_source % '
      ', 58 | opacity_source % '
      ', 59 | opacity_source % '
      ', 60 | ) 61 | 62 | 63 | @assert_no_logs 64 | def test_opacity_black(assert_same_renderings): 65 | # Regression test for #2302. 66 | assert_same_renderings( 67 | opacity_source % 'ab', 68 | opacity_source % 'ab', 69 | ) 70 | -------------------------------------------------------------------------------- /tests/draw/test_overflow.py: -------------------------------------------------------------------------------- 1 | """Test overflow and clipping.""" 2 | 3 | import pytest 4 | 5 | from ..testing_utils import assert_no_logs 6 | 7 | 8 | @assert_no_logs 9 | def test_overflow_1(assert_pixels): 10 | # See test_images 11 | assert_pixels(''' 12 | ________ 13 | ________ 14 | __rBBB__ 15 | __BBBB__ 16 | ________ 17 | ________ 18 | ________ 19 | ________ 20 | ''', ''' 21 | 26 |
      ''') 27 | 28 | 29 | @assert_no_logs 30 | def test_overflow_2(assert_pixels): 31 | # is only 1px high, but its overflow is propageted to the viewport 32 | # ie. the padding edge of the page box. 33 | assert_pixels(''' 34 | ________ 35 | ________ 36 | __rBBB__ 37 | __BBBB__ 38 | __BBBB__ 39 | ________ 40 | ________ 41 | ________ 42 | ''', ''' 43 | 47 |
      ''') 48 | 49 | 50 | @assert_no_logs 51 | def test_overflow_3(assert_pixels): 52 | # Assert that the border is not clipped by overflow: hidden 53 | assert_pixels(''' 54 | ________ 55 | ________ 56 | __BBBB__ 57 | __B__B__ 58 | __B__B__ 59 | __BBBB__ 60 | ________ 61 | ________ 62 | ''', ''' 63 | 68 |
      ''') 69 | 70 | 71 | @assert_no_logs 72 | def test_overflow_4(assert_pixels): 73 | # Assert that the page margins aren't clipped by body's overflow 74 | assert_pixels(''' 75 | rr______ 76 | rr______ 77 | __BBBB__ 78 | __BBBB__ 79 | __BBBB__ 80 | __BBBB__ 81 | ________ 82 | ________ 83 | ''', ''' 84 | 92 | ''') 93 | 94 | 95 | @assert_no_logs 96 | def test_overflow_5(assert_pixels): 97 | # Regression test for #2026. 98 | assert_pixels(''' 99 | BBBBBB__ 100 | BBBBBB__ 101 | BBBB____ 102 | BBBB____ 103 | BBBB____ 104 | ________ 105 | ________ 106 | ________ 107 | ''', ''' 108 | 113 |

      abc

      114 |

      ab
      ab
      ab
      ab

      115 | ''') 116 | 117 | 118 | @assert_no_logs 119 | @pytest.mark.parametrize('number, css, pixels', ( 120 | (1, '5px, 5px, 9px, auto', ''' 121 | ______________ 122 | ______________ 123 | ______________ 124 | ______________ 125 | ______________ 126 | ______________ 127 | ______rBBBrBg_ 128 | ______BBBBBBg_ 129 | ______BBBBBBg_ 130 | ______BBBBBBg_ 131 | ______________ 132 | ______________ 133 | ______________ 134 | ______________ 135 | ______________ 136 | ______________ 137 | '''), 138 | (2, '5px, 5px, auto, 10px', ''' 139 | ______________ 140 | ______________ 141 | ______________ 142 | ______________ 143 | ______________ 144 | ______________ 145 | ______rBBBr___ 146 | ______BBBBB___ 147 | ______BBBBB___ 148 | ______BBBBB___ 149 | ______rBBBr___ 150 | ______BBBBB___ 151 | ______ggggg___ 152 | ______________ 153 | ______________ 154 | ______________ 155 | '''), 156 | (3, '5px, auto, 9px, 10px', ''' 157 | ______________ 158 | ______________ 159 | ______________ 160 | ______________ 161 | ______________ 162 | ______________ 163 | _grBBBrBBBr___ 164 | _gBBBBBBBBB___ 165 | _gBBBBBBBBB___ 166 | _gBBBBBBBBB___ 167 | ______________ 168 | ______________ 169 | ______________ 170 | ______________ 171 | ______________ 172 | ______________ 173 | '''), 174 | (4, 'auto, 5px, 9px, 10px', ''' 175 | ______________ 176 | ______ggggg___ 177 | ______rBBBr___ 178 | ______BBBBB___ 179 | ______BBBBB___ 180 | ______BBBBB___ 181 | ______rBBBr___ 182 | ______BBBBB___ 183 | ______BBBBB___ 184 | ______BBBBB___ 185 | ______________ 186 | ______________ 187 | ______________ 188 | ______________ 189 | ______________ 190 | ______________ 191 | '''), 192 | )) 193 | def test_clip(assert_pixels, number, css, pixels): 194 | assert_pixels(pixels, ''' 195 | 203 |
      ''' % css) 204 | -------------------------------------------------------------------------------- /tests/draw/test_page.py: -------------------------------------------------------------------------------- 1 | """Test how pages are drawn.""" 2 | 3 | import pytest 4 | 5 | from ..testing_utils import assert_no_logs 6 | 7 | 8 | @assert_no_logs 9 | @pytest.mark.parametrize('rule, pixels', ( 10 | ('2n', '_R_R_R_R_R'), 11 | ('even', '_R_R_R_R_R'), 12 | ('2n+1', 'R_R_R_R_R_'), 13 | ('odd', 'R_R_R_R_R_'), 14 | ('2n+3', '__R_R_R_R_'), 15 | ('n', 'RRRRRRRRRR'), 16 | ('n-1', 'RRRRRRRRRR'), 17 | ('-n+3', 'RRR_______'), 18 | ('-2n+3', 'R_R_______'), 19 | ('-n-3', '__________'), 20 | ('3', '__R_______'), 21 | ('0n+0', '__________'), 22 | )) 23 | def test_nth_page(assert_pixels, rule, pixels): 24 | assert_pixels('\n'.join(pixels), ''' 25 | 30 | ''' % rule + 10 * '

      ') 31 | -------------------------------------------------------------------------------- /tests/draw/test_visibility.py: -------------------------------------------------------------------------------- 1 | """Test visibility.""" 2 | 3 | from ..testing_utils import assert_no_logs 4 | 5 | visibility_source = ''' 6 | 12 |
      13 | 14 | 15 |
      ''' 16 | 17 | 18 | @assert_no_logs 19 | def test_visibility_1(assert_pixels): 20 | assert_pixels(''' 21 | ____________ 22 | _rBBB_rBBB__ 23 | _BBBB_BBBB__ 24 | _BBBB_BBBB__ 25 | _BBBB_BBBB__ 26 | ____________ 27 | ____________ 28 | ''', visibility_source % '') 29 | 30 | 31 | @assert_no_logs 32 | def test_visibility_2(assert_pixels): 33 | assert_pixels(''' 34 | ____________ 35 | ____________ 36 | ____________ 37 | ____________ 38 | ____________ 39 | ____________ 40 | ____________ 41 | ''', visibility_source % 'div { visibility: hidden }') 42 | 43 | 44 | @assert_no_logs 45 | def test_visibility_3(assert_pixels): 46 | assert_pixels(''' 47 | ____________ 48 | ______rBBB__ 49 | ______BBBB__ 50 | ______BBBB__ 51 | ______BBBB__ 52 | ____________ 53 | ____________ 54 | ''', visibility_source % 'div { visibility: hidden } ' 55 | 'span { visibility: visible }') 56 | 57 | 58 | @assert_no_logs 59 | def test_visibility_4(assert_pixels): 60 | assert_pixels(''' 61 | ____________ 62 | _rBBB_rBBB__ 63 | _BBBB_BBBB__ 64 | _BBBB_BBBB__ 65 | _BBBB_BBBB__ 66 | ____________ 67 | ____________ 68 | ''', visibility_source % '@page { visibility: hidden; background: red }') 69 | -------------------------------------------------------------------------------- /tests/draw/test_whitespace.py: -------------------------------------------------------------------------------- 1 | """Test how white spaces collapse.""" 2 | 3 | from ..testing_utils import assert_no_logs 4 | 5 | 6 | @assert_no_logs 7 | def test_whitespace_inline(assert_pixels): 8 | assert_pixels(''' 9 | RRRR__RRRR____ 10 | RRRR__RRRR____ 11 | ______________ 12 | ______________ 13 | ''', ''' 14 | 23 | aa aa 24 | ''') 25 | 26 | 27 | @assert_no_logs 28 | def test_whitespace_nested_inline(assert_pixels): 29 | assert_pixels(''' 30 | RRRR__RRRR____ 31 | RRRR__RRRR____ 32 | ______________ 33 | ______________ 34 | ''', ''' 35 | 44 | aa aa 45 | ''') 46 | 47 | 48 | @assert_no_logs 49 | def test_whitespace_inline_space_between(assert_pixels): 50 | assert_pixels(''' 51 | RRRR__RRRR____ 52 | RRRR__RRRR____ 53 | ______________ 54 | ______________ 55 | ''', ''' 56 | 65 | aa aa 66 | ''') 67 | 68 | 69 | @assert_no_logs 70 | def test_whitespace_float_between(assert_pixels): 71 | assert_pixels(''' 72 | RRRR__RRRR__BB 73 | RRRR__RRRR__BB 74 | ______________ 75 | ______________ 76 | ''', ''' 77 | 87 | aa
      a
      aa 88 | ''') 89 | 90 | 91 | @assert_no_logs 92 | def test_whitespace_in_float(assert_pixels): 93 | assert_pixels(''' 94 | RRRRRRRR____BB 95 | RRRRRRRR____BB 96 | ______________ 97 | ______________ 98 | ''', ''' 99 | 112 | aa
      a
      aa 113 | ''') 114 | 115 | 116 | @assert_no_logs 117 | def test_whitespace_absolute_between(assert_pixels): 118 | assert_pixels(''' 119 | RRRR__RRRR__BB 120 | RRRR__RRRR__BB 121 | ______________ 122 | ______________ 123 | ''', ''' 124 | 139 | aa
      a
      aa 140 | ''') 141 | 142 | 143 | @assert_no_logs 144 | def test_whitespace_in_absolute(assert_pixels): 145 | assert_pixels(''' 146 | RRRRRRRR____BB 147 | RRRRRRRR____BB 148 | ______________ 149 | ______________ 150 | ''', ''' 151 | 166 | aa
      a
      aa 167 | ''') 168 | 169 | 170 | @assert_no_logs 171 | def test_whitespace_running_between(assert_pixels): 172 | assert_pixels(''' 173 | RRRR__RRRR____ 174 | RRRR__RRRR____ 175 | ______BB______ 176 | ______BB______ 177 | ''', ''' 178 | 198 | aa
      a
      aa 199 | ''') 200 | 201 | 202 | @assert_no_logs 203 | def test_whitespace_in_running(assert_pixels): 204 | assert_pixels(''' 205 | RRRRRRRR______ 206 | RRRRRRRR______ 207 | ______BB______ 208 | ______BB______ 209 | ''', ''' 210 | 230 | aa
      a
      aa 231 | ''') 232 | -------------------------------------------------------------------------------- /tests/layout/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for layout. 2 | 3 | Includes positioning and dimensioning of boxes, line breaks, page breaks. 4 | 5 | """ 6 | -------------------------------------------------------------------------------- /tests/layout/test_inline_block.py: -------------------------------------------------------------------------------- 1 | """Tests for inline blocks layout.""" 2 | 3 | from ..testing_utils import assert_no_logs, render_pages 4 | 5 | 6 | @assert_no_logs 7 | def test_inline_block_sizes(): 8 | page, = render_pages(''' 9 | 14 |
      15 |
      a
      16 |
      17 |
      19 |
      20 | Ipsum dolor sit amet, 21 | consectetur adipiscing elit. 22 | Sed sollicitudin nibh 23 | et turpis molestie tristique. 24 |
      25 |
      28 |
      29 |
      30 |
      31 |
      32 |
      33 |
      34 |
      foo
      35 |
      Supercalifragilisticexpialidocious
      ''') 37 | html, = page.children 38 | assert html.element_tag == 'html' 39 | body, = html.children 40 | assert body.element_tag == 'body' 41 | assert body.width == 200 42 | 43 | line_1, line_2, line_3, line_4 = body.children 44 | 45 | # First line: 46 | # White space in-between divs ends up preserved in TextBoxes 47 | div_1, _, div_2, _, div_3, _, div_4, _ = line_1.children 48 | 49 | # First div, one ignored space collapsing with next space 50 | assert div_1.element_tag == 'div' 51 | assert div_1.width == 0 52 | 53 | # Second div, one letter 54 | assert div_2.element_tag == 'div' 55 | assert 0 < div_2.width < 20 56 | 57 | # Third div, empty with margin 58 | assert div_3.element_tag == 'div' 59 | assert div_3.width == 0 60 | assert div_3.margin_width() == 20 61 | assert div_3.height == 100 62 | 63 | # Fourth div, empty with margin and padding 64 | assert div_4.element_tag == 'div' 65 | assert div_4.width == 0 66 | assert div_4.margin_width() == 30 67 | 68 | # Second line: 69 | div_5, _ = line_2.children 70 | 71 | # Fifth div, long text, full-width div 72 | assert div_5.element_tag == 'div' 73 | assert len(div_5.children) > 1 74 | assert div_5.width == 200 75 | 76 | # Third line: 77 | div_6, _, div_7, _ = line_3.children 78 | 79 | # Sixth div, empty div with fixed width and height 80 | assert div_6.element_tag == 'div' 81 | assert div_6.width == 100 82 | assert div_6.margin_width() == 120 83 | assert div_6.height == 100 84 | assert div_6.margin_height() == 140 85 | 86 | # Seventh div 87 | assert div_7.element_tag == 'div' 88 | assert div_7.width == 20 89 | child_line, = div_7.children 90 | # Spaces have font-size: 0, they get removed 91 | child_div_1, child_div_2 = child_line.children 92 | assert child_div_1.element_tag == 'div' 93 | assert child_div_1.width == 10 94 | assert child_div_2.element_tag == 'div' 95 | assert child_div_2.width == 2 96 | grandchild, = child_div_2.children 97 | assert grandchild.element_tag == 'div' 98 | assert grandchild.width == 10 99 | 100 | div_8, _, div_9 = line_4.children 101 | assert div_8.width == 150 102 | assert div_9.width == 10 103 | 104 | 105 | @assert_no_logs 106 | def test_inline_block_with_margin(): 107 | # Regression test for #1235. 108 | page_1, = render_pages(''' 109 | 113 | a b c d e f g h i j k l''') 114 | html, = page_1.children 115 | body, = html.children 116 | line_1, = body.children 117 | span, = line_1.children 118 | assert span.width == 40 # 100 - 2 * 30 119 | -------------------------------------------------------------------------------- /tests/layout/test_list.py: -------------------------------------------------------------------------------- 1 | """Tests for lists layout.""" 2 | 3 | import pytest 4 | 5 | from ..testing_utils import assert_no_logs, render_pages 6 | 7 | 8 | @assert_no_logs 9 | @pytest.mark.parametrize('inside', ('inside', '',)) 10 | @pytest.mark.parametrize('style, character', ( 11 | ('circle', '◦ '), 12 | ('disc', '• '), 13 | ('square', '▪ '), 14 | )) 15 | def test_lists_style(inside, style, character): 16 | page, = render_pages(''' 17 | 21 |
        22 |
      • abc
      • 23 |
      24 | ''' % (inside, style)) 25 | html, = page.children 26 | body, = html.children 27 | unordered_list, = body.children 28 | list_item, = unordered_list.children 29 | if inside: 30 | line, = list_item.children 31 | marker, content = line.children 32 | marker_text, = marker.children 33 | else: 34 | marker, line_container, = list_item.children 35 | assert marker.position_x == list_item.position_x - marker.width 36 | assert marker.position_y == list_item.position_y 37 | line, = line_container.children 38 | content, = line.children 39 | marker_line, = marker.children 40 | marker_text, = marker_line.children 41 | assert marker_text.text == character 42 | assert content.text == 'abc' 43 | 44 | 45 | def test_lists_empty_item(): 46 | # Regression test for #873. 47 | page, = render_pages(''' 48 |
        49 |
      • a
      • 50 |
      • 51 |
      • a
      • 52 |
      53 | ''') 54 | html, = page.children 55 | body, = html.children 56 | unordered_list, = body.children 57 | li1, li2, li3 = unordered_list.children 58 | assert li1.position_y != li2.position_y != li3.position_y 59 | 60 | 61 | @pytest.mark.xfail 62 | def test_lists_whitespace_item(): 63 | # Regression test for #873. 64 | page, = render_pages(''' 65 |
        66 |
      • a
      • 67 |
      • 68 |
      • a
      • 69 |
      70 | ''') 71 | html, = page.children 72 | body, = html.children 73 | unordered_list, = body.children 74 | li1, li2, li3 = unordered_list.children 75 | assert li1.position_y != li2.position_y != li3.position_y 76 | 77 | 78 | def test_lists_page_break(): 79 | # Regression test for #945. 80 | page1, page2 = render_pages(''' 81 | 85 |
        86 |
      • a
      • 87 |
      • a
      • 88 |
      • a
      • 89 |
      • a
      • 90 |
      91 | ''') 92 | html, = page1.children 93 | body, = html.children 94 | ul, = body.children 95 | assert len(ul.children) == 3 96 | for li in ul.children: 97 | assert len(li.children) == 2 98 | 99 | html, = page2.children 100 | body, = html.children 101 | ul, = body.children 102 | assert len(ul.children) == 1 103 | for li in ul.children: 104 | assert len(li.children) == 2 105 | 106 | 107 | def test_lists_page_break_margin(): 108 | # Regression test for #1058. 109 | page1, page2 = render_pages(''' 110 | 115 |
        116 |
      • a

      • 117 |
      • a

      • 118 |
      • a

      • 119 |
      • a

      • 120 |
      121 | ''') 122 | for page in (page1, page2): 123 | html, = page.children 124 | body, = html.children 125 | ul, = body.children 126 | assert len(ul.children) == 2 127 | for li in ul.children: 128 | assert len(li.children) == 2 129 | assert ( 130 | li.children[0].position_y == 131 | li.children[1].children[0].position_y) 132 | -------------------------------------------------------------------------------- /tests/layout/test_preferred.py: -------------------------------------------------------------------------------- 1 | """Tests for shrink-to-fit algorithm.""" 2 | 3 | import pytest 4 | 5 | from ..testing_utils import assert_no_logs, render_pages 6 | 7 | 8 | @assert_no_logs 9 | @pytest.mark.parametrize('margin_left', range(1, 10)) 10 | @pytest.mark.parametrize('font_size', range(1, 10)) 11 | def test_shrink_to_fit_floating_point_error_1(margin_left, font_size): 12 | # See bugs #325 and #288, see commit fac5ee9. 13 | page, = render_pages(''' 14 | 19 |

      this parrot is dead

      20 | ''' % (margin_left, font_size)) 21 | html, = page.children 22 | body, = html.children 23 | p, = body.children 24 | assert len(p.children) == 1 25 | 26 | 27 | @assert_no_logs 28 | @pytest.mark.parametrize('font_size', (1, 5, 10, 50, 100, 1000, 10000)) 29 | def test_shrink_to_fit_floating_point_error_2(font_size): 30 | letters = 1 31 | while True: 32 | page, = render_pages(''' 33 | 37 |

      mmm %s a

      38 | ''' % (font_size, font_size, font_size, 'i' * letters)) 39 | html, = page.children 40 | body, = html.children 41 | p, = body.children 42 | assert len(p.children) in (1, 2) 43 | assert len(p.children[0].children) == 2 44 | text = p.children[0].children[1].children[0].text 45 | assert text 46 | if text.endswith('i'): 47 | letters = 1 48 | break 49 | else: 50 | letters += 1 51 | 52 | 53 | @assert_no_logs 54 | def test_preferred_inline_zero_width_inline_block(): 55 | page, = render_pages(''' 56 | 59 |
      a
      60 | ''') 61 | html, = page.children 62 | body, = html.children 63 | div, = body.children 64 | assert div.width == 4 65 | assert div.height == 2 66 | 67 | 68 | @assert_no_logs 69 | def test_preferred_inline_nested_trailing_spaces(): 70 | page, = render_pages(''' 71 | 74 |
      a
      75 | ''') 76 | html, = page.children 77 | body, = html.children 78 | div, = body.children 79 | assert div.width == 2 80 | assert div.height == 2 81 | 82 | 83 | @assert_no_logs 84 | def test_preferred_inline_trailing_space_in_nested(): 85 | page, = render_pages(''' 86 | 89 |
      a
      90 | ''') 91 | html, = page.children 92 | body, = html.children 93 | div, = body.children 94 | assert div.width == 2 95 | assert div.height == 2 96 | -------------------------------------------------------------------------------- /tests/resources/acid2-reference.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The Second Acid Test (Reference Rendering) 5 | 12 | 13 | 14 |

      Hello World!

      15 |

      Follow this link to view the reference image, which should be rendered below the text "Hello World!" on the test page in the same way that this paragraph is rendered below that text on this page.

      16 | 17 | -------------------------------------------------------------------------------- /tests/resources/blue.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/blue.jpg -------------------------------------------------------------------------------- /tests/resources/border.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/resources/border2.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 12 | 19 | 26 | 33 | 40 | 47 | 54 | 61 | 68 | 75 | 82 | 89 | 96 | 103 | 110 | 117 | 124 | 131 | 138 | 145 | 152 | 159 | 167 | 175 | 183 | 191 | 192 | -------------------------------------------------------------------------------- /tests/resources/doc1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 30 | 35 | 40 | 41 | 42 | 43 |

      WeasyPrint test document (with Ünicōde)

      44 |

      Hello

      46 |
        47 |
      • 48 | Home 51 |
      • … 52 |
      53 |
      54 | 55 | WeasyPrint 56 | 57 |
      58 | 59 | -------------------------------------------------------------------------------- /tests/resources/doc1_UTF-16BE.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/doc1_UTF-16BE.html -------------------------------------------------------------------------------- /tests/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/icon.png -------------------------------------------------------------------------------- /tests/resources/latin1-test.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/latin1-test.css -------------------------------------------------------------------------------- /tests/resources/logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/logo_small.png -------------------------------------------------------------------------------- /tests/resources/mask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/resources/mini_ua.css: -------------------------------------------------------------------------------- 1 | /* Minimal user-agent stylesheet */ 2 | p { margin: 1em 0px } /* 0px should be translated to 0*/ 3 | a { text-decoration: underline } 4 | h1 { font-weight: bolder } 5 | -------------------------------------------------------------------------------- /tests/resources/not-optimized-exif.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/not-optimized-exif.jpg -------------------------------------------------------------------------------- /tests/resources/not-optimized.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/not-optimized.jpg -------------------------------------------------------------------------------- /tests/resources/pattern-transparent.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/resources/pattern.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/pattern.gif -------------------------------------------------------------------------------- /tests/resources/pattern.palette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/pattern.palette.png -------------------------------------------------------------------------------- /tests/resources/pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/pattern.png -------------------------------------------------------------------------------- /tests/resources/pattern.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/resources/really-a-png.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/really-a-png.svg -------------------------------------------------------------------------------- /tests/resources/really-a-svg.png: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/resources/sheet2.css: -------------------------------------------------------------------------------- 1 | li { 2 | margin-bottom: 3em; /* Should be masked*/ 3 | margin: 2em 0; 4 | margin-left: 4em; /* Should not be masked*/ 5 | } 6 | -------------------------------------------------------------------------------- /tests/resources/sub_directory/sheet1.css: -------------------------------------------------------------------------------- 1 | @import url(../sheet2.css) all; 2 | p { 3 | background: currentColor; 4 | } 5 | 6 | @media print { 7 | ul { 8 | /* 1ex == 0.8em for weasyprint.otf. */ 9 | margin: 2em 2.5ex; 10 | } 11 | } 12 | @media screen { 13 | ul { 14 | border-width: 1000px !important; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/resources/tests_ua.css: -------------------------------------------------------------------------------- 1 | /* Simplified user-agent stylesheet for HTML5 in tests. */ 2 | 3 | @font-face { src: url(weasyprint.otf); font-family: weasyprint } 4 | @page { background: white; bleed: 0; @footnote { margin: 0 } } 5 | 6 | html, body, div, h1, h2, h3, h4, ol, p, ul, hr, pre, section, article { display: block } 7 | 8 | body { orphans: 1; widows: 1 } 9 | li { display: list-item } 10 | head { display: none } 11 | pre { white-space: pre } 12 | br:before { content: '\A'; white-space: pre-line } 13 | ol { list-style-type: decimal } 14 | ol, ul { counter-reset: list-item } 15 | 16 | table { display: table; box-sizing: border-box } 17 | tr { display: table-row } 18 | thead { display: table-header-group } 19 | tbody { display: table-row-group } 20 | tfoot { display: table-footer-group } 21 | col { display: table-column } 22 | colgroup { display: table-column-group } 23 | td, th { display: table-cell } 24 | caption { display: table-caption } 25 | 26 | *[lang] { -weasy-lang: attr(lang) } 27 | a[href] { -weasy-link: attr(href) } 28 | a[name] { -weasy-anchor: attr(name) } 29 | *[id] { -weasy-anchor: attr(id) } 30 | h1 { bookmark-level: 1; bookmark-label: content(text) } 31 | h2 { bookmark-level: 2; bookmark-label: content(text) } 32 | h3 { bookmark-level: 3; bookmark-label: content(text) } 33 | h4 { bookmark-level: 4; bookmark-label: content(text) } 34 | h5 { bookmark-level: 5; bookmark-label: content(text) } 35 | h6 { bookmark-level: 6; bookmark-label: content(text) } 36 | 37 | ::marker { font-variant-numeric: tabular-nums } 38 | 39 | ::footnote-call { content: counter(footnote) } 40 | ::footnote-marker { content: counter(footnote) '.' } 41 | -------------------------------------------------------------------------------- /tests/resources/user.css: -------------------------------------------------------------------------------- 1 | html { 2 | /* Reversed contrast */ 3 | color: white; 4 | background-color: black; 5 | } 6 | -------------------------------------------------------------------------------- /tests/resources/utf8-test.css: -------------------------------------------------------------------------------- 1 | h1::before { 2 | content: "I løvë Unicode"; 3 | background-image: url(pattern.png) 4 | } 5 | -------------------------------------------------------------------------------- /tests/resources/weasyprint.otb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/weasyprint.otb -------------------------------------------------------------------------------- /tests/resources/weasyprint.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/weasyprint.otf -------------------------------------------------------------------------------- /tests/resources/weasyprint.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/tests/resources/weasyprint.woff -------------------------------------------------------------------------------- /tests/test_acid2.py: -------------------------------------------------------------------------------- 1 | """Check the famous Acid2 test.""" 2 | 3 | import io 4 | 5 | from PIL import Image 6 | 7 | from weasyprint import CSS, HTML 8 | 9 | from .testing_utils import assert_no_logs, capture_logs, resource_path 10 | 11 | 12 | @assert_no_logs 13 | def test_acid2(assert_pixels_equal): 14 | # Reduce image size and avoid Ghostscript rounding problems 15 | stylesheets = (CSS(string='@page { size: 500px 800px }'),) 16 | 17 | def render(filename): 18 | return HTML(resource_path(filename)).render(stylesheets=stylesheets) 19 | 20 | with capture_logs(): 21 | # This is a copy of https://www.webstandards.org/files/acid2/test.html 22 | document = render('acid2-test.html') 23 | intro_page, test_page = document.pages 24 | # Ignore the intro page: it is not in the reference 25 | test_png = document.copy([test_page]).write_png() 26 | test_pixels = Image.open(io.BytesIO(test_png)).getdata() 27 | 28 | # This is a copy of https://www.webstandards.org/files/acid2/reference.html 29 | ref_png = render('acid2-reference.html').write_png() 30 | ref_image = Image.open(io.BytesIO(ref_png)) 31 | ref_pixels = ref_image.getdata() 32 | width, height = ref_image.size 33 | 34 | assert_pixels_equal(width, height, test_pixels, ref_pixels, tolerance=2) 35 | -------------------------------------------------------------------------------- /tests/test_fonts.py: -------------------------------------------------------------------------------- 1 | """Test the fonts features.""" 2 | 3 | from .testing_utils import assert_no_logs, render_pages 4 | 5 | 6 | @assert_no_logs 7 | def test_font_face(): 8 | page, = render_pages(''' 9 | 12 | abc''') 13 | html, = page.children 14 | body, = html.children 15 | line, = body.children 16 | assert line.width == 3 * 16 17 | 18 | 19 | @assert_no_logs 20 | def test_kerning_default(): 21 | # Kerning and ligatures are on by default 22 | page, = render_pages(''' 23 | 26 | kkliga''') 27 | html, = page.children 28 | body, = html.children 29 | line, = body.children 30 | span1, span2 = line.children 31 | assert span1.width == 1.5 * 16 32 | assert span2.width == 1.5 * 16 33 | 34 | 35 | @assert_no_logs 36 | def test_ligatures_word_space(): 37 | # Regression test for #1469. 38 | # Kerning and ligatures are on for text with increased word spacing. 39 | page, = render_pages(''' 40 | 43 | aa liga aa''') 44 | html, = page.children 45 | body, = html.children 46 | assert len(body.children) == 1 47 | 48 | 49 | @assert_no_logs 50 | def test_kerning_deactivate(): 51 | # Deactivate kerning 52 | page, = render_pages(''' 53 | 66 | kkkk''') 67 | html, = page.children 68 | body, = html.children 69 | line, = body.children 70 | span1, span2 = line.children 71 | assert span1.width == 1.5 * 16 72 | assert span2.width == 2 * 16 73 | 74 | 75 | @assert_no_logs 76 | def test_kerning_ligature_deactivate(): 77 | # Deactivate kerning and ligatures 78 | page, = render_pages(''' 79 | 93 | kk ligakk liga''') 94 | html, = page.children 95 | body, = html.children 96 | line, = body.children 97 | span1, span2 = line.children 98 | assert span1.width == (1.5 + 1 + 1.5) * 16 99 | assert span2.width == (2 + 1 + 4) * 16 100 | 101 | 102 | @assert_no_logs 103 | def test_font_face_descriptors(): 104 | page, = render_pages( 105 | ''' 106 | ''' 117 | 'kk' 118 | 'subs' 119 | 'dlig' 120 | 'onum' 121 | 'zero') 122 | html, = page.children 123 | body, = html.children 124 | line, = body.children 125 | kern, subs, dlig, onum, zero = line.children 126 | assert kern.width == 1.5 * 16 127 | assert subs.width == 1.5 * 16 128 | assert dlig.width == 1.5 * 16 129 | assert onum.width == 1.5 * 16 130 | assert zero.width == 1.5 * 16 131 | 132 | 133 | @assert_no_logs 134 | def test_woff_simple(): 135 | page, = render_pages(( 136 | ''' 137 | ''' 155 | 'woff font' 156 | 'woff font' 157 | 'woff font' 158 | 'woff font')) 159 | html, = page.children 160 | body, = html.children 161 | line, = body.children 162 | span1, span2, span3, span4 = line.children 163 | # otf font matches woff font 164 | assert span1.width == span2.width 165 | # otf font matches woff font loaded from cache 166 | assert span1.width == span3.width 167 | # the default font does not match the loaded fonts 168 | assert span1.width != span4.width 169 | -------------------------------------------------------------------------------- /tests/test_stacking.py: -------------------------------------------------------------------------------- 1 | """Test CSS stacking contexts.""" 2 | 3 | import pytest 4 | 5 | from weasyprint.stacking import StackingContext 6 | 7 | from .testing_utils import assert_no_logs, render_pages, serialize 8 | 9 | z_index_source = ''' 10 | 17 |
      18 |
      19 |
      20 | 21 |
      ''' 22 | 23 | 24 | def serialize_stacking(context): 25 | return ( 26 | context.box.element_tag, 27 | [b.element_tag for b in context.blocks_and_cells], 28 | [serialize_stacking(c) for c in context.zero_z_contexts]) 29 | 30 | 31 | @assert_no_logs 32 | @pytest.mark.parametrize('source, contexts', ( 33 | (''' 34 |

      35 |
      36 |

      37 |
      ''', 38 | ('html', ['body', 'p'], [('div', ['p'], [])])), 39 | (''' 40 |
      41 |

      42 |
      ''', 43 | ('html', ['body'], [('div', [], []), ('p', [], [])])), 44 | )) 45 | def test_nested(source, contexts): 46 | page, = render_pages(source) 47 | html, = page.children 48 | assert serialize_stacking(StackingContext.from_box(html, page)) == contexts 49 | 50 | 51 | @assert_no_logs 52 | def test_image_contexts(): 53 | page, = render_pages(''' 54 | Some text: ''') 55 | html, = page.children 56 | context = StackingContext.from_box(html, page) 57 | # The image is *not* in this context: 58 | assert serialize([context.box]) == [ 59 | ('html', 'Block', [ 60 | ('body', 'Block', [ 61 | ('body', 'Line', [ 62 | ('body', 'Text', 'Some text: ')])])])] 63 | # ... but in a sub-context: 64 | assert serialize(c.box for c in context.zero_z_contexts) == [ 65 | ('img', 'InlineReplaced', '')] 66 | 67 | 68 | @assert_no_logs 69 | @pytest.mark.parametrize('z_indexes, color', ( 70 | ((3, 2, 1), 'R'), 71 | ((1, 2, 3), 'G'), 72 | ((1, 2, -3), 'B'), 73 | ((1, 2, 'auto'), 'B'), 74 | ((-1, 'auto', -2), 'B'), 75 | )) 76 | def test_z_index(assert_pixels, z_indexes, color): 77 | assert_pixels('\n'.join([color * 10] * 10), z_index_source % z_indexes) 78 | -------------------------------------------------------------------------------- /tests/test_unicode.py: -------------------------------------------------------------------------------- 1 | """Test various unicode texts and filenames.""" 2 | 3 | from weasyprint.urls import ensure_url 4 | 5 | from .draw import document_to_pixels, html_to_pixels 6 | from .testing_utils import FakeHTML, assert_no_logs, resource_path 7 | 8 | 9 | @assert_no_logs 10 | def test_unicode(assert_pixels_equal, tmp_path): 11 | text = 'I løvë Unicode' 12 | style = ''' 13 | @page { size: 200px 50px } 14 | p { color: blue } 15 | ''' 16 | expected_width, expected_height, expected_lines = html_to_pixels(f''' 17 | 18 |

      {text}

      19 | ''') 20 | 21 | stylesheet = tmp_path / 'style.css' 22 | image = tmp_path / 'pattern.png' 23 | html = tmp_path / 'doc.html' 24 | stylesheet.write_text(style, 'utf-8') 25 | image.write_bytes(resource_path('pattern.png').read_bytes()) 26 | html_content = f''' 27 | 28 |

      {text}

      29 | ''' 30 | html.write_text(html_content, 'utf-8') 31 | 32 | document = FakeHTML(html, encoding='utf-8') 33 | width, height, lines = document_to_pixels(document) 34 | assert (expected_width, expected_height) == (width, height) 35 | assert_pixels_equal(width, height, lines, expected_lines) 36 | -------------------------------------------------------------------------------- /tests/test_url.py: -------------------------------------------------------------------------------- 1 | """Test URLs.""" 2 | 3 | import re 4 | 5 | import pytest 6 | 7 | from .testing_utils import FakeHTML, capture_logs, resource_path 8 | 9 | 10 | @pytest.mark.parametrize('url, base_url', ( 11 | ('https://weasyprint.org]', resource_path('')), 12 | ('https://weasyprint.org]', 'https://weasyprint.org]'), 13 | ('https://weasyprint.org/', 'https://weasyprint.org]'), 14 | )) 15 | def test_malformed_url_link(url, base_url): 16 | """Test malformed URLs.""" 17 | with capture_logs() as logs: 18 | pdf = FakeHTML( 19 | string=f'

      My Link

      ', 20 | base_url=base_url).write_pdf() 21 | 22 | assert len(logs) == 1 23 | assert "Malformed" in logs[0] 24 | assert "]" in logs[0] 25 | 26 | uris = re.findall(b'/URI \\((.*)\\)', pdf) 27 | types = re.findall(b'/S (/\\w*)', pdf) 28 | subtypes = re.findall(b'/Subtype (/\\w*)', pdf) 29 | 30 | assert uris.pop(0) == url.encode() 31 | assert subtypes.pop(0) == b'/Link' 32 | assert types.pop(0) == b'/URI' 33 | -------------------------------------------------------------------------------- /weasyprint/anchors.py: -------------------------------------------------------------------------------- 1 | """Find anchors, links, bookmarks and inputs in documents.""" 2 | 3 | import math 4 | 5 | from .formatting_structure import boxes 6 | from .layout.percent import percentage 7 | from .matrix import Matrix 8 | 9 | 10 | def rectangle_aabb(matrix, pos_x, pos_y, width, height): 11 | """Apply a transformation matrix to an axis-aligned rectangle. 12 | 13 | Return its axis-aligned bounding box as ``(x1, y1, x2, y2)``. 14 | 15 | """ 16 | if not matrix: 17 | return pos_x, pos_y, pos_x + width, pos_y + height 18 | transform_point = matrix.transform_point 19 | x1, y1 = transform_point(pos_x, pos_y) 20 | x2, y2 = transform_point(pos_x + width, pos_y) 21 | x3, y3 = transform_point(pos_x, pos_y + height) 22 | x4, y4 = transform_point(pos_x + width, pos_y + height) 23 | box_x1 = min(x1, x2, x3, x4) 24 | box_y1 = min(y1, y2, y3, y4) 25 | box_x2 = max(x1, x2, x3, x4) 26 | box_y2 = max(y1, y2, y3, y4) 27 | return box_x1, box_y1, box_x2, box_y2 28 | 29 | 30 | def gather_anchors(box, anchors, links, bookmarks, forms, parent_matrix=None, 31 | parent_form=None): 32 | """Gather anchors and other data related to specific positions in PDF. 33 | 34 | Currently finds anchors, links, bookmarks and forms. 35 | 36 | """ 37 | # Get box transformation matrix. 38 | # "Transforms apply to block-level and atomic inline-level elements, 39 | # but do not apply to elements which may be split into 40 | # multiple inline-level boxes." 41 | # https://www.w3.org/TR/css-transforms-1/#introduction 42 | if box.style['transform'] and not isinstance(box, boxes.InlineBox): 43 | border_width = box.border_width() 44 | border_height = box.border_height() 45 | origin_x, origin_y = box.style['transform_origin'] 46 | offset_x = percentage(origin_x, border_width) 47 | offset_y = percentage(origin_y, border_height) 48 | origin_x = box.border_box_x() + offset_x 49 | origin_y = box.border_box_y() + offset_y 50 | 51 | matrix = Matrix(e=origin_x, f=origin_y) 52 | for name, args in box.style['transform']: 53 | a, b, c, d, e, f = 1, 0, 0, 1, 0, 0 54 | if name == 'scale': 55 | a, d = args 56 | elif name == 'rotate': 57 | a = d = math.cos(args) 58 | b = math.sin(args) 59 | c = -b 60 | elif name == 'translate': 61 | e = percentage(args[0], border_width) 62 | f = percentage(args[1], border_height) 63 | elif name == 'skew': 64 | b, c = math.tan(args[1]), math.tan(args[0]) 65 | else: 66 | assert name == 'matrix' 67 | a, b, c, d, e, f = args 68 | matrix = Matrix(a, b, c, d, e, f) @ matrix 69 | box.transformation_matrix = ( 70 | Matrix(e=-origin_x, f=-origin_y) @ matrix) 71 | if parent_matrix: 72 | matrix = box.transformation_matrix @ parent_matrix 73 | else: 74 | matrix = box.transformation_matrix 75 | else: 76 | matrix = parent_matrix 77 | 78 | bookmark_label = box.bookmark_label 79 | if box.style['bookmark_level'] == 'none': 80 | bookmark_level = None 81 | else: 82 | bookmark_level = box.style['bookmark_level'] 83 | state = box.style['bookmark_state'] 84 | link = box.style['link'] 85 | anchor_name = box.style['anchor'] 86 | has_bookmark = bookmark_label and bookmark_level 87 | # 'link' is inherited but redundant on text boxes 88 | has_link = link and not isinstance(box, (boxes.TextBox, boxes.LineBox)) 89 | # In case of duplicate IDs, only the first is an anchor. 90 | has_anchor = anchor_name and anchor_name not in anchors 91 | is_input = box.is_input() 92 | 93 | if box.is_form(): 94 | parent_form = box.element 95 | if parent_form not in forms: 96 | forms[parent_form] = [] 97 | 98 | if has_bookmark or has_link or has_anchor or is_input: 99 | if is_input: 100 | pos_x, pos_y = box.content_box_x(), box.content_box_y() 101 | width, height = box.width, box.height 102 | else: 103 | pos_x, pos_y, width, height = box.hit_area() 104 | if has_link or is_input: 105 | rectangle = rectangle_aabb(matrix, pos_x, pos_y, width, height) 106 | if has_link: 107 | token_type, link = link 108 | assert token_type == 'url' 109 | link_type, target = link 110 | assert isinstance(target, str) 111 | if link_type == 'external' and box.is_attachment(): 112 | link_type = 'attachment' 113 | links.append((link_type, target, rectangle, box)) 114 | if is_input: 115 | forms[parent_form].append((box.element, box.style, rectangle)) 116 | if has_bookmark: 117 | if matrix: 118 | pos_x, pos_y = matrix.transform_point(pos_x, pos_y) 119 | bookmark = (bookmark_level, bookmark_label, (pos_x, pos_y), state) 120 | bookmarks.append(bookmark) 121 | if has_anchor: 122 | pos_x1, pos_y1, pos_x2, pos_y2 = pos_x, pos_y, pos_x + width, pos_y + height 123 | if matrix: 124 | pos_x1, pos_y1 = matrix.transform_point(pos_x1, pos_y1) 125 | pos_x2, pos_y2 = matrix.transform_point(pos_x2, pos_y2) 126 | anchors[anchor_name] = (pos_x1, pos_y1, pos_x2, pos_y2) 127 | 128 | for child in box.all_children(): 129 | gather_anchors(child, anchors, links, bookmarks, forms, matrix, parent_form) 130 | 131 | 132 | def make_page_bookmark_tree(page, skipped_levels, last_by_depth, 133 | previous_level, page_number, matrix): 134 | """Make a tree of all bookmarks in a given page.""" 135 | for level, label, (point_x, point_y), state in page.bookmarks: 136 | if level > previous_level: 137 | # Example: if the previous bookmark is a

      , the next 138 | # depth "should" be for

      . If now we get a

      we’re 139 | # skipping two levels: append 6 - 3 - 1 = 2 140 | skipped_levels.append(level - previous_level - 1) 141 | else: 142 | temp = level 143 | while temp < previous_level: 144 | temp += 1 + skipped_levels.pop() 145 | if temp > previous_level: 146 | # We remove too many "skips", add some back: 147 | skipped_levels.append(temp - previous_level - 1) 148 | 149 | previous_level = level 150 | depth = level - sum(skipped_levels) 151 | assert depth == len(skipped_levels) 152 | assert depth >= 1 153 | 154 | children = [] 155 | point_x, point_y = matrix.transform_point(point_x, point_y) 156 | subtree = (label, (page_number, point_x, point_y), children, state) 157 | last_by_depth[depth - 1].append(subtree) 158 | del last_by_depth[depth:] 159 | last_by_depth.append(children) 160 | return previous_level 161 | -------------------------------------------------------------------------------- /weasyprint/css/html5_ph.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Presentational hints stylsheet for HTML. 4 | 5 | This stylesheet contains all the presentational hints rules that can be 6 | expressed as CSS. 7 | 8 | See https://www.w3.org/TR/html5/rendering.html#rendering 9 | 10 | TODO: Attribute values are not case-insensitive, but they should be. We can add 11 | a "i" flag when CSS Selectors Level 4 is supported. 12 | 13 | */ 14 | 15 | pre[wrap] { white-space: pre-wrap } 16 | 17 | br[clear=left i] { clear: left } 18 | br[clear=right i] { clear: right } 19 | br[clear=all i], br[clear=both i] { clear: both } 20 | 21 | :is(ol, li)[type="1"] { list-style-type: decimal } 22 | :is(ol, li)[type=a] { list-style-type: lower-alpha } 23 | :is(ol, li)[type=A] { list-style-type: upper-alpha } 24 | :is(ol, li)[type=i] { list-style-type: lower-roman } 25 | :is(ol, li)[type=I] { list-style-type: upper-roman } 26 | :is(ul, li)[type=disc i] { list-style-type: disc } 27 | :is(ul, li)[type=circle i] { list-style-type: circle } 28 | :is(ul, li)[type=square i] { list-style-type: square } 29 | 30 | table[align=left i] { float: left } 31 | table[align=right i] { float: right } 32 | table[align=center i] { margin-left: auto; margin-right: auto } 33 | :is(thead, tbody, tfoot, tr, td, th)[align=absmiddle i] { text-align: center } 34 | 35 | caption[align=bottom i] { caption-side: bottom } 36 | :is(p, h1, h2, h3, h4, h5, h6)[align=left i] { text-align: left } 37 | :is(p, h1, h2, h3, h4, h5, h6)[align=right i] { text-align: right } 38 | :is(p, h1, h2, h3, h4, h5, h6)[align=center i] { text-align: center } 39 | :is(p, h1, h2, h3, h4, h5, h6)[align=justify i] { text-align: justify } 40 | :is(thead, tbody, tfoot, tr, td, th)[valign=top i] { vertical-align: top } 41 | :is(thead, tbody, tfoot, tr, td, th)[valign=middle i] { vertical-align: middle } 42 | :is(thead, tbody, tfoot, tr, td, th)[valign=bottom i] { vertical-align: bottom } 43 | :is(thead, tbody, tfoot, tr, td, th)[valign=baseline i] { vertical-align: baseline } 44 | 45 | :is(td, th)[nowrap] { white-space: nowrap } 46 | 47 | table:is([rules=none i], [rules=groups i], [rules=rows i], [rules=cols i]) { border-style: hidden; border-collapse: collapse } 48 | table[border]:not([border="0 i"]) { border-style: outset } 49 | table[frame=void i] { border-style: hidden } 50 | table[frame=above i] { border-style: outset hidden hidden hidden } 51 | table[frame=below i] { border-style: hidden hidden outset hidden } 52 | table[frame=hsides i] { border-style: outset hidden outset hidden } 53 | table[frame=lhs i] { border-style: hidden hidden hidden outset } 54 | table[frame=rhs i] { border-style: hidden outset hidden hidden } 55 | table[frame=vsides i] { border-style: hidden outset } 56 | table[frame=box i], table[frame=border i] { border-style: outset } 57 | 58 | table[border]:not([border="0"]) > tr > :is(td, th), table[border]:not([border="0"]) > :is(thead, tbody, tfoot) > tr > :is(td, th) { border-width: 1px; border-style: inset } 59 | table:is([rules=none i], [rules=groups i], [rules=rows i]) > tr > :is(td, th), table:is([rules=none i], [rules=groups i], [rules=rows i]) > :is(thead, tbody, tfoot) > tr > :is(td, th) { border-width: 1px; border-style: none } 60 | table[rules=cols i] > tr > :is(td, th), table[rules=cols i] > :is(thead, tbody, tfoot) > tr > :is(td, th) { border-width: 1px; border-style: none solid } 61 | table[rules=all i] > tr > :is(td, th), table[rules=all i] > :is(thead, tbody, tfoot) > tr > :is(td, th) { border-width: 1px; border-style: solid } 62 | table[rules=groups i] > colgroup { border-left-width: 1px; border-left-style: solid; border-right-width: 1px; border-right-style: solid } 63 | table[rules=groups i] > :is(thead, tbody, tfoot) { border-top-width: 1px; border-top-style: solid; border-bottom-width: 1px; border-bottom-style: solid } 64 | table[rules=rows i] > tr, table[rules=rows i] > :is(thead, tbody, tfoot) > tr { border-top-width: 1px; border-top-style: solid; border-bottom-width: 1px; border-bottom-style: solid } 65 | 66 | hr[align=left i] { margin-left: 0; margin-right: auto } 67 | hr[align=right i] { margin-left: auto; margin-right: 0 } 68 | hr[align=center i] { margin-left: auto; margin-right: auto } 69 | hr[color], hr[noshade] { border-style: solid } 70 | 71 | iframe[frameborder="0"], iframe[frameborder=no i] { border: none } 72 | 73 | :is(applet, embed, iframe, img, input, object)[align=left i] { float: left } 74 | :is(applet, embed, iframe, img, input, object)[align=right i] { float: right } 75 | :is(applet, embed, iframe, img, input, object)[align=top i] { vertical-align: top } 76 | :is(applet, embed, iframe, img, input, object)[align=baseline i] { vertical-align: baseline } 77 | :is(applet, embed, iframe, img, input, object)[align=texttop i] { vertical-align: text-top } 78 | :is(applet, embed, iframe, img, input, object):is([align=middle i], [align=absmiddle i], [align=absmiddle i]) { vertical-align: middle } 79 | :is(applet, embed, iframe, img, input, object)[align=bottom i] { vertical-align: bottom } 80 | -------------------------------------------------------------------------------- /weasyprint/css/html5_ua_form.css: -------------------------------------------------------------------------------- 1 | /* Default stylesheet for PDF forms */ 2 | 3 | button, input, select, textarea { appearance: auto } 4 | select option, select:not([multiple])::before, input:not([type="submit"])::before {visibility: hidden } 5 | textarea { text-indent: 10000% } /* Hide text but don’t change color used by PDF form */ 6 | -------------------------------------------------------------------------------- /weasyprint/css/media_queries.py: -------------------------------------------------------------------------------- 1 | """Handle media queries. 2 | 3 | https://www.w3.org/TR/mediaqueries-4/ 4 | 5 | """ 6 | 7 | import tinycss2 8 | 9 | from ..logger import LOGGER 10 | from .utils import remove_whitespace, split_on_comma 11 | 12 | 13 | def evaluate_media_query(query_list, device_media_type): 14 | """Return the boolean evaluation of `query_list` for the given 15 | `device_media_type`. 16 | 17 | :attr query_list: a cssutilts.stlysheets.MediaList 18 | :attr device_media_type: a media type string (for now) 19 | 20 | """ 21 | # TODO: actual support for media queries, not just media types 22 | return 'all' in query_list or device_media_type in query_list 23 | 24 | 25 | def parse_media_query(tokens): 26 | tokens = remove_whitespace(tokens) 27 | if not tokens: 28 | return ['all'] 29 | else: 30 | media = [] 31 | for part in split_on_comma(tokens): 32 | types = [token.type for token in part] 33 | if types == ['ident']: 34 | media.append(part[0].lower_value) 35 | else: 36 | LOGGER.warning( 37 | 'Expected a media type, got %r', tinycss2.serialize(part)) 38 | return 39 | return media 40 | -------------------------------------------------------------------------------- /weasyprint/draw/color.py: -------------------------------------------------------------------------------- 1 | """Draw colors.""" 2 | 3 | from colorsys import hsv_to_rgb, rgb_to_hsv 4 | 5 | from tinycss2.color4 import parse_color 6 | 7 | 8 | def get_color(style, key): 9 | """Return color, taking care of possible currentColor value.""" 10 | value = style[key] 11 | return value if value != 'currentcolor' else style['color'] 12 | 13 | 14 | def darken(color): 15 | """Return a darker color.""" 16 | # TODO: handle color spaces. 17 | hue, saturation, value = rgb_to_hsv(*color.to('srgb')[:3]) 18 | value /= 1.5 19 | saturation /= 1.25 20 | return parse_color( 21 | 'rgb(%f%% %f%% %f%%/%f)' % (*hsv_to_rgb(hue, saturation, value), color.alpha)) 22 | 23 | 24 | def lighten(color): 25 | """Return a lighter color.""" 26 | # TODO: handle color spaces. 27 | hue, saturation, value = rgb_to_hsv(*color.to('srgb')[:3]) 28 | value = 1 - (1 - value) / 1.5 29 | if saturation: 30 | saturation = 1 - (1 - saturation) / 1.25 31 | return parse_color( 32 | 'rgb(%f%% %f%% %f%%/%f)' % (*hsv_to_rgb(hue, saturation, value), color.alpha)) 33 | 34 | 35 | def styled_color(style, color, side): 36 | """Return inset, outset, ridge and groove border colors.""" 37 | if style in ('inset', 'outset'): 38 | do_lighten = (side in ('top', 'left')) ^ (style == 'inset') 39 | return (lighten if do_lighten else darken)(color) 40 | elif style in ('ridge', 'groove'): 41 | if (side in ('top', 'left')) ^ (style == 'ridge'): 42 | return lighten(color), darken(color) 43 | else: 44 | return darken(color), lighten(color) 45 | return color 46 | -------------------------------------------------------------------------------- /weasyprint/draw/stack.py: -------------------------------------------------------------------------------- 1 | """Drawing stack context manager.""" 2 | 3 | from contextlib import contextmanager 4 | 5 | 6 | @contextmanager 7 | def stacked(stream): 8 | """Save and restore stream context when used with the ``with`` keyword.""" 9 | stream.push_state() 10 | try: 11 | yield 12 | finally: 13 | stream.pop_state() 14 | -------------------------------------------------------------------------------- /weasyprint/layout/leader.py: -------------------------------------------------------------------------------- 1 | """Leaders management.""" 2 | 3 | from ..formatting_structure import boxes 4 | 5 | 6 | def leader_index(box): 7 | """Get the index of the first leader box in ``box``.""" 8 | for i, child in enumerate(box.children): 9 | if child.is_leader: 10 | return (i, None), child 11 | if isinstance(child, boxes.ParentBox): 12 | child_leader_index, child_leader = leader_index(child) 13 | if child_leader_index is not None: 14 | return (i, child_leader_index), child_leader 15 | return None, None 16 | 17 | 18 | def handle_leader(context, line, containing_block): 19 | """Find a leader box in ``line`` and handle its text and its position.""" 20 | index, leader_box = leader_index(line) 21 | extra_width = 0 22 | if index is not None and leader_box.children: 23 | text_box, = leader_box.children 24 | 25 | # Abort if the leader text has no width 26 | if text_box.width <= 0: 27 | return 28 | 29 | # Extra width is the additional width taken by the leader box 30 | extra_width = containing_block.width - sum( 31 | child.margin_width() for child in line.children 32 | if child.is_in_normal_flow()) 33 | 34 | # Take care of excluded shapes 35 | for shape in context.excluded_shapes: 36 | if shape.position_y + shape.height > line.position_y: 37 | extra_width -= shape.width 38 | 39 | # Available width is the width available for the leader box 40 | available_width = extra_width + text_box.width 41 | line.width = containing_block.width 42 | 43 | # Add text boxes into the leader box 44 | number_of_leaders = int(line.width // text_box.width) 45 | position_x = line.position_x + line.width 46 | children = [] 47 | for i in range(number_of_leaders): 48 | position_x -= text_box.width 49 | if position_x < leader_box.position_x: 50 | # Don’t add leaders behind the text on the left 51 | continue 52 | elif (position_x + text_box.width > 53 | leader_box.position_x + available_width): 54 | # Don’t add leaders behind the text on the right 55 | continue 56 | text_box = text_box.copy() 57 | text_box.position_x = position_x 58 | children.append(text_box) 59 | leader_box.children = tuple(children) 60 | 61 | if line.style['direction'] == 'rtl': 62 | leader_box.translate(dx=-extra_width) 63 | 64 | # Widen leader parent boxes and translate following boxes 65 | box = line 66 | while index is not None: 67 | for child in box.children[index[0] + 1:]: 68 | if child.is_in_normal_flow(): 69 | if line.style['direction'] == 'ltr': 70 | child.translate(dx=extra_width) 71 | else: 72 | child.translate(dx=-extra_width) 73 | box = box.children[index[0]] 74 | box.width += extra_width 75 | index = index[1] 76 | -------------------------------------------------------------------------------- /weasyprint/layout/min_max.py: -------------------------------------------------------------------------------- 1 | """Decorators handling min- and max- widths and heights.""" 2 | 3 | import functools 4 | 5 | 6 | def handle_min_max_width(function): 7 | """Decorate a function setting used width, handling {min,max}-width.""" 8 | @functools.wraps(function) 9 | def wrapper(box, *args): 10 | computed_margins = box.margin_left, box.margin_right 11 | result = function(box, *args) 12 | if box.width > box.max_width: 13 | box.width = box.max_width 14 | box.margin_left, box.margin_right = computed_margins 15 | result = function(box, *args) 16 | if box.width < box.min_width: 17 | box.width = box.min_width 18 | box.margin_left, box.margin_right = computed_margins 19 | result = function(box, *args) 20 | return result 21 | wrapper.without_min_max = function 22 | return wrapper 23 | 24 | 25 | def handle_min_max_height(function): 26 | """Decorate a function setting used height, handling {min,max}-height.""" 27 | @functools.wraps(function) 28 | def wrapper(box, *args): 29 | computed_margins = box.margin_top, box.margin_bottom 30 | result = function(box, *args) 31 | if box.height > box.max_height: 32 | box.height = box.max_height 33 | box.margin_top, box.margin_bottom = computed_margins 34 | result = function(box, *args) 35 | if box.height < box.min_height: 36 | box.height = box.min_height 37 | box.margin_top, box.margin_bottom = computed_margins 38 | result = function(box, *args) 39 | return result 40 | wrapper.without_min_max = function 41 | return wrapper 42 | -------------------------------------------------------------------------------- /weasyprint/layout/percent.py: -------------------------------------------------------------------------------- 1 | """Resolve percentages into fixed values.""" 2 | 3 | from math import inf 4 | 5 | from ..formatting_structure import boxes 6 | 7 | 8 | def percentage(value, refer_to): 9 | """Return the percentage of the reference value, or the value unchanged. 10 | 11 | ``refer_to`` is the length for 100%. If ``refer_to`` is not a number, it 12 | just replaces percentages. 13 | 14 | """ 15 | if value is None or value == 'auto': 16 | return value 17 | elif value.unit == 'px': 18 | return value.value 19 | else: 20 | assert value.unit == '%' 21 | return refer_to * value.value / 100 22 | 23 | 24 | def resolve_one_percentage(box, property_name, refer_to): 25 | """Set a used length value from a computed length value. 26 | 27 | ``refer_to`` is the length for 100%. If ``refer_to`` is not a number, it 28 | just replaces percentages. 29 | 30 | """ 31 | # box.style has computed values 32 | value = box.style[property_name] 33 | # box attributes are used values 34 | percent = percentage(value, refer_to) 35 | setattr(box, property_name, percent) 36 | if property_name in ('min_width', 'min_height') and percent == 'auto': 37 | setattr(box, property_name, 0) 38 | 39 | 40 | def resolve_position_percentages(box, containing_block): 41 | cb_width, cb_height = containing_block 42 | resolve_one_percentage(box, 'left', cb_width) 43 | resolve_one_percentage(box, 'right', cb_width) 44 | resolve_one_percentage(box, 'top', cb_height) 45 | resolve_one_percentage(box, 'bottom', cb_height) 46 | 47 | 48 | def resolve_percentages(box, containing_block): 49 | """Set used values as attributes of the box object.""" 50 | if isinstance(containing_block, boxes.Box): 51 | # cb is short for containing block 52 | cb_width = containing_block.width 53 | cb_height = containing_block.height 54 | else: 55 | cb_width, cb_height = containing_block 56 | if isinstance(box, boxes.PageBox): 57 | maybe_height = cb_height 58 | else: 59 | maybe_height = cb_width 60 | resolve_one_percentage(box, 'margin_left', cb_width) 61 | resolve_one_percentage(box, 'margin_right', cb_width) 62 | resolve_one_percentage(box, 'margin_top', maybe_height) 63 | resolve_one_percentage(box, 'margin_bottom', maybe_height) 64 | resolve_one_percentage(box, 'padding_left', cb_width) 65 | resolve_one_percentage(box, 'padding_right', cb_width) 66 | resolve_one_percentage(box, 'padding_top', maybe_height) 67 | resolve_one_percentage(box, 'padding_bottom', maybe_height) 68 | resolve_one_percentage(box, 'width', cb_width) 69 | resolve_one_percentage(box, 'min_width', cb_width) 70 | resolve_one_percentage(box, 'max_width', cb_width) 71 | 72 | # XXX later: top, bottom, left and right on positioned elements 73 | 74 | if cb_height == 'auto': 75 | # Special handling when the height of the containing block 76 | # depends on its content. 77 | height = box.style['height'] 78 | if height == 'auto' or height.unit == '%': 79 | box.height = 'auto' 80 | else: 81 | assert height.unit == 'px' 82 | box.height = height.value 83 | resolve_one_percentage(box, 'min_height', 0) 84 | resolve_one_percentage(box, 'max_height', inf) 85 | else: 86 | resolve_one_percentage(box, 'height', cb_height) 87 | resolve_one_percentage(box, 'min_height', cb_height) 88 | resolve_one_percentage(box, 'max_height', cb_height) 89 | 90 | collapse = box.style['border_collapse'] == 'collapse' 91 | # Used value == computed value 92 | for side in ('top', 'right', 'bottom', 'left'): 93 | prop = f'border_{side}_width' 94 | # border-{side}-width would have been resolved 95 | # during border conflict resolution for collapsed-borders 96 | if not (collapse and hasattr(box, prop)): 97 | setattr(box, prop, box.style[prop]) 98 | 99 | # Shrink *content* widths and heights according to box-sizing 100 | adjust_box_sizing(box, 'width') 101 | adjust_box_sizing(box, 'height') 102 | 103 | 104 | def resolve_radii_percentages(box): 105 | for corner in ('top_left', 'top_right', 'bottom_right', 'bottom_left'): 106 | property_name = f'border_{corner}_radius' 107 | rx, ry = box.style[property_name] 108 | 109 | # Short track for common case 110 | if (0, 'px') in (rx, ry): 111 | setattr(box, property_name, (0, 0)) 112 | continue 113 | 114 | for side in corner.split('_'): 115 | if side in box.remove_decoration_sides: 116 | setattr(box, property_name, (0, 0)) 117 | break 118 | else: 119 | rx = percentage(rx, box.border_width()) 120 | ry = percentage(ry, box.border_height()) 121 | setattr(box, property_name, (rx, ry)) 122 | 123 | 124 | def adjust_box_sizing(box, axis): 125 | if box.style['box_sizing'] == 'border-box': 126 | if axis == 'width': 127 | delta = ( 128 | box.padding_left + box.padding_right + 129 | box.border_left_width + box.border_right_width) 130 | else: 131 | delta = ( 132 | box.padding_top + box.padding_bottom + 133 | box.border_top_width + box.border_bottom_width) 134 | elif box.style['box_sizing'] == 'padding-box': 135 | if axis == 'width': 136 | delta = box.padding_left + box.padding_right 137 | else: 138 | delta = box.padding_top + box.padding_bottom 139 | else: 140 | assert box.style['box_sizing'] == 'content-box' 141 | delta = 0 142 | 143 | # Keep at least min_* >= 0 to prevent funny output in case box.width or 144 | # box.height become negative. 145 | # Restricting max_* seems reasonable, too. 146 | if delta > 0: 147 | if getattr(box, axis) != 'auto': 148 | setattr(box, axis, max(0, getattr(box, axis) - delta)) 149 | setattr(box, f'max_{axis}', max(0, getattr(box, f'max_{axis}') - delta)) 150 | if getattr(box, f'min_{axis}') != 'auto': 151 | setattr(box, f'min_{axis}', max(0, getattr(box, f'min_{axis}') - delta)) 152 | -------------------------------------------------------------------------------- /weasyprint/logger.py: -------------------------------------------------------------------------------- 1 | """Logging setup. 2 | 3 | The rest of the code gets the logger through this module rather than 4 | ``logging.getLogger`` to make sure that it is configured. 5 | 6 | Logging levels are used for specific purposes: 7 | 8 | - errors are used in ``LOGGER`` for unreachable or unusable external resources, 9 | including unreachable stylesheets, unreachables images and unreadable images; 10 | - warnings are used in ``LOGGER`` for unknown or bad HTML/CSS syntaxes, 11 | unreachable local fonts and various non-fatal problems; 12 | - infos are used in ``PROCESS_LOGGER`` to advertise rendering steps. 13 | 14 | """ 15 | 16 | import contextlib 17 | import logging 18 | 19 | LOGGER = logging.getLogger('weasyprint') 20 | if not LOGGER.handlers: # pragma: no cover 21 | LOGGER.setLevel(logging.WARNING) 22 | LOGGER.addHandler(logging.NullHandler()) 23 | 24 | PROGRESS_LOGGER = logging.getLogger('weasyprint.progress') 25 | 26 | 27 | class CallbackHandler(logging.Handler): 28 | """A logging handler that calls a function for every message.""" 29 | def __init__(self, callback): 30 | logging.Handler.__init__(self) 31 | self.emit = callback 32 | 33 | 34 | @contextlib.contextmanager 35 | def capture_logs(logger='weasyprint', level=None): 36 | """Return a context manager that captures all logged messages.""" 37 | if level is None: 38 | level = logging.INFO 39 | logger = logging.getLogger(logger) 40 | messages = [] 41 | 42 | def emit(record): 43 | if record.name == 'weasyprint.progress': 44 | return 45 | if record.levelno < level: 46 | return 47 | messages.append(f'{record.levelname.upper()}: {record.getMessage()}') 48 | 49 | previous_handlers = logger.handlers 50 | previous_level = logger.level 51 | logger.handlers = [] 52 | logger.addHandler(CallbackHandler(emit)) 53 | logger.setLevel(logging.DEBUG) 54 | try: 55 | yield messages 56 | finally: 57 | logger.handlers = previous_handlers 58 | logger.setLevel(previous_level) 59 | -------------------------------------------------------------------------------- /weasyprint/matrix.py: -------------------------------------------------------------------------------- 1 | """Transformation matrix.""" 2 | 3 | 4 | class Matrix(list): 5 | def __init__(self, a=1, b=0, c=0, d=1, e=0, f=0, matrix=None): 6 | if matrix is None: 7 | matrix = [[a, b, 0], [c, d, 0], [e, f, 1]] 8 | super().__init__(matrix) 9 | 10 | def __matmul__(self, other): 11 | assert len(self[0]) == len(other) == len(other[0]) == 3 12 | return Matrix(matrix=[ 13 | [sum(self[i][k] * other[k][j] for k in range(3)) for j in range(3)] 14 | for i in range(len(self))]) 15 | 16 | @property 17 | def invert(self): 18 | d = self.determinant 19 | return Matrix(matrix=[ 20 | [ 21 | (self[1][1] * self[2][2] - self[1][2] * self[2][1]) / d, 22 | (self[0][1] * self[2][2] - self[0][2] * self[2][1]) / -d, 23 | (self[0][1] * self[1][2] - self[0][2] * self[1][1]) / d, 24 | ], 25 | [ 26 | (self[1][0] * self[2][2] - self[1][2] * self[2][0]) / -d, 27 | (self[0][0] * self[2][2] - self[0][2] * self[2][0]) / d, 28 | (self[0][0] * self[1][2] - self[0][2] * self[1][0]) / -d, 29 | ], 30 | [ 31 | (self[1][0] * self[2][1] - self[1][1] * self[2][0]) / d, 32 | (self[0][0] * self[2][1] - self[0][1] * self[2][0]) / -d, 33 | (self[0][0] * self[1][1] - self[0][1] * self[1][0]) / d, 34 | ], 35 | ]) 36 | 37 | @property 38 | def determinant(self): 39 | assert len(self) == len(self[0]) == 3 40 | return ( 41 | self[0][0] * (self[1][1] * self[2][2] - self[1][2] * self[2][1]) - 42 | self[1][0] * (self[0][1] * self[2][2] - self[0][2] * self[2][1]) + 43 | self[2][0] * (self[0][1] * self[1][2] - self[0][2] * self[1][1])) 44 | 45 | def transform_point(self, x, y): 46 | return (Matrix(matrix=[[x, y, 1]]) @ self)[0][:2] 47 | 48 | @property 49 | def values(self): 50 | (a, b), (c, d), (e, f) = [column[:2] for column in self] 51 | return a, b, c, d, e, f 52 | -------------------------------------------------------------------------------- /weasyprint/pdf/debug.py: -------------------------------------------------------------------------------- 1 | """PDF generation with debug information.""" 2 | 3 | import pydyf 4 | 5 | from ..matrix import Matrix 6 | 7 | 8 | def debug(pdf, metadata, document, page_streams, attachments, compress): 9 | """Set debug PDF metadata.""" 10 | 11 | # Add links on ids. 12 | pages = zip(pdf.pages['Kids'][::3], document.pages, page_streams) 13 | for pdf_page_number, document_page, stream in pages: 14 | if not document_page.anchors: 15 | continue 16 | 17 | page = pdf.objects[pdf_page_number] 18 | if 'Annots' not in page: 19 | page['Annots'] = pydyf.Array() 20 | 21 | for id, (x1, y1, x2, y2) in document_page.anchors.items(): 22 | # TODO: handle zoom correctly. 23 | matrix = Matrix(0.75, 0, 0, 0.75) @ stream.ctm 24 | x1, y1 = matrix.transform_point(x1, y1) 25 | x2, y2 = matrix.transform_point(x2, y2) 26 | annotation = pydyf.Dictionary({ 27 | 'Type': '/Annot', 28 | 'Subtype': '/Link', 29 | 'Rect': pydyf.Array([x1, y1, x2, y2]), 30 | 'BS': pydyf.Dictionary({'W': 0}), 31 | 'P': page.reference, 32 | 'T': pydyf.String(id), # id added as metadata 33 | }) 34 | 35 | # The next line makes all of this relevent to use 36 | # with PDFjs 37 | annotation['Dest'] = pydyf.String(id) 38 | 39 | pdf.add_object(annotation) 40 | page['Annots'].append(annotation.reference) 41 | 42 | 43 | VARIANTS = {'debug': (debug, {})} 44 | -------------------------------------------------------------------------------- /weasyprint/pdf/metadata.py: -------------------------------------------------------------------------------- 1 | """PDF metadata stream generation.""" 2 | 3 | from xml.etree.ElementTree import Element, SubElement, register_namespace, tostring 4 | 5 | import pydyf 6 | 7 | from .. import __version__ 8 | 9 | # XML namespaces used for metadata 10 | NS = { 11 | 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 12 | 'dc': 'http://purl.org/dc/elements/1.1/', 13 | 'xmp': 'http://ns.adobe.com/xap/1.0/', 14 | 'pdf': 'http://ns.adobe.com/pdf/1.3/', 15 | 'pdfaid': 'http://www.aiim.org/pdfa/ns/id/', 16 | 'pdfuaid': 'http://www.aiim.org/pdfua/ns/id/', 17 | } 18 | for key, value in NS.items(): 19 | register_namespace(key, value) 20 | 21 | 22 | def add_metadata(pdf, metadata, variant, version, conformance, compress): 23 | """Add PDF stream of metadata. 24 | 25 | Described in ISO-32000-1:2008, 14.3.2. 26 | 27 | """ 28 | header = b'' 29 | footer = b'' 30 | xml_data = metadata.generate_rdf_metadata(metadata, variant, version, conformance) 31 | stream_content = b'\n'.join((header, xml_data, footer)) 32 | extra = {'Type': '/Metadata', 'Subtype': '/XML'} 33 | metadata = pydyf.Stream([stream_content], extra, compress) 34 | pdf.add_object(metadata) 35 | pdf.catalog['Metadata'] = metadata.reference 36 | 37 | 38 | def generate_rdf_metadata(metadata, variant, version, conformance): 39 | """Generate RDF metadata as a bytestring. 40 | 41 | Might be replaced by DocumentMetadata.rdf_metadata_generator(). 42 | 43 | """ 44 | namespace = f'pdf{variant}id' 45 | rdf = Element(f'{{{NS["rdf"]}}}RDF') 46 | 47 | element = SubElement(rdf, f'{{{NS["rdf"]}}}Description') 48 | element.attrib[f'{{{NS["rdf"]}}}about'] = '' 49 | element.attrib[f'{{{NS[namespace]}}}part'] = str(version) 50 | if conformance: 51 | element.attrib[f'{{{NS[namespace]}}}conformance'] = conformance 52 | 53 | element = SubElement(rdf, f'{{{NS["rdf"]}}}Description') 54 | element.attrib[f'{{{NS["rdf"]}}}about'] = '' 55 | element.attrib[f'{{{NS["pdf"]}}}Producer'] = f'WeasyPrint {__version__}' 56 | 57 | if metadata.title: 58 | element = SubElement(rdf, f'{{{NS["rdf"]}}}Description') 59 | element.attrib[f'{{{NS["rdf"]}}}about'] = '' 60 | element = SubElement(element, f'{{{NS["dc"]}}}title') 61 | element = SubElement(element, f'{{{NS["rdf"]}}}Alt') 62 | element = SubElement(element, f'{{{NS["rdf"]}}}li') 63 | element.attrib['xml:lang'] = 'x-default' 64 | element.text = metadata.title 65 | if metadata.authors: 66 | element = SubElement(rdf, f'{{{NS["rdf"]}}}Description') 67 | element.attrib[f'{{{NS["rdf"]}}}about'] = '' 68 | element = SubElement(element, f'{{{NS["dc"]}}}creator') 69 | element = SubElement(element, f'{{{NS["rdf"]}}}Seq') 70 | for author in metadata.authors: 71 | author_element = SubElement(element, f'{{{NS["rdf"]}}}li') 72 | author_element.text = author 73 | if metadata.description: 74 | element = SubElement(rdf, f'{{{NS["rdf"]}}}Description') 75 | element.attrib[f'{{{NS["rdf"]}}}about'] = '' 76 | element = SubElement(element, f'{{{NS["dc"]}}}subject') 77 | element = SubElement(element, f'{{{NS["rdf"]}}}Bag') 78 | element = SubElement(element, f'{{{NS["rdf"]}}}li') 79 | element.text = metadata.description 80 | if metadata.keywords: 81 | element = SubElement(rdf, f'{{{NS["rdf"]}}}Description') 82 | element.attrib[f'{{{NS["rdf"]}}}about'] = '' 83 | element = SubElement(element, f'{{{NS["pdf"]}}}Keywords') 84 | element.text = ', '.join(metadata.keywords) 85 | if metadata.generator: 86 | element = SubElement(rdf, f'{{{NS["rdf"]}}}Description') 87 | element.attrib[f'{{{NS["rdf"]}}}about'] = '' 88 | element = SubElement(element, f'{{{NS["xmp"]}}}CreatorTool') 89 | element.text = metadata.generator 90 | if metadata.created: 91 | element = SubElement(rdf, f'{{{NS["rdf"]}}}Description') 92 | element.attrib[f'{{{NS["rdf"]}}}about'] = '' 93 | element = SubElement(element, f'{{{NS["xmp"]}}}CreateDate') 94 | element.text = metadata.created 95 | if metadata.modified: 96 | element = SubElement(rdf, f'{{{NS["rdf"]}}}Description') 97 | element.attrib[f'{{{NS["rdf"]}}}about'] = '' 98 | element = SubElement(element, f'{{{NS["xmp"]}}}ModifyDate') 99 | element.text = metadata.modified 100 | return tostring(rdf, encoding='utf-8') 101 | -------------------------------------------------------------------------------- /weasyprint/pdf/pdfa.py: -------------------------------------------------------------------------------- 1 | """PDF/A generation.""" 2 | 3 | from functools import partial 4 | 5 | import pydyf 6 | 7 | from .metadata import add_metadata 8 | 9 | 10 | def pdfa(pdf, metadata, document, page_streams, attachments, compress, 11 | version, variant): 12 | """Set metadata for PDF/A documents.""" 13 | 14 | # Handle attachments. 15 | if version == 1: 16 | # Remove embedded files dictionary. 17 | if 'Names' in pdf.catalog and 'EmbeddedFiles' in pdf.catalog['Names']: 18 | del pdf.catalog['Names']['EmbeddedFiles'] 19 | if version <= 2: 20 | # Remove attachments. 21 | for pdf_object in pdf.objects: 22 | if not isinstance(pdf_object, dict): 23 | continue 24 | if pdf_object.get('Type') != '/Filespec': 25 | continue 26 | reference = int(pdf_object['EF']['F'].split()[0]) 27 | stream = pdf.objects[reference] 28 | # Remove all attachments for version 1. 29 | # Remove non-PDF attachments for version 2. 30 | # TODO: check that PDFs are actually PDF/A-2+ files. 31 | if version == 1 or stream.extra['Subtype'] != '/application#2fpdf': 32 | del pdf_object['EF'] 33 | if version >= 3: 34 | # Add AF for attachments. 35 | relationships = { 36 | f'<{attachment.md5}>': attachment.relationship 37 | for attachment in attachments if attachment.md5} 38 | pdf_attachments = [] 39 | if 'Names' in pdf.catalog and 'EmbeddedFiles' in pdf.catalog['Names']: 40 | reference = int(pdf.catalog['Names']['EmbeddedFiles'].split()[0]) 41 | names = pdf.objects[reference] 42 | for name in names['Names'][1::2]: 43 | pdf_attachments.append(name) 44 | for pdf_object in pdf.objects: 45 | if not isinstance(pdf_object, dict): 46 | continue 47 | if pdf_object.get('Type') != '/Filespec': 48 | continue 49 | reference = int(pdf_object['EF']['F'].split()[0]) 50 | checksum = pdf.objects[reference].extra['Params']['CheckSum'] 51 | relationship = relationships.get(checksum, 'Unspecified') 52 | pdf_object['AFRelationship'] = f'/{relationship}' 53 | pdf_attachments.append(pdf_object.reference) 54 | if pdf_attachments: 55 | if 'AF' not in pdf.catalog: 56 | pdf.catalog['AF'] = pydyf.Array() 57 | pdf.catalog['AF'].extend(pdf_attachments) 58 | 59 | # Print annotations. 60 | for pdf_object in pdf.objects: 61 | if isinstance(pdf_object, dict) and pdf_object.get('Type') == '/Annot': 62 | pdf_object['F'] = 2 ** (3 - 1) 63 | 64 | # Common PDF metadata stream. 65 | if version == 1: 66 | # Metadata compression is forbidden for version 1. 67 | compress = False 68 | add_metadata(pdf, metadata, 'a', version, variant, compress) 69 | 70 | 71 | VARIANTS = { 72 | 'pdf/a-1b': ( 73 | partial(pdfa, version=1, variant='B'), 74 | {'version': '1.4', 'identifier': True, 'srgb': True}), 75 | 'pdf/a-2b': ( 76 | partial(pdfa, version=2, variant='B'), 77 | {'version': '1.7', 'identifier': True, 'srgb': True}), 78 | 'pdf/a-3b': ( 79 | partial(pdfa, version=3, variant='B'), 80 | {'version': '1.7', 'identifier': True, 'srgb': True}), 81 | 'pdf/a-4b': ( 82 | partial(pdfa, version=4, variant='B'), 83 | {'version': '2.0', 'identifier': True, 'srgb': True}), 84 | 'pdf/a-2u': ( 85 | partial(pdfa, version=2, variant='U'), 86 | {'version': '1.7', 'identifier': True, 'srgb': True}), 87 | 'pdf/a-3u': ( 88 | partial(pdfa, version=3, variant='U'), 89 | {'version': '1.7', 'identifier': True, 'srgb': True}), 90 | 'pdf/a-4u': ( 91 | partial(pdfa, version=4, variant='U'), 92 | {'version': '2.0', 'identifier': True, 'srgb': True}), 93 | } 94 | -------------------------------------------------------------------------------- /weasyprint/pdf/pdfua.py: -------------------------------------------------------------------------------- 1 | """PDF/UA generation.""" 2 | 3 | import pydyf 4 | 5 | from .metadata import add_metadata 6 | 7 | 8 | def pdfua(pdf, metadata, document, page_streams, attachments, compress): 9 | """Set metadata for PDF/UA documents.""" 10 | # Structure for PDF tagging 11 | content_mapping = pydyf.Dictionary({}) 12 | pdf.add_object(content_mapping) 13 | structure_root = pydyf.Dictionary({ 14 | 'Type': '/StructTreeRoot', 15 | 'ParentTree': content_mapping.reference, 16 | }) 17 | pdf.add_object(structure_root) 18 | structure_document = pydyf.Dictionary({ 19 | 'Type': '/StructElem', 20 | 'S': '/Document', 21 | 'P': structure_root.reference, 22 | }) 23 | pdf.add_object(structure_document) 24 | structure_root['K'] = pydyf.Array([structure_document.reference]) 25 | pdf.catalog['StructTreeRoot'] = structure_root.reference 26 | 27 | document_children = [] 28 | content_mapping['Nums'] = pydyf.Array() 29 | links = [] 30 | for page_number, page_stream in enumerate(page_streams): 31 | structure = {} 32 | document.build_element_structure(structure) 33 | parents = [None] * len(page_stream.marked) 34 | for mcid, (key, box) in enumerate(page_stream.marked): 35 | # Build structure elements 36 | kids = [mcid] 37 | if key == 'Link': 38 | object_reference = pydyf.Dictionary({ 39 | 'Type': '/OBJR', 40 | 'Obj': box.link_annotation.reference, 41 | 'Pg': pdf.page_references[page_number], 42 | }) 43 | pdf.add_object(object_reference) 44 | links.append((object_reference.reference, box.link_annotation)) 45 | etree_element = box.element 46 | child_structure_data_element = None 47 | while True: 48 | if etree_element is None: 49 | structure_data = structure.setdefault( 50 | box, {'parent': None}) 51 | else: 52 | structure_data = structure[etree_element] 53 | new_element = 'element' not in structure_data 54 | if new_element: 55 | child = structure_data['element'] = pydyf.Dictionary({ 56 | 'Type': '/StructElem', 57 | 'S': f'/{key}', 58 | 'K': pydyf.Array(kids), 59 | 'Pg': pdf.page_references[page_number], 60 | }) 61 | pdf.add_object(child) 62 | if key == 'LI': 63 | if etree_element.tag == 'dt': 64 | sub_key = 'Lbl' 65 | else: 66 | sub_key = 'LBody' 67 | real_child = pydyf.Dictionary({ 68 | 'Type': '/StructElem', 69 | 'S': f'/{sub_key}', 70 | 'K': pydyf.Array(kids), 71 | 'Pg': pdf.page_references[page_number], 72 | 'P': child.reference, 73 | }) 74 | pdf.add_object(real_child) 75 | for kid in kids: 76 | if isinstance(kid, int): 77 | parents[kid] = real_child.reference 78 | child['K'] = pydyf.Array([real_child.reference]) 79 | structure_data['element'] = real_child 80 | else: 81 | for kid in kids: 82 | if isinstance(kid, int): 83 | parents[kid] = child.reference 84 | else: 85 | child = structure_data['element'] 86 | child['K'].extend(kids) 87 | for kid in kids: 88 | if isinstance(kid, int): 89 | parents[kid] = child.reference 90 | kid = child.reference 91 | if child_structure_data_element is not None: 92 | child_structure_data_element['P'] = kid 93 | if not new_element: 94 | break 95 | kids = [kid] 96 | child_structure_data_element = child 97 | if structure_data['parent'] is None: 98 | child['P'] = structure_document.reference 99 | document_children.append(child.reference) 100 | break 101 | else: 102 | etree_element = structure_data['parent'] 103 | key = page_stream.get_marked_content_tag(etree_element.tag) 104 | content_mapping['Nums'].append(page_number) 105 | content_mapping['Nums'].append(pydyf.Array(parents)) 106 | structure_document['K'] = pydyf.Array(document_children) 107 | for i, (link, annotation) in enumerate(links, start=page_number + 1): 108 | content_mapping['Nums'].append(i) 109 | content_mapping['Nums'].append(link) 110 | annotation['StructParent'] = i 111 | annotation['F'] = 2 ** (2 - 1) 112 | 113 | # Common PDF metadata stream 114 | add_metadata(pdf, metadata, 'ua', 1, conformance=None, compress=compress) 115 | 116 | # PDF document extra metadata 117 | if 'Lang' not in pdf.catalog: 118 | pdf.catalog['Lang'] = pydyf.String() 119 | pdf.catalog['ViewerPreferences'] = pydyf.Dictionary({ 120 | 'DisplayDocTitle': 'true', 121 | }) 122 | pdf.catalog['MarkInfo'] = pydyf.Dictionary({'Marked': 'true'}) 123 | 124 | 125 | VARIANTS = {'pdf/ua-1': (pdfua, {'mark': True})} 126 | -------------------------------------------------------------------------------- /weasyprint/pdf/sRGB2014.icc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kozea/WeasyPrint/6dd9b1a20babeea0cf0d12e07ed99e689be94073/weasyprint/pdf/sRGB2014.icc -------------------------------------------------------------------------------- /weasyprint/stacking.py: -------------------------------------------------------------------------------- 1 | """Stacking contexts management.""" 2 | 3 | from .formatting_structure import boxes 4 | from .layout.absolute import AbsolutePlaceholder 5 | 6 | 7 | class StackingContext: 8 | """Stacking contexts define the paint order of all pieces of a document. 9 | 10 | https://www.w3.org/TR/CSS21/visuren.html#x43 11 | https://www.w3.org/TR/CSS21/zindex.html 12 | 13 | """ 14 | def __init__(self, box, child_contexts, blocks, floats, blocks_and_cells, 15 | page): 16 | self.box = box 17 | self.page = page 18 | self.block_level_boxes = blocks # 4: In flow, non positioned 19 | self.float_contexts = floats # 5: Non positioned 20 | self.negative_z_contexts = [] # 3: Child contexts, z-index < 0 21 | self.zero_z_contexts = [] # 8: Child contexts, z-index = 0 22 | self.positive_z_contexts = [] # 9: Child contexts, z-index > 0 23 | self.blocks_and_cells = blocks_and_cells # 7: Non positioned 24 | 25 | for context in child_contexts: 26 | if context.z_index < 0: 27 | self.negative_z_contexts.append(context) 28 | elif context.z_index == 0: 29 | self.zero_z_contexts.append(context) 30 | else: # context.z_index > 0 31 | self.positive_z_contexts.append(context) 32 | self.negative_z_contexts.sort(key=lambda context: context.z_index) 33 | self.positive_z_contexts.sort(key=lambda context: context.z_index) 34 | # sort() is stable, so the lists are now storted 35 | # by z-index, then tree order. 36 | 37 | self.z_index = box.style['z_index'] 38 | if self.z_index == 'auto': 39 | self.z_index = 0 40 | 41 | @classmethod 42 | def from_page(cls, page): 43 | # Page children (the box for the root element and margin boxes) 44 | # as well as the page box itself are unconditionally stacking contexts. 45 | child_contexts = [cls.from_box(child, page) for child in page.children] 46 | # Children are sub-contexts, remove them from the "normal" tree. 47 | page = page.copy_with_children([]) 48 | return cls(page, child_contexts, [], [], [], page) 49 | 50 | @classmethod 51 | def from_box(cls, box, page, child_contexts=None): 52 | children = [] # What will be passed to this box 53 | if child_contexts is None: 54 | child_contexts = children 55 | # child_contexts: where to put sub-contexts that we find here. 56 | # May not be the same as children for: 57 | # "treat the element as if it created a new stacking context, but any 58 | # positioned descendants and descendants which actually create a new 59 | # stacking context should be considered part of the parent stacking 60 | # context, not this new one." 61 | blocks = [] 62 | floats = [] 63 | blocks_and_cells = [] 64 | box = _dispatch_children( 65 | box, page, child_contexts, blocks, floats, blocks_and_cells) 66 | return cls(box, children, blocks, floats, blocks_and_cells, page) 67 | 68 | 69 | def _dispatch(box, page, child_contexts, blocks, floats, blocks_and_cells): 70 | if isinstance(box, AbsolutePlaceholder): 71 | box = box._box 72 | style = box.style 73 | 74 | # Remove boxes defining a new stacking context from the children list. 75 | defines_stacking_context = ( 76 | (style['position'] != 'static' and style['z_index'] != 'auto') or 77 | (box.is_grid_item and style['z_index'] != 'auto') or 78 | style['opacity'] < 1 or 79 | style['transform'] or # 'transform: none' gives a "falsy" empty list 80 | style['overflow'] != 'visible') 81 | if defines_stacking_context: 82 | child_contexts.append(StackingContext.from_box(box, page)) 83 | return 84 | 85 | stacking_classes = (boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox) 86 | if style['position'] != 'static': 87 | assert style['z_index'] == 'auto' 88 | # "Fake" context: sub-contexts will go in this `child_contexts` list. 89 | # Insert at the position before creating the sub-context. 90 | index = len(child_contexts) 91 | stacking_context = StackingContext.from_box(box, page, child_contexts) 92 | child_contexts.insert(index, stacking_context) 93 | elif box.is_floated(): 94 | floats.append(StackingContext.from_box(box, page, child_contexts)) 95 | elif isinstance(box, stacking_classes): 96 | # Have this fake stacking context be part of the "normal" box tree, 97 | # because we need its position in the middle of a tree of inline boxes. 98 | return StackingContext.from_box(box, page, child_contexts) 99 | else: 100 | if isinstance(box, boxes.BlockLevelBox): 101 | blocks_index = len(blocks) 102 | blocks_and_cells_index = len(blocks_and_cells) 103 | elif isinstance(box, boxes.TableCellBox): 104 | blocks_index = None 105 | blocks_and_cells_index = len(blocks_and_cells) 106 | else: 107 | blocks_index = None 108 | blocks_and_cells_index = None 109 | 110 | box = _dispatch_children( 111 | box, page, child_contexts, blocks, floats, blocks_and_cells) 112 | 113 | # Insert at the positions before dispatch the children. 114 | if blocks_index is not None: 115 | blocks.insert(blocks_index, box) 116 | if blocks_and_cells_index is not None: 117 | blocks_and_cells.insert(blocks_and_cells_index, box) 118 | 119 | return box 120 | 121 | 122 | def _dispatch_children(box, page, child_contexts, blocks, floats, 123 | blocks_and_cells): 124 | if not isinstance(box, boxes.ParentBox): 125 | return box 126 | 127 | new_children = [] 128 | for child in box.children: 129 | result = _dispatch( 130 | child, page, child_contexts, blocks, floats, blocks_and_cells) 131 | if result is not None: 132 | new_children.append(result) 133 | return box.copy_with_children(new_children) 134 | -------------------------------------------------------------------------------- /weasyprint/svg/css.py: -------------------------------------------------------------------------------- 1 | """Apply CSS to SVG documents.""" 2 | 3 | from urllib.parse import urljoin 4 | 5 | import cssselect2 6 | import tinycss2 7 | 8 | from ..logger import LOGGER 9 | from .utils import parse_url 10 | 11 | 12 | def find_stylesheets_rules(tree, stylesheet_rules, url): 13 | """Find rules among stylesheet rules and imports.""" 14 | for rule in stylesheet_rules: 15 | if rule.type == 'at-rule': 16 | if rule.lower_at_keyword == 'import' and rule.content is None: 17 | # TODO: support media types in @import 18 | url_token = tinycss2.parse_one_component_value(rule.prelude) 19 | if url_token.type not in ('string', 'url'): 20 | continue 21 | css_url = parse_url(urljoin(url, url_token.value)) 22 | stylesheet = tinycss2.parse_stylesheet( 23 | tree.fetch_url(css_url, 'text/css').decode()) 24 | url = css_url.geturl() 25 | yield from find_stylesheets_rules(tree, stylesheet, url) 26 | # TODO: support media types 27 | # if rule.lower_at_keyword == 'media': 28 | elif rule.type == 'qualified-rule': 29 | yield rule 30 | # TODO: warn on error 31 | # if rule.type == 'error': 32 | 33 | 34 | def parse_declarations(input): 35 | """Parse declarations in a given rule content.""" 36 | normal_declarations = [] 37 | important_declarations = [] 38 | for declaration in tinycss2.parse_blocks_contents(input): 39 | # TODO: warn on error 40 | # if declaration.type == 'error': 41 | if (declaration.type == 'declaration' and 42 | not declaration.name.startswith('-')): 43 | # Serializing perfectly good tokens just to re-parse them later :( 44 | value = tinycss2.serialize(declaration.value).strip() 45 | declarations = ( 46 | important_declarations if declaration.important 47 | else normal_declarations) 48 | declarations.append((declaration.lower_name, value)) 49 | return normal_declarations, important_declarations 50 | 51 | 52 | def parse_stylesheets(tree, url): 53 | """Find stylesheets and return rule matchers in given tree.""" 54 | normal_matcher = cssselect2.Matcher() 55 | important_matcher = cssselect2.Matcher() 56 | 57 | # Find stylesheets 58 | # TODO: support contentStyleType on 59 | stylesheets = [] 60 | for element in tree.etree_element.iter(): 61 | # https://www.w3.org/TR/SVG/styling.html#StyleElement 62 | if (element.tag == '{http://www.w3.org/2000/svg}style' and 63 | element.get('type', 'text/css') == 'text/css' and 64 | element.text): 65 | # TODO: pass href for relative URLs 66 | # TODO: support media types 67 | # TODO: what if