Import weasyprint_51.orig.tar.gz

[dgit import orig weasyprint_51.orig.tar.gz]
This commit is contained in:
Scott Kitterman 2019-12-25 07:16:42 +01:00
commit 43611e54ad
125 changed files with 42430 additions and 0 deletions

29
LICENSE Normal file
View File

@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2011-2019, Simon Sapin and contributors (see AUTHORS).
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

58
PKG-INFO Normal file
View File

@ -0,0 +1,58 @@
Metadata-Version: 2.1
Name: WeasyPrint
Version: 51
Summary: The Awesome Document Factory
Home-page: https://weasyprint.org/
Author: Simon Sapin
Author-email: community@kozea.fr
License: BSD
Project-URL: Documentation, https://weasyprint.readthedocs.io/
Project-URL: Code, https://github.com/Kozea/WeasyPrint/
Project-URL: Issue tracker, https://github.com/Kozea/WeasyPrint/issues
Project-URL: Donation, https://www.patreon.com/kozea
Description: ==========
WeasyPrint
==========
**The Awesome Document Factory**
WeasyPrint is a smart solution helping web developers to create PDF
documents. It turns simple HTML pages into gorgeous statistical reports,
invoices, tickets…
From a technical point of view, WeasyPrint is a visual rendering engine for
HTML and CSS that can export to PDF and PNG. It aims to support web standards
for printing. WeasyPrint is free software made available under a BSD license.
It is based on various libraries but *not* on a full rendering engine like
WebKit or Gecko. The CSS layout engine is written in Python, designed for
pagination, and meant to be easy to hack on.
* Free software: BSD licensed
* Python 3.5+
* Website: https://weasyprint.org/
* Documentation: https://weasyprint.readthedocs.io/
* Source code and issue tracker: https://github.com/Kozea/WeasyPrint
* Tests: https://travis-ci.org/Kozea/WeasyPrint
* Support: https://www.patreon.com/kozea
Keywords: html,css,pdf,converter
Platform: Linux
Platform: macOS
Platform: Windows
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: BSD License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Text Processing :: Markup :: HTML
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
Classifier: Topic :: Printing
Requires-Python: >=3.5
Description-Content-Type: text/x-rst
Provides-Extra: doc
Provides-Extra: test

25
README.rst Normal file
View File

@ -0,0 +1,25 @@
==========
WeasyPrint
==========
**The Awesome Document Factory**
WeasyPrint is a smart solution helping web developers to create PDF
documents. It turns simple HTML pages into gorgeous statistical reports,
invoices, tickets…
From a technical point of view, WeasyPrint is a visual rendering engine for
HTML and CSS that can export to PDF and PNG. It aims to support web standards
for printing. WeasyPrint is free software made available under a BSD license.
It is based on various libraries but *not* on a full rendering engine like
WebKit or Gecko. The CSS layout engine is written in Python, designed for
pagination, and meant to be easy to hack on.
* Free software: BSD licensed
* Python 3.5+
* Website: https://weasyprint.org/
* Documentation: https://weasyprint.readthedocs.io/
* Source code and issue tracker: https://github.com/Kozea/WeasyPrint
* Tests: https://travis-ci.org/Kozea/WeasyPrint
* Support: https://www.patreon.com/kozea

View File

@ -0,0 +1,58 @@
Metadata-Version: 2.1
Name: WeasyPrint
Version: 51
Summary: The Awesome Document Factory
Home-page: https://weasyprint.org/
Author: Simon Sapin
Author-email: community@kozea.fr
License: BSD
Project-URL: Documentation, https://weasyprint.readthedocs.io/
Project-URL: Code, https://github.com/Kozea/WeasyPrint/
Project-URL: Issue tracker, https://github.com/Kozea/WeasyPrint/issues
Project-URL: Donation, https://www.patreon.com/kozea
Description: ==========
WeasyPrint
==========
**The Awesome Document Factory**
WeasyPrint is a smart solution helping web developers to create PDF
documents. It turns simple HTML pages into gorgeous statistical reports,
invoices, tickets…
From a technical point of view, WeasyPrint is a visual rendering engine for
HTML and CSS that can export to PDF and PNG. It aims to support web standards
for printing. WeasyPrint is free software made available under a BSD license.
It is based on various libraries but *not* on a full rendering engine like
WebKit or Gecko. The CSS layout engine is written in Python, designed for
pagination, and meant to be easy to hack on.
* Free software: BSD licensed
* Python 3.5+
* Website: https://weasyprint.org/
* Documentation: https://weasyprint.readthedocs.io/
* Source code and issue tracker: https://github.com/Kozea/WeasyPrint
* Tests: https://travis-ci.org/Kozea/WeasyPrint
* Support: https://www.patreon.com/kozea
Keywords: html,css,pdf,converter
Platform: Linux
Platform: macOS
Platform: Windows
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: BSD License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Text Processing :: Markup :: HTML
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
Classifier: Topic :: Printing
Requires-Python: >=3.5
Description-Content-Type: text/x-rst
Provides-Extra: doc
Provides-Extra: test

View File

@ -0,0 +1,124 @@
LICENSE
README.rst
setup.cfg
setup.py
WeasyPrint.egg-info/PKG-INFO
WeasyPrint.egg-info/SOURCES.txt
WeasyPrint.egg-info/dependency_links.txt
WeasyPrint.egg-info/entry_points.txt
WeasyPrint.egg-info/not-zip-safe
WeasyPrint.egg-info/requires.txt
WeasyPrint.egg-info/top_level.txt
weasyprint/VERSION
weasyprint/__init__.py
weasyprint/__main__.py
weasyprint/document.py
weasyprint/draw.py
weasyprint/fonts.py
weasyprint/html.py
weasyprint/images.py
weasyprint/logger.py
weasyprint/pdf.py
weasyprint/stacking.py
weasyprint/text.py
weasyprint/urls.py
weasyprint/css/__init__.py
weasyprint/css/computed_values.py
weasyprint/css/html5_ph.css
weasyprint/css/html5_ua.css
weasyprint/css/media_queries.py
weasyprint/css/properties.py
weasyprint/css/targets.py
weasyprint/css/tests_ua.css
weasyprint/css/utils.py
weasyprint/css/validation/__init__.py
weasyprint/css/validation/descriptors.py
weasyprint/css/validation/expanders.py
weasyprint/css/validation/properties.py
weasyprint/formatting_structure/__init__.py
weasyprint/formatting_structure/boxes.py
weasyprint/formatting_structure/build.py
weasyprint/formatting_structure/counters.py
weasyprint/layout/__init__.py
weasyprint/layout/absolute.py
weasyprint/layout/backgrounds.py
weasyprint/layout/blocks.py
weasyprint/layout/columns.py
weasyprint/layout/flex.py
weasyprint/layout/float.py
weasyprint/layout/inlines.py
weasyprint/layout/min_max.py
weasyprint/layout/pages.py
weasyprint/layout/percentages.py
weasyprint/layout/preferred.py
weasyprint/layout/replaced.py
weasyprint/layout/tables.py
weasyprint/tests/__init__.py
weasyprint/tests/test_acid2.py
weasyprint/tests/test_api.py
weasyprint/tests/test_boxes.py
weasyprint/tests/test_css.py
weasyprint/tests/test_css_descriptors.py
weasyprint/tests/test_css_validation.py
weasyprint/tests/test_float.py
weasyprint/tests/test_fonts.py
weasyprint/tests/test_pdf.py
weasyprint/tests/test_presentational_hints.py
weasyprint/tests/test_stacking.py
weasyprint/tests/test_target.py
weasyprint/tests/test_text.py
weasyprint/tests/test_tools.py
weasyprint/tests/test_unicode.py
weasyprint/tests/test_variables.py
weasyprint/tests/testing_utils.py
weasyprint/tests/resources/AHEM____.TTF
weasyprint/tests/resources/acid2-reference.html
weasyprint/tests/resources/acid2-test.html
weasyprint/tests/resources/blue.jpg
weasyprint/tests/resources/doc1.html
weasyprint/tests/resources/doc1_UTF-16BE.html
weasyprint/tests/resources/icon.png
weasyprint/tests/resources/latin1-test.css
weasyprint/tests/resources/logo_small.png
weasyprint/tests/resources/mini_ua.css
weasyprint/tests/resources/pattern.gif
weasyprint/tests/resources/pattern.palette.png
weasyprint/tests/resources/pattern.png
weasyprint/tests/resources/pattern.svg
weasyprint/tests/resources/really-a-png.svg
weasyprint/tests/resources/really-a-svg.png
weasyprint/tests/resources/sheet2.css
weasyprint/tests/resources/user.css
weasyprint/tests/resources/utf8-test.css
weasyprint/tests/resources/weasyprint.otf
weasyprint/tests/resources/sub_directory/sheet1.css
weasyprint/tests/test_draw/__init__.py
weasyprint/tests/test_draw/test_background.py
weasyprint/tests/test_draw/test_before_after.py
weasyprint/tests/test_draw/test_box.py
weasyprint/tests/test_draw/test_column.py
weasyprint/tests/test_draw/test_current_color.py
weasyprint/tests/test_draw/test_gradient.py
weasyprint/tests/test_draw/test_image.py
weasyprint/tests/test_draw/test_list.py
weasyprint/tests/test_draw/test_opacity.py
weasyprint/tests/test_draw/test_overflow.py
weasyprint/tests/test_draw/test_table.py
weasyprint/tests/test_draw/test_text.py
weasyprint/tests/test_draw/test_transform.py
weasyprint/tests/test_draw/test_visibility.py
weasyprint/tests/test_layout/__init__.py
weasyprint/tests/test_layout/test_block.py
weasyprint/tests/test_layout/test_column.py
weasyprint/tests/test_layout/test_flex.py
weasyprint/tests/test_layout/test_image.py
weasyprint/tests/test_layout/test_inline.py
weasyprint/tests/test_layout/test_inline_block.py
weasyprint/tests/test_layout/test_list.py
weasyprint/tests/test_layout/test_page.py
weasyprint/tests/test_layout/test_position.py
weasyprint/tests/test_layout/test_shrink_to_fit.py
weasyprint/tests/test_layout/test_table.py
weasyprint/tools/__init__.py
weasyprint/tools/navigator.py
weasyprint/tools/renderer.py

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,3 @@
[console_scripts]
weasyprint = weasyprint.__main__:main

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,18 @@
setuptools>=39.2.0
cffi>=0.6
html5lib>=0.999999999
cairocffi>=0.9.0
tinycss2>=1.0.0
cssselect2>=0.1
CairoSVG>=2.4.0
Pyphen>=0.9.1
[doc]
sphinx
sphinx_rtd_theme
[test]
pytest-runner
pytest-cov
pytest-flake8
pytest-isort

View File

@ -0,0 +1 @@
weasyprint

111
setup.cfg Normal file
View File

@ -0,0 +1,111 @@
[metadata]
name = WeasyPrint
url = https://weasyprint.org/
version = file: weasyprint/VERSION
license = BSD
license_file = LICENSE
description = The Awesome Document Factory
long_description = file: README.rst
long_description_content_type = text/x-rst
author = Simon Sapin
author_email = community@kozea.fr
platforms =
Linux
macOS
Windows
keywords =
html
css
pdf
converter
classifiers =
Development Status :: 5 - Production/Stable
Intended Audience :: Developers
License :: OSI Approved :: BSD License
Programming Language :: Python :: 3
Programming Language :: Python :: 3.5
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Topic :: Internet :: WWW/HTTP
Topic :: Text Processing :: Markup :: HTML
Topic :: Multimedia :: Graphics :: Graphics Conversion
Topic :: Printing
project_urls =
Documentation = https://weasyprint.readthedocs.io/
Code = https://github.com/Kozea/WeasyPrint/
Issue tracker = https://github.com/Kozea/WeasyPrint/issues
Donation = https://www.patreon.com/kozea
[options]
packages = find:
zip_safe = false
setup_requires = pytest-runner
install_requires =
setuptools>=39.2.0
cffi>=0.6
html5lib>=0.999999999
cairocffi>=0.9.0
tinycss2>=1.0.0
cssselect2>=0.1
CairoSVG>=2.4.0
Pyphen>=0.9.1
tests_require =
pytest-runner
pytest-cov
pytest-flake8
pytest-isort
python_requires = >= 3.5
[options.entry_points]
console-scripts = weasyprint = weasyprint.__main__:main
[options.package_data]
weasyprint = VERSION
weasyprint.tests = resources/*.*, resources/*/*
weasyprint.css = *.css
[options.extras_require]
doc =
sphinx
sphinx_rtd_theme
test =
pytest-runner
pytest-cov
pytest-flake8
pytest-isort
[bdist_wheel]
python-tag = py3
[build_sphinx]
source-dir = docs
build-dir = docs/_build
[aliases]
test = pytest
[tool:pytest]
addopts = --flake8 --isort
norecursedirs = build dist .cache .eggs .git
[coverage:run]
branch = True
include = weasyprint/*
[coverage:report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
omit =
.*
[isort]
default_section = THIRDPARTY
multi_line_output = 4
[egg_info]
tag_build =
tag_date = 0

23
setup.py Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env python
"""
WeasyPrint
==========
WeasyPrint converts web documents to PDF.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import sys
from setuptools import setup
if sys.version_info.major < 3:
raise RuntimeError(
'WeasyPrint does not support Python 2.x anymore. '
'Please use Python 3 or install an older version of WeasyPrint.')
setup()

1
weasyprint/VERSION Normal file
View File

@ -0,0 +1 @@
51

443
weasyprint/__init__.py Normal file
View File

@ -0,0 +1,443 @@
"""
WeasyPrint
==========
WeasyPrint converts web documents to PDF.
The public API is what is accessible from this "root" packages
without importing sub-modules.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import contextlib
import os
import sys
from pathlib import Path
import cssselect2
import html5lib
import tinycss2
if sys.version_info.major < 3:
raise RuntimeError(
'WeasyPrint does not support Python 2.x anymore. '
'Please use Python 3 or install an older version of WeasyPrint.')
if hasattr(sys, 'frozen'):
if hasattr(sys, '_MEIPASS'):
# Frozen with PyInstaller
# See https://github.com/Kozea/WeasyPrint/pull/540
ROOT = Path(sys._MEIPASS)
else:
# Frozen with something else (py2exe, etc.)
# See https://github.com/Kozea/WeasyPrint/pull/269
ROOT = os.path.dirname(sys.executable)
else:
ROOT = Path(os.path.dirname(__file__))
VERSION = __version__ = (ROOT / 'VERSION').read_text().strip()
# Used for 'User-Agent' in HTTP and 'Creator' in PDF
VERSION_STRING = 'WeasyPrint %s (http://weasyprint.org/)' % VERSION
__all__ = ['HTML', 'CSS', 'Attachment', 'Document', 'Page',
'default_url_fetcher', 'VERSION']
# Import after setting the version, as the version is used in other modules
from .urls import ( # noqa isort:skip
fetch, default_url_fetcher, path2url, ensure_url, url_is_absolute)
from .logger import LOGGER, PROGRESS_LOGGER # noqa isort:skip
# Some imports are at the end of the file (after the CSS class)
# to work around circular imports.
class HTML(object):
"""Represents an HTML document parsed by html5lib.
You can just create an instance with a positional argument:
``doc = HTML(something)``
The class will try to guess if the input is a filename, an absolute URL,
or a :term:`file object`.
Alternatively, use **one** named argument so that no guessing is involved:
:type filename: str or pathlib.Path
:param filename: A filename, relative to the current directory, or
absolute.
:type url: str
:param url: An absolute, fully qualified URL.
:type file_obj: :term:`file object`
:param file_obj: Any object with a ``read`` method.
:type string: str
:param string: A string of HTML source.
Specifying multiple inputs is an error:
``HTML(filename="foo.html", url="localhost://bar.html")``
will raise a :obj:`TypeError`.
You can also pass optional named arguments:
:type encoding: str
:param encoding: Force the source character encoding.
:type base_url: str
:param base_url: The base used to resolve relative URLs
(e.g. in ``<img src="../foo.png">``). If not provided, try to use
the input filename, URL, or ``name`` attribute of :term:`file objects
<file object>`.
:type url_fetcher: function
:param url_fetcher: A function or other callable
with the same signature as :func:`default_url_fetcher` called to
fetch external resources such as stylesheets and images.
(See :ref:`url-fetchers`.)
:type media_type: str
:param media_type: The media type to use for ``@media``.
Defaults to ``'print'``. **Note:** In some cases like
``HTML(string=foo)`` relative URLs will be invalid if ``base_url``
is not provided.
"""
def __init__(self, guess=None, filename=None, url=None, file_obj=None,
string=None, encoding=None, base_url=None,
url_fetcher=default_url_fetcher, media_type='print'):
PROGRESS_LOGGER.info(
'Step 1 - Fetching and parsing HTML - %s',
guess or filename or url or
getattr(file_obj, 'name', 'HTML string'))
result = _select_source(
guess, filename, url, file_obj, string, base_url, url_fetcher)
with result as (source_type, source, base_url, protocol_encoding):
if isinstance(source, str):
result = html5lib.parse(source, namespaceHTMLElements=False)
else:
result = html5lib.parse(
source, override_encoding=encoding,
transport_encoding=protocol_encoding,
namespaceHTMLElements=False)
assert result
self.base_url = find_base_url(result, base_url)
self.url_fetcher = url_fetcher
self.media_type = media_type
self.wrapper_element = cssselect2.ElementWrapper.from_html_root(
result, content_language=None)
self.etree_element = self.wrapper_element.etree_element
def _ua_stylesheets(self):
return [HTML5_UA_STYLESHEET]
def _ph_stylesheets(self):
return [HTML5_PH_STYLESHEET]
def _get_metadata(self):
return get_html_metadata(self.wrapper_element, self.base_url)
def render(self, stylesheets=None, enable_hinting=False,
presentational_hints=False, font_config=None):
"""Lay out and paginate the document, but do not (yet) export it
to PDF or PNG.
This returns a :class:`~document.Document` object which provides
access to individual pages and various meta-data.
See :meth:`write_pdf` to get a PDF directly.
.. versionadded:: 0.15
:type stylesheets: list
:param stylesheets:
An optional list of user stylesheets. List elements are
:class:`CSS` objects, filenames, URLs, or file
objects. (See :ref:`stylesheet-origins`.)
:type enable_hinting: bool
:param enable_hinting:
Whether text, borders and background should be *hinted* to fall
at device pixel boundaries. Should be enabled for pixel-based
output (like PNG) but not for vector-based output (like PDF).
:type presentational_hints: bool
:param presentational_hints: Whether HTML presentational hints are
followed.
:type font_config: :class:`~fonts.FontConfiguration`
:param font_config: A font configuration handling ``@font-face`` rules.
:returns: A :class:`~document.Document` object.
"""
return Document._render(
self, stylesheets, enable_hinting, presentational_hints,
font_config)
def write_pdf(self, target=None, stylesheets=None, zoom=1,
attachments=None, presentational_hints=False,
font_config=None):
"""Render the document to a PDF file.
This is a shortcut for calling :meth:`render`, then
:meth:`Document.write_pdf() <document.Document.write_pdf>`.
:type target: str, pathlib.Path or file object
:param target:
A filename where the PDF file is generated, a file object, or
:obj:`None`.
:type stylesheets: list
:param stylesheets:
An optional list of user stylesheets. The list's elements
are :class:`CSS` objects, filenames, URLs, or file-like
objects. (See :ref:`stylesheet-origins`.)
:type zoom: float
:param zoom:
The zoom factor in PDF units per CSS units. **Warning**:
All CSS units are affected, including physical units like
``cm`` and named sizes like ``A4``. For values other than
1, the physical CSS units will thus be "wrong".
:type attachments: list
:param attachments: A list of additional file attachments for the
generated PDF document or :obj:`None`. The list's elements are
:class:`Attachment` objects, filenames, URLs or file-like objects.
:type presentational_hints: bool
:param presentational_hints: Whether HTML presentational hints are
followed.
:type font_config: :class:`~fonts.FontConfiguration`
:param font_config: A font configuration handling ``@font-face`` rules.
:returns:
The PDF as :obj:`bytes` if ``target`` is not provided or
:obj:`None`, otherwise :obj:`None` (the PDF is written to
``target``).
"""
return self.render(
stylesheets, enable_hinting=False,
presentational_hints=presentational_hints,
font_config=font_config).write_pdf(
target, zoom, attachments)
def write_image_surface(self, stylesheets=None, resolution=96,
presentational_hints=False, font_config=None):
"""Render pages vertically on a cairo image surface.
.. versionadded:: 0.17
There is no decoration around pages other than those specified in CSS
with ``@page`` rules. The final image is as wide as the widest page.
Each page is below the previous one, centered horizontally.
This is a shortcut for calling :meth:`render`, then
:meth:`Document.write_image_surface()
<document.Document.write_image_surface>`.
:type stylesheets: list
:param stylesheets:
An optional list of user stylesheets. The list's elements
are :class:`CSS` objects, filenames, URLs, or file-like
objects. (See :ref:`stylesheet-origins`.)
:type resolution: float
:param resolution:
The output resolution in PNG pixels per CSS inch. At 96 dpi
(the default), PNG pixels match the CSS ``px`` unit.
:type presentational_hints: bool
:param presentational_hints: Whether HTML presentational hints are
followed.
:type font_config: :class:`~fonts.FontConfiguration`
:param font_config: A font configuration handling ``@font-face`` rules.
:returns: A cairo :class:`ImageSurface <cairocffi.ImageSurface>`.
"""
surface, _width, _height = (
self.render(stylesheets, enable_hinting=True,
presentational_hints=presentational_hints,
font_config=font_config)
.write_image_surface(resolution))
return surface
def write_png(self, target=None, stylesheets=None, resolution=96,
presentational_hints=False, font_config=None):
"""Paint the pages vertically to a single PNG image.
There is no decoration around pages other than those specified in CSS
with ``@page`` rules. The final image is as wide as the widest page.
Each page is below the previous one, centered horizontally.
This is a shortcut for calling :meth:`render`, then
:meth:`Document.write_png() <document.Document.write_png>`.
:type target: str, pathlib.Path or file object
:param target:
A filename where the PNG file is generated, a file object, or
:obj:`None`.
:type stylesheets: list
:param stylesheets:
An optional list of user stylesheets. The list's elements
are :class:`CSS` objects, filenames, URLs, or file-like
objects. (See :ref:`stylesheet-origins`.)
:type resolution: float
:param resolution:
The output resolution in PNG pixels per CSS inch. At 96 dpi
(the default), PNG pixels match the CSS ``px`` unit.
:type presentational_hints: bool
:param presentational_hints: Whether HTML presentational hints are
followed.
:type font_config: :class:`~fonts.FontConfiguration`
:param font_config: A font configuration handling ``@font-face`` rules.
:returns:
The image as :obj:`bytes` if ``target`` is not provided or
:obj:`None`, otherwise :obj:`None` (the image is written to
``target``.)
"""
png_bytes, _width, _height = (
self.render(stylesheets, enable_hinting=True,
presentational_hints=presentational_hints,
font_config=font_config)
.write_png(target, resolution))
return png_bytes
class CSS(object):
"""Represents a CSS stylesheet parsed by tinycss2.
An instance is created in the same way as :class:`HTML`, with the same
arguments.
An additional argument called ``font_config`` must be provided to handle
``@font-config`` rules. The same ``fonts.FontConfiguration`` object must be
used for different ``CSS`` objects applied to the same document.
``CSS`` objects have no public attributes or methods. They are only meant
to be used in the :meth:`~HTML.write_pdf`, :meth:`~HTML.write_png` and
:meth:`~HTML.render` methods of :class:`HTML` objects.
"""
def __init__(self, guess=None, filename=None, url=None, file_obj=None,
string=None, encoding=None, base_url=None,
url_fetcher=default_url_fetcher, _check_mime_type=False,
media_type='print', font_config=None, matcher=None,
page_rules=None):
PROGRESS_LOGGER.info(
'Step 2 - Fetching and parsing CSS - %s',
filename or url or getattr(file_obj, 'name', 'CSS string'))
result = _select_source(
guess, filename, url, file_obj, string,
base_url=base_url, url_fetcher=url_fetcher,
check_css_mime_type=_check_mime_type)
with result as (source_type, source, base_url, protocol_encoding):
if source_type == 'string' and not isinstance(source, bytes):
# unicode, no encoding
stylesheet = tinycss2.parse_stylesheet(source)
else:
if source_type == 'file_obj':
source = source.read()
stylesheet, encoding = tinycss2.parse_stylesheet_bytes(
source, environment_encoding=encoding,
protocol_encoding=protocol_encoding)
self.base_url = base_url
self.matcher = matcher or cssselect2.Matcher()
self.page_rules = [] if page_rules is None else page_rules
self.fonts = []
preprocess_stylesheet(
media_type, base_url, stylesheet, url_fetcher, self.matcher,
self.page_rules, self.fonts, font_config)
class Attachment(object):
"""Represents a file attachment for a PDF document.
.. versionadded:: 0.22
An instance is created in the same way as :class:`HTML`, except that the
HTML specific arguments (``encoding`` and ``media_type``) are not
supported. An optional description can be provided with the ``description``
argument.
:param description: A description of the attachment to be included in the
PDF document. May be :obj:`None`.
"""
def __init__(self, guess=None, filename=None, url=None, file_obj=None,
string=None, base_url=None, url_fetcher=default_url_fetcher,
description=None):
self.source = _select_source(
guess, filename, url, file_obj, string,
base_url=base_url, url_fetcher=url_fetcher)
self.description = description
@contextlib.contextmanager
def _select_source(guess=None, filename=None, url=None, file_obj=None,
string=None, base_url=None, url_fetcher=default_url_fetcher,
check_css_mime_type=False):
"""
Check that only one input is not None, and return it with the
normalized ``base_url``.
"""
if base_url is not None:
base_url = ensure_url(base_url)
selected_params = [
param for param in (guess, filename, url, file_obj, string) if
param is not None]
if len(selected_params) != 1:
raise TypeError('Expected exactly one source, got ' + (
', '.join(selected_params) or 'nothing'
))
elif guess is not None:
if hasattr(guess, 'read'):
type_ = 'file_obj'
elif isinstance(guess, Path):
type_ = 'filename'
elif url_is_absolute(guess):
type_ = 'url'
else:
type_ = 'filename'
result = _select_source(
base_url=base_url, url_fetcher=url_fetcher,
check_css_mime_type=check_css_mime_type,
**{type_: guess})
with result as result:
yield result
elif filename is not None:
if isinstance(filename, Path):
filename = str(filename)
if base_url is None:
base_url = path2url(filename)
with open(filename, 'rb') as file_obj:
yield 'file_obj', file_obj, base_url, None
elif url is not None:
with fetch(url_fetcher, url) as result:
if check_css_mime_type and result['mime_type'] != 'text/css':
LOGGER.error(
'Unsupported stylesheet type %s for %s',
result['mime_type'], result['redirected_url'])
yield 'string', '', base_url, None
else:
proto_encoding = result.get('encoding')
if base_url is None:
base_url = result.get('redirected_url', url)
if 'string' in result:
yield 'string', result['string'], base_url, proto_encoding
else:
yield (
'file_obj', result['file_obj'], base_url,
proto_encoding)
elif file_obj is not None:
if base_url is None:
# filesystem file-like objects have a 'name' attribute.
name = getattr(file_obj, 'name', None)
# Some streams have a .name like '<stdin>', not a filename.
if name and not name.startswith('<'):
base_url = ensure_url(name)
yield 'file_obj', file_obj, base_url, None
elif string is not None:
yield 'string', string, base_url, None
else:
sources = dict(locals())
sources_names = ', '.join(
name for name in ('guess', 'filename', 'url', 'file_obj', 'string')
if sources[name] is not None) or 'nothing'
raise TypeError('Expected exactly one source, got ' + sources_names)
# Work around circular imports.
from .css import preprocess_stylesheet # noqa isort:skip
from .html import ( # noqa isort:skip
HTML5_UA_STYLESHEET, HTML5_PH_STYLESHEET, find_base_url, get_html_metadata)
from .document import Document, Page # noqa isort:skip

216
weasyprint/__main__.py Normal file
View File

@ -0,0 +1,216 @@
"""
weasyprint.__main__
-------------------
Command-line interface to WeasyPrint.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import argparse
import logging
import os
import sys
import cairosvg
from . import HTML, LOGGER, VERSION
from .text import cairo, pango
class PrintInfo(argparse.Action):
def __call__(*_, **__):
uname = os.uname()
print('System:', uname.sysname)
print('Machine:', uname.machine)
print('Version:', uname.version)
print('Release:', uname.release)
print()
print('WeasyPrint version:', VERSION)
print('Python version:', sys.version.split()[0])
print('Cairo version:', cairo.cairo_version())
print('Pango version:', pango.pango_version())
print('CairoSVG version:', cairosvg.__version__)
sys.exit()
def main(argv=None, stdout=None, stdin=None):
"""The ``weasyprint`` program takes at least two arguments:
.. code-block:: sh
weasyprint [options] <input> <output>
The input is a filename or URL to an HTML document, or ``-`` to read
HTML from stdin. The output is a filename, or ``-`` to write to stdout.
Options can be mixed anywhere before, between, or after the input and
output.
.. option:: -e <input_encoding>, --encoding <input_encoding>
Force the input character encoding (e.g. ``-e utf8``).
.. option:: -f <output_format>, --format <output_format>
Choose the output file format among PDF and PNG (e.g. ``-f png``).
Required if the output is not a ``.pdf`` or ``.png`` filename.
.. option:: -s <filename_or_URL>, --stylesheet <filename_or_URL>
Filename or URL of a user cascading stylesheet (see
:ref:`stylesheet-origins`) to add to the document
(e.g. ``-s print.css``). Multiple stylesheets are allowed.
.. option:: -m <type>, --media-type <type>
Set the media type to use for ``@media``. Defaults to ``print``.
.. option:: -r <dpi>, --resolution <dpi>
For PNG output only. Set the resolution in PNG pixel per CSS inch.
Defaults to 96, which means that PNG pixels match CSS pixels.
.. option:: -u <URL>, --base-url <URL>
Set the base for relative URLs in the HTML input.
Defaults to the inputs own URL, or the current directory for stdin.
.. option:: -a <file>, --attachment <file>
Adds an attachment to the document. The attachment is
included in the PDF output. This option can be used multiple
times.
.. option:: -p, --presentational-hints
Follow `HTML presentational hints
<https://www.w3.org/TR/html/rendering.html\
#the-css-user-agent-style-sheet-and-presentational-hints>`_.
.. option:: -v, --verbose
Show warnings and information messages.
.. option:: -d, --debug
Show debugging messages.
.. option:: --version
Show the version number. Other options and arguments are ignored.
.. option:: -h, --help
Show the command-line usage. Other options and arguments are ignored.
"""
parser = argparse.ArgumentParser(
prog='weasyprint', description='Renders web pages to PDF or PNG.')
parser.add_argument('--version', action='version',
version='WeasyPrint version %s' % VERSION,
help="Print WeasyPrint's version number and exit.")
parser.add_argument('-i', '--info', action=PrintInfo, nargs=0,
help='Print system information and exit.')
parser.add_argument('-e', '--encoding',
help='Character encoding of the input')
parser.add_argument('-f', '--format', choices=['pdf', 'png'],
help='Output format. Can be omitted if `output` '
'ends with a .pdf or .png extension.')
parser.add_argument('-s', '--stylesheet', action='append',
help='URL or filename for a user CSS stylesheet. '
'May be given multiple times.')
parser.add_argument('-m', '--media-type', default='print',
help='Media type to use for @media, defaults to print')
parser.add_argument('-r', '--resolution', type=float,
help='PNG only: the resolution in pixel per CSS inch. '
'Defaults to 96, one PNG pixel per CSS pixel.')
parser.add_argument('-u', '--base-url',
help='Base for relative URLs in the HTML input. '
"Defaults to the input's own filename or URL "
'or the current directory for stdin.')
parser.add_argument('-a', '--attachment', action='append',
help='URL or filename of a file '
'to attach to the PDF document')
parser.add_argument('-p', '--presentational-hints', action='store_true',
help='Follow HTML presentational hints.')
parser.add_argument('-v', '--verbose', action='store_true',
help='Show warnings and information messages.')
parser.add_argument('-d', '--debug', action='store_true',
help='Show debugging messages.')
parser.add_argument('-q', '--quiet', action='store_true',
help='Hide logging messages.')
parser.add_argument(
'input', help='URL or filename of the HTML input, or - for stdin')
parser.add_argument(
'output', help='Filename where output is written, or - for stdout')
args = parser.parse_args(argv)
if args.format is None:
output_lower = args.output.lower()
if output_lower.endswith('.pdf'):
format_ = 'pdf'
elif output_lower.endswith('.png'):
format_ = 'png'
else:
parser.error(
'Either specify a format with -f or choose an '
'output filename that ends in .pdf or .png')
else:
format_ = args.format.lower()
if args.input == '-':
if stdin is None:
stdin = sys.stdin
# stdin.buffer on Py3, stdin on Py2
source = getattr(stdin, 'buffer', stdin)
if args.base_url is None:
args.base_url = '.' # current directory
elif args.base_url == '':
args.base_url = None # no base URL
else:
source = args.input
if args.output == '-':
if stdout is None:
stdout = sys.stdout
# stdout.buffer on Py3, stdout on Py2
output = getattr(stdout, 'buffer', stdout)
else:
output = args.output
kwargs = {
'stylesheets': args.stylesheet,
'presentational_hints': args.presentational_hints}
if args.resolution:
if format_ == 'png':
kwargs['resolution'] = args.resolution
else:
parser.error('--resolution only applies for the PNG format.')
if args.attachment:
if format_ == 'pdf':
kwargs['attachments'] = args.attachment
else:
parser.error('--attachment only applies for the PDF format.')
# Default to logging to stderr.
if args.debug:
LOGGER.setLevel(logging.DEBUG)
elif args.verbose:
LOGGER.setLevel(logging.INFO)
if not args.quiet:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
LOGGER.addHandler(handler)
html = HTML(source, base_url=args.base_url, encoding=args.encoding,
media_type=args.media_type)
getattr(html, 'write_' + format_)(output, **kwargs)
if __name__ == '__main__': # pragma: no cover
main()

967
weasyprint/css/__init__.py Normal file
View File

@ -0,0 +1,967 @@
"""
weasyprint.css
--------------
This module takes care of steps 3 and 4 of CSS 2.1 processing model:
Retrieve stylesheets associated with a document and annotate every element
with a value for every CSS property.
http://www.w3.org/TR/CSS21/intro.html#processing-model
This module does this in more than two steps. The
:func:`get_all_computed_styles` function does everything, but it is itsef
based on other functions in this module.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from collections import namedtuple
from logging import DEBUG, WARNING
import cssselect2
import tinycss2
import tinycss2.nth
from .. import CSS
from ..logger import LOGGER, PROGRESS_LOGGER
from ..urls import URLFetchingError, get_url_attribute, url_join
from . import computed_values, media_queries
from .properties import INHERITED, INITIAL_NOT_COMPUTED, INITIAL_VALUES
from .utils import remove_whitespace
from .validation import preprocess_declarations
from .validation.descriptors import preprocess_descriptors
# Reject anything not in here:
PSEUDO_ELEMENTS = (
None, 'before', 'after', 'marker', 'first-line', 'first-letter')
PageType = namedtuple('PageType', ['side', 'blank', 'first', 'index', 'name'])
class StyleFor:
"""Convenience function to get the computed styles for an element."""
def __init__(self, html, sheets, presentational_hints, target_collector):
# keys: (element, pseudo_element_type)
# element: an ElementTree Element or the '@page' string
# pseudo_element_type: a string such as 'first' (for @page) or
# 'after', or None for normal elements
# values: dicts of
# keys: property name as a string
# values: (values, weight)
# values: a PropertyValue-like object
# weight: values with a greater weight take precedence, see
# http://www.w3.org/TR/CSS21/cascade.html#cascading-order
self._cascaded_styles = cascaded_styles = {}
# keys: (element, pseudo_element_type), like cascaded_styles
# values: style dict objects:
# keys: property name as a string
# values: a PropertyValue-like object
self._computed_styles = {}
self._sheets = sheets
PROGRESS_LOGGER.info('Step 3 - Applying CSS')
for specificity, attributes in find_style_attributes(
html.etree_element, presentational_hints, html.base_url):
element, declarations, base_url = attributes
style = cascaded_styles.setdefault((element, None), {})
for name, values, importance in preprocess_declarations(
base_url, declarations):
precedence = declaration_precedence('author', importance)
weight = (precedence, specificity)
old_weight = style.get(name, (None, None))[1]
if old_weight is None or old_weight <= weight:
style[name] = values, weight
# First, add declarations and set computed styles for "real" elements
# *in tree order*. Tree order is important so that parents have
# computed styles before their children, for inheritance.
# Iterate on all elements, even if there is no cascaded style for them.
for element in html.wrapper_element.iter_subtree():
for sheet, origin, sheet_specificity in sheets:
# Add declarations for matched elements
for selector in sheet.matcher.match(element):
specificity, order, pseudo_type, declarations = selector
specificity = sheet_specificity or specificity
style = cascaded_styles.setdefault(
(element.etree_element, pseudo_type), {})
for name, values, importance in declarations:
precedence = declaration_precedence(origin, importance)
weight = (precedence, specificity)
old_weight = style.get(name, (None, None))[1]
if old_weight is None or old_weight <= weight:
style[name] = values, weight
parent = element.parent.etree_element if element.parent else None
self.set_computed_styles(
element.etree_element, root=html.etree_element, parent=parent,
base_url=html.base_url, target_collector=target_collector)
# Then computed styles for pseudo elements, in any order.
# Pseudo-elements inherit from their associated element so they come
# last. Do them in a second pass as there is no easy way to iterate
# on the pseudo-elements for a given element with the current structure
# of cascaded_styles. (Keys are (element, pseudo_type) tuples.)
# Only iterate on pseudo-elements that have cascaded styles. (Others
# might as well not exist.)
for element, pseudo_type in cascaded_styles:
if pseudo_type and not isinstance(element, PageType):
self.set_computed_styles(
element, pseudo_type=pseudo_type,
# The pseudo-element inherits from the element.
root=html.etree_element, parent=element,
base_url=html.base_url, target_collector=target_collector)
# Clear the cascaded styles, we don't need them anymore. Keep the
# dictionary, it is used later for page margins.
self._cascaded_styles.clear()
def __call__(self, element, pseudo_type=None):
style = self._computed_styles.get((element, pseudo_type))
if style:
if 'table' in style['display']:
if (style['display'] in ('table', 'inline-table') and
style['border_collapse'] == 'collapse'):
# Padding do not apply
for side in ['top', 'bottom', 'left', 'right']:
style['padding_' + side] = computed_values.ZERO_PIXELS
if (style['display'].startswith('table-') and
style['display'] != 'table-caption'):
# Margins do not apply
for side in ['top', 'bottom', 'left', 'right']:
style['margin_' + side] = computed_values.ZERO_PIXELS
return style
def set_computed_styles(self, element, parent, root=None, pseudo_type=None,
base_url=None, target_collector=None):
"""Set the computed values of styles to ``element``.
Take the properties left by ``apply_style_rule`` on an element or
pseudo-element and assign computed values with respect to the cascade,
declaration priority (ie. ``!important``) and selector specificity.
"""
cascaded_styles = self.get_cascaded_styles()
computed_styles = self.get_computed_styles()
if element == root and pseudo_type is None:
assert parent is None
parent_style = None
root_style = {
# When specified on the font-size property of the root element,
# the rem units refer to the propertys initial value.
'font_size': INITIAL_VALUES['font_size'],
}
else:
assert parent is not None
parent_style = computed_styles[parent, None]
root_style = computed_styles[root, None]
cascaded = cascaded_styles.get((element, pseudo_type), {})
computed_styles[element, pseudo_type] = computed_from_cascaded(
element, cascaded, parent_style, pseudo_type, root_style, base_url,
target_collector)
# The style of marker is deleted when display is different from
# list-item.
if pseudo_type is None:
for pseudo in (None, 'before', 'after'):
pseudo_style = cascaded_styles.get((element, pseudo), {})
if 'display' in pseudo_style:
if pseudo_style['display'][0] == 'list-item':
break
else:
if (element, 'marker') in cascaded_styles:
del cascaded_styles[element, 'marker']
def add_page_declarations(self, page_type):
for sheet, origin, sheet_specificity in self._sheets:
for _rule, selector_list, declarations in sheet.page_rules:
for selector in selector_list:
specificity, pseudo_type, selector_page_type = selector
if self._page_type_match(selector_page_type, page_type):
specificity = sheet_specificity or specificity
style = self._cascaded_styles.setdefault(
(page_type, pseudo_type), {})
for name, values, importance in declarations:
precedence = declaration_precedence(
origin, importance)
weight = (precedence, specificity)
old_weight = style.get(name, (None, None))[1]
if old_weight is None or old_weight <= weight:
style[name] = values, weight
def get_cascaded_styles(self):
return self._cascaded_styles
def get_computed_styles(self):
return self._computed_styles
@staticmethod
def _page_type_match(selector_page_type, page_type):
if selector_page_type.side not in (None, page_type.side):
return False
if selector_page_type.blank not in (None, page_type.blank):
return False
if selector_page_type.first not in (None, page_type.first):
return False
if selector_page_type.name not in (None, page_type.name):
return False
if selector_page_type.index is not None:
a, b, group = selector_page_type.index
# TODO: handle group
if a:
if (page_type.index + 1 - b) % a:
return False
else:
if page_type.index + 1 != b:
return False
return True
def get_child_text(element):
"""Return the text directly in the element, not descendants."""
content = [element.text] if element.text else []
for child in element:
if child.tail:
content.append(child.tail)
return ''.join(content)
def find_stylesheets(wrapper_element, device_media_type, url_fetcher, base_url,
font_config, page_rules):
"""Yield the stylesheets in ``element_tree``.
The output order is the same as the source order.
"""
from ..html import element_has_link_type # Work around circular imports.
for wrapper in wrapper_element.query_all('style', 'link'):
element = wrapper.etree_element
mime_type = element.get('type', 'text/css').split(';', 1)[0].strip()
# Only keep 'type/subtype' from 'type/subtype ; param1; param2'.
if mime_type != 'text/css':
continue
media_attr = element.get('media', '').strip() or 'all'
media = [media_type.strip() for media_type in media_attr.split(',')]
if not media_queries.evaluate_media_query(media, device_media_type):
continue
if element.tag == 'style':
# Content is text that is directly in the <style> element, not its
# descendants
content = get_child_text(element)
# ElementTree should give us either unicode or ASCII-only
# bytestrings, so we don't need `encoding` here.
css = CSS(
string=content, base_url=base_url,
url_fetcher=url_fetcher, media_type=device_media_type,
font_config=font_config, page_rules=page_rules)
yield css
elif element.tag == 'link' and element.get('href'):
if not element_has_link_type(element, 'stylesheet') or \
element_has_link_type(element, 'alternate'):
continue
href = get_url_attribute(element, 'href', base_url)
if href is not None:
try:
yield CSS(
url=href, url_fetcher=url_fetcher,
_check_mime_type=True, media_type=device_media_type,
font_config=font_config, page_rules=page_rules)
except URLFetchingError as exc:
LOGGER.error(
'Failed to load stylesheet at %s : %s', href, exc)
def find_style_attributes(tree, presentational_hints=False, base_url=None):
"""Yield ``specificity, (element, declaration, base_url)`` rules.
Rules from "style" attribute are returned with specificity
``(1, 0, 0)``.
If ``presentational_hints`` is ``True``, rules from presentational hints
are returned with specificity ``(0, 0, 0)``.
"""
def check_style_attribute(element, style_attribute):
declarations = tinycss2.parse_declaration_list(style_attribute)
return element, declarations, base_url
for element in tree.iter():
specificity = (1, 0, 0)
style_attribute = element.get('style')
if style_attribute:
yield specificity, check_style_attribute(element, style_attribute)
if not presentational_hints:
continue
specificity = (0, 0, 0)
if element.tag == 'body':
# TODO: we should check the container frame element
for part, position in (
('height', 'top'), ('height', 'bottom'),
('width', 'left'), ('width', 'right')):
style_attribute = None
for prop in ('margin%s' % part, '%smargin' % position):
if element.get(prop):
style_attribute = 'margin-%s:%spx' % (
position, element.get(prop))
break
if style_attribute:
yield specificity, check_style_attribute(
element, style_attribute)
if element.get('background'):
style_attribute = 'background-image:url(%s)' % (
element.get('background'))
yield specificity, check_style_attribute(
element, style_attribute)
if element.get('bgcolor'):
style_attribute = 'background-color:%s' % (
element.get('bgcolor'))
yield specificity, check_style_attribute(
element, style_attribute)
if element.get('text'):
style_attribute = 'color:%s' % element.get('text')
yield specificity, check_style_attribute(
element, style_attribute)
# TODO: we should support link, vlink, alink
elif element.tag == 'center':
yield specificity, check_style_attribute(
element, 'text-align:center')
elif element.tag == 'div':
align = element.get('align', '').lower()
if align == 'middle':
yield specificity, check_style_attribute(
element, 'text-align:center')
elif align in ('center', 'left', 'right', 'justify'):
yield specificity, check_style_attribute(
element, 'text-align:%s' % align)
elif element.tag == 'font':
if element.get('color'):
yield specificity, check_style_attribute(
element, 'color:%s' % element.get('color'))
if element.get('face'):
yield specificity, check_style_attribute(
element, 'font-family:%s' % element.get('face'))
if element.get('size'):
size = element.get('size').strip()
relative_plus = size.startswith('+')
relative_minus = size.startswith('-')
if relative_plus or relative_minus:
size = size[1:].strip()
try:
size = int(size)
except ValueError:
LOGGER.warning('Invalid value for size: %s', size)
else:
font_sizes = {
1: 'x-small',
2: 'small',
3: 'medium',
4: 'large',
5: 'x-large',
6: 'xx-large',
7: '48px', # 1.5 * xx-large
}
if relative_plus:
size += 3
elif relative_minus:
size -= 3
size = max(1, min(7, size))
yield specificity, check_style_attribute(
element, 'font-size:%s' % font_sizes[size])
elif element.tag == 'table':
if element.get('cellspacing'):
yield specificity, check_style_attribute(
element,
'border-spacing:%spx' % element.get('cellspacing'))
if element.get('cellpadding'):
cellpadding = element.get('cellpadding')
if cellpadding.isdigit():
cellpadding += 'px'
# TODO: don't match subtables cells
for subelement in element.iter():
if subelement.tag in ('td', 'th'):
yield specificity, check_style_attribute(
subelement,
'padding-left:%s;padding-right:%s;'
'padding-top:%s;padding-bottom:%s;' % (
4 * (cellpadding,)))
if element.get('hspace'):
hspace = element.get('hspace')
if hspace.isdigit():
hspace += 'px'
yield specificity, check_style_attribute(
element,
'margin-left:%s;margin-right:%s' % (hspace, hspace))
if element.get('vspace'):
vspace = element.get('vspace')
if vspace.isdigit():
vspace += 'px'
yield specificity, check_style_attribute(
element,
'margin-top:%s;margin-bottom:%s' % (vspace, vspace))
if element.get('width'):
style_attribute = 'width:%s' % element.get('width')
if element.get('width').isdigit():
style_attribute += 'px'
yield specificity, check_style_attribute(
element, style_attribute)
if element.get('height'):
style_attribute = 'height:%s' % element.get('height')
if element.get('height').isdigit():
style_attribute += 'px'
yield specificity, check_style_attribute(
element, style_attribute)
if element.get('background'):
style_attribute = 'background-image:url(%s)' % (
element.get('background'))
yield specificity, check_style_attribute(
element, style_attribute)
if element.get('bgcolor'):
style_attribute = 'background-color:%s' % (
element.get('bgcolor'))
yield specificity, check_style_attribute(
element, style_attribute)
if element.get('bordercolor'):
style_attribute = 'border-color:%s' % (
element.get('bordercolor'))
yield specificity, check_style_attribute(
element, style_attribute)
if element.get('border'):
style_attribute = 'border-width:%spx' % (
element.get('border'))
yield specificity, check_style_attribute(
element, style_attribute)
elif element.tag in ('tr', 'td', 'th', 'thead', 'tbody', 'tfoot'):
align = element.get('align', '').lower()
# TODO: we should align descendants too
if align == 'middle':
yield specificity, check_style_attribute(
element, 'text-align:center')
elif align in ('center', 'left', 'right', 'justify'):
yield specificity, check_style_attribute(
element, 'text-align:%s' % align)
if element.get('background'):
style_attribute = 'background-image:url(%s)' % (
element.get('background'))
yield specificity, check_style_attribute(
element, style_attribute)
if element.get('bgcolor'):
style_attribute = 'background-color:%s' % (
element.get('bgcolor'))
yield specificity, check_style_attribute(
element, style_attribute)
if element.tag in ('tr', 'td', 'th'):
if element.get('height'):
style_attribute = 'height:%s' % element.get('height')
if element.get('height').isdigit():
style_attribute += 'px'
yield specificity, check_style_attribute(
element, style_attribute)
if element.tag in ('td', 'th'):
if element.get('width'):
style_attribute = 'width:%s' % element.get('width')
if element.get('width').isdigit():
style_attribute += 'px'
yield specificity, check_style_attribute(
element, style_attribute)
elif element.tag == 'caption':
align = element.get('align', '').lower()
# TODO: we should align descendants too
if align == 'middle':
yield specificity, check_style_attribute(
element, 'text-align:center')
elif align in ('center', 'left', 'right', 'justify'):
yield specificity, check_style_attribute(
element, 'text-align:%s' % align)
elif element.tag == 'col':
if element.get('width'):
style_attribute = 'width:%s' % element.get('width')
if element.get('width').isdigit():
style_attribute += 'px'
yield specificity, check_style_attribute(
element, style_attribute)
elif element.tag == 'hr':
size = 0
if element.get('size'):
try:
size = int(element.get('size'))
except ValueError:
LOGGER.warning('Invalid value for size: %s', size)
if (element.get('color'), element.get('noshade')) != (None, None):
if size >= 1:
yield specificity, check_style_attribute(
element, 'border-width:%spx' % (size / 2))
elif size == 1:
yield specificity, check_style_attribute(
element, 'border-bottom-width:0')
elif size > 1:
yield specificity, check_style_attribute(
element, 'height:%spx' % (size - 2))
if element.get('width'):
style_attribute = 'width:%s' % element.get('width')
if element.get('width').isdigit():
style_attribute += 'px'
yield specificity, check_style_attribute(
element, style_attribute)
if element.get('color'):
yield specificity, check_style_attribute(
element, 'color:%s' % element.get('color'))
elif element.tag in (
'iframe', 'applet', 'embed', 'img', 'input', 'object'):
if (element.tag != 'input' or
element.get('type', '').lower() == 'image'):
align = element.get('align', '').lower()
if align in ('middle', 'center'):
# TODO: middle and center values are wrong
yield specificity, check_style_attribute(
element, 'vertical-align:middle')
if element.get('hspace'):
hspace = element.get('hspace')
if hspace.isdigit():
hspace += 'px'
yield specificity, check_style_attribute(
element,
'margin-left:%s;margin-right:%s' % (hspace, hspace))
if element.get('vspace'):
vspace = element.get('vspace')
if vspace.isdigit():
vspace += 'px'
yield specificity, check_style_attribute(
element,
'margin-top:%s;margin-bottom:%s' % (vspace, vspace))
# TODO: img seems to be excluded for width and height, but a
# lot of W3C tests rely on this attribute being applied to img
if element.get('width'):
style_attribute = 'width:%s' % element.get('width')
if element.get('width').isdigit():
style_attribute += 'px'
yield specificity, check_style_attribute(
element, style_attribute)
if element.get('height'):
style_attribute = 'height:%s' % element.get('height')
if element.get('height').isdigit():
style_attribute += 'px'
yield specificity, check_style_attribute(
element, style_attribute)
if element.tag in ('img', 'object', 'input'):
if element.get('border'):
yield specificity, check_style_attribute(
element,
'border-width:%spx;border-style:solid' %
element.get('border'))
elif element.tag == 'ol':
# From https://www.w3.org/TR/css-lists-3/
if element.get('start'):
yield specificity, check_style_attribute(
element,
'counter-reset:list-item %s;'
'counter-increment:list-item -1' % element.get('start'))
elif element.tag == 'ul':
# From https://www.w3.org/TR/css-lists-3/
if element.get('value'):
yield specificity, check_style_attribute(
element,
'counter-reset:list-item %s;'
'counter-increment:none' % element.get('value'))
def declaration_precedence(origin, importance):
"""Return the precedence for a declaration.
Precedence values have no meaning unless compared to each other.
Acceptable values for ``origin`` are the strings ``'author'``, ``'user'``
and ``'user agent'``.
"""
# See http://www.w3.org/TR/CSS21/cascade.html#cascading-order
if origin == 'user agent':
return 1
elif origin == 'user' and not importance:
return 2
elif origin == 'author' and not importance:
return 3
elif origin == 'author': # and importance
return 4
else:
assert origin == 'user' # and importance
return 5
def computed_from_cascaded(element, cascaded, parent_style, pseudo_type=None,
root_style=None, base_url=None,
target_collector=None):
"""Get a dict of computed style mixed from parent and cascaded styles."""
if not cascaded and parent_style is not None:
# Fast path for anonymous boxes:
# no cascaded style, only implicitly initial or inherited values.
computed = dict(INITIAL_VALUES)
for name in parent_style:
if name in INHERITED or name.startswith('__'):
computed[name] = parent_style[name]
# page is not inherited but taken from the ancestor if 'auto'
computed['page'] = parent_style['page']
# border-*-style is none, so border-width computes to zero.
# Other than that, properties that would need computing are
# border-*-color, but they do not apply.
computed['border_top_width'] = 0
computed['border_bottom_width'] = 0
computed['border_left_width'] = 0
computed['border_right_width'] = 0
computed['outline_width'] = 0
return computed
# Handle inheritance and initial values
specified = {}
computed = {}
if parent_style:
for name in parent_style:
if name.startswith('__'):
computed[name] = specified[name] = parent_style[name]
for name in cascaded:
if name.startswith('__'):
computed[name] = specified[name] = cascaded[name][0]
for name, initial in INITIAL_VALUES.items():
if name in cascaded:
value, _precedence = cascaded[name]
keyword = value
else:
if name in INHERITED:
keyword = 'inherit'
else:
keyword = 'initial'
if keyword == 'inherit' and parent_style is None:
# On the root element, 'inherit' from initial values
keyword = 'initial'
if keyword == 'initial':
value = initial
if name not in INITIAL_NOT_COMPUTED:
# The value is the same as when computed
computed[name] = value
elif keyword == 'inherit':
value = parent_style[name]
# Values in parent_style are already computed.
computed[name] = value
specified[name] = value
if specified['page'] == 'auto':
# The page property does not inherit. However, if the page value on
# an element is auto, then its used value is the value specified on
# its nearest ancestor with a non-auto value. When specified on the
# root element, the used value for auto is the empty string.
computed['page'] = specified['page'] = (
'' if parent_style is None else parent_style['page'])
return computed_values.compute(
element, pseudo_type, specified, computed, parent_style, root_style,
base_url, target_collector)
def parse_page_selectors(rule):
"""Parse a page selector rule.
Return a list of page data if the rule is correctly parsed. Page data are a
dict containing:
- 'side' ('left', 'right' or None),
- 'blank' (True or None),
- 'first' (True or None),
- 'index' (page number or None),
- 'name' (page name string or None), and
- 'specificity' (list of numbers).
Return ``None` if something went wrong while parsing the rule.
"""
# See https://drafts.csswg.org/css-page-3/#syntax-page-selector
tokens = list(remove_whitespace(rule.prelude))
page_data = []
# TODO: Specificity is probably wrong, should clean and test that.
if not tokens:
page_data.append({
'side': None, 'blank': None, 'first': None, 'index': None,
'name': None, 'specificity': [0, 0, 0]})
return page_data
while tokens:
types = {
'side': None, 'blank': None, 'first': None, 'index': None,
'name': None, 'specificity': [0, 0, 0]}
if tokens[0].type == 'ident':
token = tokens.pop(0)
types['name'] = token.value
types['specificity'][0] = 1
if len(tokens) == 1:
return None
elif not tokens:
page_data.append(types)
return page_data
while tokens:
literal = tokens.pop(0)
if literal.type != 'literal':
return None
if literal.value == ':':
if not tokens:
return None
if tokens[0].type == 'ident':
ident = tokens.pop(0)
pseudo_class = ident.lower_value
if pseudo_class in ('left', 'right'):
if types['side'] and types['side'] != pseudo_class:
return None
types['side'] = pseudo_class
types['specificity'][2] += 1
continue
elif pseudo_class in ('blank', 'first'):
types[pseudo_class] = True
types['specificity'][1] += 1
continue
elif tokens[0].type == 'function':
function = tokens.pop(0)
if function.name != 'nth':
return None
for i, argument in enumerate(function.arguments):
if argument.type == 'ident' and argument.value == 'of':
nth = function.arguments[:i - 1]
group = function.arguments[i:]
break
else:
nth = function.arguments
group = None
nth_values = tinycss2.nth.parse_nth(nth)
if nth_values is None:
return None
if group is not None:
group = [
token for token in group
if token.type not in (
'comment', 'whitespacespace')]
if len(group) != 1:
return None
group, = group
if group.type != 'ident':
return None
group = group.value
# TODO: handle page groups
return None
types['index'] = (*nth_values, group)
# TODO: specificity is not specified yet
# https://github.com/w3c/csswg-drafts/issues/3524
types['specificity'][1] += 1
continue
return None
elif literal.value == ',':
if tokens and any(types['specificity']):
break
else:
return None
page_data.append(types)
return page_data
def preprocess_stylesheet(device_media_type, base_url, stylesheet_rules,
url_fetcher, matcher, page_rules, fonts,
font_config, ignore_imports=False):
"""Do the work that can be done early on stylesheet, before they are
in a document.
"""
for rule in stylesheet_rules:
if getattr(rule, 'content', None) is None and (
rule.type != 'at-rule' or rule.lower_at_keyword != 'import'):
continue
if rule.type == 'qualified-rule':
declarations = list(preprocess_declarations(
base_url, tinycss2.parse_declaration_list(rule.content)))
if declarations:
logger_level = WARNING
try:
selectors = cssselect2.compile_selector_list(rule.prelude)
for selector in selectors:
matcher.add_selector(selector, declarations)
if selector.pseudo_element not in PSEUDO_ELEMENTS:
if selector.pseudo_element.startswith('-'):
logger_level = DEBUG
raise cssselect2.SelectorError(
'ignored prefixed pseudo-element: %s'
% selector.pseudo_element)
else:
raise cssselect2.SelectorError(
'unknown pseudo-element: %s'
% selector.pseudo_element)
ignore_imports = True
except cssselect2.SelectorError as exc:
LOGGER.log(
logger_level,
"Invalid or unsupported selector '%s', %s",
tinycss2.serialize(rule.prelude), exc)
continue
else:
ignore_imports = True
elif rule.type == 'at-rule' and rule.lower_at_keyword == 'import':
if ignore_imports:
LOGGER.warning('@import rule "%s" not at the beginning of the '
'the whole rule was ignored at %s:%s.',
tinycss2.serialize(rule.prelude),
rule.source_line, rule.source_column)
continue
tokens = remove_whitespace(rule.prelude)
if tokens and tokens[0].type in ('url', 'string'):
url = tokens[0].value
else:
continue
media = media_queries.parse_media_query(tokens[1:])
if media is None:
LOGGER.warning('Invalid media type "%s" '
'the whole @import rule was ignored at %s:%s.',
tinycss2.serialize(rule.prelude),
rule.source_line, rule.source_column)
continue
if not media_queries.evaluate_media_query(
media, device_media_type):
continue
url = url_join(
base_url, url, allow_relative=False,
context='@import at %s:%s',
context_args=(rule.source_line, rule.source_column))
if url is not None:
try:
CSS(
url=url, url_fetcher=url_fetcher,
media_type=device_media_type, font_config=font_config,
matcher=matcher, page_rules=page_rules)
except URLFetchingError as exc:
LOGGER.error(
'Failed to load stylesheet at %s : %s', url, exc)
elif rule.type == 'at-rule' and rule.lower_at_keyword == 'media':
media = media_queries.parse_media_query(rule.prelude)
if media is None:
LOGGER.warning('Invalid media type "%s" '
'the whole @media rule was ignored at %s:%s.',
tinycss2.serialize(rule.prelude),
rule.source_line, rule.source_column)
continue
ignore_imports = True
if not media_queries.evaluate_media_query(
media, device_media_type):
continue
content_rules = tinycss2.parse_rule_list(rule.content)
preprocess_stylesheet(
device_media_type, base_url, content_rules, url_fetcher,
matcher, page_rules, fonts, font_config, ignore_imports=True)
elif rule.type == 'at-rule' and rule.lower_at_keyword == 'page':
data = parse_page_selectors(rule)
if data is None:
LOGGER.warning(
'Unsupported @page selector "%s", '
'the whole @page rule was ignored at %s:%s.',
tinycss2.serialize(rule.prelude),
rule.source_line, rule.source_column)
continue
ignore_imports = True
for page_type in data:
specificity = page_type.pop('specificity')
page_type = PageType(**page_type)
content = tinycss2.parse_declaration_list(rule.content)
declarations = list(preprocess_declarations(base_url, content))
if declarations:
selector_list = [(specificity, None, page_type)]
page_rules.append((rule, selector_list, declarations))
for margin_rule in content:
if margin_rule.type != 'at-rule' or (
margin_rule.content is None):
continue
declarations = list(preprocess_declarations(
base_url,
tinycss2.parse_declaration_list(margin_rule.content)))
if declarations:
selector_list = [(
specificity, '@' + margin_rule.lower_at_keyword,
page_type)]
page_rules.append(
(margin_rule, selector_list, declarations))
elif rule.type == 'at-rule' and rule.lower_at_keyword == 'font-face':
ignore_imports = True
content = tinycss2.parse_declaration_list(rule.content)
rule_descriptors = dict(preprocess_descriptors(base_url, content))
for key in ('src', 'font_family'):
if key not in rule_descriptors:
LOGGER.warning(
"Missing %s descriptor in '@font-face' rule at %s:%s",
key.replace('_', '-'),
rule.source_line, rule.source_column)
break
else:
if font_config is not None:
font_filename = font_config.add_font_face(
rule_descriptors, url_fetcher)
if font_filename:
fonts.append(font_filename)
def get_all_computed_styles(html, user_stylesheets=None,
presentational_hints=False, font_config=None,
page_rules=None, target_collector=None):
"""Compute all the computed styles of all elements in ``html`` document.
Do everything from finding author stylesheets to parsing and applying them.
Return a ``style_for`` function that takes an element and an optional
pseudo-element type, and return a style dict object.
"""
# List stylesheets. Order here is not important ('origin' is).
sheets = []
for sheet in (html._ua_stylesheets() or []):
sheets.append((sheet, 'user agent', None))
if presentational_hints:
for sheet in (html._ph_stylesheets() or []):
sheets.append((sheet, 'author', (0, 0, 0)))
for sheet in find_stylesheets(
html.wrapper_element, html.media_type, html.url_fetcher,
html.base_url, font_config, page_rules):
sheets.append((sheet, 'author', None))
for sheet in (user_stylesheets or []):
sheets.append((sheet, 'user', None))
return StyleFor(html, sheets, presentational_hints, target_collector)

View File

@ -0,0 +1,789 @@
"""
weasyprint.css.computed_values
------------------------------
Convert *specified* property values (the result of the cascade and
inhertance) into *computed* values (that are inherited).
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from collections import OrderedDict
from urllib.parse import unquote
from tinycss2.color3 import parse_color
from .. import text
from ..logger import LOGGER
from ..urls import get_link_attribute
from .properties import (
INHERITED, INITIAL_NOT_COMPUTED, INITIAL_VALUES, Dimension)
from .utils import (
ANGLE_TO_RADIANS, LENGTH_UNITS, LENGTHS_TO_PIXELS, check_var_function,
safe_urljoin)
ZERO_PIXELS = Dimension(0, 'px')
# Value in pixels of font-size for <absolute-size> keywords: 12pt (16px) for
# medium, and scaling factors given in CSS3 for others:
# http://www.w3.org/TR/css3-fonts/#font-size-prop
FONT_SIZE_KEYWORDS = OrderedDict(
# medium is 16px, others are a ratio of medium
(name, INITIAL_VALUES['font_size'] * a / b)
for name, a, b in (
('xx-small', 3, 5),
('x-small', 3, 4),
('small', 8, 9),
('medium', 1, 1),
('large', 6, 5),
('x-large', 3, 2),
('xx-large', 2, 1),
)
)
# These are unspecified, other than 'thin' <='medium' <= 'thick'.
# Values are in pixels.
BORDER_WIDTH_KEYWORDS = {
'thin': 1,
'medium': 3,
'thick': 5,
}
assert INITIAL_VALUES['border_top_width'] == BORDER_WIDTH_KEYWORDS['medium']
# http://www.w3.org/TR/CSS21/fonts.html#propdef-font-weight
FONT_WEIGHT_RELATIVE = dict(
bolder={
100: 400,
200: 400,
300: 400,
400: 700,
500: 700,
600: 900,
700: 900,
800: 900,
900: 900,
},
lighter={
100: 100,
200: 100,
300: 100,
400: 100,
500: 100,
600: 400,
700: 400,
800: 700,
900: 700,
},
)
# http://www.w3.org/TR/css3-page/#size
# name=(width in pixels, height in pixels)
PAGE_SIZES = dict(
a5=(
Dimension(148, 'mm'),
Dimension(210, 'mm'),
),
a4=(
Dimension(210, 'mm'),
Dimension(297, 'mm'),
),
a3=(
Dimension(297, 'mm'),
Dimension(420, 'mm'),
),
b5=(
Dimension(176, 'mm'),
Dimension(250, 'mm'),
),
b4=(
Dimension(250, 'mm'),
Dimension(353, 'mm'),
),
letter=(
Dimension(8.5, 'in'),
Dimension(11, 'in'),
),
legal=(
Dimension(8.5, 'in'),
Dimension(14, 'in'),
),
ledger=(
Dimension(11, 'in'),
Dimension(17, 'in'),
),
)
# In "portrait" orientation.
for w, h in PAGE_SIZES.values():
assert w.value < h.value
INITIAL_PAGE_SIZE = PAGE_SIZES['a4']
INITIAL_VALUES['size'] = tuple(
d.value * LENGTHS_TO_PIXELS[d.unit] for d in INITIAL_PAGE_SIZE)
def _computing_order():
"""Some computed values are required by others, so order matters."""
first = [
'font_stretch', 'font_weight', 'font_family', 'font_variant',
'font_style', 'font_size', 'line_height', 'marks']
order = sorted(INITIAL_VALUES)
for name in first:
order.remove(name)
return tuple(first + order)
COMPUTING_ORDER = _computing_order()
# Maps property names to functions returning the computed values
COMPUTER_FUNCTIONS = {}
def _resolve_var(computed, variable_name, default):
known_variable_names = [variable_name]
computed_value = computed.get(variable_name)
if computed_value and len(computed_value) == 1:
value = computed_value[0]
if value.type == 'ident' and value.value == 'initial':
return default
computed_value = computed.get(variable_name, default)
while (computed_value and
isinstance(computed_value, tuple)
and len(computed_value) == 1):
var_function = check_var_function(computed_value[0])
if var_function:
new_variable_name, new_default = var_function[1]
if new_variable_name in known_variable_names:
computed_value = default
break
known_variable_names.append(new_variable_name)
computed_value = computed.get(new_variable_name, new_default)
default = new_default
else:
break
return computed_value
def register_computer(name):
"""Decorator registering a property ``name`` for a function."""
name = name.replace('-', '_')
def decorator(function):
"""Register the property ``name`` for ``function``."""
COMPUTER_FUNCTIONS[name] = function
return function
return decorator
def compute(element, pseudo_type, specified, computed, parent_style,
root_style, base_url, target_collector):
"""Create a dict of computed values.
:param element: The HTML element these style apply to
:param pseudo_type: The type of pseudo-element, eg 'before', None
:param specified: A dict of specified values. Should contain
values for all properties.
:param computed: A dict of already known computed values.
Only contains some properties (or none).
:param parent_style: A dict of computed values of the parent
element (should contain values for all properties),
or ``None`` if ``element`` is the root element.
:param base_url: The base URL used to resolve relative URLs.
:param target_collector: A target collector used to get computed targets.
"""
from .validation.properties import PROPERTIES
computer = {
'is_root_element': parent_style is None,
'element': element,
'pseudo_type': pseudo_type,
'specified': specified,
'computed': computed,
'parent_style': parent_style or INITIAL_VALUES,
'root_style': root_style,
'base_url': base_url,
'target_collector': target_collector,
}
getter = COMPUTER_FUNCTIONS.get
for name in COMPUTING_ORDER:
if name in computed:
# Already computed
continue
value = specified[name]
function = getter(name)
already_computed_value = False
if value and isinstance(value, tuple) and value[0] == 'var()':
variable_name, default = value[1]
computed_value = _resolve_var(computed, variable_name, default)
if computed_value is None:
new_value = None
else:
prop = PROPERTIES[name.replace('_', '-')]
if prop.wants_base_url:
new_value = prop(computed_value, base_url)
else:
new_value = prop(computed_value)
# See https://drafts.csswg.org/css-variables/#invalid-variables
if new_value is None:
try:
computed_value = ''.join(
token.serialize() for token in computed_value)
except BaseException:
pass
LOGGER.warning(
'Unsupported computed value `%s` set in variable `%s` '
'for property `%s`.', computed_value,
variable_name.replace('_', '-'), name.replace('_', '-'))
if name in INHERITED and parent_style:
already_computed_value = True
value = parent_style[name]
else:
already_computed_value = name not in INITIAL_NOT_COMPUTED
value = INITIAL_VALUES[name]
else:
value = new_value
if function is not None and not already_computed_value:
value = function(computer, name, value)
# else: same as specified
computed[name] = value
computed['_weasy_specified_display'] = specified['display']
return computed
@register_computer('background-image')
def background_image(computer, name, values):
"""Compute lenghts in gradient background-image."""
for type_, value in values:
if type_ in ('linear-gradient', 'radial-gradient'):
value.stop_positions = tuple(
length(computer, name, pos) if pos is not None else None
for pos in value.stop_positions)
if type_ == 'radial-gradient':
value.center, = compute_position(
computer, name, (value.center,))
if value.size_type == 'explicit':
value.size = length_or_percentage_tuple(
computer, name, value.size)
return values
@register_computer('background-position')
@register_computer('object-position')
def compute_position(computer, name, values):
"""Compute lengths in background-position."""
return tuple(
(origin_x, length(computer, name, pos_x),
origin_y, length(computer, name, pos_y))
for origin_x, pos_x, origin_y, pos_y in values)
@register_computer('transform-origin')
def length_or_percentage_tuple(computer, name, values):
"""Compute the lists of lengths that can be percentages."""
return tuple(length(computer, name, value) for value in values)
@register_computer('border-spacing')
@register_computer('size')
@register_computer('clip')
def length_tuple(computer, name, values):
"""Compute the properties with a list of lengths."""
return tuple(length(computer, name, value, pixels_only=True)
for value in values)
@register_computer('break-after')
@register_computer('break-before')
def break_before_after(computer, name, value):
"""Compute the ``break-before`` and ``break-after`` properties."""
# 'always' is defined as an alias to 'page' in multi-column
# https://www.w3.org/TR/css3-multicol/#column-breaks
if value == 'always':
return 'page'
else:
return value
@register_computer('top')
@register_computer('right')
@register_computer('left')
@register_computer('bottom')
@register_computer('margin-top')
@register_computer('margin-right')
@register_computer('margin-bottom')
@register_computer('margin-left')
@register_computer('height')
@register_computer('width')
@register_computer('min-width')
@register_computer('min-height')
@register_computer('max-width')
@register_computer('max-height')
@register_computer('padding-top')
@register_computer('padding-right')
@register_computer('padding-bottom')
@register_computer('padding-left')
@register_computer('text-indent')
@register_computer('hyphenate-limit-zone')
@register_computer('flex-basis')
def length(computer, name, value, font_size=None, pixels_only=False):
"""Compute a length ``value``."""
if value in ('auto', 'content'):
return value
if value.value == 0:
return 0 if pixels_only else ZERO_PIXELS
unit = value.unit
if unit == 'px':
return value.value if pixels_only else value
elif unit in LENGTHS_TO_PIXELS:
# Convert absolute lengths to pixels
result = value.value * LENGTHS_TO_PIXELS[unit]
elif unit in ('em', 'ex', 'ch', 'rem'):
if font_size is None:
font_size = computer['computed']['font_size']
if unit == 'ex':
# TODO: cache
result = value.value * font_size * ex_ratio(computer['computed'])
elif unit == 'ch':
# TODO: cache
# TODO: use context to use @font-face fonts
layout = text.Layout(
context=None, font_size=font_size,
style=computer['computed'])
layout.set_text('0')
line, _ = layout.get_first_line()
logical_width, _ = text.get_size(line, computer['computed'])
result = value.value * logical_width
elif unit == 'em':
result = value.value * font_size
elif unit == 'rem':
result = value.value * computer['root_style']['font_size']
else:
# A percentage or 'auto': no conversion needed.
return value
return result if pixels_only else Dimension(result, 'px')
@register_computer('bleed-left')
@register_computer('bleed-right')
@register_computer('bleed-top')
@register_computer('bleed-bottom')
def bleed(computer, name, value):
if value == 'auto':
if 'crop' in computer['computed']['marks']:
return Dimension(8, 'px') # 6pt
else:
return Dimension(0, 'px')
else:
return length(computer, name, value)
@register_computer('letter-spacing')
def pixel_length(computer, name, value):
if value == 'normal':
return value
else:
return length(computer, name, value, pixels_only=True)
@register_computer('background-size')
def background_size(computer, name, values):
"""Compute the ``background-size`` properties."""
return tuple(
value if value in ('contain', 'cover') else
length_or_percentage_tuple(computer, name, value)
for value in values)
@register_computer('border-top-width')
@register_computer('border-right-width')
@register_computer('border-left-width')
@register_computer('border-bottom-width')
@register_computer('column-rule-width')
@register_computer('outline-width')
def border_width(computer, name, value):
"""Compute the ``border-*-width`` properties."""
style = computer['computed'][name.replace('width', 'style')]
if style in ('none', 'hidden'):
return 0
if value in BORDER_WIDTH_KEYWORDS:
return BORDER_WIDTH_KEYWORDS[value]
if isinstance(value, int):
# The initial value can get here, but length() would fail as
# it does not have a 'unit' attribute.
return value
return length(computer, name, value, pixels_only=True)
@register_computer('column-width')
def column_width(computer, name, value):
"""Compute the ``column-width`` property."""
return length(computer, name, value, pixels_only=True)
@register_computer('border-top-left-radius')
@register_computer('border-top-right-radius')
@register_computer('border-bottom-left-radius')
@register_computer('border-bottom-right-radius')
def border_radius(computer, name, values):
"""Compute the ``border-*-radius`` properties."""
return tuple(length(computer, name, value) for value in values)
@register_computer('column-gap')
def column_gap(computer, name, value):
"""Compute the ``column-gap`` property."""
if value == 'normal':
value = Dimension(1, 'em')
return length(computer, name, value, pixels_only=True)
def compute_attr_function(computer, values):
# TODO: use real token parsing instead of casting with Python types
func_name, value = values
assert func_name == 'attr()'
attr_name, type_or_unit, fallback = value
# computer['element'] sometimes is None
# computer['element'] sometimes is a 'PageType' object without .get()
# so wrapt the .get() into try and return None instead of crashing
try:
attr_value = computer['element'].get(attr_name, fallback)
if type_or_unit == 'string':
pass # Keep the string
elif type_or_unit == 'url':
if attr_value.startswith('#'):
attr_value = ('internal', unquote(attr_value[1:]))
else:
attr_value = (
'external', safe_urljoin(computer['base_url'], attr_value))
elif type_or_unit == 'color':
attr_value = parse_color(attr_value.strip())
elif type_or_unit == 'integer':
attr_value = int(attr_value.strip())
elif type_or_unit == 'number':
attr_value = float(attr_value.strip())
elif type_or_unit == '%':
attr_value = Dimension(float(attr_value.strip()), '%')
type_or_unit = 'length'
elif type_or_unit in LENGTH_UNITS:
attr_value = Dimension(float(attr_value.strip()), type_or_unit)
type_or_unit = 'length'
elif type_or_unit in ANGLE_TO_RADIANS:
attr_value = Dimension(float(attr_value.strip()), type_or_unit)
type_or_unit = 'angle'
except Exception:
return
return (type_or_unit, attr_value)
def _content_list(computer, values):
computed_values = []
for value in values:
if value[0] in ('string', 'content', 'url', 'quote', 'leader()'):
computed_value = value
elif value[0] == 'attr()':
assert value[1][1] == 'string'
computed_value = compute_attr_function(computer, value)
elif value[0] in (
'counter()', 'counters()', 'content()', 'element()',
'string()',
):
# Other values need layout context, their computed value cannot be
# better than their specified value yet.
# See build.compute_content_list.
computed_value = value
elif value[0] in (
'target-counter()', 'target-counters()', 'target-text()'):
anchor_token = value[1][0]
if anchor_token[0] == 'attr()':
attr = compute_attr_function(computer, anchor_token)
if attr is None:
computed_value = None
else:
computed_value = (value[0], (
(attr,) + value[1][1:]))
else:
computed_value = value
if computer['target_collector'] and computed_value:
computer['target_collector'].collect_computed_target(
computed_value[1][0])
if computed_value is None:
LOGGER.warning('Unable to compute %s\'s value for content: %s' % (
computer['element'], ', '.join(str(item) for item in value)))
else:
computed_values.append(computed_value)
return tuple(computed_values)
@register_computer('bookmark-label')
def bookmark_label(computer, name, values):
"""Compute the ``bookmark-label`` property."""
return _content_list(computer, values)
@register_computer('string-set')
def string_set(computer, name, values):
"""Compute the ``string-set`` property."""
# Spec asks for strings after custom keywords, but we allow content-lists
return tuple(
(string_set[0], _content_list(computer, string_set[1]))
for string_set in values)
@register_computer('content')
def content(computer, name, values):
"""Compute the ``content`` property."""
if len(values) == 1:
value, = values
if value == 'normal':
return 'inhibit' if computer['pseudo_type'] else 'contents'
elif value == 'none':
return 'inhibit'
return _content_list(computer, values)
@register_computer('display')
def display(computer, name, value):
"""Compute the ``display`` property.
See http://www.w3.org/TR/CSS21/visuren.html#dis-pos-flo
"""
float_ = computer['specified']['float']
position = computer['specified']['position']
if position in ('absolute', 'fixed') or float_ != 'none' or \
computer['is_root_element']:
if value == 'inline-table':
return'table'
elif value in ('inline', 'table-row-group', 'table-column',
'table-column-group', 'table-header-group',
'table-footer-group', 'table-row', 'table-cell',
'table-caption', 'inline-block'):
return 'block'
return value
@register_computer('float')
def compute_float(computer, name, value):
"""Compute the ``float`` property.
See http://www.w3.org/TR/CSS21/visuren.html#dis-pos-flo
"""
if computer['specified']['position'] in ('absolute', 'fixed'):
return 'none'
else:
return value
@register_computer('font-size')
def font_size(computer, name, value):
"""Compute the ``font-size`` property."""
if value in FONT_SIZE_KEYWORDS:
return FONT_SIZE_KEYWORDS[value]
keyword_values = list(FONT_SIZE_KEYWORDS.values())
parent_font_size = computer['parent_style']['font_size']
if value == 'larger':
for i, keyword_value in enumerate(keyword_values):
if keyword_value > parent_font_size:
return keyword_values[i]
else:
return parent_font_size * 1.2
elif value == 'smaller':
for i, keyword_value in enumerate(keyword_values[::-1]):
if keyword_value < parent_font_size:
return keyword_values[-i - 1]
else:
return parent_font_size * 0.8
elif value.unit == '%':
return value.value * parent_font_size / 100.
else:
return length(
computer, name, value, pixels_only=True,
font_size=parent_font_size)
@register_computer('font-weight')
def font_weight(computer, name, value):
"""Compute the ``font-weight`` property."""
if value == 'normal':
return 400
elif value == 'bold':
return 700
elif value in ('bolder', 'lighter'):
parent_value = computer['parent_style']['font_weight']
return FONT_WEIGHT_RELATIVE[value][parent_value]
else:
return value
@register_computer('line-height')
def line_height(computer, name, value):
"""Compute the ``line-height`` property."""
if value == 'normal':
return value
elif not value.unit:
return ('NUMBER', value.value)
elif value.unit == '%':
factor = value.value / 100.
font_size_value = computer['computed']['font_size']
pixels = factor * font_size_value
else:
pixels = length(computer, name, value, pixels_only=True)
return ('PIXELS', pixels)
@register_computer('anchor')
def anchor(computer, name, values):
"""Compute the ``anchor`` property."""
if values != 'none':
_, key = values
anchor_name = computer['element'].get(key) or None
computer['target_collector'].collect_anchor(anchor_name)
return anchor_name
@register_computer('link')
def link(computer, name, values):
"""Compute the ``link`` property."""
if values == 'none':
return None
else:
type_, value = values
if type_ == 'attr()':
return get_link_attribute(
computer['element'], value, computer['base_url'])
else:
return values
@register_computer('lang')
def lang(computer, name, values):
"""Compute the ``lang`` property."""
if values == 'none':
return None
else:
type_, key = values
if type_ == 'attr()':
return computer['element'].get(key) or None
elif type_ == 'string':
return key
@register_computer('tab-size')
def tab_size(computer, name, value):
"""Compute the ``tab-size`` property."""
if isinstance(value, int):
return value
else:
return length(computer, name, value)
@register_computer('transform')
def transform(computer, name, value):
"""Compute the ``transform`` property."""
result = []
for function, args in value:
if function == 'translate':
args = length_or_percentage_tuple(computer, name, args)
result.append((function, args))
return tuple(result)
@register_computer('vertical-align')
def vertical_align(computer, name, value):
"""Compute the ``vertical-align`` property."""
# Use +/- half an em for super and sub, same as Pango.
# (See the SUPERSUB_RISE constant in pango-markup.c)
if value in ('baseline', 'middle', 'text-top', 'text-bottom',
'top', 'bottom'):
return value
elif value == 'super':
return computer['computed']['font_size'] * 0.5
elif value == 'sub':
return computer['computed']['font_size'] * -0.5
elif value.unit == '%':
height, _ = strut_layout(computer['computed'])
return height * value.value / 100.
else:
return length(computer, name, value, pixels_only=True)
@register_computer('word-spacing')
def word_spacing(computer, name, value):
"""Compute the ``word-spacing`` property."""
if value == 'normal':
return 0
else:
return length(computer, name, value, pixels_only=True)
def strut_layout(style, context=None):
"""Return a tuple of the used value of ``line-height`` and the baseline.
The baseline is given from the top edge of line height.
"""
# TODO: always get the real value for `context`? (if we really care…)
if style['font_size'] == 0:
return 0, 0
if context:
key = (
style['font_size'], style['font_language_override'], style['lang'],
tuple(style['font_family']), style['font_style'],
style['font_stretch'], style['font_weight'], style['line_height'])
if key in context.strut_layouts:
return context.strut_layouts[key]
layout = text.Layout(context, style['font_size'], style)
layout.set_text(' ')
line, _ = layout.get_first_line()
_, _, _, _, text_height, baseline = text.first_line_metrics(
line, '', layout, resume_at=None, space_collapse=False, style=style)
if style['line_height'] == 'normal':
result = text_height, baseline
if context:
context.strut_layouts[key] = result
return result
type_, line_height = style['line_height']
if type_ == 'NUMBER':
line_height *= style['font_size']
result = line_height, baseline + (line_height - text_height) / 2
if context:
context.strut_layouts[key] = result
return result
def ex_ratio(style):
"""Return the ratio 1ex/font_size, according to given style."""
font_size = 1000 # big value
# TODO: use context to use @font-face fonts
layout = text.Layout(context=None, font_size=font_size, style=style)
layout.set_text('x')
line, _ = layout.get_first_line()
_, ink_height_above_baseline = text.get_ink_position(line)
# Zero means some kind of failure, fallback is 0.5.
# We round to try keeping exact values that were altered by Pango.
return round(-ink_height_above_baseline / font_size, 5) or 0.5

192
weasyprint/css/html5_ph.css Normal file
View File

@ -0,0 +1,192 @@
/*
Presentational hints stylsheet for HTML.
This stylesheet contains all the presentational hints rules that can be
expressed as CSS.
See https://www.w3.org/TR/html5/rendering.html#rendering
TODO: Attribute values are not case-insensitive, but they should be. We can add
a "i" flag when CSS Selectors Level 4 is supported.
*/
pre[wrap] { white-space: pre-wrap; }
br[clear=left] { clear: left; }
br[clear=right] { clear: right; }
br[clear=all], br[clear=both] { clear: both; }
ol[type=1], li[type=1] { list-style-type: decimal; }
ol[type=a], li[type=a] { list-style-type: lower-alpha; }
ol[type=A], li[type=A] { list-style-type: upper-alpha; }
ol[type=i], li[type=i] { list-style-type: lower-roman; }
ol[type=I], li[type=I] { list-style-type: upper-roman; }
ul[type=disc], li[type=disc] { list-style-type: disc; }
ul[type=circle], li[type=circle] { list-style-type: circle; }
ul[type=square], li[type=square] { list-style-type: square; }
table[align=left] { float: left; }
table[align=right] { float: right; }
table[align=center] { margin-left: auto; margin-right: auto; }
thead[align=absmiddle], tbody[align=absmiddle], tfoot[align=absmiddle],
tr[align=absmiddle], td[align=absmiddle], th[align=absmiddle] {
text-align: center;
}
caption[align=bottom] { caption-side: bottom; }
p[align=left], h1[align=left], h2[align=left], h3[align=left],
h4[align=left], h5[align=left], h6[align=left] {
text-align: left;
}
p[align=right], h1[align=right], h2[align=right], h3[align=right],
h4[align=right], h5[align=right], h6[align=right] {
text-align: right;
}
p[align=center], h1[align=center], h2[align=center], h3[align=center],
h4[align=center], h5[align=center], h6[align=center] {
text-align: center;
}
p[align=justify], h1[align=justify], h2[align=justify], h3[align=justify],
h4[align=justify], h5[align=justify], h6[align=justify] {
text-align: justify;
}
thead[valign=top], tbody[valign=top], tfoot[valign=top],
tr[valign=top], td[valign=top], th[valign=top] {
vertical-align: top;
}
thead[valign=middle], tbody[valign=middle], tfoot[valign=middle],
tr[valign=middle], td[valign=middle], th[valign=middle] {
vertical-align: middle;
}
thead[valign=bottom], tbody[valign=bottom], tfoot[valign=bottom],
tr[valign=bottom], td[valign=bottom], th[valign=bottom] {
vertical-align: bottom;
}
thead[valign=baseline], tbody[valign=baseline], tfoot[valign=baseline],
tr[valign=baseline], td[valign=baseline], th[valign=baseline] {
vertical-align: baseline;
}
td[nowrap], th[nowrap] { white-space: nowrap; }
table[rules=none], table[rules=groups], table[rules=rows],
table[rules=cols], table[rules=all] {
border-style: hidden;
border-collapse: collapse;
}
table[border]:not([border="0"]) { border-style: outset; }
table[frame=void] { border-style: hidden; }
table[frame=above] { border-style: outset hidden hidden hidden; }
table[frame=below] { border-style: hidden hidden outset hidden; }
table[frame=hsides] { border-style: outset hidden outset hidden; }
table[frame=lhs] { border-style: hidden hidden hidden outset; }
table[frame=rhs] { border-style: hidden outset hidden hidden; }
table[frame=vsides] { border-style: hidden outset; }
table[frame=box], table[frame=border] { border-style: outset; }
table[border]:not([border="0"]) > tr > td, table[border]:not([border="0"]) > tr > th,
table[border]:not([border="0"]) > thead > tr > td, table[border]:not([border="0"]) > thead > tr > th,
table[border]:not([border="0"]) > tbody > tr > td, table[border]:not([border="0"]) > tbody > tr > th,
table[border]:not([border="0"]) > tfoot > tr > td, table[border]:not([border="0"]) > tfoot > tr > th {
border-width: 1px;
border-style: inset;
}
table[rules=none] > tr > td, table[rules=none] > tr > th,
table[rules=none] > thead > tr > td, table[rules=none] > thead > tr > th,
table[rules=none] > tbody > tr > td, table[rules=none] > tbody > tr > th,
table[rules=none] > tfoot > tr > td, table[rules=none] > tfoot > tr > th,
table[rules=groups] > tr > td, table[rules=groups] > tr > th,
table[rules=groups] > thead > tr > td, table[rules=groups] > thead > tr > th,
table[rules=groups] > tbody > tr > td, table[rules=groups] > tbody > tr > th,
table[rules=groups] > tfoot > tr > td, table[rules=groups] > tfoot > tr > th,
table[rules=rows] > tr > td, table[rules=rows] > tr > th,
table[rules=rows] > thead > tr > td, table[rules=rows] > thead > tr > th,
table[rules=rows] > tbody > tr > td, table[rules=rows] > tbody > tr > th,
table[rules=rows] > tfoot > tr > td, table[rules=rows] > tfoot > tr > th {
border-width: 1px;
border-style: none;
}
table[rules=cols] > tr > td, table[rules=cols] > tr > th,
table[rules=cols] > thead > tr > td, table[rules=cols] > thead > tr > th,
table[rules=cols] > tbody > tr > td, table[rules=cols] > tbody > tr > th,
table[rules=cols] > tfoot > tr > td, table[rules=cols] > tfoot > tr > th {
border-width: 1px;
border-style: none solid;
}
table[rules=all] > tr > td, table[rules=all] > tr > th,
table[rules=all] > thead > tr > td, table[rules=all] > thead > tr > th,
table[rules=all] > tbody > tr > td, table[rules=all] > tbody > tr > th,
table[rules=all] > tfoot > tr > td, table[rules=all] > tfoot > tr > th {
border-width: 1px;
border-style: solid;
}
table[rules=groups] > colgroup {
border-left-width: 1px;
border-left-style: solid;
border-right-width: 1px;
border-right-style: solid;
}
table[rules=groups] > thead,
table[rules=groups] > tbody,
table[rules=groups] > tfoot {
border-top-width: 1px;
border-top-style: solid;
border-bottom-width: 1px;
border-bottom-style: solid;
}
table[rules=rows] > tr, table[rules=rows] > thead > tr,
table[rules=rows] > tbody > tr, table[rules=rows] > tfoot > tr {
border-top-width: 1px;
border-top-style: solid;
border-bottom-width: 1px;
border-bottom-style: solid;
}
hr[align=left] { margin-left: 0; margin-right: auto; }
hr[align=right] { margin-left: auto; margin-right: 0; }
hr[align=center] { margin-left: auto; margin-right: auto; }
hr[color], hr[noshade] { border-style: solid; }
iframe[frameborder=0], iframe[frameborder=no] { border: none; }
applet[align=left], embed[align=left], iframe[align=left],
img[align=left], input[type=image][align=left], object[align=left] {
float: left;
}
applet[align=right], embed[align=right], iframe[align=right],
img[align=right], input[type=image][align=right], object[align=right] {
float: right;
}
applet[align=top], embed[align=top], iframe[align=top],
img[align=top], input[type=image][align=top], object[align=top] {
vertical-align: top;
}
applet[align=baseline], embed[align=baseline], iframe[align=baseline],
img[align=baseline], input[type=image][align=baseline], object[align=baseline] {
vertical-align: baseline;
}
applet[align=texttop], embed[align=texttop], iframe[align=texttop],
img[align=texttop], input[type=image][align=texttop], object[align=texttop] {
vertical-align: text-top;
}
applet[align=absmiddle], embed[align=absmiddle], iframe[align=absmiddle],
img[align=absmiddle], input[type=image][align=absmiddle], object[align=absmiddle],
applet[align=abscenter], embed[align=abscenter], iframe[align=abscenter],
img[align=abscenter], input[type=image][align=abscenter], object[align=abscenter] {
vertical-align: middle;
}
applet[align=bottom], embed[align=bottom], iframe[align=bottom],
img[align=bottom], input[type=image][align=bottom],
object[align=bottom] {
vertical-align: bottom;
}

539
weasyprint/css/html5_ua.css Normal file
View File

@ -0,0 +1,539 @@
/*
User agent stylsheet for HTML.
Contributed by Peter Moulder.
Based on suggested styles in the HTML5 specification, CSS 2.1, and
what various web browsers use.
*/
/* http://www.w3.org/TR/html5/Overview#scroll-to-the-fragment-identifier */
*[id] { -weasy-anchor: attr(id); }
a[name] { -weasy-anchor: attr(name); }
*[dir] { unicode-bidi: embed; }
*[hidden] { display: none; }
*[dir=ltr] { direction: ltr; }
*[dir=rtl] { direction: rtl; }
:dir(ltr) { direction: ltr; }
:dir(rtl) { direction: rtl; }
:root { quotes: '\201c' '\201d' '\2018' '\2019'; }
*[lang] { -weasy-lang: attr(lang); }
[lang|=af] { quotes: '\201c' '\201d' '\2018' '\201d'; }
[lang|=agq] { quotes: '\0027' '\0027' '\201e' '\201d'; }
[lang|=ak] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=am] { quotes: '\00ab' '\00bb' '\2039' '\203a'; }
[lang|=ar] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=asa] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=az] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=bas] { quotes: '\00ab' '\00bb' '\201c' '\201e'; }
[lang|=be] { quotes: '\201e' '\201d' '\00ab' '\00bb'; }
[lang|=bem] { quotes: '\0027' '\0027' '\201c' '\201c'; }
[lang|=bez] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=bg] { quotes: '\201e' '\201c' '\2018' '\2019'; }
[lang|=bm] { quotes: '\00ab' '\00bb' '\201c' '\201d'; }
[lang|=bn] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=brx] { quotes: '\0027' '\0027' '\0022' '\0022'; }
[lang|=bs] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=ca] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=cgg] { quotes: '\0027' '\0027' '\201c' '\201e'; }
[lang|=chr] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=cs] { quotes: '\201e' '\201c' '\201a' '\2018'; }
[lang|=cy] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=da] { quotes: '\201d' '\201d' '\201d' '\201d'; }
[lang|=dav] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=de] { quotes: '\201e' '\201c' '\201a' '\2018'; }
[lang|=de-CH] { quotes: '\00ab' '\00bb' '\2039' '\203a'; }
[lang|=dje] { quotes: '\201c' '\201d' '\00ab' '\00bb'; }
[lang|=dua] { quotes: '\00ab' '\00bb' '\0027' '\0027'; }
[lang|=dyo] { quotes: '\00ab' '\00bb' '\201c' '\201d'; }
[lang|=dz] { quotes: '\0022' '\0022' '\0027' '\0027'; }
[lang|=ebu] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=ee] { quotes: '\0027' '\0027' '\201c' '\201c'; }
[lang|=el] { quotes: '\00ab' '\00bb' '\2018' '\2019'; }
[lang|=el-POLYTON] { quotes: '\00ab' '\00bb' '\201b' '\2019'; }
[lang|=en] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=en-GB] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=es] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=et] { quotes: '\201e' '\201c' '\201e' '\201c'; }
[lang|=eu] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=ewo] { quotes: '\00ab' '\00bb' '\201c' '\201d'; }
[lang|=fa] { quotes: '\00ab' '\00bb' '\2039' '\203a'; }
[lang|=ff] { quotes: '\201e' '\201d' '\0027' '\0027'; }
[lang|=fi] { quotes: '\201d' '\201d' '\2019' '\2019'; }
[lang|=fil] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=fo] { quotes: '\201d' '\201d' '\2019' '\2019'; }
[lang|=fr] { quotes: '\00ab' '\00bb' '\201c' '\201d'; }
[lang|=fr-CH] { quotes: '\00ab' '\00bb' '\2039' '\203a'; }
[lang|=fur] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=gaa] { quotes: '\0027' '\0027' '\0022' '\0022'; }
[lang|=gd] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=gl] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=gsw] { quotes: '\00ab' '\00bb' '\2039' '\203a'; }
[lang|=gu] { quotes: '\0027' '\0027' '\0022' '\0022'; }
[lang|=guz] { quotes: '\0027' '\2018' '\201c' '\201c'; }
[lang|=ha] { quotes: '\0027' '\0027' '\201c' '\201c'; }
[lang|=he] { quotes: '\201c' '\201d' '\0022' '\0022'; }
[lang|=hi] { quotes: '\0027' '\0027' '\0022' '\0022'; }
[lang|=hr] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=hu] { quotes: '\201e' '\201d' '\201e' '\201d'; }
[lang|=ia] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=id] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=ig] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=is] { quotes: '\201e' '\201c' '\201a' '\2018'; }
[lang|=it] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=it-CH] { quotes: '\00ab' '\00bb' '\2039' '\203a'; }
[lang|=ja] { quotes: '\300c' '\300d' '\300e' '\300f'; }
[lang|=jmc] { quotes: '\0027' '\0027' '\201c' '\201c'; }
[lang|=ka] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=kab] { quotes: '\00ab' '\00bb' '\201c' '\201d'; }
[lang|=kam] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=kde] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=kea] { quotes: '\201c' '\201d' '\00ab' '\00bb'; }
[lang|=khq] { quotes: '\201c' '\201d' '\00ab' '\00bb'; }
[lang|=ki] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=kl] { quotes: '\00bb' '\00ab' '\203a' '\2039'; }
[lang|=kln] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=km] { quotes: '\0027' '\0027' '\0022' '\0022'; }
[lang|=kn] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=ko] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=ksb] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=ksf] { quotes: '\0027' '\0027' '\00ab' '\00bb'; }
[lang|=ksh] { quotes: '\201e' '\201c' '\201a' '\2018'; }
[lang|=lag] { quotes: '\201d' '\201d' '\0027' '\0027'; }
[lang|=lg] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=ln] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=lo] { quotes: '\0027' '\0027' '\0022' '\0022'; }
[lang|=lt] { quotes: '\201e' '\201c' '\201e' '\201c'; }
[lang|=lu] { quotes: '\2018' '\2018' '\201c' '\201c'; }
[lang|=luo] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=luy] { quotes: '\0027' '\0027' '\201e' '\201c'; }
[lang|=lv] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=mas] { quotes: '\0027' '\0027' '\201d' '\201c'; }
[lang|=mer] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=mfe] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=mg] { quotes: '\00ab' '\00bb' '\201c' '\201d'; }
[lang|=ml] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=mn] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=mr] { quotes: '\0027' '\0027' '\0022' '\0022'; }
[lang|=ms] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=mt] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=mua] { quotes: '\00ab' '\00bb' '\201c' '\201d'; }
[lang|=my] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=naq] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=nb] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=nd] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=nds] { quotes: '\201e' '\201c' '\201a' '\2018'; }
[lang|=ne] { quotes: '\0027' '\0027' '\0022' '\0022'; }
[lang|=nl] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=nmg] { quotes: '\201c' '\201e' '\00ab' '\00bb'; }
[lang|=nn] { quotes: '\00ab' '\00bb' '\201c' '\201d'; }
[lang|=nr] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=nso] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=nus] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=nyn] { quotes: '\0027' '\0027' '\201c' '\201e'; }
[lang|=pa] { quotes: '\0027' '\0027' '\0022' '\0022'; }
[lang|=pl] { quotes: '\2018' '\2019' '\201e' '\201d'; }
[lang|=pt] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=rm] { quotes: '\00ab' '\00bb' '\2039' '\203a'; }
[lang|=rn] { quotes: '\0027' '\0027' '\201d' '\201d'; }
[lang|=ro] { quotes: '\201e' '\201d' '\00ab' '\00bb'; }
[lang|=rof] { quotes: '\0027' '\0027' '\201c' '\201c'; }
[lang|=ru] { quotes: '\00ab' '\00bb' '\201e' '\201c'; }
[lang|=rw] { quotes: '\2018' '\2019' '\00ab' '\00bb'; }
[lang|=rwk] { quotes: '\0027' '\0027' '\201c' '\201c'; }
[lang|=saq] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=sbp] { quotes: '\0027' '\0027' '\201c' '\201d'; }
[lang|=se] { quotes: '\201d' '\201d' '\2019' '\2019'; }
[lang|=seh] { quotes: '\0027' '\0027' '\0027' '\0027'; }
[lang|=ses] { quotes: '\201c' '\201d' '\00ab' '\00bb'; }
[lang|=sg] { quotes: '\00ab' '\00bb' '\201c' '\2019'; }
[lang|=shi] { quotes: '\00ab' '\00bb' '\201e' '\201d'; }
[lang|=shi-Tfng] { quotes: '\00ab' '\00bb' '\201e' '\201d'; }
[lang|=sk] { quotes: '\201a' '\2018' '\201e' '\201c'; }
[lang|=sl] { quotes: '\00bb' '\00ab' '\201e' '\201c'; }
[lang|=sn] { quotes: '\0027' '\0027' '\201d' '\201d'; }
[lang|=so] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=sr] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=ss] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=st] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=sv] { quotes: '\201d' '\201d' '\2019' '\2019'; }
[lang|=sw] { quotes: '\0027' '\0027' '\0027' '\0027'; }
[lang|=swc] { quotes: '\0027' '\0027' '\201d' '\201c'; }
[lang|=ta] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=te] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=teo] { quotes: '\0027' '\2019' '\201c' '\201d'; }
[lang|=tg] { quotes: '\00ab' '\00bb' '\00ab' '\201e'; }
[lang|=th] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=ti-ER] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=tn] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=to] { quotes: '\201c' '\201d' '\00ab' '\00bb'; }
[lang|=tr] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=trv] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=ts] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=twq] { quotes: '\201c' '\201d' '\00ab' '\00bb'; }
[lang|=tzm] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=uk] { quotes: '\00ab' '\00bb' '\201e' '\201c'; }
[lang|=ur] { quotes: '\0022' '\0022' '\0027' '\0027'; }
[lang|=vai] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=vai-Latn] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=ve] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=vi] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=vun] { quotes: '\0027' '\0027' '\201c' '\201c'; }
[lang|=wae] { quotes: '\00ab' '\00bb' '\2039' '\203a'; }
[lang|=xh] { quotes: '\2018' '\2019' '\201c' '\201d'; }
[lang|=xog] { quotes: '\0027' '\0027' '\201c' '\201e'; }
[lang|=yav] { quotes: '\00ab' '\00bb' '\00ab' '\00bb'; }
[lang|=yo] { quotes: '\0027' '\0027' '\0027' '\0027'; }
[lang|=zh] { quotes: '\201c' '\201d' '\2018' '\2019'; }
[lang|=zh-Hant] { quotes: '\300c' '\300d' '\300e' '\300f'; }
[lang|=zu] { quotes: '\2018' '\2019' '\201c' '\201d'; }
:link { color: #0000EE; text-decoration: underline; }
a[href] { -weasy-link: attr(href); }
:visited { color: #551A8B; text-decoration: underline; }
a:link[rel~=help] { cursor: help; }
a:visited[rel~=help] { cursor: help; }
abbr[title] { text-decoration: dotted underline; }
acronym[title] { text-decoration: dotted underline; }
address { display: block; font-style: italic; unicode-bidi: isolate; }
area { display: none; }
area:link[rel~=help] { cursor: help; }
area:visited[rel~=help] { cursor: help; }
article { display: block; unicode-bidi: isolate; }
aside { display: block; unicode-bidi: isolate; }
b { font-weight: bold; }
base { display: none; }
basefont { display: none; }
bdi { unicode-bidi: isolate; }
bdi[dir] { unicode-bidi: isolate; }
bdo { unicode-bidi: bidi-override; }
bdo[dir] { unicode-bidi: bidi-override; }
big { font-size: larger; }
blink { text-decoration: blink; }
blockquote { display: block; margin: 1em 40px; unicode-bidi: isolate; }
body { display: block; margin: 8px; }
br::before { content: '\A'; white-space: pre-line; }
button { display: inline-block; text-align: center; text-indent: 0; }
caption { display: table-caption; unicode-bidi: isolate; }
center { display: block; text-align: center; unicode-bidi: isolate; }
cite { font-style: italic; }
code { font-family: monospace; }
col { display: table-column; unicode-bidi: isolate; }
col[hidden] { display: table-column; unicode-bidi: isolate; visibility: collapse; }
colgroup { display: table-column-group; unicode-bidi: isolate; }
colgroup[hidden] { display: table-column-group; unicode-bidi: isolate; visibility: collapse; }
command { display: none; }
datalist { display: none; }
dd { display: block; margin-left: 40px; unicode-bidi: isolate; }
*[dir=ltr] dd { margin-left: 0; margin-right: 40px; }
*[dir=rtl] dd { margin-left: 40px; margin-right: 0; }
*[dir] *[dir=ltr] dd { margin-left: 0; margin-right: 40px; }
*[dir] *[dir=rtl] dd { margin-left: 40px; margin-right: 0; }
*[dir] *[dir] *[dir=ltr] dd { margin-left: 0; margin-right: 40px; }
*[dir] *[dir] *[dir=rtl] dd { margin-left: 40px; margin-right: 0; }
dd[dir=ltr][dir][dir] { margin-left: 0; margin-right: 40px; }
dd[dir=rtl][dir][dir] { margin-left: 40px; margin-right: 0; }
details { display: block; unicode-bidi: isolate; }
del { text-decoration: line-through; }
dfn { font-style: italic; }
dir { display: block; list-style-type: disc; margin-bottom: 1em; margin-top: 1em; padding-left: 40px; unicode-bidi: isolate; }
*[dir=rtl] dir { padding-left: 0; padding-right: 40px; }
*[dir=ltr] dir { padding-left: 40px; padding-right: 0; }
*[dir] *[dir=rtl] dir { padding-left: 0; padding-right: 40px; }
*[dir] *[dir=ltr] dir { padding-left: 40px; padding-right: 0; }
*[dir] *[dir] *[dir=rtl] dir { padding-left: 0; padding-right: 40px; }
*[dir] *[dir] *[dir=ltr] dir { padding-left: 40px; padding-right: 0; }
dir[dir=rtl][dir][dir] { padding-left: 0; padding-right: 40px; }
dir[dir=ltr][dir][dir] { padding-left: 40px; padding-right: 0; }
dir dir { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dl dir { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
menu dir { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ol dir { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ul dir { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
div { display: block; unicode-bidi: isolate; }
dl { display: block; margin-bottom: 1em; margin-top: 1em; unicode-bidi: isolate; }
dir dl { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dl dl { margin-bottom: 0; margin-top: 0; }
ol dl { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ul dl { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dir dir dl { list-style-type: square; }
dir menu dl { list-style-type: square; }
dir ol dl { list-style-type: square; }
dir ul dl { list-style-type: square; }
menu dir dl { list-style-type: square; }
menu dl { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
menu menu dl { list-style-type: square; }
menu ol dl { list-style-type: square; }
menu ul dl { list-style-type: square; }
ol dir dl { list-style-type: square; }
ol menu dl { list-style-type: square; }
ol ol dl { list-style-type: square; }
ol ul dl { list-style-type: square; }
ul dir dl { list-style-type: square; }
ul menu dl { list-style-type: square; }
ul ol dl { list-style-type: square; }
ul ul dl { list-style-type: square; }
ol, ul { counter-reset: list-item }
dt { display: block; unicode-bidi: isolate; }
em { font-style: italic; }
fieldset { display: block; border-style: groove; border-width: 2px; margin-left: 2px; margin-right: 2px; padding: .35em .625em .75em .625em; }
figcaption { display: block; unicode-bidi: isolate; }
figure { display: block; margin: 1em 40px; unicode-bidi: isolate; }
footer { display: block; unicode-bidi: isolate; }
form { display: block; unicode-bidi: isolate; }
frame { display: block; }
frameset { display: block; }
h1 { display: block; font-size: 2em; font-weight: bold; hyphens: manual; margin-bottom: .67em; margin-top: .67em; page-break-after: avoid; page-break-inside: avoid; unicode-bidi: isolate; bookmark-level: 1; bookmark-label: content(text); }
section h1 { font-size: 1.50em; margin-bottom: .83em; margin-top: .83em; }
section section h1 { font-size: 1.17em; margin-bottom: 1.00em; margin-top: 1.00em; }
section section section h1 { font-size: 1.00em; margin-bottom: 1.33em; margin-top: 1.33em; }
section section section section h1 { font-size: .83em; margin-bottom: 1.67em; margin-top: 1.67em; }
section section section section section h1 { font-size: .67em; margin-bottom: 2.33em; margin-top: 2.33em; }
h2 { display: block; font-size: 1.50em; font-weight: bold; hyphens: manual; margin-bottom: .83em; margin-top: .83em; page-break-after: avoid; page-break-inside: avoid; unicode-bidi: isolate; bookmark-level: 2; bookmark-label: content(text); }
h3 { display: block; font-size: 1.17em; font-weight: bold; hyphens: manual; margin-bottom: 1.00em; margin-top: 1.00em; page-break-after: avoid; page-break-inside: avoid; unicode-bidi: isolate; bookmark-level: 3; bookmark-label: content(text); }
h4 { display: block; font-size: 1.00em; font-weight: bold; hyphens: manual; margin-bottom: 1.33em; margin-top: 1.33em; page-break-after: avoid; page-break-inside: avoid; unicode-bidi: isolate; bookmark-level: 4; bookmark-label: content(text); }
h5 { display: block; font-size: .83em; font-weight: bold; hyphens: manual; margin-bottom: 1.67em; margin-top: 1.67em; page-break-after: avoid; unicode-bidi: isolate; bookmark-level: 5; bookmark-label: content(text); }
h6 { display: block; font-size: .67em; font-weight: bold; hyphens: manual; margin-bottom: 2.33em; margin-top: 2.33em; page-break-after: avoid; unicode-bidi: isolate; bookmark-level: 6; bookmark-label: content(text); }
head { display: none; }
header { display: block; unicode-bidi: isolate; }
hgroup { display: block; unicode-bidi: isolate; }
hr { border-style: inset; border-width: 1px; color: gray; display: block; margin-bottom: .5em; margin-left: auto; margin-right: auto; margin-top: .5em; unicode-bidi: isolate; }
html { display: block; }
i { font-style: italic; }
*[dir=auto] { unicode-bidi: isolate; }
bdo[dir=auto] { unicode-bidi: bidi-override isolate; }
input[type=hidden] { display: none; }
menu[type=context] { display: none; }
pre[dir=auto] { unicode-bidi: plaintext; }
table[frame=above] { border-color: black; }
table[frame=below] { border-color: black; }
table[frame=border] { border-color: black; }
table[frame=box] { border-color: black; }
table[frame=hsides] { border-color: black; }
table[frame=lhs] { border-color: black; }
table[frame=rhs] { border-color: black; }
table[frame=void] { border-color: black; }
table[frame=vsides] { border-color: black; }
table[rules=all] { border-color: black; }
table[rules=cols] { border-color: black; }
table[rules=groups] { border-color: black; }
table[rules=none] { border-color: black; }
table[rules=rows] { border-color: black; }
textarea[dir=auto] { unicode-bidi: plaintext; }
iframe { border: 2px inset; }
iframe[seamless] { border: none; }
input { display: inline-block; text-indent: 0; }
ins { text-decoration: underline; }
kbd { font-family: monospace; }
keygen { display: inline-block; text-indent: 0; }
legend { display: block; unicode-bidi: isolate; }
li { display: list-item; unicode-bidi: isolate; }
link { display: none; }
listing { display: block; font-family: monospace; margin-bottom: 1em; margin-top: 1em; unicode-bidi: isolate; white-space: pre; }
mark { background: yellow; color: black; }
main { display: block; unicode-bidi: isolate; }
menu { display: block; list-style-type: disc; margin-bottom: 1em; margin-top: 1em; padding-left: 40px; unicode-bidi: isolate; }
*[dir=rtl] menu { padding-left: 0; padding-right: 40px; }
*[dir=ltr] menu { padding-left: 40px; padding-right: 0; }
*[dir] *[dir=rtl] menu { padding-left: 0; padding-right: 40px; }
*[dir] *[dir=ltr] menu { padding-left: 40px; padding-right: 0; }
*[dir] *[dir] *[dir=rtl] menu { padding-left: 0; padding-right: 40px; }
*[dir] *[dir] *[dir=ltr] menu { padding-left: 40px; padding-right: 0; }
menu[dir=rtl][dir][dir] { padding-left: 0; padding-right: 40px; }
menu[dir=ltr][dir][dir] { padding-left: 40px; padding-right: 0; }
dir menu { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dl menu { margin-bottom: 0; margin-top: 0; }
menu menu { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dir dir menu { list-style-type: square; }
dir menu menu { list-style-type: square; }
dir ol menu { list-style-type: square; }
dir ul menu { list-style-type: square; }
menu dir menu { list-style-type: square; }
menu menu menu { list-style-type: square; }
menu ol menu { list-style-type: square; }
menu ul menu { list-style-type: square; }
ol menu { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ol dir menu { list-style-type: square; }
ol menu menu { list-style-type: square; }
ol ol menu { list-style-type: square; }
ol ul menu { list-style-type: square; }
ul dir menu { list-style-type: square; }
ul menu menu { list-style-type: square; }
ul menu { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ul ol menu { list-style-type: square; }
ul ul menu { list-style-type: square; }
meta { display: none; }
nav { display: block; unicode-bidi: isolate; }
nobr { white-space: nowrap; }
noembed { display: none; }
/* The HTML5 spec suggests display:none for the old (now forbidden) noframes element,
* but Morp doesn't currently handle frames, so we might as well render it.
*/
/*noframes { display: none; }*/
noframes { display: block; }
ol { page-break-before: avoid; }
ol { display: block; list-style-type: decimal; margin-bottom: 1em; margin-top: 1em; padding-left: 40px; unicode-bidi: isolate; }
*[dir=ltr] ol { padding-left: 0; padding-right: 40px; }
*[dir=rtl] ol { padding-left: 40px; padding-right: 0; }
*[dir] *[dir=ltr] ol { padding-left: 0; padding-right: 40px; }
*[dir] *[dir=rtl] ol { padding-left: 40px; padding-right: 0; }
*[dir] *[dir] *[dir=ltr] ol { padding-left: 0; padding-right: 40px; }
*[dir] *[dir] *[dir=rtl] ol { padding-left: 40px; padding-right: 0; }
ol[dir=ltr][dir][dir] { padding-left: 0; padding-right: 40px; }
ol[dir=rtl][dir][dir] { padding-left: 40px; padding-right: 0; }
dir ol { margin-bottom: 0; margin-top: 0; }
dl ol { margin-bottom: 0; margin-top: 0; }
menu ol { margin-bottom: 0; margin-top: 0; }
ol ol { margin-bottom: 0; margin-top: 0; }
ul ol { margin-bottom: 0; margin-top: 0; }
optgroup { text-indent: 0; }
option { text-indent: 0; display: none; } /* Don't display the tag, it's replaced content in dynamic browsers */
output { unicode-bidi: isolate; }
output[dir] { unicode-bidi: isolate; }
p { display: block; margin-bottom: 1em; margin-top: 1em; unicode-bidi: isolate; }
param { display: none; }
plaintext { display: block; font-family: monospace; margin-bottom: 1em; margin-top: 1em; unicode-bidi: isolate; white-space: pre; }
pre { display: block; font-family: monospace; margin-bottom: 1em; margin-top: 1em; unicode-bidi: isolate; white-space: pre; }
q::after { content: close-quote; }
q::before { content: open-quote; }
rp { display: none; }
rt { display: ruby-text; }
ruby { display: ruby; }
s { text-decoration: line-through; }
samp { font-family: monospace; }
script { display: none; }
section { display: block; unicode-bidi: isolate; }
select { text-indent: 0; }
small { font-size: smaller; }
source { display: none; }
strike { text-decoration: line-through; }
strong { font-weight: bolder; }
style { display: none; }
sub { font-size: smaller; line-height: normal; vertical-align: sub; }
summary { display: block; unicode-bidi: isolate; }
sup { font-size: smaller; line-height: normal; vertical-align: super; }
table { border-collapse: separate; border-color: gray; border-spacing: 2px; display: table; text-indent: 0; unicode-bidi: isolate; }
/* The html5 spec doesn't mention the following, though the CSS 2.1 spec does
* hint at its use, and a couple of UAs do have this. I haven't looked into
* why the HTML5 spec doesn't include this rule.
*/
table { box-sizing: border-box; }
tbody { border-color: inherit; display: table-row-group; unicode-bidi: isolate; vertical-align: middle; }
tbody[hidden] { display: table-row-group; unicode-bidi: isolate; visibility: collapse; }
td { border-color: gray; display: table-cell; padding: 1px; unicode-bidi: isolate; vertical-align: inherit; }
td[hidden] { display: table-cell; unicode-bidi: isolate; visibility: collapse; }
textarea { display: inline-block; text-indent: 0; white-space: pre-wrap; }
tfoot { border-color: inherit; display: table-footer-group; unicode-bidi: isolate; vertical-align: middle; }
tfoot[hidden] { display: table-footer-group; unicode-bidi: isolate; visibility: collapse; }
table[rules=none] > tr > td, table[rules=none] > tr > th, table[rules=groups] > tr > td, table[rules=groups] > tr > th, table[rules=rows] > tr > td, table[rules=rows] > tr > th, table[rules=cols] > tr > td, table[rules=cols] > tr > th, table[rules=all] > tr > td, table[rules=all] > tr > th, table[rules=none] > thead > tr > td, table[rules=none] > thead > tr > th, table[rules=groups] > thead > tr > td, table[rules=groups] > thead > tr > th, table[rules=rows] > thead > tr > td, table[rules=rows] > thead > tr > th, table[rules=cols] > thead > tr > td, table[rules=cols] > thead > tr > th, table[rules=all] > thead > tr > td, table[rules=all] > thead > tr > th, table[rules=none] > tbody > tr > td, table[rules=none] > tbody > tr > th, table[rules=groups] > tbody > tr > td, table[rules=groups] > tbody > tr > th, table[rules=rows] > tbody > tr > td, table[rules=rows] > tbody > tr > th, table[rules=cols] > tbody > tr > td, table[rules=cols] > tbody > tr > th, table[rules=all] > tbody > tr > td, table[rules=all] > tbody > tr > th, table[rules=none] > tfoot > tr > td, table[rules=none] > tfoot > tr > th, table[rules=groups] > tfoot > tr > td, table[rules=groups] > tfoot > tr > th, table[rules=rows] > tfoot > tr > td, table[rules=rows] > tfoot > tr > th, table[rules=cols] > tfoot > tr > td, table[rules=cols] > tfoot > tr > th, table[rules=all] > tfoot > tr > td, table[rules=all] > tfoot > tr > th { border-color: black; }
th { border-color: gray; display: table-cell; font-weight: bold; padding: 1px; unicode-bidi: isolate; vertical-align: inherit; }
th[hidden] { display: table-cell; unicode-bidi: isolate; visibility: collapse; }
thead { border-color: inherit; display: table-header-group; unicode-bidi: isolate; vertical-align: middle; }
thead[hidden] { display: table-header-group; unicode-bidi: isolate; visibility: collapse; }
title { display: none; }
table > tr { vertical-align: middle; }
tr { border-color: inherit; display: table-row; unicode-bidi: isolate; vertical-align: inherit; }
tr[hidden] { display: table-row; unicode-bidi: isolate; visibility: collapse; }
track { display: none; }
tt { font-family: monospace; }
u { text-decoration: underline; }
::marker { unicode-bidi: isolate; font-variant-numeric: tabular-nums; }
ul { display: block; list-style-type: disc; margin-bottom: 1em; margin-top: 1em; padding-left: 40px; unicode-bidi: isolate; }
*[dir=ltr] ul { padding-left: 40px; padding-right: 0; }
*[dir=rtl] ul { padding-left: 0; padding-right: 40px; }
*[dir] *[dir=ltr] ul { padding-left: 40px; padding-right: 0; }
*[dir] *[dir=rtl] ul { padding-left: 0; padding-right: 40px; }
*[dir] *[dir] *[dir=ltr] ul { padding-left: 40px; padding-right: 0; }
*[dir] *[dir] *[dir=rtl] ul { padding-left: 0; padding-right: 40px; }
ul[dir=ltr][dir][dir] { padding-left: 40px; padding-right: 0; }
ul[dir=rtl][dir][dir] { padding-left: 0; padding-right: 40px; }
/* This isn't in the HTML5 spec's suggested styling, and should probably be a
* mere hint rather than a demand. It usually is the right thing, though.
*/
ul { display: block; page-break-before: avoid; }
dir ul { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dl ul { margin-bottom: 0; margin-top: 0; }
menu ul { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ol ul { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ul ul { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dir dir ul { list-style-type: square; }
dir menu ul { list-style-type: square; }
dir ol ul { list-style-type: square; }
dir ul ul { list-style-type: square; }
menu dir ul { list-style-type: square; }
menu menu ul { list-style-type: square; }
menu ol ul { list-style-type: square; }
menu ul ul { list-style-type: square; }
ol dir ul { list-style-type: square; }
ol menu ul { list-style-type: square; }
ol ol ul { list-style-type: square; }
ol ul ul { list-style-type: square; }
ul dir ul { list-style-type: square; }
ul menu ul { list-style-type: square; }
ul ol ul { list-style-type: square; }
ul ul ul { list-style-type: square; }
var { font-style: italic; }
video { object-fit: contain; }
xmp { display: block; font-family: monospace; margin-bottom: 1em; margin-top: 1em; unicode-bidi: isolate; white-space: pre; }
@page {
/* `size: auto` (the initial) is A4 portrait */
margin: 75px;
@top-left-corner { text-align: right; vertical-align: middle }
@top-left { text-align: left; vertical-align: middle }
@top-center { text-align: center; vertical-align: middle }
@top-right { text-align: right; vertical-align: middle }
@top-right-corner { text-align: left; vertical-align: middle }
@left-top { text-align: center; vertical-align: top }
@left-middle { text-align: center; vertical-align: middle }
@left-bottom { text-align: center; vertical-align: bottom }
@right-top { text-align: center; vertical-align: top }
@right-middle { text-align: center; vertical-align: middle }
@right-bottom { text-align: center; vertical-align: bottom }
@bottom-left-corner { text-align: right; vertical-align: middle }
@bottom-left { text-align: left; vertical-align: middle }
@bottom-center { text-align: center; vertical-align: middle }
@bottom-right { text-align: right; vertical-align: middle }
@bottom-right-corner { text-align: left; vertical-align: middle }
}

View File

@ -0,0 +1,46 @@
"""
weasyprint.media_queries
------------------------
This module handles media queries.
https://www.w3.org/TR/mediaqueries-4/
:copyright: Copyright 2011-2018 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import tinycss2
from ..logger import LOGGER
from .utils import remove_whitespace, split_on_comma
def evaluate_media_query(query_list, device_media_type):
"""Return the boolean evaluation of `query_list` for the given
`device_media_type`.
:attr query_list: a cssutilts.stlysheets.MediaList
:attr device_media_type: a media type string (for now)
"""
# TODO: actual support for media queries, not just media types
return 'all' in query_list or device_media_type in query_list
def parse_media_query(tokens):
tokens = remove_whitespace(tokens)
if not tokens:
return ['all']
else:
media = []
for part in split_on_comma(tokens):
types = [token.type for token in part]
if types == ['ident']:
media.append(part[0].lower_value)
else:
LOGGER.warning(
'Expected a media type, got %s', tinycss2.serialize(part))
return
return media

View File

@ -0,0 +1,339 @@
"""
weasyprint.css.properties
-------------------------
Various data about known properties.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import collections
from tinycss2.color3 import parse_color
Dimension = collections.namedtuple('Dimension', ['value', 'unit'])
# See http://www.w3.org/TR/CSS21/propidx.html
INITIAL_VALUES = {
'bottom': 'auto',
'caption_side': 'top',
'clear': 'none',
'clip': (), # computed value for 'auto'
'color': parse_color('black'), # chosen by the user agent
# Means 'none', but allow `display: list-item` to increment the
# list-item counter. If we ever have a way for authors to query
# computed values (JavaScript?), this value should serialize to 'none'.
'counter_increment': 'auto',
'counter_reset': (), # parsed value for 'none'
# 'counter_set': (), # parsed value for 'none'
'direction': 'ltr',
'display': 'inline',
'empty_cells': 'show',
'float': 'none',
'height': 'auto',
'left': 'auto',
'line_height': 'normal',
'list_style_image': ('none', None),
'list_style_position': 'outside',
'list_style_type': 'disc',
'margin_top': Dimension(0, 'px'),
'margin_right': Dimension(0, 'px'),
'margin_bottom': Dimension(0, 'px'),
'margin_left': Dimension(0, 'px'),
'max_height': Dimension(float('inf'), 'px'), # parsed value for 'none'
'max_width': Dimension(float('inf'), 'px'),
'padding_top': Dimension(0, 'px'),
'padding_right': Dimension(0, 'px'),
'padding_bottom': Dimension(0, 'px'),
'padding_left': Dimension(0, 'px'),
'position': 'static',
'right': 'auto',
'table_layout': 'auto',
'top': 'auto',
'unicode_bidi': 'normal',
'vertical_align': 'baseline',
'visibility': 'visible',
'width': 'auto',
'z_index': 'auto',
# Backgrounds and Borders 3 (CR): https://www.w3.org/TR/css3-background/
'background_attachment': ('scroll',),
'background_clip': ('border-box',),
'background_color': parse_color('transparent'),
'background_image': (('none', None),),
'background_origin': ('padding-box',),
'background_position': (('left', Dimension(0, '%'),
'top', Dimension(0, '%')),),
'background_repeat': (('repeat', 'repeat'),),
'background_size': (('auto', 'auto'),),
'border_bottom_color': 'currentColor',
'border_bottom_left_radius': (Dimension(0, 'px'), Dimension(0, 'px')),
'border_bottom_right_radius': (Dimension(0, 'px'), Dimension(0, 'px')),
'border_bottom_style': 'none',
'border_bottom_width': 3,
'border_collapse': 'separate',
'border_left_color': 'currentColor',
'border_left_style': 'none',
'border_left_width': 3,
'border_right_color': 'currentColor',
'border_right_style': 'none',
'border_right_width': 3,
'border_spacing': (0, 0),
'border_top_color': 'currentColor',
'border_top_left_radius': (Dimension(0, 'px'), Dimension(0, 'px')),
'border_top_right_radius': (Dimension(0, 'px'), Dimension(0, 'px')),
'border_top_style': 'none',
'border_top_width': 3, # computed value for 'medium'
# Color 3 (REC): https://www.w3.org/TR/css3-color/
'opacity': 1,
# Multi-column Layout (WD): https://www.w3.org/TR/css-multicol-1/
'column_width': 'auto',
'column_count': 'auto',
'column_gap': Dimension(1, 'em'),
'column_rule_color': 'currentColor',
'column_rule_style': 'none',
'column_rule_width': 'medium',
'column_fill': 'balance',
'column_span': 'none',
# Fonts 3 (REC): https://www.w3.org/TR/css-fonts-3/
'font_family': ('serif',), # depends on user agent
'font_feature_settings': 'normal',
'font_kerning': 'auto',
'font_language_override': 'normal',
'font_size': 16, # actually medium, but we define medium from this
'font_stretch': 'normal',
'font_style': 'normal',
'font_variant': 'normal',
'font_variant_alternates': 'normal',
'font_variant_caps': 'normal',
'font_variant_east_asian': 'normal',
'font_variant_ligatures': 'normal',
'font_variant_numeric': 'normal',
'font_variant_position': 'normal',
'font_weight': 400,
# Fragmentation 3/4 (CR/WD): https://www.w3.org/TR/css-break-4/
'box_decoration_break': 'slice',
'break_after': 'auto',
'break_before': 'auto',
'break_inside': 'auto',
'margin_break': 'auto',
'orphans': 2,
'widows': 2,
# Generated Content 3 (WD): https://www.w3.org/TR/css-content-3/
'bookmark_label': (('content', 'text'),),
'bookmark_level': 'none',
'bookmark_state': 'open',
'content': 'normal',
'quotes': list('“”‘’'), # chosen by the user agent
'string_set': 'none',
# Images 3/4 (CR/WD): https://www.w3.org/TR/css4-images/
'image_resolution': 1, # dppx
'image_rendering': 'auto',
# https://drafts.csswg.org/css-images-3/
'object_fit': 'fill',
'object_position': (('left', Dimension(50, '%'),
'top', Dimension(50, '%')),),
# Paged Media 3 (WD): https://www.w3.org/TR/css-page-3/
'size': None, # set to A4 in computed_values
'page': 'auto',
'bleed_left': 'auto',
'bleed_right': 'auto',
'bleed_top': 'auto',
'bleed_bottom': 'auto',
'marks': (), # computed value for 'none'
# Text 3/4 (WD/WD): https://www.w3.org/TR/css-text-4/
'hyphenate_character': '', # computed value chosen by the user agent
'hyphenate_limit_chars': (5, 2, 2),
'hyphenate_limit_zone': Dimension(0, 'px'),
'hyphens': 'manual',
'letter_spacing': 'normal',
'tab_size': 8,
'text_align': '-weasy-start',
'text_indent': Dimension(0, 'px'),
'text_transform': 'none',
'white_space': 'normal',
'word_spacing': 0, # computed value for 'normal'
# Transforms 1 (CR): https://www.w3.org/TR/css-transforms-1/
'transform_origin': (Dimension(50, '%'), Dimension(50, '%')),
'transform': (), # computed value for 'none'
# User Interface 3 (REC): https://www.w3.org/TR/css-ui-3/
'box_sizing': 'content-box',
'outline_color': 'currentColor', # invert is not supported
'outline_style': 'none',
'outline_width': 3, # computed value for 'medium'
'overflow_wrap': 'normal',
# Flexible Box Layout Module 1 (CR): https://www.w3.org/TR/css-flexbox-1/
'align_content': 'stretch',
'align_items': 'stretch',
'align_self': 'auto',
'flex_basis': 'auto',
'flex_direction': 'row',
'flex_grow': 0,
'flex_shrink': 1,
'flex_wrap': 'nowrap',
'justify_content': 'flex-start',
'min_height': 'auto',
'min_width': 'auto',
'order': 0,
# Text Decoration Module 3 (CR): https://www.w3.org/TR/css-text-decor-3/
'text_decoration_line': 'none',
'text_decoration_color': 'currentColor',
'text_decoration_style': 'solid',
# Overflow Module 3 (WD): https://www.w3.org/TR/css-overflow-3/
'overflow': 'visible',
'text_overflow': 'clip',
# Proprietary
'anchor': None, # computed value of 'none'
'link': None, # computed value of 'none'
'lang': None, # computed value of 'none'
# Internal, to implement the "static position" for absolute boxes.
'_weasy_specified_display': 'inline',
}
KNOWN_PROPERTIES = set(name.replace('_', '-') for name in INITIAL_VALUES)
# Do not list shorthand properties here as we handle them before inheritance.
#
# text_decoration is not a really inherited, see
# http://www.w3.org/TR/CSS2/text.html#propdef-text-decoration
#
# link: click events normally bubble up to link ancestors
# See http://lists.w3.org/Archives/Public/www-style/2012Jun/0315.html
INHERITED = {
'border_collapse',
'border_spacing',
'caption_side',
'color',
'direction',
'empty_cells',
'font_family',
'font_feature_settings',
'font_kerning',
'font_language_override',
'font_size',
'font_style',
'font_stretch',
'font_variant',
'font_variant_alternates',
'font_variant_caps',
'font_variant_east_asian',
'font_variant_ligatures',
'font_variant_numeric',
'font_variant_position',
'font_weight',
'hyphens',
'hyphenate_character',
'hyphenate_limit_chars',
'hyphenate_limit_zone',
'image_rendering',
'image_resolution',
'lang',
'letter_spacing',
'line_height',
'link',
'list_style_image',
'list_style_position',
'list_style_type',
'orphans',
'overflow_wrap',
'quotes',
'tab_size',
'text_align',
'text_decoration_line',
'text_decoration_color',
'text_decoration_style',
'text_indent',
'text_transform',
'visibility',
'white_space',
'widows',
'word_spacing',
}
# Inherited but not applicable to print:
# azimuth
# cursor
# elevation
# pitch_range
# pitch
# richness
# speak_header
# speak_numeral
# speak_punctuation
# speak
# speech_rate
# stress
# voice_family
# volume
# http://www.w3.org/TR/CSS21/tables.html#model
# See also http://lists.w3.org/Archives/Public/www-style/2012Jun/0066.html
# Only non-inherited properties need to be included here.
TABLE_WRAPPER_BOX_PROPERTIES = {
'bottom',
'break_after',
'break_before',
'break_inside',
'clear',
'counter_increment',
'counter_reset',
'float',
'left',
'margin_top',
'margin_bottom',
'margin_left',
'margin_right',
'opacity',
'overflow',
'position',
'right',
'top',
'transform',
'transform_origin',
'vertical_align',
'z_index',
}
# Properties that have an initial value that is not always the same when
# computed.
INITIAL_NOT_COMPUTED = {
'display',
'column_gap',
'bleed_top',
'bleed_left',
'bleed_bottom',
'bleed_right',
'outline_width',
'outline_color',
'column_rule_width',
'column_rule_color',
'border_top_width',
'border_left_width',
'border_bottom_width',
'border_right_width',
'border_top_color',
'border_left_color',
'border_bottom_color',
'border_right_color',
}

248
weasyprint/css/targets.py Normal file
View File

@ -0,0 +1,248 @@
"""
weasyprint.formatting_structure.targets
---------------------------------------
Handle target-counter, target-counters and target-text.
The TargetCollector is a structure providing required targets'
counter_values and stuff needed to build pending targets later,
when the layout of all targetted anchors has been done.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import copy
from ..logger import LOGGER
class TargetLookupItem(object):
"""Item controlling pending targets and page based target counters.
Collected in the TargetCollector's ``items``.
"""
def __init__(self, state='pending'):
self.state = state
# Required by target-counter and target-counters to access the
# target's .cached_counter_values.
# Needed for target-text via TEXT_CONTENT_EXTRACTORS.
self.target_box = None
# Functions that have to been called to check pending targets.
# Keys are (source_box, css_token).
self.parse_again_functions = {}
# Anchor position during pagination (page_number - 1)
self.page_maker_index = None
# target_box's page_counters during pagination
self.cached_page_counter_values = {}
class CounterLookupItem(object):
"""Item controlling page based counters.
Collected in the TargetCollector's ``counter_lookup_items``.
"""
def __init__(self, parse_again, missing_counters, missing_target_counters):
# Function that have to been called to check pending counter.
self.parse_again = parse_again
# Missing counters and target counters
self.missing_counters = missing_counters
self.missing_target_counters = missing_target_counters
# Box position during pagination (page_number - 1)
self.page_maker_index = None
# Marker for remake_page
self.pending = False
# Targeting box's page_counters during pagination
self.cached_page_counter_values = {}
class TargetCollector(object):
"""Collector of HTML targets used by CSS content with ``target-*``."""
def __init__(self):
# Lookup items for targets and page counters
self.target_lookup_items = {}
self.counter_lookup_items = {}
# When collecting is True, compute_content_list() collects missing
# page counters in CounterLookupItems. Otherwise, it mixes in the
# TargetLookupItem's cached_page_counter_values.
# Is switched to False in check_pending_targets().
self.collecting = True
# had_pending_targets is set to True when a target is needed but has
# not been seen yet. check_pending_targets then uses this information
# to call the needed parse_again functions.
self.had_pending_targets = False
# List of anchors that have already been seen during parsing.
self.existing_anchors = []
def anchor_name_from_token(self, anchor_token):
"""Get anchor name from string or uri token."""
if anchor_token[0] == 'string' and anchor_token[1].startswith('#'):
return anchor_token[1][1:]
elif anchor_token[0] == 'url' and anchor_token[1][0] == 'internal':
return anchor_token[1][1]
def collect_anchor(self, anchor_name):
"""Store ``anchor_name`` in ``existing_anchors``."""
if anchor_name and isinstance(anchor_name, str):
if anchor_name in self.existing_anchors:
LOGGER.warning('Anchor defined twice: %s', anchor_name)
else:
self.existing_anchors.append(anchor_name)
def collect_computed_target(self, anchor_token):
"""Store a computed internal target's ``anchor_name``.
``anchor_name`` must not start with '#' and be already unquoted.
"""
anchor_name = self.anchor_name_from_token(anchor_token)
if anchor_name:
self.target_lookup_items.setdefault(
anchor_name, TargetLookupItem())
def lookup_target(self, anchor_token, source_box, css_token, parse_again):
"""Get a TargetLookupItem corresponding to ``anchor_token``.
If it is already filled by a previous anchor-element, the status is
'up-to-date'. Otherwise, it is 'pending', we must parse the whole
tree again.
"""
anchor_name = self.anchor_name_from_token(anchor_token)
item = self.target_lookup_items.get(
anchor_name, TargetLookupItem('undefined'))
if item.state == 'pending':
if anchor_name in self.existing_anchors:
self.had_pending_targets = True
item.parse_again_functions.setdefault(
(source_box, css_token), parse_again)
else:
item.state = 'undefined'
if item.state == 'undefined':
LOGGER.error(
'Content discarded: target points to undefined anchor "%s"',
anchor_token)
return item
def store_target(self, anchor_name, target_counter_values, target_box):
"""Store a target called ``anchor_name``.
If there is a pending TargetLookupItem, it is updated. Only previously
collected anchors are stored.
"""
item = self.target_lookup_items.get(anchor_name)
if item and item.state == 'pending':
item.state = 'up-to-date'
item.target_box = target_box
# Store the counter_values in the target_box like
# compute_content_list does.
# TODO: remove attribute or set a default value in Box class
if not hasattr(target_box, 'cached_counter_values'):
target_box.cached_counter_values = copy.deepcopy(
target_counter_values)
def collect_missing_counters(self, parent_box, css_token,
parse_again_function, missing_counters,
missing_target_counters):
"""Collect missing (probably page-based) counters during formatting.
The ``missing_counters`` are re-used during pagination.
The ``missing_link`` attribute added to the parent_box is required to
connect the paginated boxes to their originating ``parent_box``.
"""
# No counter collection during pagination
if not self.collecting:
return
# No need to add empty miss-lists
if missing_counters or missing_target_counters:
# TODO: remove attribute or set a default value in Box class
if not hasattr(parent_box, 'missing_link'):
parent_box.missing_link = parent_box
counter_lookup_item = CounterLookupItem(
parse_again_function, missing_counters,
missing_target_counters)
self.counter_lookup_items.setdefault(
(parent_box, css_token), counter_lookup_item)
def check_pending_targets(self):
"""Check pending targets if needed."""
if self.had_pending_targets:
for item in self.target_lookup_items.values():
for function in item.parse_again_functions.values():
function()
self.had_pending_targets = False
# Ready for pagination
self.collecting = False
def cache_target_page_counters(self, anchor_name, page_counter_values,
page_maker_index, page_maker):
"""Store target's current ``page_maker_index`` and page counter values.
Eventually update associated targeting boxes.
"""
# Only store page counters when paginating
if self.collecting:
return
item = self.target_lookup_items.get(anchor_name)
if item and item.state == 'up-to-date':
item.page_maker_index = page_maker_index
if item.cached_page_counter_values != page_counter_values:
item.cached_page_counter_values = copy.deepcopy(
page_counter_values)
# Spread the news: update boxes affected by a change in the
# anchor's page counter values.
for (_, css_token), item in self.counter_lookup_items.items():
# Only update items that need counters in their content
if css_token != 'content':
continue
# Don't update if item has no missing target counter
missing_counters = item.missing_target_counters.get(
anchor_name)
if missing_counters is None:
continue
# Pending marker for remake_page
if (item.page_maker_index is None or
item.page_maker_index >= len(page_maker)):
item.pending = True
continue
# TODO: Is the item at all interested in the new
# page_counter_values? It probably is and this check is a
# brake.
for counter_name in missing_counters:
counter_value = page_counter_values.get(counter_name)
if counter_value is not None:
remake_state = (
page_maker[item.page_maker_index][-1])
remake_state['content_changed'] = True
item.parse_again(item.cached_page_counter_values)
break
# Hint: the box's own cached page counters trigger a
# separate 'content_changed'.

View File

@ -0,0 +1,36 @@
/*
Simplified user-agent stylesheet for HTML5 in tests.
*/
@page { bleed: 0 }
html, body, div, h1, h2, h3, h4, ol, p, ul, hr, pre, section, article
{ display: block; }
li { display: list-item }
head { display: none }
pre { white-space: pre }
br:before { content: '\A'; white-space: pre-line }
ol { list-style-type: decimal }
ol, ul { counter-reset: list-item }
table, x-table { display: table;
box-sizing: border-box }
tr, x-tr { display: table-row }
thead, x-thead { display: table-header-group }
tbody, x-tbody { display: table-row-group }
tfoot, x-tfoot { display: table-footer-group }
col, x-col { display: table-column }
colgroup, x-colgroup { display: table-column-group }
td, th, x-td, x-th { display: table-cell }
caption, x-caption { display: table-caption }
*[lang] { -weasy-lang: attr(lang); }
a[href] { -weasy-link: attr(href); }
a[name] { -weasy-anchor: attr(name); }
*[id] { -weasy-anchor: attr(id); }
h1 { bookmark-level: 1; bookmark-label: content(text); }
h2 { bookmark-level: 2; bookmark-label: content(text); }
h3 { bookmark-level: 3; bookmark-label: content(text); }
h4 { bookmark-level: 4; bookmark-label: content(text); }
h5 { bookmark-level: 5; bookmark-label: content(text); }
h6 { bookmark-level: 6; bookmark-label: content(text); }
::marker { unicode-bidi: isolate; font-variant-numeric: tabular-nums; }

747
weasyprint/css/utils.py Normal file
View File

@ -0,0 +1,747 @@
"""
weasyprint.css.utils
--------------------
Utils for CSS properties.
See http://www.w3.org/TR/CSS21/propidx.html and various CSS3 modules.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import functools
import math
from urllib.parse import unquote, urljoin
from tinycss2.color3 import parse_color
from ..formatting_structure import counters
from ..urls import iri_to_uri, url_is_absolute
from .properties import Dimension
# http://dev.w3.org/csswg/css3-values/#angles
# 1<unit> is this many radians.
ANGLE_TO_RADIANS = {
'rad': 1,
'turn': 2 * math.pi,
'deg': math.pi / 180,
'grad': math.pi / 200,
}
# How many CSS pixels is one <unit>?
# http://www.w3.org/TR/CSS21/syndata.html#length-units
LENGTHS_TO_PIXELS = {
'px': 1,
'pt': 1. / 0.75,
'pc': 16., # LENGTHS_TO_PIXELS['pt'] * 12
'in': 96., # LENGTHS_TO_PIXELS['pt'] * 72
'cm': 96. / 2.54, # LENGTHS_TO_PIXELS['in'] / 2.54
'mm': 96. / 25.4, # LENGTHS_TO_PIXELS['in'] / 25.4
'q': 96. / 25.4 / 4., # LENGTHS_TO_PIXELS['mm'] / 4
}
# http://dev.w3.org/csswg/css-values/#resolution
RESOLUTION_TO_DPPX = {
'dppx': 1,
'dpi': 1 / LENGTHS_TO_PIXELS['in'],
'dpcm': 1 / LENGTHS_TO_PIXELS['cm'],
}
# Sets of possible length units
LENGTH_UNITS = set(LENGTHS_TO_PIXELS) | set(['ex', 'em', 'ch', 'rem'])
# Constants about background positions
ZERO_PERCENT = Dimension(0, '%')
FIFTY_PERCENT = Dimension(50, '%')
HUNDRED_PERCENT = Dimension(100, '%')
BACKGROUND_POSITION_PERCENTAGES = {
'top': ZERO_PERCENT,
'left': ZERO_PERCENT,
'center': FIFTY_PERCENT,
'bottom': HUNDRED_PERCENT,
'right': HUNDRED_PERCENT,
}
# Direction keywords used for gradients
DIRECTION_KEYWORDS = {
# ('angle', radians) 0 upwards, then clockwise
('to', 'top'): ('angle', 0),
('to', 'right'): ('angle', math.pi / 2),
('to', 'bottom'): ('angle', math.pi),
('to', 'left'): ('angle', math.pi * 3 / 2),
# ('corner', keyword)
('to', 'top', 'left'): ('corner', 'top_left'),
('to', 'left', 'top'): ('corner', 'top_left'),
('to', 'top', 'right'): ('corner', 'top_right'),
('to', 'right', 'top'): ('corner', 'top_right'),
('to', 'bottom', 'left'): ('corner', 'bottom_left'),
('to', 'left', 'bottom'): ('corner', 'bottom_left'),
('to', 'bottom', 'right'): ('corner', 'bottom_right'),
('to', 'right', 'bottom'): ('corner', 'bottom_right'),
}
# Default fallback values used in attr() functions
ATTR_FALLBACKS = {
'string': ('string', ''),
'color': ('ident', 'currentcolor'),
'url': ('external', 'about:invalid'),
'integer': ('number', 0),
'number': ('number', 0),
'%': ('number', 0),
}
for unit in LENGTH_UNITS:
ATTR_FALLBACKS[unit] = ('length', Dimension('0', unit))
for unit in ANGLE_TO_RADIANS:
ATTR_FALLBACKS[unit] = ('angle', Dimension('0', unit))
class InvalidValues(ValueError):
"""Invalid or unsupported values for a known CSS property."""
class CenterKeywordFakeToken(object):
type = 'ident'
lower_value = 'center'
unit = None
def split_on_comma(tokens):
"""Split a list of tokens on commas, ie ``LiteralToken(',')``.
Only "top-level" comma tokens are splitting points, not commas inside a
function or blocks.
"""
parts = []
this_part = []
for token in tokens:
if token.type == 'literal' and token.value == ',':
parts.append(this_part)
this_part = []
else:
this_part.append(token)
parts.append(this_part)
return tuple(parts)
def split_on_optional_comma(tokens):
"""Split a list of tokens on optional commas, ie ``LiteralToken(',')``."""
parts = []
for split_part in split_on_comma(tokens):
if not split_part:
# Happens when there's a comma at the beginning, at the end, or
# when two commas are next to each other.
return
for part in split_part:
parts.append(part)
return parts
def remove_whitespace(tokens):
"""Remove any top-level whitespace and comments in a token list."""
return tuple(
token for token in tokens
if token.type not in ('whitespace', 'comment'))
def safe_urljoin(base_url, url):
if url_is_absolute(url):
return iri_to_uri(url)
elif base_url:
return iri_to_uri(urljoin(base_url, url))
else:
raise InvalidValues(
'Relative URI reference without a base URI: %r' % url)
def comma_separated_list(function):
"""Decorator for validators that accept a comma separated list."""
@functools.wraps(function)
def wrapper(tokens, *args):
results = []
for part in split_on_comma(tokens):
result = function(remove_whitespace(part), *args)
if result is None:
return None
results.append(result)
return tuple(results)
wrapper.single_value = function
return wrapper
def get_keyword(token):
"""If ``token`` is a keyword, return its lowercase name.
Otherwise return ``None``.
"""
if token.type == 'ident':
return token.lower_value
def get_custom_ident(token):
"""If ``token`` is a keyword, return its name.
Otherwise return ``None``.
"""
if token.type == 'ident':
return token.value
def get_single_keyword(tokens):
"""If ``values`` is a 1-element list of keywords, return its name.
Otherwise return ``None``.
"""
if len(tokens) == 1:
token = tokens[0]
if token.type == 'ident':
return token.lower_value
def single_keyword(function):
"""Decorator for validators that only accept a single keyword."""
@functools.wraps(function)
def keyword_validator(tokens):
"""Wrap a validator to call get_single_keyword on tokens."""
keyword = get_single_keyword(tokens)
if function(keyword):
return keyword
return keyword_validator
def single_token(function):
"""Decorator for validators that only accept a single token."""
@functools.wraps(function)
def single_token_validator(tokens, *args):
"""Validate a property whose token is single."""
if len(tokens) == 1:
return function(tokens[0], *args)
single_token_validator.__func__ = function
return single_token_validator
def parse_linear_gradient_parameters(arguments):
first_arg = arguments[0]
if len(first_arg) == 1:
angle = get_angle(first_arg[0])
if angle is not None:
return ('angle', angle), arguments[1:]
else:
result = DIRECTION_KEYWORDS.get(tuple(map(get_keyword, first_arg)))
if result is not None:
return result, arguments[1:]
return ('angle', math.pi), arguments # Default direction is 'to bottom'
def parse_2d_position(tokens):
"""Common syntax of background-position and transform-origin."""
if len(tokens) == 1:
tokens = [tokens[0], CenterKeywordFakeToken]
elif len(tokens) != 2:
return None
token_1, token_2 = tokens
length_1 = get_length(token_1, percentage=True)
length_2 = get_length(token_2, percentage=True)
if length_1 and length_2:
return length_1, length_2
keyword_1, keyword_2 = map(get_keyword, tokens)
if length_1 and keyword_2 in ('top', 'center', 'bottom'):
return length_1, BACKGROUND_POSITION_PERCENTAGES[keyword_2]
elif length_2 and keyword_1 in ('left', 'center', 'right'):
return BACKGROUND_POSITION_PERCENTAGES[keyword_1], length_2
elif (keyword_1 in ('left', 'center', 'right') and
keyword_2 in ('top', 'center', 'bottom')):
return (BACKGROUND_POSITION_PERCENTAGES[keyword_1],
BACKGROUND_POSITION_PERCENTAGES[keyword_2])
elif (keyword_1 in ('top', 'center', 'bottom') and
keyword_2 in ('left', 'center', 'right')):
# Swap tokens. They need to be in (horizontal, vertical) order.
return (BACKGROUND_POSITION_PERCENTAGES[keyword_2],
BACKGROUND_POSITION_PERCENTAGES[keyword_1])
def parse_position(tokens):
"""Parse background-position and object-position.
See http://dev.w3.org/csswg/css3-background/#the-background-position
https://drafts.csswg.org/css-images-3/#propdef-object-position
"""
result = parse_2d_position(tokens)
if result is not None:
pos_x, pos_y = result
return 'left', pos_x, 'top', pos_y
if len(tokens) == 4:
keyword_1 = get_keyword(tokens[0])
keyword_2 = get_keyword(tokens[2])
length_1 = get_length(tokens[1], percentage=True)
length_2 = get_length(tokens[3], percentage=True)
if length_1 and length_2:
if (keyword_1 in ('left', 'right') and
keyword_2 in ('top', 'bottom')):
return keyword_1, length_1, keyword_2, length_2
if (keyword_2 in ('left', 'right') and
keyword_1 in ('top', 'bottom')):
return keyword_2, length_2, keyword_1, length_1
if len(tokens) == 3:
length = get_length(tokens[2], percentage=True)
if length is not None:
keyword = get_keyword(tokens[1])
other_keyword = get_keyword(tokens[0])
else:
length = get_length(tokens[1], percentage=True)
other_keyword = get_keyword(tokens[2])
keyword = get_keyword(tokens[0])
if length is not None:
if other_keyword == 'center':
if keyword in ('top', 'bottom'):
return 'left', FIFTY_PERCENT, keyword, length
if keyword in ('left', 'right'):
return keyword, length, 'top', FIFTY_PERCENT
elif (keyword in ('left', 'right') and
other_keyword in ('top', 'bottom')):
return keyword, length, other_keyword, ZERO_PERCENT
elif (keyword in ('top', 'bottom') and
other_keyword in ('left', 'right')):
return other_keyword, ZERO_PERCENT, keyword, length
def parse_radial_gradient_parameters(arguments):
shape = None
position = None
size = None
size_shape = None
stack = arguments[0][::-1]
while stack:
token = stack.pop()
keyword = get_keyword(token)
if keyword == 'at':
position = parse_position(stack[::-1])
if position is None:
return
break
elif keyword in ('circle', 'ellipse') and shape is None:
shape = keyword
elif keyword in ('closest-corner', 'farthest-corner',
'closest-side', 'farthest-side') and size is None:
size = 'keyword', keyword
else:
if stack and size is None:
length_1 = get_length(token, percentage=True)
length_2 = get_length(stack[-1], percentage=True)
if None not in (length_1, length_2):
size = 'explicit', (length_1, length_2)
size_shape = 'ellipse'
stack.pop()
if size is None:
length_1 = get_length(token)
if length_1 is not None:
size = 'explicit', (length_1, length_1)
size_shape = 'circle'
if size is None:
return
if (shape, size_shape) in (('circle', 'ellipse'), ('circle', 'ellipse')):
return
return (
shape or size_shape or 'ellipse',
size or ('keyword', 'farthest-corner'),
position or ('left', FIFTY_PERCENT, 'top', FIFTY_PERCENT),
arguments[1:])
def parse_color_stop(tokens):
if len(tokens) == 1:
color = parse_color(tokens[0])
if color == 'currentColor':
# TODO: return the current color instead
return parse_color('black'), None
if color is not None:
return color, None
elif len(tokens) == 2:
color = parse_color(tokens[0])
position = get_length(tokens[1], negative=True, percentage=True)
if color is not None and position is not None:
return color, position
raise InvalidValues
def parse_function(function_token):
"""Parse functional notation.
Return ``(name, args)`` if the given token is a function with comma- or
space-separated arguments. Return ``None`` otherwise.
"""
if not getattr(function_token, 'type', None) == 'function':
return
content = list(remove_whitespace(function_token.arguments))
arguments = []
last_is_comma = False
while content:
token = content.pop(0)
is_comma = token.type == 'literal' and token.value == ','
if last_is_comma and is_comma:
return
if is_comma:
last_is_comma = True
else:
last_is_comma = False
if token.type == 'function':
argument_function = parse_function(token)
if argument_function is None:
return
arguments.append(token)
if last_is_comma:
return
return function_token.lower_name, arguments
def check_attr_function(token, allowed_type=None):
function = parse_function(token)
if function is None:
return
name, args = function
if name == 'attr' and len(args) in (1, 2, 3):
if args[0].type != 'ident':
return
attr_name = args[0].value
if len(args) == 1:
type_or_unit = 'string'
fallback = ''
else:
if args[1].type != 'ident':
return
type_or_unit = args[1].value
if type_or_unit not in ATTR_FALLBACKS:
return
if len(args) == 2:
fallback = ATTR_FALLBACKS[type_or_unit]
else:
fallback_type = args[2].type
if fallback_type == 'string':
fallback = args[2].value
else:
# TODO: handle other fallback types
return
if allowed_type in (None, type_or_unit):
return ('attr()', (attr_name, type_or_unit, fallback))
def check_counter_function(token, allowed_type=None):
function = parse_function(token)
if function is None:
return
name, args = function
arguments = []
if (name == 'counter' and len(args) in (1, 2)) or (
name == 'counters' and len(args) in (2, 3)):
ident = args.pop(0)
if ident.type != 'ident':
return
arguments.append(ident.value)
if name == 'counters':
string = args.pop(0)
if string.type != 'string':
return
arguments.append(string.value)
if args:
counter_style = get_keyword(args.pop(0))
if counter_style not in ['none'] + list(counters.STYLES):
return
arguments.append(counter_style)
else:
arguments.append('decimal')
return ('%s()' % name, tuple(arguments))
def check_content_function(token):
function = parse_function(token)
if function is None:
return
name, args = function
if name == 'content':
if len(args) == 0:
return ('content()', 'text')
elif len(args) == 1:
ident = args.pop(0)
if ident.type == 'ident' and ident.lower_value in (
'text', 'before', 'after', 'first-letter', 'marker'):
return ('content()', ident.lower_value)
def check_string_or_element_function(string_or_element, token):
function = parse_function(token)
if function is None:
return
name, args = function
if name == string_or_element and len(args) in (1, 2):
custom_ident = args.pop(0)
if custom_ident.type != 'ident':
return
custom_ident = custom_ident.value
if args:
ident = args.pop(0)
if ident.type != 'ident' or ident.lower_value not in (
'first', 'start', 'last', 'first-except'):
return
ident = ident.lower_value
else:
ident = 'first'
return ('%s()' % string_or_element, (custom_ident, ident))
def check_var_function(token):
function = parse_function(token)
if function is None:
return
name, args = function
if name == 'var' and args:
ident = args.pop(0)
if ident.type != 'ident' or not ident.value.startswith('--'):
return
# TODO: we should check authorized tokens
# https://drafts.csswg.org/css-syntax-3/#typedef-declaration-value
return ('var()', (ident.value.replace('-', '_'), args or None))
def get_string(token):
"""Parse a <string> token."""
if token.type == 'string':
return ('string', token.value)
if token.type == 'function':
if token.name == 'attr':
return check_attr_function(token, 'string')
elif token.name in ('counter', 'counters'):
return check_counter_function(token)
elif token.name == 'content':
return check_content_function(token)
elif token.name == 'string':
return check_string_or_element_function('string', token)
def get_length(token, negative=True, percentage=False):
"""Parse a <length> token."""
if percentage and token.type == 'percentage':
if negative or token.value >= 0:
return Dimension(token.value, '%')
if token.type == 'dimension' and token.unit in LENGTH_UNITS:
if negative or token.value >= 0:
return Dimension(token.value, token.unit)
if token.type == 'number' and token.value == 0:
return Dimension(0, None)
def get_angle(token):
"""Parse an <angle> token in radians."""
if token.type == 'dimension':
factor = ANGLE_TO_RADIANS.get(token.unit)
if factor is not None:
return token.value * factor
def get_resolution(token):
"""Parse a <resolution> token in ddpx."""
if token.type == 'dimension':
factor = RESOLUTION_TO_DPPX.get(token.unit)
if factor is not None:
return token.value * factor
def get_image(token, base_url):
"""Parse an <image> token."""
from ..images import LinearGradient, RadialGradient
if token.type != 'function':
parsed_url = get_url(token, base_url)
if parsed_url:
assert parsed_url[0] == 'url'
if parsed_url[1][0] == 'external':
return 'url', parsed_url[1][1]
return
arguments = split_on_comma(remove_whitespace(token.arguments))
name = token.lower_name
if name in ('linear-gradient', 'repeating-linear-gradient'):
direction, color_stops = parse_linear_gradient_parameters(arguments)
if color_stops:
return 'linear-gradient', LinearGradient(
[parse_color_stop(stop) for stop in color_stops],
direction, 'repeating' in name)
elif name in ('radial-gradient', 'repeating-radial-gradient'):
result = parse_radial_gradient_parameters(arguments)
if result is not None:
shape, size, position, color_stops = result
else:
shape = 'ellipse'
size = 'keyword', 'farthest-corner'
position = 'left', FIFTY_PERCENT, 'top', FIFTY_PERCENT
color_stops = arguments
if color_stops:
return 'radial-gradient', RadialGradient(
[parse_color_stop(stop) for stop in color_stops],
shape, size, position, 'repeating' in name)
def get_url(token, base_url):
"""Parse an <url> token."""
if token.type == 'url':
if token.value.startswith('#'):
return ('url', ('internal', unquote(token.value[1:])))
else:
return ('url', ('external', safe_urljoin(base_url, token.value)))
elif token.type == 'function':
if token.name == 'attr':
return check_attr_function(token, 'url')
def get_quote(token):
"""Parse a <quote> token."""
keyword = get_keyword(token)
if keyword in (
'open-quote', 'close-quote',
'no-open-quote', 'no-close-quote'):
return keyword
def get_target(token, base_url):
"""Parse a <target> token."""
function = parse_function(token)
if function is None:
return
name, args = function
args = split_on_optional_comma(args)
if not args:
return
if name == 'target-counter':
if len(args) not in (2, 3):
return
elif name == 'target-counters':
if len(args) not in (3, 4):
return
elif name == 'target-text':
if len(args) not in (1, 2):
return
else:
return
values = []
link = args.pop(0)
string_link = get_string(link)
if string_link is None:
url = get_url(link, base_url)
if url is None:
return
values.append(url)
else:
values.append(string_link)
if name.startswith('target-counter'):
if not args:
return
ident = args.pop(0)
if ident.type != 'ident':
return
values.append(ident.value)
if name == 'target-counters':
string = get_string(args.pop(0))
if string is None:
return
values.append(string)
if args:
counter_style = get_keyword(args.pop(0))
if counter_style not in counters.STYLES:
return
else:
counter_style = 'decimal'
values.append(counter_style)
else:
if args:
content = get_keyword(args.pop(0))
if content not in ('content', 'before', 'after', 'first-letter'):
return
else:
content = 'content'
values.append(content)
return ('%s()' % name, tuple(values))
def get_content_list(tokens, base_url):
"""Parse <content-list> tokens."""
# See https://www.w3.org/TR/css-content-3/#typedef-content-list
parsed_tokens = [
get_content_list_token(token, base_url) for token in tokens]
if None not in parsed_tokens:
return parsed_tokens
def get_content_list_token(token, base_url):
"""Parse one of the <content-list> tokens."""
# See https://www.w3.org/TR/css-content-3/#typedef-content-list
# <string>
string = get_string(token)
if string is not None:
return string
# contents
if get_keyword(token) == 'contents':
return ('content', 'text')
# <uri>
url = get_url(token, base_url)
if url is not None:
return url
# <quote>
quote = get_quote(token)
if quote is not None:
return ('quote', quote)
# <target>
target = get_target(token, base_url)
if target is not None:
return target
# <leader>
function = parse_function(token)
if function is None:
return
name, args = function
if name == 'leader':
if len(args) != 1:
return
arg, = args
if arg.type == 'ident':
if arg.value == 'dotted':
string = '.'
elif arg.value == 'solid':
string = '_'
elif arg.value == 'space':
string = ' '
else:
return
elif arg.type == 'string':
string = arg.value
return ('leader()', ('string', string))
elif name == 'element':
return check_string_or_element_function('element', token)

View File

@ -0,0 +1,136 @@
"""
weasyprint.css.validation
-------------------------
Validate properties, expanders and descriptors.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from tinycss2 import serialize
from ... import LOGGER
from ..utils import InvalidValues, remove_whitespace
from .expanders import EXPANDERS
from .properties import PREFIX, PROPRIETARY, UNSTABLE, validate_non_shorthand
# Not applicable to the print media
NOT_PRINT_MEDIA = {
# Aural media
'azimuth',
'cue',
'cue-after',
'cue-before',
'elevation',
'pause',
'pause-after',
'pause-before',
'pitch-range',
'pitch',
'play-during',
'richness',
'speak-header',
'speak-numeral',
'speak-punctuation',
'speak',
'speech-rate',
'stress',
'voice-family',
'volume',
# Interactive
'cursor',
# Animations and transitions
'animation',
'animation-delay',
'animation-direction',
'animation-duration',
'animation-fill-mode',
'animation-iteration-count',
'animation-name',
'animation-play-state',
'animation-timing-function',
'transition',
'transition-delay',
'transition-duration',
'transition-property',
'transition-timing-function',
}
def preprocess_declarations(base_url, declarations):
"""Expand shorthand properties, filter unsupported properties and values.
Log a warning for every ignored declaration.
Return a iterable of ``(name, value, important)`` tuples.
"""
for declaration in declarations:
if declaration.type == 'error':
LOGGER.warning(
'Error: %s at %i:%i.',
declaration.message,
declaration.source_line, declaration.source_column)
if declaration.type != 'declaration':
continue
name = declaration.name
if not name.startswith('--'):
name = declaration.lower_name
def validation_error(level, reason):
getattr(LOGGER, level)(
'Ignored `%s:%s` at %i:%i, %s.',
declaration.name, serialize(declaration.value),
declaration.source_line, declaration.source_column, reason)
if name in NOT_PRINT_MEDIA:
validation_error(
'debug', 'the property does not apply for the print media')
continue
if name.startswith(PREFIX):
unprefixed_name = name[len(PREFIX):]
if unprefixed_name in PROPRIETARY:
name = unprefixed_name
elif unprefixed_name in UNSTABLE:
LOGGER.warning(
'Deprecated `%s:%s` at %i:%i, '
'prefixes on unstable attributes are deprecated, '
'use `%s` instead.',
declaration.name, serialize(declaration.value),
declaration.source_line, declaration.source_column,
unprefixed_name)
name = unprefixed_name
else:
LOGGER.warning(
'Ignored `%s:%s` at %i:%i, '
'prefix on this attribute is not supported, '
'use `%s` instead.',
declaration.name, serialize(declaration.value),
declaration.source_line, declaration.source_column,
unprefixed_name)
continue
if name.startswith('-') and not name.startswith('--'):
validation_error('debug', 'prefixed selectors are ignored')
continue
expander_ = EXPANDERS.get(name, validate_non_shorthand)
tokens = remove_whitespace(declaration.value)
try:
# Use list() to consume generators now and catch any error.
result = list(expander_(base_url, name, tokens))
except InvalidValues as exc:
validation_error(
'warning',
exc.args[0] if exc.args and exc.args[0] else 'invalid value')
continue
important = declaration.important
for long_name, value in result:
yield long_name.replace('-', '_'), value, important

View File

@ -0,0 +1,208 @@
"""
weasyprint.css.descriptors
--------------------------
Validate descriptors used for @font-face rules.
See https://www.w3.org/TR/css-fonts-3/#font-resources.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import tinycss2
from ...logger import LOGGER
from ..utils import (
InvalidValues, comma_separated_list, get_keyword, get_single_keyword,
get_url, remove_whitespace, single_keyword, single_token)
from . import properties
DESCRIPTORS = {}
class NoneFakeToken(object):
type = 'ident'
lower_value = 'none'
class NormalFakeToken(object):
type = 'ident'
lower_value = 'normal'
def preprocess_descriptors(base_url, descriptors):
"""Filter unsupported names and values for descriptors.
Log a warning for every ignored descriptor.
Return a iterable of ``(name, value)`` tuples.
"""
for descriptor in descriptors:
if descriptor.type != 'declaration' or descriptor.important:
continue
tokens = remove_whitespace(descriptor.value)
try:
# Use list() to consume generators now and catch any error.
if descriptor.name not in DESCRIPTORS:
raise InvalidValues('descriptor not supported')
function = DESCRIPTORS[descriptor.name]
if function.wants_base_url:
value = function(tokens, base_url)
else:
value = function(tokens)
if value is None:
raise InvalidValues
result = ((descriptor.name, value),)
except InvalidValues as exc:
LOGGER.warning(
'Ignored `%s:%s` at %i:%i, %s.',
descriptor.name, tinycss2.serialize(descriptor.value),
descriptor.source_line, descriptor.source_column,
exc.args[0] if exc.args and exc.args[0] else 'invalid value')
continue
for long_name, value in result:
yield long_name.replace('-', '_'), value
def descriptor(descriptor_name=None, wants_base_url=False):
"""Decorator adding a function to the ``DESCRIPTORS``.
The name of the descriptor covered by the decorated function is set to
``descriptor_name`` if given, or is inferred from the function name
(replacing underscores by hyphens).
:param wants_base_url:
The function takes the stylesheets base URL as an additional
parameter.
"""
def decorator(function):
"""Add ``function`` to the ``DESCRIPTORS``."""
if descriptor_name is None:
name = function.__name__.replace('_', '-')
else:
name = descriptor_name
assert name not in DESCRIPTORS, name
function.wants_base_url = wants_base_url
DESCRIPTORS[name] = function
return function
return decorator
def expand_font_variant(tokens):
keyword = get_single_keyword(tokens)
if keyword in ('normal', 'none'):
for suffix in (
'-alternates', '-caps', '-east-asian', '-numeric',
'-position'):
yield suffix, [NormalFakeToken]
token = NormalFakeToken if keyword == 'normal' else NoneFakeToken
yield '-ligatures', [token]
else:
features = {
'alternates': [],
'caps': [],
'east-asian': [],
'ligatures': [],
'numeric': [],
'position': []}
for token in tokens:
keyword = get_keyword(token)
if keyword == 'normal':
# We don't allow 'normal', only the specific values
raise InvalidValues
for feature in features:
function_name = 'font_variant_%s' % feature.replace('-', '_')
if getattr(properties, function_name)([token]):
features[feature].append(token)
break
else:
raise InvalidValues
for feature, tokens in features.items():
if tokens:
yield '-%s' % feature, tokens
@descriptor()
def font_family(tokens, allow_spaces=False):
"""``font-family`` descriptor validation."""
allowed_types = ['ident']
if allow_spaces:
allowed_types.append('whitespace')
if len(tokens) == 1 and tokens[0].type == 'string':
return tokens[0].value
if tokens and all(token.type in allowed_types for token in tokens):
return ' '.join(
token.value for token in tokens if token.type == 'ident')
@descriptor(wants_base_url=True)
@comma_separated_list
def src(tokens, base_url):
"""``src`` descriptor validation."""
if len(tokens) <= 2:
tokens, token = tokens[:-1], tokens[-1]
if token.type == 'function' and token.lower_name == 'format':
tokens, token = tokens[:-1], tokens[-1]
if token.type == 'function' and token.lower_name == 'local':
return 'local', font_family(token.arguments, allow_spaces=True)
url = get_url(token, base_url)
if url is not None and url[0] == 'url':
return url[1]
@descriptor()
@single_keyword
def font_style(keyword):
"""``font-style`` descriptor validation."""
return keyword in ('normal', 'italic', 'oblique')
@descriptor()
@single_token
def font_weight(token):
"""``font-weight`` descriptor validation."""
keyword = get_keyword(token)
if keyword in ('normal', 'bold'):
return keyword
if token.type == 'number' and token.int_value is not None:
if token.int_value in [100, 200, 300, 400, 500, 600, 700, 800, 900]:
return token.int_value
@descriptor()
@single_keyword
def font_stretch(keyword):
"""Validation for the ``font-stretch`` descriptor."""
return keyword in (
'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed',
'normal',
'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded')
@descriptor('font-feature-settings')
def font_feature_settings_descriptor(tokens):
"""``font-feature-settings`` descriptor validation."""
return properties.font_feature_settings(tokens)
@descriptor()
def font_variant(tokens):
"""``font-variant`` descriptor validation."""
if len(tokens) == 1:
keyword = get_keyword(tokens[0])
if keyword in ('normal', 'none', 'inherit'):
return []
values = []
for name, sub_tokens in expand_font_variant(tokens):
try:
values.append(properties.validate_non_shorthand(
None, 'font-variant' + name, sub_tokens, required=True))
except InvalidValues:
return None
return values

View File

@ -0,0 +1,598 @@
"""
weasyprint.css.expanders
------------------------
Validate properties expanders.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import functools
from tinycss2.color3 import parse_color
from ..properties import INITIAL_VALUES, Dimension
from ..utils import (
InvalidValues, get_keyword, get_single_keyword, split_on_comma)
from .descriptors import expand_font_variant
from .properties import (
background_attachment, background_image, background_position,
background_repeat, background_size, border_style, border_width, box,
column_count, column_width, flex_basis, flex_direction, flex_grow_shrink,
flex_wrap, font_family, font_size, font_stretch, font_style, font_weight,
line_height, list_style_image, list_style_position, list_style_type,
other_colors, overflow_wrap, validate_non_shorthand)
EXPANDERS = {}
def expander(property_name):
"""Decorator adding a function to the ``EXPANDERS``."""
def expander_decorator(function):
"""Add ``function`` to the ``EXPANDERS``."""
assert property_name not in EXPANDERS, property_name
EXPANDERS[property_name] = function
return function
return expander_decorator
@expander('border-color')
@expander('border-style')
@expander('border-width')
@expander('margin')
@expander('padding')
@expander('bleed')
def expand_four_sides(base_url, name, tokens):
"""Expand properties setting a token for the four sides of a box."""
# Make sure we have 4 tokens
if len(tokens) == 1:
tokens *= 4
elif len(tokens) == 2:
tokens *= 2 # (bottom, left) defaults to (top, right)
elif len(tokens) == 3:
tokens += (tokens[1],) # left defaults to right
elif len(tokens) != 4:
raise InvalidValues(
'Expected 1 to 4 token components got %i' % len(tokens))
for suffix, token in zip(('-top', '-right', '-bottom', '-left'), tokens):
i = name.rfind('-')
if i == -1:
new_name = name + suffix
else:
# eg. border-color becomes border-*-color, not border-color-*
new_name = name[:i] + suffix + name[i:]
# validate_non_shorthand returns ((name, value),), we want
# to yield (name, value)
result, = validate_non_shorthand(
base_url, new_name, [token], required=True)
yield result
@expander('border-radius')
def border_radius(base_url, name, tokens):
"""Validator for the `border-radius` property."""
current = horizontal = []
vertical = []
for token in tokens:
if token.type == 'literal' and token.value == '/':
if current is horizontal:
if token == tokens[-1]:
raise InvalidValues('Expected value after "/" separator')
else:
current = vertical
else:
raise InvalidValues('Expected only one "/" separator')
else:
current.append(token)
if not vertical:
vertical = horizontal[:]
for values in horizontal, vertical:
# Make sure we have 4 tokens
if len(values) == 1:
values *= 4
elif len(values) == 2:
values *= 2 # (br, bl) defaults to (tl, tr)
elif len(values) == 3:
values.append(values[1]) # bl defaults to tr
elif len(values) != 4:
raise InvalidValues(
'Expected 1 to 4 token components got %i' % len(values))
corners = ('top-left', 'top-right', 'bottom-right', 'bottom-left')
for corner, tokens in zip(corners, zip(horizontal, vertical)):
new_name = 'border-%s-radius' % corner
# validate_non_shorthand returns [(name, value)], we want
# to yield (name, value)
result, = validate_non_shorthand(
base_url, new_name, tokens, required=True)
yield result
def generic_expander(*expanded_names, **kwargs):
"""Decorator helping expanders to handle ``inherit`` and ``initial``.
Wrap an expander so that it does not have to handle the 'inherit' and
'initial' cases, and can just yield name suffixes. Missing suffixes
get the initial value.
"""
wants_base_url = kwargs.pop('wants_base_url', False)
assert not kwargs
def generic_expander_decorator(wrapped):
"""Decorate the ``wrapped`` expander."""
@functools.wraps(wrapped)
def generic_expander_wrapper(base_url, name, tokens):
"""Wrap the expander."""
keyword = get_single_keyword(tokens)
if keyword in ('inherit', 'initial'):
results = dict.fromkeys(expanded_names, keyword)
skip_validation = True
else:
skip_validation = False
results = {}
if wants_base_url:
result = wrapped(name, tokens, base_url)
else:
result = wrapped(name, tokens)
for new_name, new_token in result:
assert new_name in expanded_names, new_name
if new_name in results:
raise InvalidValues(
'got multiple %s values in a %s shorthand'
% (new_name.strip('-'), name))
results[new_name] = new_token
for new_name in expanded_names:
if new_name.startswith('-'):
# new_name is a suffix
actual_new_name = name + new_name
else:
actual_new_name = new_name
if new_name in results:
value = results[new_name]
if not skip_validation:
# validate_non_shorthand returns ((name, value),)
(actual_new_name, value), = validate_non_shorthand(
base_url, actual_new_name, value, required=True)
else:
value = 'initial'
yield actual_new_name, value
return generic_expander_wrapper
return generic_expander_decorator
@expander('list-style')
@generic_expander('-type', '-position', '-image', wants_base_url=True)
def expand_list_style(name, tokens, base_url):
"""Expand the ``list-style`` shorthand property.
See http://www.w3.org/TR/CSS21/generate.html#propdef-list-style
"""
type_specified = image_specified = False
none_count = 0
for token in tokens:
if get_keyword(token) == 'none':
# Can be either -style or -image, see at the end which is not
# otherwise specified.
none_count += 1
none_token = token
continue
if list_style_type([token]) is not None:
suffix = '-type'
type_specified = True
elif list_style_position([token]) is not None:
suffix = '-position'
elif list_style_image([token], base_url) is not None:
suffix = '-image'
image_specified = True
else:
raise InvalidValues
yield suffix, [token]
if not type_specified and none_count:
yield '-type', [none_token]
none_count -= 1
if not image_specified and none_count:
yield '-image', [none_token]
none_count -= 1
if none_count:
# Too many none tokens.
raise InvalidValues
@expander('border')
def expand_border(base_url, name, tokens):
"""Expand the ``border`` shorthand property.
See http://www.w3.org/TR/CSS21/box.html#propdef-border
"""
for suffix in ('-top', '-right', '-bottom', '-left'):
for new_prop in expand_border_side(base_url, name + suffix, tokens):
yield new_prop
@expander('border-top')
@expander('border-right')
@expander('border-bottom')
@expander('border-left')
@expander('column-rule')
@expander('outline')
@generic_expander('-width', '-color', '-style')
def expand_border_side(name, tokens):
"""Expand the ``border-*`` shorthand properties.
See http://www.w3.org/TR/CSS21/box.html#propdef-border-top
"""
for token in tokens:
if parse_color(token) is not None:
suffix = '-color'
elif border_width([token]) is not None:
suffix = '-width'
elif border_style([token]) is not None:
suffix = '-style'
else:
raise InvalidValues
yield suffix, [token]
@expander('background')
def expand_background(base_url, name, tokens):
"""Expand the ``background`` shorthand property.
See http://dev.w3.org/csswg/css3-background/#the-background
"""
properties = [
'background_color', 'background_image', 'background_repeat',
'background_attachment', 'background_position', 'background_size',
'background_clip', 'background_origin']
keyword = get_single_keyword(tokens)
if keyword in ('initial', 'inherit'):
for name in properties:
yield name, keyword
return
def parse_layer(tokens, final_layer=False):
results = {}
def add(name, value):
if value is None:
return False
name = 'background_' + name
if name in results:
raise InvalidValues
results[name] = value
return True
# Make `tokens` a stack
tokens = tokens[::-1]
while tokens:
if add('repeat',
background_repeat.single_value(tokens[-2:][::-1])):
del tokens[-2:]
continue
token = tokens[-1:]
if final_layer and add('color', other_colors(token)):
tokens.pop()
continue
if add('image', background_image.single_value(token, base_url)):
tokens.pop()
continue
if add('repeat', background_repeat.single_value(token)):
tokens.pop()
continue
if add('attachment', background_attachment.single_value(token)):
tokens.pop()
continue
for n in (4, 3, 2, 1)[-len(tokens):]:
n_tokens = tokens[-n:][::-1]
position = background_position.single_value(n_tokens)
if position is not None:
assert add('position', position)
del tokens[-n:]
if (tokens and tokens[-1].type == 'literal' and
tokens[-1].value == '/'):
for n in (3, 2)[-len(tokens):]:
# n includes the '/' delimiter.
n_tokens = tokens[-n:-1][::-1]
size = background_size.single_value(n_tokens)
if size is not None:
assert add('size', size)
del tokens[-n:]
break
if position is not None:
continue
if add('origin', box.single_value(token)):
tokens.pop()
next_token = tokens[-1:]
if add('clip', box.single_value(next_token)):
tokens.pop()
else:
# The same keyword sets both:
assert add('clip', box.single_value(token))
continue
raise InvalidValues
color = results.pop(
'background_color', INITIAL_VALUES['background_color'])
for name in properties:
if name not in results:
results[name] = INITIAL_VALUES[name][0]
return color, results
layers = reversed(split_on_comma(tokens))
color, last_layer = parse_layer(next(layers), final_layer=True)
results = dict((k, [v]) for k, v in last_layer.items())
for tokens in layers:
_, layer = parse_layer(tokens)
for name, value in layer.items():
results[name].append(value)
for name, values in results.items():
yield name, values[::-1] # "Un-reverse"
yield 'background-color', color
@expander('text-decoration')
def expand_text_decoration(base_url, name, tokens):
text_decoration_line = set()
text_decoration_color = None
text_decoration_style = None
for token in tokens:
keyword = get_keyword(token)
if keyword in (
'none', 'underline', 'overline', 'line-through', 'blink'):
text_decoration_line.add(keyword)
elif keyword in ('solid', 'double', 'dotted', 'dashed', 'wavy'):
if text_decoration_style is not None:
raise InvalidValues
else:
text_decoration_style = keyword
else:
color = parse_color(token)
if color is None:
raise InvalidValues
elif text_decoration_color is not None:
raise InvalidValues
else:
text_decoration_color = color
if 'none' in text_decoration_line:
if len(text_decoration_line) != 1:
raise InvalidValues
text_decoration_line = 'none'
elif not text_decoration_line:
text_decoration_line = 'none'
yield 'text_decoration_line', text_decoration_line
yield 'text_decoration_color', text_decoration_color or 'currentColor'
yield 'text_decoration_style', text_decoration_style or 'solid'
@expander('page-break-after')
@expander('page-break-before')
def expand_page_break_before_after(base_url, name, tokens):
"""Expand legacy ``page-break-before`` and ``page-break-after`` properties.
See https://www.w3.org/TR/css-break-3/#page-break-properties
"""
keyword = get_single_keyword(tokens)
new_name = name.split('-', 1)[1]
if keyword in ('auto', 'left', 'right', 'avoid'):
yield new_name, keyword
elif keyword == 'always':
yield new_name, 'page'
@expander('page-break-inside')
def expand_page_break_inside(base_url, name, tokens):
"""Expand the legacy ``page-break-inside`` property.
See https://www.w3.org/TR/css-break-3/#page-break-properties
"""
keyword = get_single_keyword(tokens)
if keyword in ('auto', 'avoid'):
yield 'break-inside', keyword
@expander('columns')
@generic_expander('column-width', 'column-count')
def expand_columns(name, tokens):
"""Expand the ``columns`` shorthand property."""
name = None
if len(tokens) == 2 and get_keyword(tokens[0]) == 'auto':
tokens = tokens[::-1]
for token in tokens:
if column_width([token]) is not None and name != 'column-width':
name = 'column-width'
elif column_count([token]) is not None:
name = 'column-count'
else:
raise InvalidValues
yield name, [token]
@expander('font-variant')
@generic_expander('-alternates', '-caps', '-east-asian', '-ligatures',
'-numeric', '-position')
def font_variant(name, tokens):
"""Expand the ``font-variant`` shorthand property.
https://www.w3.org/TR/css-fonts-3/#font-variant-prop
"""
return expand_font_variant(tokens)
@expander('font')
@generic_expander('-style', '-variant-caps', '-weight', '-stretch', '-size',
'line-height', '-family') # line-height is not a suffix
def expand_font(name, tokens):
"""Expand the ``font`` shorthand property.
https://www.w3.org/TR/css-fonts-3/#font-prop
"""
expand_font_keyword = get_single_keyword(tokens)
if expand_font_keyword in ('caption', 'icon', 'menu', 'message-box',
'small-caption', 'status-bar'):
raise InvalidValues('System fonts are not supported')
# Make `tokens` a stack
tokens = list(reversed(tokens))
# Values for font-style, font-variant-caps, font-weight and font-stretch
# can come in any order and are all optional.
for _ in range(4):
token = tokens.pop()
if get_keyword(token) == 'normal':
# Just ignore 'normal' keywords. Unspecified properties will get
# their initial token, which is 'normal' for all four here.
continue
if font_style([token]) is not None:
suffix = '-style'
elif get_keyword(token) in ('normal', 'small-caps'):
suffix = '-variant-caps'
elif font_weight([token]) is not None:
suffix = '-weight'
elif font_stretch([token]) is not None:
suffix = '-stretch'
else:
# Were done with these four, continue with font-size
break
yield suffix, [token]
if not tokens:
raise InvalidValues
else:
token = tokens.pop()
# Then font-size is mandatory
# Latest `token` from the loop.
if font_size([token]) is None:
raise InvalidValues
yield '-size', [token]
# Then line-height is optional, but font-family is not so the list
# must not be empty yet
if not tokens:
raise InvalidValues
token = tokens.pop()
if token.type == 'literal' and token.value == '/':
token = tokens.pop()
if line_height([token]) is None:
raise InvalidValues
yield 'line-height', [token]
else:
# We pop()ed a font-family, add it back
tokens.append(token)
# Reverse the stack to get normal list
tokens.reverse()
if font_family(tokens) is None:
raise InvalidValues
yield '-family', tokens
@expander('word-wrap')
def expand_word_wrap(base_url, name, tokens):
"""Expand the ``word-wrap`` legacy property.
See http://http://www.w3.org/TR/css3-text/#overflow-wrap
"""
keyword = overflow_wrap(tokens)
if keyword is None:
raise InvalidValues
yield 'overflow-wrap', keyword
@expander('flex')
def expand_flex(base_url, name, tokens):
"""Expand the ``flex`` property."""
keyword = get_single_keyword(tokens)
if keyword == 'none':
yield 'flex-grow', 0
yield 'flex-shrink', 0
yield 'flex-basis', 'auto'
else:
grow, shrink, basis = 1, 1, Dimension(0, 'px')
grow_found, shrink_found, basis_found = False, False, False
for token in tokens:
# "A unitless zero that is not already preceded by two flex factors
# must be interpreted as a flex factor."
forced_flex_factor = (
token.type == 'number' and token.int_value == 0 and
not all((grow_found, shrink_found)))
if not basis_found and not forced_flex_factor:
new_basis = flex_basis([token])
if new_basis is not None:
basis = new_basis
basis_found = True
continue
if not grow_found:
new_grow = flex_grow_shrink([token])
if new_grow is None:
raise InvalidValues
else:
grow = new_grow
grow_found = True
continue
elif not shrink_found:
new_shrink = flex_grow_shrink([token])
if new_shrink is None:
raise InvalidValues
else:
shrink = new_shrink
shrink_found = True
continue
else:
raise InvalidValues
yield 'flex-grow', grow
yield 'flex-shrink', shrink
yield 'flex-basis', basis
@expander('flex-flow')
def expand_flex_flow(base_url, name, tokens):
"""Expand the ``flex-flow`` property."""
if len(tokens) == 2:
for sorted_tokens in tokens, tokens[::-1]:
direction = flex_direction([sorted_tokens[0]])
wrap = flex_wrap([sorted_tokens[1]])
if direction and wrap:
yield 'flex-direction', direction
yield 'flex-wrap', wrap
break
else:
raise InvalidValues
elif len(tokens) == 1:
direction = flex_direction([tokens[0]])
if direction:
yield 'flex-direction', direction
else:
wrap = flex_wrap([tokens[0]])
if wrap:
yield 'flex-wrap', wrap
else:
raise InvalidValues
else:
raise InvalidValues

File diff suppressed because it is too large Load Diff

786
weasyprint/document.py Normal file
View File

@ -0,0 +1,786 @@
"""
weasyprint.document
-------------------
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import collections
import functools
import io
import math
import shutil
import warnings
import cairocffi as cairo
from weasyprint.layout import LayoutContext
from . import CSS
from .css import get_all_computed_styles
from .css.targets import TargetCollector
from .draw import draw_page, stacked
from .fonts import FontConfiguration
from .formatting_structure import boxes
from .formatting_structure.build import build_formatting_structure
from .html import W3C_DATE_RE
from .images import get_image_from_uri as original_get_image_from_uri
from .layout import layout_document
from .layout.percentages import percentage
from .logger import LOGGER, PROGRESS_LOGGER
from .pdf import write_pdf_metadata
if cairo.cairo_version() < 11504:
warnings.warn(
'There are known rendering problems and missing features with '
'cairo < 1.15.4. WeasyPrint may work with older versions, but please '
'read the note about the needed cairo version on the "Install" page '
'of the documentation before reporting bugs. '
'http://weasyprint.readthedocs.io/en/latest/install.html')
def _get_matrix(box):
"""Return the matrix for the CSS transforms on this box.
:returns: a :class:`cairocffi.Matrix` object or :obj:`None`.
"""
# "Transforms apply to block-level and atomic inline-level elements,
# but do not apply to elements which may be split into
# multiple inline-level boxes."
# http://www.w3.org/TR/css3-2d-transforms/#introduction
if box.style['transform'] and not isinstance(box, boxes.InlineBox):
border_width = box.border_width()
border_height = box.border_height()
origin_x, origin_y = box.style['transform_origin']
offset_x = percentage(origin_x, border_width)
offset_y = percentage(origin_y, border_height)
origin_x = box.border_box_x() + offset_x
origin_y = box.border_box_y() + offset_y
matrix = cairo.Matrix()
matrix.translate(origin_x, origin_y)
for name, args in box.style['transform']:
if name == 'scale':
matrix.scale(*args)
elif name == 'rotate':
matrix.rotate(args)
elif name == 'translate':
translate_x, translate_y = args
matrix.translate(
percentage(translate_x, border_width),
percentage(translate_y, border_height),
)
else:
if name == 'skewx':
args = (1, 0, math.tan(args), 1, 0, 0)
elif name == 'skewy':
args = (1, math.tan(args), 0, 1, 0, 0)
else:
assert name == 'matrix'
matrix = cairo.Matrix(*args) * matrix
matrix.translate(-origin_x, -origin_y)
box.transformation_matrix = matrix
return matrix
def rectangle_aabb(matrix, pos_x, pos_y, width, height):
"""Apply a transformation matrix to an axis-aligned rectangle
and return its axis-aligned bounding box as ``(x, y, width, height)``
"""
transform_point = matrix.transform_point
x1, y1 = transform_point(pos_x, pos_y)
x2, y2 = transform_point(pos_x + width, pos_y)
x3, y3 = transform_point(pos_x, pos_y + height)
x4, y4 = transform_point(pos_x + width, pos_y + height)
box_x1 = min(x1, x2, x3, x4)
box_y1 = min(y1, y2, y3, y4)
box_x2 = max(x1, x2, x3, x4)
box_y2 = max(y1, y2, y3, y4)
return box_x1, box_y1, box_x2 - box_x1, box_y2 - box_y1
def _gather_links_and_bookmarks(box, bookmarks, links, anchors, matrix):
transform = _get_matrix(box)
if transform:
matrix = transform * matrix if matrix else transform
bookmark_label = box.bookmark_label
if box.style['bookmark_level'] == 'none':
bookmark_level = None
else:
bookmark_level = box.style['bookmark_level']
state = box.style['bookmark_state']
link = box.style['link']
anchor_name = box.style['anchor']
has_bookmark = bookmark_label and bookmark_level
# 'link' is inherited but redundant on text boxes
has_link = link and not isinstance(box, boxes.TextBox)
# In case of duplicate IDs, only the first is an anchor.
has_anchor = anchor_name and anchor_name not in anchors
is_attachment = hasattr(box, 'is_attachment') and box.is_attachment
if has_bookmark or has_link or has_anchor:
pos_x, pos_y, width, height = box.hit_area()
if has_link:
token_type, link = link
assert token_type == 'url'
link_type, target = link
assert isinstance(target, str)
if link_type == 'external' and is_attachment:
link_type = 'attachment'
if matrix:
link = (
link_type, target, rectangle_aabb(
matrix, pos_x, pos_y, width, height))
else:
link = (link_type, target, (pos_x, pos_y, width, height))
links.append(link)
if matrix and (has_bookmark or has_anchor):
pos_x, pos_y = matrix.transform_point(pos_x, pos_y)
if has_bookmark:
bookmarks.append(
(bookmark_level, bookmark_label, (pos_x, pos_y), state))
if has_anchor:
anchors[anchor_name] = pos_x, pos_y
for child in box.all_children():
_gather_links_and_bookmarks(child, bookmarks, links, anchors, matrix)
def _w3c_date_to_iso(string, attr_name):
"""Tranform W3C date to ISO-8601 format."""
if string is None:
return None
match = W3C_DATE_RE.match(string)
if match is None:
LOGGER.warning('Invalid %s date: %r', attr_name, string)
return None
groups = match.groupdict()
iso_date = '%04i-%02i-%02iT%02i:%02i:%02i' % (
int(groups['year']),
int(groups['month'] or 1),
int(groups['day'] or 1),
int(groups['hour'] or 0),
int(groups['minute'] or 0),
int(groups['second'] or 0))
if groups['hour']:
assert groups['minute']
if groups['tz_hour']:
assert groups['tz_hour'].startswith(('+', '-'))
assert groups['tz_minute']
iso_date += '%+03i:%02i' % (
int(groups['tz_hour']), int(groups['tz_minute']))
else:
iso_date += '+00:00'
return iso_date
class Page(object):
"""Represents a single rendered page.
.. versionadded:: 0.15
Should be obtained from :attr:`Document.pages` but not
instantiated directly.
"""
def __init__(self, page_box, enable_hinting=False):
#: The page width, including margins, in CSS pixels.
self.width = page_box.margin_width()
#: The page height, including margins, in CSS pixels.
self.height = page_box.margin_height()
#: The page bleed widths as a :obj:`dict` with ``'top'``, ``'right'``,
#: ``'bottom'`` and ``'left'`` as keys, and values in CSS pixels.
self.bleed = {
side: page_box.style['bleed_%s' % side].value
for side in ('top', 'right', 'bottom', 'left')}
#: The :obj:`list` of ``(bookmark_level, bookmark_label, target)``
#: :obj:`tuples <tuple>`. ``bookmark_level`` and ``bookmark_label``
#: are respectively an :obj:`int` and a :obj:`string <str>`, based on
#: the CSS properties of the same names. ``target`` is an ``(x, y)``
#: point in CSS pixels from the top-left of the page.
self.bookmarks = []
#: The :obj:`list` of ``(link_type, target, rectangle)`` :obj:`tuples
#: <tuple>`. A ``rectangle`` is ``(x, y, width, height)``, in CSS
#: pixels from the top-left of the page. ``link_type`` is one of three
#: strings:
#:
#: * ``'external'``: ``target`` is an absolute URL
#: * ``'internal'``: ``target`` is an anchor name (see
#: :attr:`Page.anchors`).
#: The anchor might be defined in another page,
#: in multiple pages (in which case the first occurence is used),
#: or not at all.
#: * ``'attachment'``: ``target`` is an absolute URL and points
#: to a resource to attach to the document.
self.links = []
#: The :obj:`dict` mapping each anchor name to its target, an
#: ``(x, y)`` point in CSS pixels from the top-left of the page.
self.anchors = {}
_gather_links_and_bookmarks(
page_box, self.bookmarks, self.links, self.anchors, matrix=None)
self._page_box = page_box
self._enable_hinting = enable_hinting
def paint(self, cairo_context, left_x=0, top_y=0, scale=1, clip=False):
"""Paint the page in cairo, on any type of surface.
:type cairo_context: :class:`cairocffi.Context`
:param cairo_context:
Any cairo context object.
:type left_x: float
:param left_x:
X coordinate of the left of the page, in cairo user units.
:type top_y: float
:param top_y:
Y coordinate of the top of the page, in cairo user units.
:type scale: float
:param scale:
Zoom scale in cairo user units per CSS pixel.
:type clip: bool
:param clip:
Whether to clip/cut content outside the page. If false or
not provided, content can overflow.
"""
with stacked(cairo_context):
if self._enable_hinting:
left_x, top_y = cairo_context.user_to_device(left_x, top_y)
# Hint in device space
left_x = int(left_x)
top_y = int(top_y)
left_x, top_y = cairo_context.device_to_user(left_x, top_y)
# Make (0, 0) the top-left corner:
cairo_context.translate(left_x, top_y)
# Make user units CSS pixels:
cairo_context.scale(scale, scale)
if clip:
width = self.width
height = self.height
if self._enable_hinting:
width, height = (
cairo_context.user_to_device_distance(width, height))
# Hint in device space
width = int(math.ceil(width))
height = int(math.ceil(height))
width, height = (
cairo_context.device_to_user_distance(width, height))
cairo_context.rectangle(0, 0, width, height)
cairo_context.clip()
draw_page(self._page_box, cairo_context, self._enable_hinting)
class DocumentMetadata(object):
"""Meta-information belonging to a whole :class:`Document`.
.. versionadded:: 0.20
New attributes may be added in future versions of WeasyPrint.
"""
def __init__(self, title=None, authors=None, description=None,
keywords=None, generator=None, created=None, modified=None,
attachments=None):
#: The title of the document, as a string or :obj:`None`.
#: Extracted from the ``<title>`` element in HTML
#: and written to the ``/Title`` info field in PDF.
self.title = title
#: The authors of the document, as a list of strings.
#: (Defaults to the empty list.)
#: Extracted from the ``<meta name=author>`` elements in HTML
#: and written to the ``/Author`` info field in PDF.
self.authors = authors or []
#: The description of the document, as a string or :obj:`None`.
#: Extracted from the ``<meta name=description>`` element in HTML
#: and written to the ``/Subject`` info field in PDF.
self.description = description
#: Keywords associated with the document, as a list of strings.
#: (Defaults to the empty list.)
#: Extracted from ``<meta name=keywords>`` elements in HTML
#: and written to the ``/Keywords`` info field in PDF.
self.keywords = keywords or []
#: The name of one of the software packages
#: used to generate the document, as a string or :obj:`None`.
#: Extracted from the ``<meta name=generator>`` element in HTML
#: and written to the ``/Creator`` info field in PDF.
self.generator = generator
#: The creation date of the document, as a string or :obj:`None`.
#: Dates are in one of the six formats specified in
#: `W3Cs profile of ISO 8601 <http://www.w3.org/TR/NOTE-datetime>`_.
#: Extracted from the ``<meta name=dcterms.created>`` element in HTML
#: and written to the ``/CreationDate`` info field in PDF.
self.created = created
#: The modification date of the document, as a string or :obj:`None`.
#: Dates are in one of the six formats specified in
#: `W3Cs profile of ISO 8601 <http://www.w3.org/TR/NOTE-datetime>`_.
#: Extracted from the ``<meta name=dcterms.modified>`` element in HTML
#: and written to the ``/ModDate`` info field in PDF.
self.modified = modified
#: File attachments, as a list of tuples of URL and a description or
#: :obj:`None`. (Defaults to the empty list.)
#: Extracted from the ``<link rel=attachment>`` elements in HTML
#: and written to the ``/EmbeddedFiles`` dictionary in PDF.
#:
#: .. versionadded:: 0.22
self.attachments = attachments or []
BookmarkSubtree = collections.namedtuple(
'BookmarkSubtree', ('label', 'destination', 'children', 'state'))
class Document(object):
"""A rendered document ready to be painted on a cairo surface.
Typically obtained from :meth:`HTML.render() <weasyprint.HTML.render>`, but
can also be instantiated directly with a list of :class:`pages <Page>`, a
set of :class:`metadata <DocumentMetadata>`, a :func:`url_fetcher
<weasyprint.default_url_fetcher>` function, and a :class:`font_config
<weasyprint.fonts.FontConfiguration>`.
"""
@classmethod
def _build_layout_context(cls, html, stylesheets, enable_hinting,
presentational_hints=False, font_config=None):
if font_config is None:
font_config = FontConfiguration()
target_collector = TargetCollector()
page_rules = []
user_stylesheets = []
for css in stylesheets or []:
if not hasattr(css, 'matcher'):
css = CSS(
guess=css, media_type=html.media_type,
font_config=font_config)
user_stylesheets.append(css)
style_for = get_all_computed_styles(
html, user_stylesheets, presentational_hints, font_config,
page_rules, target_collector)
get_image_from_uri = functools.partial(
original_get_image_from_uri, {}, html.url_fetcher)
PROGRESS_LOGGER.info('Step 4 - Creating formatting structure')
context = LayoutContext(
enable_hinting, style_for, get_image_from_uri, font_config,
target_collector)
return context
@classmethod
def _render(cls, html, stylesheets, enable_hinting,
presentational_hints=False, font_config=None):
if font_config is None:
font_config = FontConfiguration()
context = cls._build_layout_context(
html, stylesheets, enable_hinting, presentational_hints,
font_config)
root_box = build_formatting_structure(
html.etree_element, context.style_for, context.get_image_from_uri,
html.base_url, context.target_collector)
page_boxes = layout_document(html, root_box, context)
rendering = cls(
[Page(page_box, enable_hinting) for page_box in page_boxes],
DocumentMetadata(**html._get_metadata()),
html.url_fetcher, font_config)
return rendering
def __init__(self, pages, metadata, url_fetcher, font_config):
#: A list of :class:`Page` objects.
self.pages = pages
#: A :class:`DocumentMetadata` object.
#: Contains information that does not belong to a specific page
#: but to the whole document.
self.metadata = metadata
#: A function or other callable with the same signature as
#: :func:`default_url_fetcher` called to fetch external resources such
#: as stylesheets and images. (See :ref:`url-fetchers`.)
self.url_fetcher = url_fetcher
# Keep a reference to font_config to avoid its garbage collection until
# rendering is destroyed. This is needed as font_config.__del__ removes
# fonts that may be used when rendering
self._font_config = font_config
def copy(self, pages='all'):
"""Take a subset of the pages.
.. versionadded:: 0.15
:type pages: :term:`iterable`
:param pages:
An iterable of :class:`Page` objects from :attr:`pages`.
:return:
A new :class:`Document` object.
Examples:
Write two PDF files for odd-numbered and even-numbered pages::
# Python lists count from 0 but pages are numbered from 1.
# [::2] is a slice of even list indexes but odd-numbered pages.
document.copy(document.pages[::2]).write_pdf('odd_pages.pdf')
document.copy(document.pages[1::2]).write_pdf('even_pages.pdf')
Write each page to a numbred PNG file::
for i, page in enumerate(document.pages):
document.copy(page).write_png('page_%s.png' % i)
Combine multiple documents into one PDF file,
using metadata from the first::
all_pages = [p for doc in documents for p in doc.pages]
documents[0].copy(all_pages).write_pdf('combined.pdf')
"""
if pages == 'all':
pages = self.pages
elif not isinstance(pages, list):
pages = list(pages)
return type(self)(
pages, self.metadata, self.url_fetcher, self._font_config)
def resolve_links(self):
"""Resolve internal hyperlinks.
.. versionadded:: 0.15
Links to a missing anchor are removed with a warning.
If multiple anchors have the same name, the first one is used.
:returns:
A generator yielding lists (one per page) like :attr:`Page.links`,
except that ``target`` for internal hyperlinks is
``(page_number, x, y)`` instead of an anchor name.
The page number is a 0-based index into the :attr:`pages` list,
and ``x, y`` are in CSS pixels from the top-left of the page.
"""
anchors = set()
paged_anchors = []
for i, page in enumerate(self.pages):
paged_anchors.append([])
for anchor_name, (point_x, point_y) in page.anchors.items():
if anchor_name not in anchors:
paged_anchors[-1].append((anchor_name, point_x, point_y))
anchors.add(anchor_name)
for page in self.pages:
page_links = []
for link in page.links:
link_type, anchor_name, rectangle = link
if link_type == 'internal':
if anchor_name not in anchors:
LOGGER.error(
'No anchor #%s for internal URI reference',
anchor_name)
else:
page_links.append((link_type, anchor_name, rectangle))
else:
# External link
page_links.append(link)
yield page_links, paged_anchors.pop(0)
def make_bookmark_tree(self):
"""Make a tree of all bookmarks in the document.
.. versionadded:: 0.15
:return: A list of bookmark subtrees.
A subtree is ``(label, target, children, state)``. ``label`` is
a string, ``target`` is ``(page_number, x, y)`` like in
:meth:`resolve_links`, and ``children`` is a
list of child subtrees.
"""
root = []
# At one point in the document, for each "output" depth, how much
# to add to get the source level (CSS values of bookmark-level).
# E.g. with <h1> then <h3>, level_shifts == [0, 1]
# 1 means that <h3> has depth 3 - 1 = 2 in the output.
skipped_levels = []
last_by_depth = [root]
previous_level = 0
for page_number, page in enumerate(self.pages):
for level, label, (point_x, point_y), state in page.bookmarks:
if level > previous_level:
# Example: if the previous bookmark is a <h2>, the next
# depth "should" be for <h3>. If now we get a <h6> were
# skipping two levels: append 6 - 3 - 1 = 2
skipped_levels.append(level - previous_level - 1)
else:
temp = level
while temp < previous_level:
temp += 1 + skipped_levels.pop()
if temp > previous_level:
# We remove too many "skips", add some back:
skipped_levels.append(temp - previous_level - 1)
previous_level = level
depth = level - sum(skipped_levels)
assert depth == len(skipped_levels)
assert depth >= 1
children = []
subtree = BookmarkSubtree(
label, (page_number, point_x, point_y), children, state)
last_by_depth[depth - 1].append(subtree)
del last_by_depth[depth:]
last_by_depth.append(children)
return root
def add_hyperlinks(self, links, anchors, context, scale):
"""Include hyperlinks in current PDF page.
.. versionadded:: 43
"""
if cairo.cairo_version() < 11504:
return
# We round floats to avoid locale problems, see
# https://github.com/Kozea/WeasyPrint/issues/742
# TODO: Instead of using rects, we could use the drawing rectangles
# defined by cairo when drawing targets. This would give a feeling
# similiar to what browsers do with links that span multiple lines.
for link in links:
link_type, link_target, rectangle = link
if link_type == 'external':
attributes = "rect=[{} {} {} {}] uri='{}'".format(*(
[int(round(i * scale)) for i in rectangle] +
[link_target.replace("'", '%27')]))
elif link_type == 'internal':
attributes = "rect=[{} {} {} {}] dest='{}'".format(*(
[int(round(i * scale)) for i in rectangle] +
[link_target.replace("'", '%27')]))
elif link_type == 'attachment':
# Attachments are handled in write_pdf_metadata
continue
context.tag_begin(cairo.TAG_LINK, attributes)
context.tag_end(cairo.TAG_LINK)
for anchor in anchors:
anchor_name, x, y = anchor
attributes = "name='{}' x={} y={}".format(
anchor_name.replace("'", '%27'), int(round(x * scale)),
int(round(y * scale)))
context.tag_begin(cairo.TAG_DEST, attributes)
context.tag_end(cairo.TAG_DEST)
def write_pdf(self, target=None, zoom=1, attachments=None):
"""Paint the pages in a PDF file, with meta-data.
PDF files written directly by cairo do not have meta-data such as
bookmarks/outlines and hyperlinks.
:type target: str, pathlib.Path or file object
:param target:
A filename where the PDF file is generated, a file object, or
:obj:`None`.
:type zoom: float
:param zoom:
The zoom factor in PDF units per CSS units. **Warning**:
All CSS units are affected, including physical units like
``cm`` and named sizes like ``A4``. For values other than
1, the physical CSS units will thus be "wrong".
:type attachments: list
:param attachments: A list of additional file attachments for the
generated PDF document or :obj:`None`. The list's elements are
:class:`Attachment` objects, filenames, URLs or file-like objects.
:returns:
The PDF as :obj:`bytes` if ``target`` is not provided or
:obj:`None`, otherwise :obj:`None` (the PDF is written to
``target``).
"""
# 0.75 = 72 PDF point (cairo units) per inch / 96 CSS pixel per inch
scale = zoom * 0.75
# Use an in-memory buffer, as we will need to seek for
# metadata. Directly using the target when possible doesn't
# significantly save time and memory use.
file_obj = io.BytesIO()
# (1, 1) is overridden by .set_size() below.
surface = cairo.PDFSurface(file_obj, 1, 1)
context = cairo.Context(surface)
PROGRESS_LOGGER.info('Step 6 - Drawing')
paged_links_and_anchors = list(self.resolve_links())
for page, links_and_anchors in zip(
self.pages, paged_links_and_anchors):
links, anchors = links_and_anchors
surface.set_size(
math.floor(scale * (
page.width + page.bleed['left'] + page.bleed['right'])),
math.floor(scale * (
page.height + page.bleed['top'] + page.bleed['bottom'])))
with stacked(context):
context.translate(
page.bleed['left'] * scale, page.bleed['top'] * scale)
page.paint(context, scale=scale)
self.add_hyperlinks(links, anchors, context, scale)
surface.show_page()
PROGRESS_LOGGER.info('Step 7 - Adding PDF metadata')
# TODO: overwrite producer when possible in cairo
if cairo.cairo_version() >= 11504:
# Set document information
for attr, key in (
('title', cairo.PDF_METADATA_TITLE),
('description', cairo.PDF_METADATA_SUBJECT),
('generator', cairo.PDF_METADATA_CREATOR)):
value = getattr(self.metadata, attr)
if value is not None:
surface.set_metadata(key, value)
for attr, key in (
('authors', cairo.PDF_METADATA_AUTHOR),
('keywords', cairo.PDF_METADATA_KEYWORDS)):
value = getattr(self.metadata, attr)
if value is not None:
surface.set_metadata(key, ', '.join(value))
for attr, key in (
('created', cairo.PDF_METADATA_CREATE_DATE),
('modified', cairo.PDF_METADATA_MOD_DATE)):
value = getattr(self.metadata, attr)
if value is not None:
surface.set_metadata(key, _w3c_date_to_iso(value, attr))
# Set bookmarks
bookmarks = self.make_bookmark_tree()
levels = [cairo.PDF_OUTLINE_ROOT] * len(bookmarks)
while bookmarks:
bookmark = bookmarks.pop(0)
title = bookmark.label
destination = bookmark.destination
children = bookmark.children
state = bookmark.state
page, x, y = destination
# We round floats to avoid locale problems, see
# https://github.com/Kozea/WeasyPrint/issues/742
link_attribs = 'page={} pos=[{} {}]'.format(
page + 1, int(round(x * scale)),
int(round(y * scale)))
outline = surface.add_outline(
levels.pop(), title, link_attribs,
cairo.PDF_OUTLINE_FLAG_OPEN if state == 'open' else 0)
levels.extend([outline] * len(children))
bookmarks = children + bookmarks
surface.finish()
# Add extra PDF metadata: attachments, embedded files
attachment_links = [
[link for link in page_links if link[0] == 'attachment']
for page_links, page_anchors in paged_links_and_anchors]
# Write extra PDF metadata only when there is a least one from:
# - attachments in metadata
# - attachments as function parameters
# - attachments as PDF links
# - bleed boxes
condition = (
self.metadata.attachments or
attachments or
any(attachment_links) or
any(any(page.bleed.values()) for page in self.pages))
if condition:
write_pdf_metadata(
file_obj, scale, self.url_fetcher,
self.metadata.attachments + (attachments or []),
attachment_links, self.pages)
if target is None:
return file_obj.getvalue()
else:
file_obj.seek(0)
if hasattr(target, 'write'):
shutil.copyfileobj(file_obj, target)
else:
with open(target, 'wb') as fd:
shutil.copyfileobj(file_obj, fd)
def write_image_surface(self, resolution=96):
"""Render pages on a cairo image surface.
.. versionadded:: 0.17
There is no decoration around pages other than those specified in CSS
with ``@page`` rules. The final image is as wide as the widest page.
Each page is below the previous one, centered horizontally.
:type resolution: float
:param resolution:
The output resolution in PNG pixels per CSS inch. At 96 dpi
(the default), PNG pixels match the CSS ``px`` unit.
:returns:
A ``(surface, png_width, png_height)`` tuple. ``surface`` is a
cairo :class:`ImageSurface <cairocffi.ImageSurface>`. ``png_width``
and ``png_height`` are the size of the final image, in PNG pixels.
"""
dppx = resolution / 96
# This duplicates the hinting logic in Page.paint. There is a
# dependency cycle otherwise:
# this → hinting logic → context → surface → this
# But since we do no transform here, cairo_context.user_to_device and
# friends are identity functions.
widths = [int(math.ceil(p.width * dppx)) for p in self.pages]
heights = [int(math.ceil(p.height * dppx)) for p in self.pages]
max_width = max(widths)
sum_heights = sum(heights)
surface = cairo.ImageSurface(
cairo.FORMAT_ARGB32, max_width, sum_heights)
context = cairo.Context(surface)
pos_y = 0
PROGRESS_LOGGER.info('Step 6 - Drawing')
for page, width, height in zip(self.pages, widths, heights):
pos_x = (max_width - width) / 2
page.paint(context, pos_x, pos_y, scale=dppx, clip=True)
pos_y += height
return surface, max_width, sum_heights
def write_png(self, target=None, resolution=96):
"""Paint the pages vertically to a single PNG image.
There is no decoration around pages other than those specified in CSS
with ``@page`` rules. The final image is as wide as the widest page.
Each page is below the previous one, centered horizontally.
:param target:
A filename, file-like object, or :obj:`None`.
:type resolution: float
:param resolution:
The output resolution in PNG pixels per CSS inch. At 96 dpi
(the default), PNG pixels match the CSS ``px`` unit.
:returns:
A ``(png_bytes, png_width, png_height)`` tuple. ``png_bytes`` is a
byte string if ``target`` is :obj:`None`, otherwise :obj:`None`
(the image is written to ``target``). ``png_width`` and
``png_height`` are the size of the final image, in PNG pixels.
"""
surface, max_width, sum_heights = self.write_image_surface(resolution)
if target is None:
target = io.BytesIO()
surface.write_to_png(target)
png_bytes = target.getvalue()
else:
surface.write_to_png(target)
png_bytes = None
return png_bytes, max_width, sum_heights

1130
weasyprint/draw.py Normal file

File diff suppressed because it is too large Load Diff

456
weasyprint/fonts.py Normal file
View File

@ -0,0 +1,456 @@
"""
weasyprint.fonts
----------------
Interface with external libraries managing fonts installed on the system.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import os
import pathlib
import sys
import tempfile
import warnings
from .logger import LOGGER
from .text import (
cairo, dlopen, ffi, get_font_features, gobject, pango, pangocairo)
from .urls import FILESYSTEM_ENCODING, fetch
# Cairo crashes with font-size: 0 when using Win32 API
# See https://github.com/Kozea/WeasyPrint/pull/599
# Probably it will crash on macOS, too, when native font rendering is used,
# Set to True on startup when fontconfig is inoperable.
# Used by text/Layout() to mask font-size: 0 with a font_size of 1.
# TODO: Should we set it to true on Windows and macOS if Pango < 13800?
ZERO_FONTSIZE_CRASHES_CAIRO = False
class FontConfiguration:
"""Font configuration."""
def __init__(self):
"""Create a font configuration before rendering a document."""
self.font_map = None
def add_font_face(self, rule_descriptors, url_fetcher):
"""Add a font into the application."""
if pango.pango_version() < 13800:
warnings.warn('@font-face support needs Pango >= 1.38')
else:
# No need to try...catch:
# If there's no fontconfig library, cairocffi already crashed the script
# with OSError: dlopen() failed to load a library: cairo / cairo-2
# So let's hope we find the same file as cairo already did ;)
# Same applies to pangocairo requiring pangoft2
fontconfig = dlopen(ffi, 'fontconfig', 'libfontconfig',
'libfontconfig-1.dll',
'libfontconfig.so.1', 'libfontconfig-1.dylib')
pangoft2 = dlopen(ffi, 'pangoft2-1.0', 'libpangoft2-1.0-0',
'libpangoft2-1.0.so', 'libpangoft2-1.0.dylib')
ffi.cdef('''
// FontConfig
typedef int FcBool;
typedef struct _FcConfig FcConfig;
typedef struct _FcPattern FcPattern;
typedef struct _FcStrList FcStrList;
typedef unsigned char FcChar8;
typedef enum {
FcResultMatch, FcResultNoMatch, FcResultTypeMismatch, FcResultNoId,
FcResultOutOfMemory
} FcResult;
typedef enum {
FcMatchPattern, FcMatchFont, FcMatchScan
} FcMatchKind;
typedef struct _FcFontSet {
int nfont;
int sfont;
FcPattern **fonts;
} FcFontSet;
typedef enum _FcSetName {
FcSetSystem = 0,
FcSetApplication = 1
} FcSetName;
FcConfig * FcInitLoadConfigAndFonts (void);
void FcConfigDestroy (FcConfig *config);
FcBool FcConfigAppFontAddFile (
FcConfig *config, const FcChar8 *file);
FcConfig * FcConfigGetCurrent (void);
FcBool FcConfigSetCurrent (FcConfig *config);
FcBool FcConfigParseAndLoad (
FcConfig *config, const FcChar8 *file, FcBool complain);
FcFontSet * FcConfigGetFonts(FcConfig *config, FcSetName set);
FcStrList * FcConfigGetConfigFiles(FcConfig *config);
FcChar8 * FcStrListNext(FcStrList *list);
void FcDefaultSubstitute (FcPattern *pattern);
FcBool FcConfigSubstitute (
FcConfig *config, FcPattern *p, FcMatchKind kind);
FcPattern * FcPatternCreate (void);
FcPattern * FcPatternDestroy (FcPattern *p);
FcBool FcPatternAddString (
FcPattern *p, const char *object, const FcChar8 *s);
FcResult FcPatternGetString (
FcPattern *p, const char *object, int n, FcChar8 **s);
FcPattern * FcFontMatch (
FcConfig *config, FcPattern *p, FcResult *result);
// PangoFT2
typedef ... PangoFcFontMap;
void pango_fc_font_map_set_config (
PangoFcFontMap *fcfontmap, FcConfig *fcconfig);
void pango_fc_font_map_shutdown (PangoFcFontMap *fcfontmap);
// PangoCairo
typedef ... PangoCairoFontMap;
void pango_cairo_font_map_set_default (PangoCairoFontMap *fontmap);
PangoFontMap * pango_cairo_font_map_new_for_font_type (
cairo_font_type_t fonttype);
''')
FONTCONFIG_WEIGHT_CONSTANTS = {
'normal': 'normal',
'bold': 'bold',
100: 'thin',
200: 'extralight',
300: 'light',
400: 'normal',
500: 'medium',
600: 'demibold',
700: 'bold',
800: 'extrabold',
900: 'black',
}
FONTCONFIG_STYLE_CONSTANTS = {
'normal': 'roman',
'italic': 'italic',
'oblique': 'oblique',
}
FONTCONFIG_STRETCH_CONSTANTS = {
'normal': 'normal',
'ultra-condensed': 'ultracondensed',
'extra-condensed': 'extracondensed',
'condensed': 'condensed',
'semi-condensed': 'semicondensed',
'semi-expanded': 'semiexpanded',
'expanded': 'expanded',
'extra-expanded': 'extraexpanded',
'ultra-expanded': 'ultraexpanded',
}
def _check_font_configuration(font_config, warn=False):
"""Check whether the given font_config has fonts.
The default fontconfig configuration file may be missing (particularly
on Windows or macOS, where installation of fontconfig isn't as
standardized as on Liniux), resulting in "Fontconfig error: Cannot load
default config file".
Fontconfig tries to retrieve the system fonts as fallback, which may or
may not work, especially on macOS, where fonts can be installed at
various loactions.
On Windows (at least since fontconfig 2.13) the fallback seems to work.
No default config && system fonts fallback fails == No fonts.
Config file exists, but doesn't provide fonts == No fonts.
No fonts == expect ugly output.
If you happen to have no fonts and an html without a valid @font-face
all letters turn into rectangles.
If you happen to have an html with at least one valid @font-face
all text is styled with that font.
On Windows and macOS we can cause Pango to use native font rendering
instead of rendering fonts with FreeType. But then we must do without
@font-face. Expect other missing features and ugly output.
"""
# On Linux we can do nothing but give warnings.
has_native_mode = (
sys.platform.startswith('win') or
sys.platform.startswith('darwin'))
if not has_native_mode and not warn:
return True
# Having fonts means: fontconfig's config file returns fonts or
# fontconfig managed to retrieve system fallback-fonts. On Windows the
# fallback stragegy seems to work since fontconfig >= 2.13
fonts = fontconfig.FcConfigGetFonts(
font_config, fontconfig.FcSetSystem)
# Of course, with nfont == 1 the user wont be happy, too...
if fonts.nfont > 0:
return True
# whats the reason for zero fonts?
config_files = fontconfig.FcConfigGetConfigFiles(font_config)
config_file = fontconfig.FcStrListNext(config_files)
if config_file == ffi.NULL:
# no config file, no system fonts found
# on Windows and macOS it might help to fall back to native font
# rendering
if has_native_mode:
if warn:
warnings.warn(
'@font-face not supported: '
'FontConfig cannot load default config file')
return False
else:
if warn:
warnings.warn(
'FontConfig cannot load default config file.'
'Expect ugly output.')
return True
else:
# useless config file or indeed no fonts
if warn:
warnings.warn(
'FontConfig: No fonts configured. '
'Expect ugly output.')
return True
# TODO: on Windows we could try to add the system fonts like that:
# fontdir = os.path.join(os.environ['WINDIR'], 'Fonts')
# fontconfig.FcConfigAppFontAddDir(
# font_config,
# # not sure which encoding fontconfig expects
# fontdir.encode('mbcs'))
class FontConfiguration(FontConfiguration):
"""A FreeType font configuration.
.. versionadded:: 0.32
Keep a list of fonts, including fonts installed on the system, fonts
installed for the current user, and fonts referenced by cascading
stylesheets.
When created, an instance of this class gathers available fonts. It can
then be given to :class:`weasyprint.HTML` methods or to
:class:`weasyprint.CSS` to find fonts in ``@font-face`` rules.
"""
def __init__(self):
"""Create a FreeType font configuration.
See Behdad's blog:
https://mces.blogspot.fr/2015/05/
how-to-use-custom-application-fonts.html
"""
# Load the master config file and the fonts.
self._fontconfig_config = ffi.gc(
fontconfig.FcInitLoadConfigAndFonts(),
fontconfig.FcConfigDestroy)
if _check_font_configuration(self._fontconfig_config):
self.font_map = ffi.gc(
pangocairo.pango_cairo_font_map_new_for_font_type(
cairo.FONT_TYPE_FT),
gobject.g_object_unref)
pangoft2.pango_fc_font_map_set_config(
ffi.cast('PangoFcFontMap *', self.font_map),
self._fontconfig_config)
# pango_fc_font_map_set_config keeps a reference to config
fontconfig.FcConfigDestroy(self._fontconfig_config)
else:
self.font_map = None
# On Windows the font tempfiles cannot be deleted,
# putting them in a subfolder made my life easier.
self._tempdir = None
if sys.platform.startswith('win'):
self._tempdir = os.path.join(
tempfile.gettempdir(), 'weasyprint')
try:
os.mkdir(self._tempdir)
except FileExistsError:
pass
except Exception:
# Back to default.
self._tempdir = None
self._filenames = []
def add_font_face(self, rule_descriptors, url_fetcher):
if self.font_map is None:
return
for font_type, url in rule_descriptors['src']:
if url is None:
continue
if font_type in ('external', 'local'):
config = self._fontconfig_config
fetch_as_url = True
if font_type == 'local':
font_name = url.encode('utf-8')
pattern = ffi.gc(
fontconfig.FcPatternCreate(),
fontconfig.FcPatternDestroy)
fontconfig.FcConfigSubstitute(
config, pattern, fontconfig.FcMatchFont)
fontconfig.FcDefaultSubstitute(pattern)
fontconfig.FcPatternAddString(
pattern, b'fullname', font_name)
fontconfig.FcPatternAddString(
pattern, b'postscriptname', font_name)
family = ffi.new('FcChar8 **')
postscript = ffi.new('FcChar8 **')
result = ffi.new('FcResult *')
matching_pattern = fontconfig.FcFontMatch(
config, pattern, result)
# prevent RuntimeError, see issue #677
if matching_pattern == ffi.NULL:
LOGGER.debug(
'Failed to get matching local font for "%s"',
font_name.decode('utf-8'))
continue
# TODO: do many fonts have multiple family values?
fontconfig.FcPatternGetString(
matching_pattern, b'fullname', 0, family)
fontconfig.FcPatternGetString(
matching_pattern, b'postscriptname', 0, postscript)
family = ffi.string(family[0])
postscript = ffi.string(postscript[0])
if font_name.lower() in (
family.lower(), postscript.lower()):
filename = ffi.new('FcChar8 **')
matching_pattern = fontconfig.FcFontMatch(
config, pattern, result)
fontconfig.FcPatternGetString(
matching_pattern, b'file', 0, filename)
path = ffi.string(filename[0]).decode(
FILESYSTEM_ENCODING)
url = pathlib.Path(path).as_uri()
else:
LOGGER.debug(
'Failed to load local font "%s"',
font_name.decode('utf-8'))
continue
try:
if fetch_as_url:
with fetch(url_fetcher, url) as result:
if 'string' in result:
font = result['string']
else:
font = result['file_obj'].read()
else:
with open(url, 'rb') as fd:
font = fd.read()
except Exception as exc:
LOGGER.debug(
'Failed to load font at "%s" (%s)', url, exc)
continue
font_features = {
rules[0][0].replace('-', '_'): rules[0][1] for rules in
rule_descriptors.get('font_variant', [])}
if 'font_feature_settings' in rule_descriptors:
font_features['font_feature_settings'] = (
rule_descriptors['font_feature_settings'])
features_string = ''
for key, value in get_font_features(
**font_features).items():
features_string += '<string>%s %s</string>' % (
key, value)
fd, filename = tempfile.mkstemp(dir=self._tempdir)
os.write(fd, font)
os.close(fd)
self._filenames.append(filename)
xml = '''<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<match target="scan">
<test name="file" compare="eq">
<string>%s</string>
</test>
<edit name="family" mode="assign_replace">
<string>%s</string>
</edit>
<edit name="slant" mode="assign_replace">
<const>%s</const>
</edit>
<edit name="weight" mode="assign_replace">
<const>%s</const>
</edit>
<edit name="width" mode="assign_replace">
<const>%s</const>
</edit>
</match>
<match target="font">
<test name="file" compare="eq">
<string>%s</string>
</test>
<edit name="fontfeatures"
mode="assign_replace">%s</edit>
</match>
</fontconfig>''' % (
filename,
rule_descriptors['font_family'],
FONTCONFIG_STYLE_CONSTANTS[
rule_descriptors.get('font_style', 'normal')],
FONTCONFIG_WEIGHT_CONSTANTS[
rule_descriptors.get('font_weight', 'normal')],
FONTCONFIG_STRETCH_CONSTANTS[
rule_descriptors.get('font_stretch', 'normal')],
filename, features_string)
fd, conf_filename = tempfile.mkstemp(dir=self._tempdir)
# TODO: is this encoding OK?
os.write(fd, xml.encode('utf-8'))
os.close(fd)
self._filenames.append(conf_filename)
fontconfig.FcConfigParseAndLoad(
config, conf_filename.encode('ascii'), True)
font_added = fontconfig.FcConfigAppFontAddFile(
config, filename.encode('ascii'))
if font_added:
# TODO: We should mask local fonts with the same name
# too as explained in Behdad's blog entry.
# TODO: What about pango_fc_font_map_config_changed()
# as suggested in Behdad's blog entry?
# Though it seems to work without…
return filename
else:
LOGGER.debug('Failed to load font at "%s"', url)
LOGGER.warning(
'Font-face "%s" cannot be loaded',
rule_descriptors['font_family'])
def __del__(self):
"""Clean a font configuration for a document."""
# Can't cleanup the temporary font files on Windows, library has
# still open file handles. On Unix `os.remove()` a file that is in
# use works fine, on Windows a PermissionError is raised.
# FcConfigAppFontClear and pango_fc_font_map_shutdown don't help.
for filename in self._filenames:
try:
os.remove(filename)
except OSError:
continue
_fontconfig_config = ffi.gc(
fontconfig.FcInitLoadConfigAndFonts(),
fontconfig.FcConfigDestroy)
if not _check_font_configuration(_fontconfig_config, warn=True):
warnings.warn('Expect ugly output with font-size: 0')
ZERO_FONTSIZE_CRASHES_CAIRO = True

View File

@ -0,0 +1,12 @@
"""
weasyprint.formatting_structure
-------------------------------
The formatting structure is a tree of boxes. It is either "before layout",
close to the element tree is it built from, or "after layout", with
line breaks and page breaks.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""

View File

@ -0,0 +1,727 @@
"""
weasyprint.formatting_structure.boxes
-------------------------------------
Classes for all types of boxes in the CSS formatting structure / box model.
See http://www.w3.org/TR/CSS21/visuren.html
Names are the same as in CSS 2.1 with the exception of ``TextBox``. In
WeasyPrint, any text is in a ``TextBox``. What CSS calls anonymous
inline boxes are text boxes but not all text boxes are anonymous
inline boxes.
See http://www.w3.org/TR/CSS21/visuren.html#anonymous
Abstract classes, should not be instantiated:
* Box
* BlockLevelBox
* InlineLevelBox
* BlockContainerBox
* ReplacedBox
* ParentBox
* AtomicInlineLevelBox
Concrete classes:
* PageBox
* BlockBox
* InlineBox
* InlineBlockBox
* BlockReplacedBox
* InlineReplacedBox
* TextBox
* LineBox
* Various table-related Box subclasses
All concrete box classes whose name contains "Inline" or "Block" have
one of the following "outside" behavior:
* Block-level (inherits from :class:`BlockLevelBox`)
* Inline-level (inherits from :class:`InlineLevelBox`)
and one of the following "inside" behavior:
* Block container (inherits from :class:`BlockContainerBox`)
* Inline content (InlineBox and :class:`TextBox`)
* Replaced content (inherits from :class:`ReplacedBox`)
... with various combinasions of both.
See respective docstrings for details.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import itertools
from ..css import computed_from_cascaded
from ..css.properties import Dimension
class Box(object):
"""Abstract base class for all boxes."""
# Definitions for the rules generating anonymous table boxes
# http://www.w3.org/TR/CSS21/tables.html#anonymous-boxes
proper_table_child = False
internal_table_or_caption = False
tabular_container = False
# Keep track of removed collapsing spaces for wrap opportunities.
leading_collapsible_space = False
trailing_collapsible_space = False
# Default, may be overriden on instances.
is_table_wrapper = False
is_flex_item = False
is_for_root_element = False
is_column = False
# Other properties
transformation_matrix = None
bookmark_label = None
string_set = None
# Default, overriden on some subclasses
def all_children(self):
return ()
def __init__(self, element_tag, style, element):
self.element_tag = element_tag
self.element = element
self.style = style
def __repr__(self):
return '<%s %s>' % (type(self).__name__, self.element_tag)
@classmethod
def anonymous_from(cls, parent, *args, **kwargs):
"""Return an anonymous box that inherits from ``parent``."""
style = computed_from_cascaded(
cascaded={}, parent_style=parent.style, element=None)
return cls(parent.element_tag, style, parent.element, *args, **kwargs)
def copy(self):
"""Return shallow copy of the box."""
cls = type(self)
# Create a new instance without calling __init__: parameters are
# different depending on the class.
new_box = cls.__new__(cls)
# Copy attributes
new_box.__dict__.update(self.__dict__)
return new_box
def deepcopy(self):
"""Return a copy of the box with recursive copies of its children."""
return self.copy()
def translate(self, dx=0, dy=0, ignore_floats=False):
"""Change the boxs position.
Also update the childrens positions accordingly.
"""
# Overridden in ParentBox to also translate children, if any.
if dx == 0 and dy == 0:
return
self.position_x += dx
self.position_y += dy
for child in self.all_children():
if not (ignore_floats and child.is_floated()):
child.translate(dx, dy, ignore_floats)
# Heights and widths
def padding_width(self):
"""Width of the padding box."""
return self.width + self.padding_left + self.padding_right
def padding_height(self):
"""Height of the padding box."""
return self.height + self.padding_top + self.padding_bottom
def border_width(self):
"""Width of the border box."""
return self.padding_width() + self.border_left_width + \
self.border_right_width
def border_height(self):
"""Height of the border box."""
return self.padding_height() + self.border_top_width + \
self.border_bottom_width
def margin_width(self):
"""Width of the margin box (aka. outer box)."""
return self.border_width() + self.margin_left + self.margin_right
def margin_height(self):
"""Height of the margin box (aka. outer box)."""
return self.border_height() + self.margin_top + self.margin_bottom
# Corners positions
def content_box_x(self):
"""Absolute horizontal position of the content box."""
return self.position_x + self.margin_left + self.padding_left + \
self.border_left_width
def content_box_y(self):
"""Absolute vertical position of the content box."""
return self.position_y + self.margin_top + self.padding_top + \
self.border_top_width
def padding_box_x(self):
"""Absolute horizontal position of the padding box."""
return self.position_x + self.margin_left + self.border_left_width
def padding_box_y(self):
"""Absolute vertical position of the padding box."""
return self.position_y + self.margin_top + self.border_top_width
def border_box_x(self):
"""Absolute horizontal position of the border box."""
return self.position_x + self.margin_left
def border_box_y(self):
"""Absolute vertical position of the border box."""
return self.position_y + self.margin_top
def hit_area(self):
"""Return the (x, y, w, h) rectangle where the box is clickable."""
# "Border area. That's the area that hit-testing is done on."
# http://lists.w3.org/Archives/Public/www-style/2012Jun/0318.html
# TODO: manage the border radii, use outer_border_radii instead
return (self.border_box_x(), self.border_box_y(),
self.border_width(), self.border_height())
def rounded_box(self, bt, br, bb, bl):
"""Position, size and radii of a box inside the outer border box.
bt, br, bb, and bl are distances from the outer border box,
defining a rectangle to be rounded.
"""
tlrx, tlry = self.border_top_left_radius
trrx, trry = self.border_top_right_radius
brrx, brry = self.border_bottom_right_radius
blrx, blry = self.border_bottom_left_radius
tlrx = max(0, tlrx - bl)
tlry = max(0, tlry - bt)
trrx = max(0, trrx - br)
trry = max(0, trry - bt)
brrx = max(0, brrx - br)
brry = max(0, brry - bb)
blrx = max(0, blrx - bl)
blry = max(0, blry - bb)
x = self.border_box_x() + bl
y = self.border_box_y() + bt
width = self.border_width() - bl - br
height = self.border_height() - bt - bb
# Fix overlapping curves
# See http://www.w3.org/TR/css3-background/#corner-overlap
ratio = min([1] + [
extent / sum_radii
for extent, sum_radii in [
(width, tlrx + trrx),
(width, blrx + brrx),
(height, tlry + blry),
(height, trry + brry),
]
if sum_radii > 0
])
return (
x, y, width, height,
(tlrx * ratio, tlry * ratio),
(trrx * ratio, trry * ratio),
(brrx * ratio, brry * ratio),
(blrx * ratio, blry * ratio))
def rounded_box_ratio(self, ratio):
return self.rounded_box(
self.border_top_width * ratio,
self.border_right_width * ratio,
self.border_bottom_width * ratio,
self.border_left_width * ratio)
def rounded_padding_box(self):
"""Return the position, size and radii of the rounded padding box."""
return self.rounded_box(
self.border_top_width,
self.border_right_width,
self.border_bottom_width,
self.border_left_width)
def rounded_border_box(self):
"""Return the position, size and radii of the rounded border box."""
return self.rounded_box(0, 0, 0, 0)
def rounded_content_box(self):
"""Return the position, size and radii of the rounded content box."""
return self.rounded_box(
self.border_top_width + self.padding_top,
self.border_right_width + self.padding_right,
self.border_bottom_width + self.padding_bottom,
self.border_left_width + self.padding_left)
# Positioning schemes
def is_floated(self):
"""Return whether this box is floated."""
return self.style['float'] != 'none'
def is_absolutely_positioned(self):
"""Return whether this box is in the absolute positioning scheme."""
return self.style['position'] in ('absolute', 'fixed')
def is_running(self):
"""Return whether this box is a running element."""
return self.style['position'][0] == 'running()'
def is_in_normal_flow(self):
"""Return whether this box is in normal flow."""
return not (
self.is_floated() or self.is_absolutely_positioned() or
self.is_running())
# Start and end page values for named pages
def page_values(self):
"""Return start and end page values."""
return (self.style['page'], self.style['page'])
class ParentBox(Box):
"""A box that has children."""
def __init__(self, element_tag, style, element, children):
super().__init__(element_tag, style, element)
self.children = tuple(children)
def all_children(self):
return self.children
def _reset_spacing(self, side):
"""Set to 0 the margin, padding and border of ``side``."""
self.style['margin_%s' % side] = Dimension(0, 'px')
self.style['padding_%s' % side] = Dimension(0, 'px')
self.style['border_%s_width' % side] = 0
if side in ('top', 'bottom'):
self.style['border_%s_left_radius' % side] = (
Dimension(0, 'px'), Dimension(0, 'px'))
self.style['border_%s_right_radius' % side] = (
Dimension(0, 'px'), Dimension(0, 'px'))
else:
self.style['border_bottom_%s_radius' % side] = (
Dimension(0, 'px'), Dimension(0, 'px'))
self.style['border_top_%s_radius' % side] = (
Dimension(0, 'px'), Dimension(0, 'px'))
setattr(self, 'margin_%s' % side, 0)
setattr(self, 'padding_%s' % side, 0)
setattr(self, 'border_%s_width' % side, 0)
def _remove_decoration(self, start, end):
if start or end:
old_style = self.style
self.style = self.style.copy()
if start:
self._reset_spacing('top')
if end:
self._reset_spacing('bottom')
if (start or end) and old_style == self.style:
# Don't copy style if there's no need to, save some memory
self.style = old_style
def copy_with_children(self, new_children, is_start=True, is_end=True):
"""Create a new equivalent box with given ``new_children``."""
new_box = self.copy()
new_box.children = tuple(new_children)
if self.style['box_decoration_break'] == 'slice':
new_box._remove_decoration(not is_start, not is_end)
return new_box
def deepcopy(self):
result = self.copy()
result.children = tuple(child.deepcopy() for child in self.children)
return result
def descendants(self):
"""A flat generator for a box, its children and descendants."""
yield self
for child in self.children:
if hasattr(child, 'descendants'):
for grand_child in child.descendants():
yield grand_child
else:
yield child
def get_wrapped_table(self):
"""Get the table wrapped by the box."""
if self.is_table_wrapper:
for child in self.children:
if isinstance(child, TableBox):
return child
else: # pragma: no cover
raise ValueError('Table wrapper without a table')
def page_values(self):
start_value, end_value = super().page_values()
if self.children:
if len(self.children) == 1:
page_values = self.children[0].page_values()
start_value = page_values[0] or start_value
end_value = page_values[1] or end_value
else:
start_box, end_box = self.children[0], self.children[-1]
start_value = start_box.page_values()[0] or start_value
end_value = end_box.page_values()[1] or end_value
return start_value, end_value
class BlockLevelBox(Box):
"""A box that participates in an block formatting context.
An element with a ``display`` value of ``block``, ``list-item`` or
``table`` generates a block-level box.
"""
clearance = None
class BlockContainerBox(ParentBox):
"""A box that contains only block-level boxes or only line boxes.
A box that either contains only block-level boxes or establishes an inline
formatting context and thus contains only line boxes.
A non-replaced element with a ``display`` value of ``block``,
``list-item``, ``inline-block`` or 'table-cell' generates a block container
box.
"""
class BlockBox(BlockContainerBox, BlockLevelBox):
"""A block-level box that is also a block container.
A non-replaced element with a ``display`` value of ``block``, ``list-item``
generates a block box.
"""
class LineBox(ParentBox):
"""A box that represents a line in an inline formatting context.
Can only contain inline-level boxes.
In early stages of building the box tree a single line box contains many
consecutive inline boxes. Later, during layout phase, each line boxes will
be split into multiple line boxes, one for each actual line.
"""
text_overflow = 'clip'
@classmethod
def anonymous_from(cls, parent, *args, **kwargs):
box = super().anonymous_from(parent, *args, **kwargs)
if parent.style['overflow'] != 'visible':
box.text_overflow = parent.style['text_overflow']
return box
class InlineLevelBox(Box):
"""A box that participates in an inline formatting context.
An inline-level box that is not an inline box is said to be "atomic". Such
boxes are inline blocks, replaced elements and inline tables.
An element with a ``display`` value of ``inline``, ``inline-table``, or
``inline-block`` generates an inline-level box.
"""
def _remove_decoration(self, start, end):
if start or end:
old_style = self.style
self.style = self.style.copy()
ltr = self.style['direction'] == 'ltr'
if start:
self._reset_spacing('left' if ltr else 'right')
if end:
self._reset_spacing('right' if ltr else 'left')
if (start or end) and old_style == self.style:
# Don't copy style if there's no need to, save some memory
self.style = old_style
class InlineBox(InlineLevelBox, ParentBox):
"""An inline box with inline children.
A box that participates in an inline formatting context and whose content
also participates in that inline formatting context.
A non-replaced element with a ``display`` value of ``inline`` generates an
inline box.
"""
def hit_area(self):
"""Return the (x, y, w, h) rectangle where the box is clickable."""
# Use line-height (margin_height) rather than border_height
return (self.border_box_x(), self.position_y,
self.border_width(), self.margin_height())
class TextBox(InlineLevelBox):
"""A box that contains only text and has no box children.
Any text in the document ends up in a text box. What CSS calls "anonymous
inline boxes" are also text boxes.
"""
justification_spacing = 0
# http://stackoverflow.com/questions/16317534/
ascii_to_wide = dict((i, chr(i + 0xfee0)) for i in range(0x21, 0x7f))
ascii_to_wide.update({0x20: '\u3000', 0x2D: '\u2212'})
def __init__(self, element_tag, style, element, text):
assert text
super().__init__(element_tag, style, element)
text_transform = style['text_transform']
if text_transform != 'none':
text = {
'uppercase': lambda t: t.upper(),
'lowercase': lambda t: t.lower(),
# Pythons unicode.captitalize is not the same.
'capitalize': lambda t: t.title(),
'full-width': lambda t: t.translate(self.ascii_to_wide),
}[text_transform](text)
if style['hyphens'] == 'none':
text = text.replace('\u00AD', '') # U+00AD SOFT HYPHEN (SHY)
self.text = text
def copy_with_text(self, text):
"""Return a new TextBox identical to this one except for the text."""
assert text
new_box = self.copy()
new_box.text = text
return new_box
class AtomicInlineLevelBox(InlineLevelBox):
"""An atomic box in an inline formatting context.
This inline-level box cannot be split for line breaks.
"""
class InlineBlockBox(AtomicInlineLevelBox, BlockContainerBox):
"""A box that is both inline-level and a block container.
It behaves as inline on the outside and as a block on the inside.
A non-replaced element with a 'display' value of 'inline-block' generates
an inline-block box.
"""
class ReplacedBox(Box):
"""A box whose content is replaced.
For example, ``<img>`` are replaced: their content is rendered externally
and is opaque from CSSs point of view.
"""
def __init__(self, element_tag, style, element, replacement):
super().__init__(element_tag, style, element)
self.replacement = replacement
class BlockReplacedBox(ReplacedBox, BlockLevelBox):
"""A box that is both replaced and block-level.
A replaced element with a ``display`` value of ``block``, ``liste-item`` or
``table`` generates a block-level replaced box.
"""
class InlineReplacedBox(ReplacedBox, AtomicInlineLevelBox):
"""A box that is both replaced and inline-level.
A replaced element with a ``display`` value of ``inline``,
``inline-table``, or ``inline-block`` generates an inline-level replaced
box.
"""
class TableBox(BlockLevelBox, ParentBox):
"""Box for elements with ``display: table``"""
# Definitions for the rules generating anonymous table boxes
# http://www.w3.org/TR/CSS21/tables.html#anonymous-boxes
tabular_container = True
def all_children(self):
return itertools.chain(self.children, self.column_groups)
def translate(self, dx=0, dy=0, ignore_floats=False):
if dx == 0 and dy == 0:
return
self.column_positions = [
position + dx for position in self.column_positions]
return super().translate(dx, dy, ignore_floats)
def page_values(self):
return (self.style['page'], self.style['page'])
class InlineTableBox(TableBox):
"""Box for elements with ``display: inline-table``"""
class TableRowGroupBox(ParentBox):
"""Box for elements with ``display: table-row-group``"""
proper_table_child = True
internal_table_or_caption = True
tabular_container = True
proper_parents = (TableBox, InlineTableBox)
# Default values. May be overriden on instances.
is_header = False
is_footer = False
class TableRowBox(ParentBox):
"""Box for elements with ``display: table-row``"""
proper_table_child = True
internal_table_or_caption = True
tabular_container = True
proper_parents = (TableBox, InlineTableBox, TableRowGroupBox)
class TableColumnGroupBox(ParentBox):
"""Box for elements with ``display: table-column-group``"""
proper_table_child = True
internal_table_or_caption = True
proper_parents = (TableBox, InlineTableBox)
# Default value. May be overriden on instances.
span = 1
# Columns groups never have margins or paddings
margin_top = 0
margin_bottom = 0
margin_left = 0
margin_right = 0
padding_top = 0
padding_bottom = 0
padding_left = 0
padding_right = 0
def get_cells(self):
"""Return cells that originate in the group's columns."""
return [
cell for column in self.children for cell in column.get_cells()]
# Not really a parent box, but pretending to be removes some corner cases.
class TableColumnBox(ParentBox):
"""Box for elements with ``display: table-column``"""
proper_table_child = True
internal_table_or_caption = True
proper_parents = (TableBox, InlineTableBox, TableColumnGroupBox)
# Default value. May be overriden on instances.
span = 1
# Columns never have margins or paddings
margin_top = 0
margin_bottom = 0
margin_left = 0
margin_right = 0
padding_top = 0
padding_bottom = 0
padding_left = 0
padding_right = 0
def get_cells(self):
"""Return cells that originate in the column.
May be overriden on instances.
"""
return []
class TableCellBox(BlockContainerBox):
"""Box for elements with ``display: table-cell``"""
internal_table_or_caption = True
# Default values. May be overriden on instances.
colspan = 1
rowspan = 1
class TableCaptionBox(BlockBox):
"""Box for elements with ``display: table-caption``"""
proper_table_child = True
internal_table_or_caption = True
proper_parents = (TableBox, InlineTableBox)
class PageBox(ParentBox):
"""Box for a page.
Initially the whole document will be in the box for the root element.
During layout a new page box is created after every page break.
"""
def __init__(self, page_type, style):
self.page_type = page_type
# Page boxes are not linked to any element.
super().__init__(
element_tag=None, style=style, element=None, children=[])
def __repr__(self):
return '<%s %s>' % (type(self).__name__, self.page_type)
class MarginBox(BlockContainerBox):
"""Box in page margins, as defined in CSS3 Paged Media"""
def __init__(self, at_keyword, style):
self.at_keyword = at_keyword
# Margin boxes are not linked to any element.
super().__init__(
element_tag=None, style=style, element=None, children=[])
def __repr__(self):
return '<%s %s>' % (type(self).__name__, self.at_keyword)
class FlexContainerBox(ParentBox):
"""A box that contains only flex-items."""
class FlexBox(FlexContainerBox, BlockLevelBox):
"""A box that is both block-level and a flex container.
It behaves as block on the outside and as a flex container on the inside.
"""
class InlineFlexBox(FlexContainerBox, InlineLevelBox):
"""A box that is both inline-level and a flex container.
It behaves as inline on the outside and as a flex container on the inside.
"""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,290 @@
"""
weasyprint.formatting_structure.counters
----------------------------------------
Implement the various counter types and list-style-type values.
These are defined in the same terms as CSS 3 Lists:
http://dev.w3.org/csswg/css3-lists/#predefined-counters
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import functools
__all__ = ['format', 'format_list_marker']
# Initial values for counter style descriptors.
INITIAL_VALUES = dict(
negative=('-', ''),
prefix='',
suffix='. ',
range=(float('-inf'), float('inf')),
fallback='decimal',
# type and symbols ommited here.
)
# Maps counter-style names to a dict of descriptors.
STYLES = {
# Included here for format_list_marker().
# format() special-cases decimal and does not use this.
'decimal': INITIAL_VALUES,
}
# Maps counter types to a function implementing it.
# The functions take three arguments: the values of the `symbols`
# (or `additive-symbols` for the additive type) and `negative` descriptors,
# and the integer value being formatted.
# They return the representation as a string or None. None means that
# the value can not represented and the fallback should be used.
FORMATTERS = {}
def register_style(name, type='symbolic', **descriptors):
"""Register a counter style."""
if type == 'override':
# TODO: when @counter-style rules are supported, change override
# to bind when a value is generated, not when the @rule is parsed.
style = dict(STYLES[descriptors.pop('override')])
else:
style = dict(INITIAL_VALUES, formatter=functools.partial(
FORMATTERS[type],
descriptors.pop('symbols'),
descriptors.pop('negative', INITIAL_VALUES['negative'])))
style.update(descriptors)
STYLES[name] = style
def register_formatter(function):
"""Register a counter type/algorithm."""
FORMATTERS[function.__name__.replace('_', '-')] = function
return function
@register_formatter
def repeating(symbols, _negative, value):
"""Implement the algorithm for `type: repeating`."""
return symbols[(value - 1) % len(symbols)]
@register_formatter
def numeric(symbols, negative, value):
"""Implement the algorithm for `type: numeric`."""
if value == 0:
return symbols[0]
is_negative = value < 0
if is_negative:
value = abs(value)
prefix, suffix = negative
reversed_parts = [suffix]
else:
reversed_parts = []
length = len(symbols)
value = abs(value)
while value != 0:
reversed_parts.append(symbols[value % length])
value //= length
if is_negative:
reversed_parts.append(prefix)
return ''.join(reversed(reversed_parts))
@register_formatter
def alphabetic(symbols, _negative, value):
"""Implement the algorithm for `type: alphabetic`."""
if value <= 0:
return None
length = len(symbols)
reversed_parts = []
while value != 0:
value -= 1
reversed_parts.append(symbols[value % length])
value //= length
return ''.join(reversed(reversed_parts))
@register_formatter
def symbolic(symbols, _negative, value):
"""Implement the algorithm for `type: symbolic`."""
if value <= 0:
return None
length = len(symbols)
return symbols[value % length] * ((value - 1) // length)
@register_formatter
def non_repeating(symbols, _negative, value):
"""Implement the algorithm for `type: non-repeating`."""
first_symbol_value, symbols = symbols
value -= first_symbol_value
if 0 <= value < len(symbols):
return symbols[value]
@register_formatter
def additive(symbols, negative, value):
"""Implement the algorithm for `type: additive`."""
if value == 0:
for weight, symbol in symbols:
if weight == 0:
return symbol
is_negative = value < 0
if is_negative:
value = abs(value)
prefix, suffix = negative
parts = [prefix]
else:
parts = []
for weight, symbol in symbols:
repetitions = value // weight
parts.extend([symbol] * repetitions)
value -= weight * repetitions
if value == 0:
if is_negative:
parts.append(suffix)
return ''.join(parts)
return None # Failed to find a representation for this value
# 'decimal' behaves the same as this, but defining it this way is silly.
# Well special-case it and just use str().
# register_style(
# 'decimal',
# type='numeric',
# symbols='0 1 2 3 4 5 6 7 8 9'.split(),
# )
register_style(
'decimal-leading-zero',
type='non-repeating',
symbols=(-9, '''-09 -08 -07 -06 -05 -04 -03 -02 -01
00 01 02 03 04 05 06 07 08 09'''.split()),
)
register_style(
'lower-roman',
type='additive',
range=(1, 4999),
symbols=[(1000, 'm'), (900, 'cm'), (500, 'd'), (400, 'cd'),
(100, 'c'), (90, 'xc'), (50, 'l'), (40, 'xl'),
(10, 'x'), (9, 'ix'), (5, 'v'), (4, 'iv'),
(1, 'i')],
)
register_style(
'upper-roman',
type='additive',
range=(1, 4999),
symbols=[(1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'),
(100, 'C'), (90, 'XC'), (50, 'L'), (40, 'XL'),
(10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'),
(1, 'I')],
)
register_style(
'georgian',
type='additive',
range=(1, 19999),
symbols=[
(10000, ''), (9000, ''), (8000, ''), (7000, ''), (6000, ''),
(5000, ''), (4000, ''), (3000, ''), (2000, ''), (1000, ''),
(900, ''), (800, ''), (700, ''), (600, ''),
(500, ''), (400, ''), (300, ''), (200, ''), (100, ''),
(90, ''), (80, ''), (70, ''), (60, ''),
(50, ''), (40, ''), (30, ''), (20, ''), (10, ''),
(9, ''), (8, ''), (7, ''), (6, ''),
(5, ''), (4, ''), (3, ''), (2, ''), (1, '')],
)
register_style(
'armenian',
type='additive',
range=(1, 9999),
symbols=[
(9000, 'Ք'), (8000, 'Փ'), (7000, 'Ւ'), (6000, 'Ց'),
(5000, 'Ր'), (4000, 'Տ'), (3000, 'Վ'), (2000, 'Ս'), (1000, 'Ռ'),
(900, 'Ջ'), (800, 'Պ'), (700, 'Չ'), (600, 'Ո'),
(500, 'Շ'), (400, 'Ն'), (300, 'Յ'), (200, 'Մ'), (100, 'Ճ'),
(90, 'Ղ'), (80, 'Ձ'), (70, 'Հ'), (60, 'Կ'),
(50, 'Ծ'), (40, 'Խ'), (30, 'Լ'), (20, 'Ի'), (10, 'Ժ'),
(9, 'Թ'), (8, 'Ը'), (7, 'Է'), (6, 'Զ'),
(5, 'Ե'), (4, 'Դ'), (3, 'Գ'), (2, 'Բ'), (1, 'Ա')],
)
register_style(
'lower-alpha',
type='alphabetic',
symbols='a b c d e f g h i j k l m n o p q r s t u v w x y z'.split(),
)
register_style(
'upper-alpha',
type='alphabetic',
symbols='A B C D E F G H I J K L M N O P Q R S T U V W X Y Z'.split(),
)
register_style(
'lower-greek',
type='alphabetic',
symbols='α β γ δ ε ζ η θ ι κ λ μ ν ξ ο π ρ σ τ υ φ χ ψ ω'.split()
)
register_style(
'disc',
type='repeating',
symbols=[''], # U+2022, BULLET
suffix=' ',
)
register_style(
'circle',
type='repeating',
symbols=[''], # U+25E6 WHITE BULLET
suffix=' ',
)
register_style(
'square',
type='repeating',
# CSS Lists 3 suggests U+25FE BLACK MEDIUM SMALL SQUARE
# But I think this one looks better.
symbols=[''], # U+25AA BLACK SMALL SQUARE
suffix=' ',
)
register_style(
'lower-latin',
type='override',
override='lower-alpha',
)
register_style(
'upper-latin',
type='override',
override='upper-alpha',
)
def format(value, counter_style):
"""
Return a representation of ``value`` formatted by ``counter_style``
or one of its fallback.
The representation includes negative signs, but not the prefix and suffix.
"""
if counter_style == 'none':
return ''
failed_styles = set() # avoid fallback loops
while True:
if counter_style == 'decimal' or counter_style in failed_styles:
return str(value)
style = STYLES[counter_style]
low, high = style['range']
if low <= value <= high:
representation = style['formatter'](value)
if representation is not None:
return representation
failed_styles.add(counter_style)
counter_style = style['fallback']
def format_list_marker(value, counter_style):
"""
Return a representation of ``value`` formatted for a list marker.
This is the same as :func:`format()`, but includes the counters
prefix and suffix.
"""
style = STYLES[counter_style]
return style['prefix'] + format(value, counter_style) + style['suffix']

373
weasyprint/html.py Normal file
View File

@ -0,0 +1,373 @@
"""
weasyprint.html
---------------
Specific handling for some HTML elements, especially replaced elements.
Replaced elements (eg. <img> elements) are rendered externally and
behave as an atomic opaque box in CSS. In general, they may or may not
have intrinsic dimensions. But the only replaced elements currently
supported in WeasyPrint are images with intrinsic dimensions.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import logging
import re
from urllib.parse import urljoin
from . import CSS, ROOT
from .css import get_child_text
from .formatting_structure import boxes
from .logger import LOGGER
from .urls import get_url_attribute
# XXX temporarily disable logging for user-agent stylesheet
level = LOGGER.level
LOGGER.setLevel(logging.ERROR)
HTML5_UA_STYLESHEET = CSS(filename=(ROOT / 'css' / 'html5_ua.css'))
HTML5_PH_STYLESHEET = CSS(filename=(ROOT / 'css' / 'html5_ph.css'))
LOGGER.setLevel(level)
# http://whatwg.org/C#space-character
HTML_WHITESPACE = ' \t\n\f\r'
HTML_SPACE_SEPARATED_TOKENS_RE = re.compile('[^%s]+' % HTML_WHITESPACE)
def ascii_lower(string):
r"""Transform (only) ASCII letters to lower case: A-Z is mapped to a-z.
:param string: An Unicode string.
:returns: A new Unicode string.
This is used for `ASCII case-insensitive
<http://whatwg.org/C#ascii-case-insensitive>`_ matching.
This is different from the :meth:`~py:str.lower` method of Unicode strings
which also affect non-ASCII characters,
sometimes mapping them into the ASCII range:
>>> keyword = u'Bac\N{KELVIN SIGN}ground'
>>> assert keyword.lower() == u'background'
>>> assert ascii_lower(keyword) != keyword.lower()
>>> assert ascii_lower(keyword) == u'bac\N{KELVIN SIGN}ground'
"""
# This turns out to be faster than unicode.translate()
return string.encode('utf8').lower().decode('utf8')
def element_has_link_type(element, link_type):
"""
Return whether the given element has a ``rel`` attribute with the
given link type.
:param link_type: Must be a lower-case string.
"""
return any(ascii_lower(token) == link_type for token in
HTML_SPACE_SEPARATED_TOKENS_RE.findall(element.get('rel', '')))
# Maps HTML tag names to function taking an HTML element and returning a Box.
HTML_HANDLERS = {}
def handle_element(element, box, get_image_from_uri, base_url):
"""Handle HTML elements that need special care.
:returns: a (possibly empty) list of boxes.
"""
if box.element_tag in HTML_HANDLERS:
return HTML_HANDLERS[element.tag](
element, box, get_image_from_uri, base_url)
else:
return [box]
def handler(tag):
"""Return a decorator registering a function handling ``tag`` elements."""
def decorator(function):
"""Decorator registering a function handling ``tag`` elements."""
HTML_HANDLERS[tag] = function
return function
return decorator
def make_replaced_box(element, box, image):
"""Wrap an image in a replaced box.
That box is either block-level or inline-level, depending on what the
element should be.
"""
if box.style['display'] in ('block', 'list-item', 'table'):
type_ = boxes.BlockReplacedBox
else:
# TODO: support images with 'display: table-cell'?
type_ = boxes.InlineReplacedBox
new_box = type_(element.tag, box.style, element, image)
# TODO: check other attributes that need to be copied
# TODO: find another solution
new_box.string_set = box.string_set
new_box.bookmark_label = box.bookmark_label
return new_box
@handler('img')
def handle_img(element, box, get_image_from_uri, base_url):
"""Handle ``<img>`` elements, return either an image or the alt-text.
See: http://www.w3.org/TR/html5/embedded-content-1.html#the-img-element
"""
src = get_url_attribute(element, 'src', base_url)
alt = element.get('alt')
if src:
image = get_image_from_uri(src)
if image is not None:
return [make_replaced_box(element, box, image)]
else:
# Invalid image, use the alt-text.
if alt:
box.children = [boxes.TextBox.anonymous_from(box, alt)]
return [box]
elif alt == '':
# The element represents nothing
return []
else:
assert alt is None
# TODO: find some indicator that an image is missing.
# For now, just remove the image.
return []
else:
if alt:
box.children = [boxes.TextBox.anonymous_from(box, alt)]
return [box]
else:
return []
@handler('embed')
def handle_embed(element, box, get_image_from_uri, base_url):
"""Handle ``<embed>`` elements, return either an image or nothing.
See: https://www.w3.org/TR/html5/embedded-content-0.html#the-embed-element
"""
src = get_url_attribute(element, 'src', base_url)
type_ = element.get('type', '').strip()
if src:
image = get_image_from_uri(src, type_)
if image is not None:
return [make_replaced_box(element, box, image)]
# No fallback.
return []
@handler('object')
def handle_object(element, box, get_image_from_uri, base_url):
"""Handle ``<object>`` elements, return either an image or the fallback
content.
See: https://www.w3.org/TR/html5/embedded-content-0.html#the-object-element
"""
data = get_url_attribute(element, 'data', base_url)
type_ = element.get('type', '').strip()
if data:
image = get_image_from_uri(data, type_)
if image is not None:
return [make_replaced_box(element, box, image)]
# The elements children are the fallback.
return [box]
def integer_attribute(element, box, name, minimum=1):
"""Read an integer attribute from the HTML element and set it on the box.
"""
value = element.get(name, '').strip()
if value:
try:
value = int(value)
except ValueError:
pass
else:
if value >= minimum:
setattr(box, name, value)
@handler('colgroup')
def handle_colgroup(element, box, _get_image_from_uri, _base_url):
"""Handle the ``span`` attribute."""
if isinstance(box, boxes.TableColumnGroupBox):
if any(child.tag == 'col' for child in element):
box.span = None # sum of the childrens spans
else:
integer_attribute(element, box, 'span')
box.children = (
boxes.TableColumnBox.anonymous_from(box, [])
for _i in range(box.span))
return [box]
@handler('col')
def handle_col(element, box, _get_image_from_uri, _base_url):
"""Handle the ``span`` attribute."""
if isinstance(box, boxes.TableColumnBox):
integer_attribute(element, box, 'span')
if box.span > 1:
# Generate multiple boxes
# http://lists.w3.org/Archives/Public/www-style/2011Nov/0293.html
return [box.copy() for _i in range(box.span)]
return [box]
@handler('th')
@handler('td')
def handle_td(element, box, _get_image_from_uri, _base_url):
"""Handle the ``colspan``, ``rowspan`` attributes."""
if isinstance(box, boxes.TableCellBox):
# HTML 4.01 gives special meaning to colspan=0
# http://www.w3.org/TR/html401/struct/tables.html#adef-rowspan
# but HTML 5 removed it
# http://www.w3.org/TR/html5/tabular-data.html#attr-tdth-colspan
# rowspan=0 is still there though.
integer_attribute(element, box, 'colspan')
integer_attribute(element, box, 'rowspan', minimum=0)
return [box]
@handler('a')
def handle_a(element, box, _get_image_from_uri, base_url):
"""Handle the ``rel`` attribute."""
box.is_attachment = element_has_link_type(element, 'attachment')
return [box]
def find_base_url(html_document, fallback_base_url):
"""Return the base URL for the document.
See http://www.w3.org/TR/html5/urls.html#document-base-url
"""
first_base_element = next(iter(html_document.iter('base')), None)
if first_base_element is not None:
href = first_base_element.get('href', '').strip()
if href:
return urljoin(fallback_base_url, href)
return fallback_base_url
def get_html_metadata(wrapper_element, base_url):
"""
Relevant specs:
http://www.whatwg.org/html#the-title-element
http://www.whatwg.org/html#standard-metadata-names
http://wiki.whatwg.org/wiki/MetaExtensions
http://microformats.org/wiki/existing-rel-values#HTML5_link_type_extensions
"""
title = None
description = None
generator = None
keywords = []
authors = []
created = None
modified = None
attachments = []
for element in wrapper_element.query_all('title', 'meta', 'link'):
element = element.etree_element
if element.tag == 'title' and title is None:
title = get_child_text(element)
elif element.tag == 'meta':
name = ascii_lower(element.get('name', ''))
content = element.get('content', '')
if name == 'keywords':
for keyword in map(strip_whitespace, content.split(',')):
if keyword not in keywords:
keywords.append(keyword)
elif name == 'author':
authors.append(content)
elif name == 'description' and description is None:
description = content
elif name == 'generator' and generator is None:
generator = content
elif name == 'dcterms.created' and created is None:
created = parse_w3c_date(name, content)
elif name == 'dcterms.modified' and modified is None:
modified = parse_w3c_date(name, content)
elif element.tag == 'link' and element_has_link_type(
element, 'attachment'):
url = get_url_attribute(element, 'href', base_url)
attachment_title = element.get('title', None)
if url is None:
LOGGER.error('Missing href in <link rel="attachment">')
else:
attachments.append((url, attachment_title))
return dict(title=title, description=description, generator=generator,
keywords=keywords, authors=authors,
created=created, modified=modified,
attachments=attachments)
def strip_whitespace(string):
"""Use the HTML definition of "space character",
not all Unicode Whitespace.
http://www.whatwg.org/html#strip-leading-and-trailing-whitespace
http://www.whatwg.org/html#space-character
"""
return string.strip(HTML_WHITESPACE)
# YYYY (eg 1997)
# YYYY-MM (eg 1997-07)
# YYYY-MM-DD (eg 1997-07-16)
# YYYY-MM-DDThh:mmTZD (eg 1997-07-16T19:20+01:00)
# YYYY-MM-DDThh:mm:ssTZD (eg 1997-07-16T19:20:30+01:00)
# YYYY-MM-DDThh:mm:ss.sTZD (eg 1997-07-16T19:20:30.45+01:00)
W3C_DATE_RE = re.compile('''
^
[ \t\n\f\r]*
(?P<year>\\d\\d\\d\\d)
(?:
-(?P<month>0\\d|1[012])
(?:
-(?P<day>[012]\\d|3[01])
(?:
T(?P<hour>[01]\\d|2[0-3])
:(?P<minute>[0-5]\\d)
(?:
:(?P<second>[0-5]\\d)
(?:\\.\\d+)? # Second fraction, ignored
)?
(?:
Z | # UTC
(?P<tz_hour>[+-](?:[01]\\d|2[0-3]))
:(?P<tz_minute>[0-5]\\d)
)
)?
)?
)?
[ \t\n\f\r]*
$
''', re.VERBOSE)
def parse_w3c_date(meta_name, string):
"""http://www.w3.org/TR/NOTE-datetime"""
if W3C_DATE_RE.match(string):
return string
else:
LOGGER.warning(
'Invalid date in <meta name="%s"> %r', meta_name, string)

528
weasyprint/images.py Normal file
View File

@ -0,0 +1,528 @@
"""
weasyprint.images
-----------------
Fetch and decode images in various formats.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import math
from io import BytesIO
from xml.etree import ElementTree
import cairocffi
import cairosvg.parser
import cairosvg.surface
from .layout.percentages import percentage
from .logger import LOGGER
from .urls import URLFetchingError, fetch
try:
from cairocffi import pixbuf
except OSError:
pixbuf = None
assert cairosvg.surface.cairo is cairocffi, (
'CairoSVG is using pycairo instead of cairocffi. '
'Make sure it is not imported before WeasyPrint.')
# Map values of the image-rendering property to cairo FILTER values:
# Values are normalized to lower case.
IMAGE_RENDERING_TO_FILTER = {
'auto': cairocffi.FILTER_BILINEAR,
'crisp-edges': cairocffi.FILTER_BEST,
'pixelated': cairocffi.FILTER_NEAREST,
}
class ImageLoadingError(ValueError):
"""An error occured when loading an image.
The image data is probably corrupted or in an invalid format.
"""
@classmethod
def from_exception(cls, exception):
name = type(exception).__name__
value = str(exception)
return cls('%s: %s' % (name, value) if value else name)
class RasterImage(object):
def __init__(self, image_surface):
self.image_surface = image_surface
self._intrinsic_width = image_surface.get_width()
self._intrinsic_height = image_surface.get_height()
self.intrinsic_ratio = (
self._intrinsic_width / self._intrinsic_height
if self._intrinsic_height != 0 else float('inf'))
def get_intrinsic_size(self, image_resolution, _font_size):
# Raster images are affected by the 'image-resolution' property.
return (self._intrinsic_width / image_resolution,
self._intrinsic_height / image_resolution)
def draw(self, context, concrete_width, concrete_height, image_rendering):
has_size = (
concrete_width > 0
and concrete_height > 0
and self._intrinsic_width > 0
and self._intrinsic_height > 0
)
if not has_size:
return
# Use the real intrinsic size here,
# not affected by 'image-resolution'.
context.scale(concrete_width / self._intrinsic_width,
concrete_height / self._intrinsic_height)
context.set_source_surface(self.image_surface)
context.get_source().set_filter(
IMAGE_RENDERING_TO_FILTER[image_rendering])
context.paint()
class ScaledSVGSurface(cairosvg.surface.SVGSurface):
"""
Have the cairo Surface object have intrinsic dimension
in pixels instead of points.
"""
@property
def device_units_per_user_units(self):
scale = super().device_units_per_user_units
return scale / 0.75
class FakeSurface(object):
"""Fake CairoSVG surface used to get SVG attributes."""
context_height = 0
context_width = 0
font_size = 12
dpi = 96
class SVGImage(object):
def __init__(self, svg_data, base_url, url_fetcher):
# Dont pass data URIs to CairoSVG.
# They are useless for relative URIs anyway.
self._base_url = (
base_url if not base_url.lower().startswith('data:') else None)
self._svg_data = svg_data
self._url_fetcher = url_fetcher
try:
self._tree = ElementTree.fromstring(self._svg_data)
except Exception as e:
raise ImageLoadingError.from_exception(e)
def _cairosvg_url_fetcher(self, src, mimetype):
data = self._url_fetcher(src)
if 'string' in data:
return data['string']
return data['file_obj'].read()
def get_intrinsic_size(self, _image_resolution, font_size):
# Vector images may be affected by the font size.
fake_surface = FakeSurface()
fake_surface.font_size = font_size
# Percentages don't provide an intrinsic size, we transform percentages
# into 0 using a (0, 0) context size:
# http://www.w3.org/TR/SVG/coords.html#IntrinsicSizing
self._width = cairosvg.surface.size(
fake_surface, self._tree.get('width'))
self._height = cairosvg.surface.size(
fake_surface, self._tree.get('height'))
_, _, viewbox = cairosvg.surface.node_format(fake_surface, self._tree)
self._intrinsic_width = self._width or None
self._intrinsic_height = self._height or None
self.intrinsic_ratio = None
if viewbox:
if self._width and self._height:
self.intrinsic_ratio = self._width / self._height
else:
if viewbox[2] and viewbox[3]:
self.intrinsic_ratio = viewbox[2] / viewbox[3]
if self._width:
self._intrinsic_height = (
self._width / self.intrinsic_ratio)
elif self._height:
self._intrinsic_width = (
self._height * self.intrinsic_ratio)
elif self._width and self._height:
self.intrinsic_ratio = self._width / self._height
return self._intrinsic_width, self._intrinsic_height
def draw(self, context, concrete_width, concrete_height, _image_rendering):
try:
svg = ScaledSVGSurface(
cairosvg.parser.Tree(
bytestring=self._svg_data, url=self._base_url,
url_fetcher=self._cairosvg_url_fetcher),
output=None, dpi=96, output_width=concrete_width,
output_height=concrete_height)
if svg.width and svg.height:
context.scale(
concrete_width / svg.width, concrete_height / svg.height)
context.set_source_surface(svg.cairo)
context.paint()
except Exception as e:
LOGGER.error(
'Failed to draw an SVG image at %s : %s', self._base_url, e)
def get_image_from_uri(cache, url_fetcher, url, forced_mime_type=None):
"""Get a cairo Pattern from an image URI."""
missing = object()
image = cache.get(url, missing)
if image is not missing:
return image
try:
with fetch(url_fetcher, url) as result:
if 'string' in result:
string = result['string']
else:
string = result['file_obj'].read()
mime_type = forced_mime_type or result['mime_type']
if mime_type == 'image/svg+xml':
# No fallback for XML-based mimetypes as defined by MIME
# Sniffing Standard, see https://mimesniff.spec.whatwg.org/
image = SVGImage(string, url, url_fetcher)
else:
# Try to rely on given mimetype
try:
if mime_type == 'image/png':
try:
surface = cairocffi.ImageSurface.create_from_png(
BytesIO(string))
except Exception as exception:
raise ImageLoadingError.from_exception(exception)
else:
image = RasterImage(surface)
else:
image = None
except ImageLoadingError:
image = None
# Relying on mimetype didn't work, give the image to GDK-Pixbuf
if not image:
if pixbuf is None:
raise ImageLoadingError(
'Could not load GDK-Pixbuf. PNG and SVG are '
'the only image formats available.')
try:
image = SVGImage(string, url, url_fetcher)
except BaseException:
try:
surface, format_name = (
pixbuf.decode_to_image_surface(string))
except pixbuf.ImageLoadingError as exception:
raise ImageLoadingError(str(exception))
if format_name == 'jpeg':
surface.set_mime_data('image/jpeg', string)
image = RasterImage(surface)
except (URLFetchingError, ImageLoadingError) as exc:
LOGGER.error('Failed to load image at "%s" (%s)', url, exc)
image = None
cache[url] = image
return image
def process_color_stops(gradient_line_size, positions):
"""
Gradient line size: distance between the starting point and ending point.
Positions: list of None, or Dimension in px or %.
0 is the starting point, 1 the ending point.
http://dev.w3.org/csswg/css-images-3/#color-stop-syntax
Return processed color stops, as a list of floats in px.
"""
positions = [
percentage(position, gradient_line_size) for position in positions]
# First and last default to 100%
if positions[0] is None:
positions[0] = 0
if positions[-1] is None:
positions[-1] = gradient_line_size
# Make sure positions are increasing.
previous_pos = positions[0]
for i, position in enumerate(positions):
if position is not None:
if position < previous_pos:
positions[i] = previous_pos
else:
previous_pos = position
# Assign missing values
previous_i = -1
for i, position in enumerate(positions):
if position is not None:
base = positions[previous_i]
increment = (position - base) / (i - previous_i)
for j in range(previous_i + 1, i):
positions[j] = base + j * increment
previous_i = i
return positions
def normalize_stop_postions(positions):
"""Normalize to [0..1]."""
first = positions[0]
last = positions[-1]
total_length = last - first
if total_length != 0:
positions = [(pos - first) / total_length for pos in positions]
else:
positions = [0 for _ in positions]
return first, last, positions
def gradient_average_color(colors, positions):
"""
http://dev.w3.org/csswg/css-images-3/#find-the-average-color-of-a-gradient
"""
nb_stops = len(positions)
assert nb_stops > 1
assert nb_stops == len(colors)
total_length = positions[-1] - positions[0]
if total_length == 0:
positions = list(range(nb_stops))
total_length = nb_stops - 1
premul_r = [r * a for r, g, b, a in colors]
premul_g = [g * a for r, g, b, a in colors]
premul_b = [b * a for r, g, b, a in colors]
alpha = [a for r, g, b, a in colors]
result_r = result_g = result_b = result_a = 0
total_weight = 2 * total_length
for i, position in enumerate(positions[1:], 1):
weight = (position - positions[i - 1]) / total_weight
for j in (i - 1, i):
result_r += premul_r[j] * weight
result_g += premul_g[j] * weight
result_b += premul_b[j] * weight
result_a += alpha[j] * weight
# Un-premultiply:
return (result_r / result_a, result_g / result_a,
result_b / result_a, result_a) if result_a != 0 else (0, 0, 0, 0)
PATTERN_TYPES = dict(
linear=cairocffi.LinearGradient,
radial=cairocffi.RadialGradient,
solid=cairocffi.SolidPattern)
class Gradient(object):
def __init__(self, color_stops, repeating):
assert color_stops
#: List of (r, g, b, a), list of Dimension
self.colors = [color for color, position in color_stops]
self.stop_positions = [position for color, position in color_stops]
#: bool
self.repeating = repeating
def get_intrinsic_size(self, _image_resolution, _font_size):
# Gradients are not affected by image resolution, parent or font size.
return None, None
intrinsic_ratio = None
def draw(self, context, concrete_width, concrete_height, _image_rendering):
scale_y, type_, init, stop_positions, stop_colors = self.layout(
concrete_width, concrete_height, context.user_to_device_distance)
context.scale(1, scale_y)
pattern = PATTERN_TYPES[type_](*init)
for position, color in zip(stop_positions, stop_colors):
pattern.add_color_stop_rgba(position, *color)
pattern.set_extend(cairocffi.EXTEND_REPEAT if self.repeating
else cairocffi.EXTEND_PAD)
context.set_source(pattern)
context.paint()
def layout(self, width, height, user_to_device_distance):
"""width, height: Gradient box. Top-left is at coordinates (0, 0).
user_to_device_distance: a (dx, dy) -> (ddx, ddy) function
Returns (scale_y, type_, init, positions, colors).
scale_y: float, used for ellipses radial gradients. 1 otherwise.
positions: list of floats in [0..1].
0 at the starting point, 1 at the ending point.
colors: list of (r, g, b, a)
type_ is either:
'solid': init is (r, g, b, a). positions and colors are empty.
'linear': init is (x0, y0, x1, y1)
coordinates of the starting and ending points.
'radial': init is (cx0, cy0, radius0, cx1, cy1, radius1)
coordinates of the starting end ending circles
"""
raise NotImplementedError
class LinearGradient(Gradient):
def __init__(self, color_stops, direction, repeating):
Gradient.__init__(self, color_stops, repeating)
#: ('corner', keyword) or ('angle', radians)
self.direction_type, self.direction = direction
def layout(self, width, height, user_to_device_distance):
if len(self.colors) == 1:
return 1, 'solid', self.colors[0], [], []
# (dx, dy) is the unit vector giving the direction of the gradient.
# Positive dx: right, positive dy: down.
if self.direction_type == 'corner':
factor_x, factor_y = {
'top_left': (-1, -1), 'top_right': (1, -1),
'bottom_left': (-1, 1), 'bottom_right': (1, 1)}[self.direction]
diagonal = math.hypot(width, height)
# Note the direction swap: dx based on height, dy based on width
# The gradient line is perpendicular to a diagonal.
dx = factor_x * height / diagonal
dy = factor_y * width / diagonal
else:
angle = self.direction # 0 upwards, then clockwise
dx = math.sin(angle)
dy = -math.cos(angle)
# Distance between center and ending point,
# ie. half of between the starting point and ending point:
distance = abs(width * dx) + abs(height * dy)
positions = process_color_stops(distance, self.stop_positions)
first, last, positions = normalize_stop_postions(positions)
device_per_user_units = math.hypot(*user_to_device_distance(dx, dy))
if (last - first) * device_per_user_units < len(positions):
if self.repeating:
color = gradient_average_color(self.colors, positions)
return 1, 'solid', color, [], []
else:
# 100 is an Arbitrary non-zero number of device units.
offset = 100 / device_per_user_units
if first != last:
factor = (offset + last - first) / (last - first)
positions = [pos / factor for pos in positions]
last += offset
start_x = (width - dx * distance) / 2
start_y = (height - dy * distance) / 2
points = (start_x + dx * first, start_y + dy * first,
start_x + dx * last, start_y + dy * last)
return 1, 'linear', points, positions, self.colors
class RadialGradient(Gradient):
def __init__(self, color_stops, shape, size, center, repeating):
Gradient.__init__(self, color_stops, repeating)
# Center of the ending shape. (origin_x, pos_x, origin_y, pos_y)
self.center = center
#: Type of ending shape: 'circle' or 'ellipse'
self.shape = shape
# size_type: 'keyword'
# size: 'closest-corner', 'farthest-corner',
# 'closest-side', or 'farthest-side'
# size_type: 'explicit'
# size: (radius_x, radius_y)
self.size_type, self.size = size
def layout(self, width, height, user_to_device_distance):
if len(self.colors) == 1:
return 1, 'solid', self.colors[0], [], []
origin_x, center_x, origin_y, center_y = self.center
center_x = percentage(center_x, width)
center_y = percentage(center_y, height)
if origin_x == 'right':
center_x = width - center_x
if origin_y == 'bottom':
center_y = height - center_y
size_x, size_y = self._resolve_size(width, height, center_x, center_y)
# http://dev.w3.org/csswg/css-images-3/#degenerate-radials
if size_x == size_y == 0:
size_x = size_y = 1e-7
elif size_x == 0:
size_x = 1e-7
size_y = 1e7
elif size_y == 0:
size_x = 1e7
size_y = 1e-7
scale_y = size_y / size_x
colors = self.colors
positions = process_color_stops(size_x, self.stop_positions)
gradient_line_size = positions[-1] - positions[0]
if self.repeating and any(
gradient_line_size * unit < len(positions)
for unit in (math.hypot(*user_to_device_distance(1, 0)),
math.hypot(*user_to_device_distance(0, scale_y)))):
color = gradient_average_color(colors, positions)
return 1, 'solid', color, [], []
if positions[0] < 0:
# Cairo does not like negative radiuses,
# shift into the positive realm.
if self.repeating:
offset = gradient_line_size * math.ceil(
-positions[0] / gradient_line_size)
positions = [p + offset for p in positions]
else:
for i, position in enumerate(positions):
if position > 0:
# `i` is the first positive stop.
# Interpolate with the previous to get the color at 0.
assert i > 0
color = colors[i]
neg_color = colors[i - 1]
neg_position = positions[i - 1]
assert neg_position < 0
intermediate_color = gradient_average_color(
[neg_color, neg_color, color, color],
[neg_position, 0, 0, position])
colors = [intermediate_color] + colors[i:]
positions = [0] + positions[i:]
break
else:
# All stops are negatives,
# everything is "padded" with the last color.
return 1, 'solid', self.colors[-1], [], []
first, last, positions = normalize_stop_postions(positions)
if last == first:
last += 100 # Arbitrary non-zero
circles = (center_x, center_y / scale_y, first,
center_x, center_y / scale_y, last)
return scale_y, 'radial', circles, positions, colors
def _resolve_size(self, width, height, center_x, center_y):
if self.size_type == 'explicit':
size_x, size_y = self.size
size_x = percentage(size_x, width)
size_y = percentage(size_y, height)
return size_x, size_y
left = abs(center_x)
right = abs(width - center_x)
top = abs(center_y)
bottom = abs(height - center_y)
pick = min if self.size.startswith('closest') else max
if self.size.endswith('side'):
if self.shape == 'circle':
size_xy = pick(left, right, top, bottom)
return size_xy, size_xy
# else: ellipse
return pick(left, right), pick(top, bottom)
# else: corner
if self.shape == 'circle':
size_xy = pick(math.hypot(left, top), math.hypot(left, bottom),
math.hypot(right, top), math.hypot(right, bottom))
return size_xy, size_xy
# else: ellipse
corner_x, corner_y = pick(
(left, top), (left, bottom), (right, top), (right, bottom),
key=lambda a: math.hypot(*a))
return corner_x * math.sqrt(2), corner_y * math.sqrt(2)

View File

@ -0,0 +1,284 @@
"""
weasyprint.layout
-----------------
Transform a "before layout" box tree into an "after layout" tree.
(Surprising, hu?)
Break boxes across lines and pages; determine the size and dimension
of each box fragement.
Boxes in the new tree have *used values* in their ``position_x``,
``position_y``, ``width`` and ``height`` attributes, amongst others.
See http://www.w3.org/TR/CSS21/cascade.html#used-value
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from collections import defaultdict
from ..formatting_structure import boxes
from ..logger import PROGRESS_LOGGER
from .absolute import absolute_box_layout, absolute_layout
from .backgrounds import layout_backgrounds
from .pages import make_all_pages, make_margin_boxes
def initialize_page_maker(context, root_box):
"""Initialize ``context.page_maker``.
Collect the pagination's states required for page based counters.
"""
context.page_maker = []
# Special case the root box
page_break = root_box.style['break_before']
# TODO: take care of text direction and writing mode
# https://www.w3.org/TR/css3-page/#progression
if page_break in 'right':
right_page = True
elif page_break == 'left':
right_page = False
elif page_break in 'recto':
right_page = root_box.style['direction'] == 'ltr'
elif page_break == 'verso':
right_page = root_box.style['direction'] == 'rtl'
else:
right_page = root_box.style['direction'] == 'ltr'
resume_at = None
next_page = {'break': 'any', 'page': root_box.page_values()[0]}
# page_state is prerequisite for filling in missing page based counters
# although neither a variable quote_depth nor counter_scopes are needed
# in page-boxes -- reusing
# `formatting_structure.build.update_counters()` to avoid redundant
# code requires a full `state`.
# The value of **pages**, of course, is unknown until we return and
# might change when 'content_changed' triggers re-pagination...
# So we start with an empty state
page_state = (
# Shared mutable objects:
[0], # quote_depth: single integer
{'pages': [0]},
[{'pages'}] # counter_scopes
)
# Initial values
remake_state = {
'content_changed': False,
'pages_wanted': False,
'anchors': [], # first occurrence of anchor
'content_lookups': [] # first occurr. of content-CounterLookupItem
}
context.page_maker.append((
resume_at, next_page, right_page, page_state, remake_state))
def layout_fixed_boxes(context, pages, containing_page):
"""Lay out and yield fixed boxes of ``pages`` on ``containing_page``."""
for page in pages:
for box in page.fixed_boxes:
# As replaced boxes are never copied during layout, ensure that we
# have different boxes (with a possibly different layout) for
# each pages.
if isinstance(box, boxes.ReplacedBox):
box = box.copy()
# Absolute boxes in fixed boxes are rendered as fixed boxes'
# children, even when they are fixed themselves.
absolute_boxes = []
yield absolute_box_layout(
context, box, containing_page, absolute_boxes)
while absolute_boxes:
new_absolute_boxes = []
for box in absolute_boxes:
absolute_layout(
context, box, containing_page, new_absolute_boxes)
absolute_boxes = new_absolute_boxes
def layout_document(html, root_box, context, max_loops=8):
"""Lay out the whole document.
This includes line breaks, page breaks, absolute size and position for all
boxes. Page based counters might require multiple passes.
:param root_box: root of the box tree (formatting structure of the html)
the pages' boxes are created from that tree, i.e. this
structure is not lost during pagination
:returns: a list of laid out Page objects.
"""
initialize_page_maker(context, root_box)
pages = []
actual_total_pages = 0
for loop in range(max_loops):
if loop > 0:
PROGRESS_LOGGER.info(
'Step 5 - Creating layout - Repagination #%i' % loop)
initial_total_pages = actual_total_pages
pages = list(make_all_pages(context, root_box, html, pages))
actual_total_pages = len(pages)
# Check whether another round is required
reloop_content = False
reloop_pages = False
for page_data in context.page_maker:
# Update pages
_, _, _, page_state, remake_state = page_data
page_counter_values = page_state[1]
page_counter_values['pages'] = [actual_total_pages]
if remake_state['content_changed']:
reloop_content = True
if remake_state['pages_wanted']:
reloop_pages = initial_total_pages != actual_total_pages
# No need for another loop, stop here
if not reloop_content and not reloop_pages:
break
# Calculate string-sets and bookmark-label containing page based counters
# when pagination is finished. No need to do that (maybe multiple times) in
# make_page because they dont create boxes, only appear in MarginBoxes and
# in the final PDF.
for i, page in enumerate(pages):
# We need the updated page_counter_values
resume_at, next_page, right_page, page_state, remake_state = (
context.page_maker[i + 1])
page_counter_values = page_state[1]
for child in page.descendants():
# TODO: remove attribute or set a default value in Box class
if hasattr(child, 'missing_link'):
for (box, css_token), item in (
context.target_collector.counter_lookup_items.items()):
if child.missing_link == box and css_token != 'content':
item.parse_again(page_counter_values)
# Collect the string_sets in the LayoutContext
string_sets = child.string_set
if string_sets and string_sets != 'none':
for string_set in string_sets:
string_name, text = string_set
context.string_set[string_name][i+1].append(text)
# Add margin boxes
for i, page in enumerate(pages):
root_children = []
root, = page.children
root_children.extend(layout_fixed_boxes(context, pages[:i], page))
root_children.extend(root.children)
root_children.extend(layout_fixed_boxes(context, pages[i + 1:], page))
root.children = root_children
context.current_page = i + 1 # page_number starts at 1
# page_maker's page_state is ready for the MarginBoxes
state = context.page_maker[context.current_page][3]
page.children = (root,) + tuple(
make_margin_boxes(context, page, state))
layout_backgrounds(page, context.get_image_from_uri)
yield page
class LayoutContext(object):
def __init__(self, enable_hinting, style_for, get_image_from_uri,
font_config, target_collector):
self.enable_hinting = enable_hinting
self.style_for = style_for
self.get_image_from_uri = get_image_from_uri
self.font_config = font_config
self.target_collector = target_collector
self._excluded_shapes_lists = []
self.excluded_shapes = None # Not initialized yet
self.string_set = defaultdict(lambda: defaultdict(lambda: list()))
self.running_elements = defaultdict(
lambda: defaultdict(lambda: list()))
self.current_page = None
self.forced_break = False
# Cache
self.strut_layouts = {}
self.font_features = {}
self.tables = {}
self.dictionaries = {}
def create_block_formatting_context(self):
self.excluded_shapes = []
self._excluded_shapes_lists.append(self.excluded_shapes)
def finish_block_formatting_context(self, root_box):
# See http://www.w3.org/TR/CSS2/visudet.html#root-height
if root_box.style['height'] == 'auto' and self.excluded_shapes:
box_bottom = root_box.content_box_y() + root_box.height
max_shape_bottom = max([
shape.position_y + shape.margin_height()
for shape in self.excluded_shapes] + [box_bottom])
root_box.height += max_shape_bottom - box_bottom
self._excluded_shapes_lists.pop()
if self._excluded_shapes_lists:
self.excluded_shapes = self._excluded_shapes_lists[-1]
else:
self.excluded_shapes = None
def get_string_set_for(self, page, name, keyword='first'):
"""Resolve value of string function."""
return self.get_string_or_element_for(
self.string_set, page, name, keyword)
def get_running_element_for(self, page, name, keyword='first'):
"""Resolve value of element function."""
return self.get_string_or_element_for(
self.running_elements, page, name, keyword)
def get_string_or_element_for(self, store, page, name, keyword):
"""Resolve value of string or element function.
We'll have something like this that represents all assignments on a
given page:
{1: ['First Header'], 3: ['Second Header'],
4: ['Third Header', '3.5th Header']}
Value depends on current page.
http://dev.w3.org/csswg/css-gcpm/#funcdef-string
:param store: dictionary where the resolved value is stored.
:param page: current page.
:param name: name of the named string or running element.
:param keyword: indicates which value of the named string or running
element to use. Default is the first assignment on the
current page else the most recent assignment.
:returns: text for string set, box for running element
"""
if self.current_page in store[name]:
# A value was assigned on this page
first_string = store[name][self.current_page][0]
last_string = store[name][self.current_page][-1]
if keyword == 'first':
return first_string
elif keyword == 'start':
element = page
while element:
if element.style['string_set'] != 'none':
for (string_name, _) in element.style['string_set']:
if string_name == name:
return first_string
if isinstance(element, boxes.ParentBox):
if element.children:
element = element.children[0]
continue
break
elif keyword == 'last':
return last_string
elif keyword == 'first-except':
return
# Search backwards through previous pages
for previous_page in range(self.current_page - 1, 0, -1):
if previous_page in store[name]:
return store[name][previous_page][-1]

View File

@ -0,0 +1,382 @@
"""
weasyprint.absolute
-------------------
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..formatting_structure import boxes
from .min_max import handle_min_max_width
from .percentages import resolve_percentages, resolve_position_percentages
from .preferred import shrink_to_fit
from .tables import table_wrapper_width
class AbsolutePlaceholder(object):
"""Left where an absolutely-positioned box was taken out of the flow."""
def __init__(self, box):
assert not isinstance(box, AbsolutePlaceholder)
# Work around the overloaded __setattr__
object.__setattr__(self, '_box', box)
object.__setattr__(self, '_layout_done', False)
def set_laid_out_box(self, new_box):
object.__setattr__(self, '_box', new_box)
object.__setattr__(self, '_layout_done', True)
def translate(self, dx=0, dy=0, ignore_floats=False):
if dx == 0 and dy == 0:
return
if self._layout_done:
self._box.translate(dx, dy, ignore_floats)
else:
# Descendants do not have a position yet.
self._box.position_x += dx
self._box.position_y += dy
def copy(self):
new_placeholder = AbsolutePlaceholder(self._box.copy())
object.__setattr__(new_placeholder, '_layout_done', self._layout_done)
return new_placeholder
# Pretend to be the box itself
def __getattr__(self, name):
return getattr(self._box, name)
def __setattr__(self, name, value):
setattr(self._box, name, value)
def __repr__(self):
return '<Placeholder %r>' % self._box
@handle_min_max_width
def absolute_width(box, context, containing_block):
# http://www.w3.org/TR/CSS2/visudet.html#abs-replaced-width
# These names are waaay too long
margin_l = box.margin_left
margin_r = box.margin_right
padding_l = box.padding_left
padding_r = box.padding_right
border_l = box.border_left_width
border_r = box.border_right_width
width = box.width
left = box.left
right = box.right
cb_x, cb_y, cb_width, cb_height = containing_block
# TODO: handle bidi
padding_plus_borders_x = padding_l + padding_r + border_l + border_r
translate_x = 0
translate_box_width = False
default_translate_x = cb_x - box.position_x
if left == right == width == 'auto':
if margin_l == 'auto':
box.margin_left = 0
if margin_r == 'auto':
box.margin_right = 0
available_width = cb_width - (
padding_plus_borders_x + box.margin_left + box.margin_right)
box.width = shrink_to_fit(context, box, available_width)
elif left != 'auto' and right != 'auto' and width != 'auto':
width_for_margins = cb_width - (
right + left + padding_plus_borders_x)
if margin_l == margin_r == 'auto':
if width + padding_plus_borders_x + right + left <= cb_width:
box.margin_left = box.margin_right = width_for_margins / 2
else:
box.margin_left = 0
box.margin_right = width_for_margins
elif margin_l == 'auto':
box.margin_left = width_for_margins
elif margin_r == 'auto':
box.margin_right = width_for_margins
else:
box.margin_right = width_for_margins
translate_x = left + default_translate_x
else:
if margin_l == 'auto':
box.margin_left = 0
if margin_r == 'auto':
box.margin_right = 0
spacing = padding_plus_borders_x + box.margin_left + box.margin_right
if left == width == 'auto':
box.width = shrink_to_fit(
context, box, cb_width - spacing - right)
translate_x = cb_width - right - spacing + default_translate_x
translate_box_width = True
elif left == right == 'auto':
pass # Keep the static position
elif width == right == 'auto':
box.width = shrink_to_fit(context, box, cb_width - spacing - left)
translate_x = left + default_translate_x
elif left == 'auto':
translate_x = (
cb_width + default_translate_x - right - spacing - width)
elif width == 'auto':
box.width = cb_width - right - left - spacing
translate_x = left + default_translate_x
elif right == 'auto':
translate_x = left + default_translate_x
return translate_box_width, translate_x
def absolute_height(box, context, containing_block):
# These names are waaay too long
margin_t = box.margin_top
margin_b = box.margin_bottom
padding_t = box.padding_top
padding_b = box.padding_bottom
border_t = box.border_top_width
border_b = box.border_bottom_width
height = box.height
top = box.top
bottom = box.bottom
cb_x, cb_y, cb_width, cb_height = containing_block
# http://www.w3.org/TR/CSS2/visudet.html#abs-non-replaced-height
paddings_plus_borders_y = padding_t + padding_b + border_t + border_b
translate_y = 0
translate_box_height = False
default_translate_y = cb_y - box.position_y
if top == bottom == height == 'auto':
# Keep the static position
if margin_t == 'auto':
box.margin_top = 0
if margin_b == 'auto':
box.margin_bottom = 0
elif top != 'auto' and bottom != 'auto' and height != 'auto':
height_for_margins = cb_height - (
top + bottom + paddings_plus_borders_y)
if margin_t == margin_b == 'auto':
box.margin_top = box.margin_bottom = height_for_margins / 2
elif margin_t == 'auto':
box.margin_top = height_for_margins
elif margin_b == 'auto':
box.margin_bottom = height_for_margins
else:
box.margin_bottom = height_for_margins
translate_y = top + default_translate_y
else:
if margin_t == 'auto':
box.margin_top = 0
if margin_b == 'auto':
box.margin_bottom = 0
spacing = paddings_plus_borders_y + box.margin_top + box.margin_bottom
if top == height == 'auto':
translate_y = cb_height - bottom - spacing + default_translate_y
translate_box_height = True
elif top == bottom == 'auto':
pass # Keep the static position
elif height == bottom == 'auto':
translate_y = top + default_translate_y
elif top == 'auto':
translate_y = (
cb_height + default_translate_y - bottom - spacing - height)
elif height == 'auto':
box.height = cb_height - bottom - top - spacing
translate_y = top + default_translate_y
elif bottom == 'auto':
translate_y = top + default_translate_y
return translate_box_height, translate_y
def absolute_block(context, box, containing_block, fixed_boxes):
cb_x, cb_y, cb_width, cb_height = containing_block
translate_box_width, translate_x = absolute_width(
box, context, containing_block)
translate_box_height, translate_y = absolute_height(
box, context, containing_block)
# This box is the containing block for absolute descendants.
absolute_boxes = []
if box.is_table_wrapper:
table_wrapper_width(context, box, (cb_width, cb_height))
# avoid a circular import
from .blocks import block_container_layout
new_box, _, _, _, _ = block_container_layout(
context, box, max_position_y=float('inf'), skip_stack=None,
page_is_empty=False, absolute_boxes=absolute_boxes,
fixed_boxes=fixed_boxes, adjoining_margins=None)
for child_placeholder in absolute_boxes:
absolute_layout(context, child_placeholder, new_box, fixed_boxes)
if translate_box_width:
translate_x -= new_box.width
if translate_box_height:
translate_y -= new_box.height
new_box.translate(translate_x, translate_y)
return new_box
def absolute_flex(context, box, containing_block_sizes, fixed_boxes,
containing_block):
# Avoid a circular import
from .flex import flex_layout
# TODO: this function is really close to absolute_block, we should have
# only one function.
# TODO: having containing_block_sizes and containing_block is stupid.
cb_x, cb_y, cb_width, cb_height = containing_block_sizes
translate_box_width, translate_x = absolute_width(
box, context, containing_block_sizes)
translate_box_height, translate_y = absolute_height(
box, context, containing_block_sizes)
# This box is the containing block for absolute descendants.
absolute_boxes = []
if box.is_table_wrapper:
table_wrapper_width(context, box, (cb_width, cb_height))
new_box, _, _, _, _ = flex_layout(
context, box, max_position_y=float('inf'), skip_stack=None,
containing_block=containing_block, page_is_empty=False,
absolute_boxes=absolute_boxes, fixed_boxes=fixed_boxes)
for child_placeholder in absolute_boxes:
absolute_layout(context, child_placeholder, new_box, fixed_boxes)
if translate_box_width:
translate_x -= new_box.width
if translate_box_height:
translate_y -= new_box.height
new_box.translate(translate_x, translate_y)
return new_box
def absolute_layout(context, placeholder, containing_block, fixed_boxes):
"""Set the width of absolute positioned ``box``."""
assert not placeholder._layout_done
box = placeholder._box
placeholder.set_laid_out_box(
absolute_box_layout(context, box, containing_block, fixed_boxes))
def absolute_box_layout(context, box, containing_block, fixed_boxes):
cb = containing_block
# TODO: handle inline boxes (point 10.1.4.1)
# http://www.w3.org/TR/CSS2/visudet.html#containing-block-details
if isinstance(containing_block, boxes.PageBox):
cb_x = cb.content_box_x()
cb_y = cb.content_box_y()
cb_width = cb.width
cb_height = cb.height
else:
cb_x = cb.padding_box_x()
cb_y = cb.padding_box_y()
cb_width = cb.padding_width()
cb_height = cb.padding_height()
containing_block = cb_x, cb_y, cb_width, cb_height
resolve_percentages(box, (cb_width, cb_height))
resolve_position_percentages(box, (cb_width, cb_height))
context.create_block_formatting_context()
# Absolute tables are wrapped into block boxes
if isinstance(box, boxes.BlockBox):
new_box = absolute_block(context, box, containing_block, fixed_boxes)
elif isinstance(box, boxes.FlexContainerBox):
new_box = absolute_flex(
context, box, containing_block, fixed_boxes, cb)
else:
assert isinstance(box, boxes.BlockReplacedBox)
new_box = absolute_replaced(context, box, containing_block)
context.finish_block_formatting_context(new_box)
return new_box
def absolute_replaced(context, box, containing_block):
# avoid a circular import
from .inlines import inline_replaced_box_width_height
inline_replaced_box_width_height(box, containing_block)
cb_x, cb_y, cb_width, cb_height = containing_block
ltr = box.style['direction'] == 'ltr'
# http://www.w3.org/TR/CSS21/visudet.html#abs-replaced-width
if box.left == box.right == 'auto':
# static position:
if ltr:
box.left = box.position_x - cb_x
else:
box.right = cb_x + cb_width - box.position_x
if 'auto' in (box.left, box.right):
if box.margin_left == 'auto':
box.margin_left = 0
if box.margin_right == 'auto':
box.margin_right = 0
remaining = cb_width - box.margin_width()
if box.left == 'auto':
box.left = remaining - box.right
if box.right == 'auto':
box.right = remaining - box.left
elif 'auto' in (box.margin_left, box.margin_right):
remaining = cb_width - (box.border_width() + box.left + box.right)
if box.margin_left == box.margin_right == 'auto':
if remaining >= 0:
box.margin_left = box.margin_right = remaining // 2
elif ltr:
box.margin_left = 0
box.margin_right = remaining
else:
box.margin_left = remaining
box.margin_right = 0
elif box.margin_left == 'auto':
box.margin_left = remaining
else:
box.margin_right = remaining
else:
# Over-constrained
if ltr:
box.right = cb_width - (box.margin_width() + box.left)
else:
box.left = cb_width - (box.margin_width() + box.right)
# http://www.w3.org/TR/CSS21/visudet.html#abs-replaced-height
if box.top == box.bottom == 'auto':
box.top = box.position_y - cb_y
if 'auto' in (box.top, box.bottom):
if box.margin_top == 'auto':
box.margin_top = 0
if box.margin_bottom == 'auto':
box.margin_bottom = 0
remaining = cb_height - box.margin_height()
if box.top == 'auto':
box.top = remaining
if box.bottom == 'auto':
box.bottom = remaining
elif 'auto' in (box.margin_top, box.margin_bottom):
remaining = cb_height - (box.border_height() + box.top + box.bottom)
if box.margin_top == box.margin_bottom == 'auto':
box.margin_top = box.margin_bottom = remaining // 2
elif box.margin_top == 'auto':
box.margin_top = remaining
else:
box.margin_bottom = remaining
else:
# Over-constrained
box.bottom = cb_height - (box.margin_height() + box.top)
# No children for replaced boxes, no need to .translate()
box.position_x = cb_x + box.left
box.position_y = cb_y + box.top
return box

View File

@ -0,0 +1,242 @@
"""
weasyprint.backgrounds
----------------------
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from collections import namedtuple
from itertools import cycle
from ..formatting_structure import boxes
from . import replaced
from .percentages import percentage, resolve_radii_percentages
Background = namedtuple('Background', 'color, layers, image_rendering')
BackgroundLayer = namedtuple(
'BackgroundLayer',
'image, size, position, repeat, unbounded, '
'painting_area, positioning_area, clipped_boxes')
def box_rectangle(box, which_rectangle):
if which_rectangle == 'border-box':
return (
box.border_box_x(),
box.border_box_y(),
box.border_width(),
box.border_height(),
)
elif which_rectangle == 'padding-box':
return (
box.padding_box_x(),
box.padding_box_y(),
box.padding_width(),
box.padding_height(),
)
else:
assert which_rectangle == 'content-box', which_rectangle
return (
box.content_box_x(),
box.content_box_y(),
box.width,
box.height,
)
def layout_box_backgrounds(page, box, get_image_from_uri):
"""Fetch and position background images."""
from ..draw import get_color
# Resolve percentages in border-radius properties
resolve_radii_percentages(box)
for child in box.all_children():
layout_box_backgrounds(page, child, get_image_from_uri)
style = box.style
if style['visibility'] == 'hidden':
box.background = None
if page != box: # Pages need a background for bleed box
return
images = [get_image_from_uri(value) if type_ == 'url' else value
for type_, value in style['background_image']]
color = get_color(style, 'background_color')
if color.alpha == 0 and not any(images):
box.background = None
if page != box: # Pages need a background for bleed box
return
layers = [
layout_background_layer(box, page, style['image_resolution'], *layer)
for layer in zip(images, *map(cycle, [
style['background_size'],
style['background_clip'],
style['background_repeat'],
style['background_origin'],
style['background_position'],
style['background_attachment']]))]
box.background = Background(
color=color, image_rendering=style['image_rendering'], layers=layers)
def layout_background_layer(box, page, resolution, image, size, clip, repeat,
origin, position, attachment):
# TODO: respect box-sizing for table cells?
clipped_boxes = []
painting_area = 0, 0, 0, 0
if box is page:
painting_area = 0, 0, page.margin_width(), page.margin_height()
# XXX: how does border-radius work on pages?
clipped_boxes = [box.rounded_border_box()]
elif isinstance(box, boxes.TableRowGroupBox):
clipped_boxes = []
total_height = 0
for row in box.children:
if row.children:
clipped_boxes += [
cell.rounded_border_box() for cell in row.children]
total_height = max(total_height, max(
cell.border_box_y() + cell.border_height()
for cell in row.children))
painting_area = [
box.border_box_x(), box.border_box_y(),
box.border_box_x() + box.border_width(), total_height]
elif isinstance(box, boxes.TableRowBox):
if box.children:
clipped_boxes = [
cell.rounded_border_box() for cell in box.children]
height = max(
cell.border_height() for cell in box.children)
painting_area = [
box.border_box_x(), box.border_box_y(),
box.border_box_x() + box.border_width(),
box.border_box_y() + height]
elif isinstance(box, (boxes.TableColumnGroupBox, boxes.TableColumnBox)):
cells = box.get_cells()
if cells:
clipped_boxes = [cell.rounded_border_box() for cell in cells]
max_x = max(
cell.border_box_x() + cell.border_width()
for cell in cells)
painting_area = [
box.border_box_x(), box.border_box_y(),
max_x - box.border_box_x(),
box.border_box_y() + box.border_height()]
else:
painting_area = box_rectangle(box, clip)
if clip == 'border-box':
clipped_boxes = [box.rounded_border_box()]
elif clip == 'padding-box':
clipped_boxes = [box.rounded_padding_box()]
else:
assert clip == 'content-box', clip
clipped_boxes = [box.rounded_content_box()]
if image is None or 0 in image.get_intrinsic_size(1, 1):
return BackgroundLayer(
image=None, unbounded=(box is page), painting_area=painting_area,
size='unused', position='unused', repeat='unused',
positioning_area='unused', clipped_boxes=clipped_boxes)
if attachment == 'fixed':
# Initial containing block
positioning_area = box_rectangle(page, 'content-box')
else:
positioning_area = box_rectangle(box, origin)
positioning_x, positioning_y, positioning_width, positioning_height = (
positioning_area)
painting_x, painting_y, painting_width, painting_height = (
painting_area)
if size == 'cover':
image_width, image_height = replaced.cover_constraint_image_sizing(
positioning_width, positioning_height, image.intrinsic_ratio)
elif size == 'contain':
image_width, image_height = replaced.contain_constraint_image_sizing(
positioning_width, positioning_height, image.intrinsic_ratio)
else:
size_width, size_height = size
iwidth, iheight = image.get_intrinsic_size(
resolution, box.style['font_size'])
image_width, image_height = replaced.default_image_sizing(
iwidth, iheight, image.intrinsic_ratio,
percentage(size_width, positioning_width),
percentage(size_height, positioning_height),
positioning_width, positioning_height)
origin_x, position_x, origin_y, position_y = position
ref_x = positioning_width - image_width
ref_y = positioning_height - image_height
position_x = percentage(position_x, ref_x)
position_y = percentage(position_y, ref_y)
if origin_x == 'right':
position_x = ref_x - position_x
if origin_y == 'bottom':
position_y = ref_y - position_y
repeat_x, repeat_y = repeat
if repeat_x == 'round':
n_repeats = max(1, round(positioning_width / image_width))
new_width = positioning_width / n_repeats
position_x = 0 # Ignore background-position for this dimension
if repeat_y != 'round' and size[1] == 'auto':
image_height *= new_width / image_width
image_width = new_width
if repeat_y == 'round':
n_repeats = max(1, round(positioning_height / image_height))
new_height = positioning_height / n_repeats
position_y = 0 # Ignore background-position for this dimension
if repeat_x != 'round' and size[0] == 'auto':
image_width *= new_height / image_height
image_height = new_height
return BackgroundLayer(
image=image,
size=(image_width, image_height),
position=(position_x, position_y),
repeat=repeat,
unbounded=(box is page),
painting_area=painting_area,
positioning_area=positioning_area,
clipped_boxes=clipped_boxes)
def set_canvas_background(page):
"""Set a ``canvas_background`` attribute on the PageBox,
with style for the canvas background, taken from the root elememt
or a <body> child of the root element.
See http://www.w3.org/TR/CSS21/colors.html#background
"""
assert not isinstance(page.children[0], boxes.MarginBox)
root_box = page.children[0]
chosen_box = root_box
if root_box.element_tag.lower() == 'html' and root_box.background is None:
for child in root_box.children:
if child.element_tag.lower() == 'body':
chosen_box = child
break
if chosen_box.background:
painting_area = box_rectangle(page, 'padding-box')
page.canvas_background = chosen_box.background._replace(
# TODO: shouldnt background-clip be considered here?
layers=[
l._replace(painting_area=painting_area)
for l in chosen_box.background.layers])
chosen_box.background = None
else:
page.canvas_background = None
def layout_backgrounds(page, get_image_from_uri):
layout_box_backgrounds(page, page, get_image_from_uri)
set_canvas_background(page)

851
weasyprint/layout/blocks.py Normal file
View File

@ -0,0 +1,851 @@
"""
weasyprint.layout.blocks
------------------------
Page breaking and layout for block-level and block-container boxes.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..formatting_structure import boxes
from .absolute import AbsolutePlaceholder, absolute_layout
from .columns import columns_layout
from .flex import flex_layout
from .float import avoid_collisions, float_layout, get_clearance
from .inlines import (
iter_line_boxes, min_max_auto_replaced, replaced_box_height,
replaced_box_width)
from .min_max import handle_min_max_width
from .percentages import resolve_percentages, resolve_position_percentages
from .tables import table_layout, table_wrapper_width
def block_level_layout(context, box, max_position_y, skip_stack,
containing_block, page_is_empty, absolute_boxes,
fixed_boxes, adjoining_margins):
"""Lay out the block-level ``box``.
:param max_position_y: the absolute vertical position (as in
``some_box.position_y``) of the bottom of the
content box of the current page area.
"""
if not isinstance(box, boxes.TableBox):
resolve_percentages(box, containing_block)
if box.margin_top == 'auto':
box.margin_top = 0
if box.margin_bottom == 'auto':
box.margin_bottom = 0
if (context.current_page > 1 and page_is_empty):
# TODO: we should take care of cases when this box doesn't have
# collapsing margins with the first child of the page, see
# test_margin_break_clearance.
if box.style['margin_break'] == 'discard':
box.margin_top = 0
elif box.style['margin_break'] == 'auto':
if not context.forced_break:
box.margin_top = 0
collapsed_margin = collapse_margin(
adjoining_margins + [box.margin_top])
box.clearance = get_clearance(context, box, collapsed_margin)
if box.clearance is not None:
top_border_edge = box.position_y + collapsed_margin + box.clearance
box.position_y = top_border_edge - box.margin_top
adjoining_margins = []
return block_level_layout_switch(
context, box, max_position_y, skip_stack, containing_block,
page_is_empty, absolute_boxes, fixed_boxes, adjoining_margins)
def block_level_layout_switch(context, box, max_position_y, skip_stack,
containing_block, page_is_empty, absolute_boxes,
fixed_boxes, adjoining_margins):
"""Call the layout function corresponding to the ``box`` type."""
if isinstance(box, boxes.TableBox):
return table_layout(
context, box, max_position_y, skip_stack, containing_block,
page_is_empty, absolute_boxes, fixed_boxes)
elif isinstance(box, boxes.BlockBox):
return block_box_layout(
context, box, max_position_y, skip_stack, containing_block,
page_is_empty, absolute_boxes, fixed_boxes, adjoining_margins)
elif isinstance(box, boxes.BlockReplacedBox):
box = block_replaced_box_layout(box, containing_block)
# Don't collide with floats
# http://www.w3.org/TR/CSS21/visuren.html#floats
box.position_x, box.position_y, _ = avoid_collisions(
context, box, containing_block, outer=False)
resume_at = None
next_page = {'break': 'any', 'page': None}
adjoining_margins = []
collapsing_through = False
return box, resume_at, next_page, adjoining_margins, collapsing_through
elif isinstance(box, boxes.FlexBox):
return flex_layout(
context, box, max_position_y, skip_stack, containing_block,
page_is_empty, absolute_boxes, fixed_boxes)
else: # pragma: no cover
raise TypeError('Layout for %s not handled yet' % type(box).__name__)
def block_box_layout(context, box, max_position_y, skip_stack,
containing_block, page_is_empty, absolute_boxes,
fixed_boxes, adjoining_margins):
"""Lay out the block ``box``."""
if (box.style['column_width'] != 'auto' or
box.style['column_count'] != 'auto'):
result = columns_layout(
context, box, max_position_y, skip_stack, containing_block,
page_is_empty, absolute_boxes, fixed_boxes, adjoining_margins)
resume_at = result[1]
# TODO: this condition and the whole relayout are probably wrong
if resume_at is None:
new_box = result[0]
bottom_spacing = (
new_box.margin_bottom + new_box.padding_bottom +
new_box.border_bottom_width)
if bottom_spacing:
max_position_y -= bottom_spacing
result = columns_layout(
context, box, max_position_y, skip_stack,
containing_block, page_is_empty, absolute_boxes,
fixed_boxes, adjoining_margins)
return result
elif box.is_table_wrapper:
table_wrapper_width(
context, box, (containing_block.width, containing_block.height))
block_level_width(box, containing_block)
new_box, resume_at, next_page, adjoining_margins, collapsing_through = \
block_container_layout(
context, box, max_position_y, skip_stack, page_is_empty,
absolute_boxes, fixed_boxes, adjoining_margins)
if new_box and new_box.is_table_wrapper:
# Don't collide with floats
# http://www.w3.org/TR/CSS21/visuren.html#floats
position_x, position_y, _ = avoid_collisions(
context, new_box, containing_block, outer=False)
new_box.translate(
position_x - new_box.position_x, position_y - new_box.position_y)
return new_box, resume_at, next_page, adjoining_margins, collapsing_through
@handle_min_max_width
def block_replaced_width(box, containing_block):
# http://www.w3.org/TR/CSS21/visudet.html#block-replaced-width
replaced_box_width.without_min_max(box, containing_block)
block_level_width.without_min_max(box, containing_block)
def block_replaced_box_layout(box, containing_block):
"""Lay out the block :class:`boxes.ReplacedBox` ``box``."""
box = box.copy()
if box.style['width'] == 'auto' and box.style['height'] == 'auto':
computed_margins = box.margin_left, box.margin_right
block_replaced_width.without_min_max(
box, containing_block)
replaced_box_height.without_min_max(box)
min_max_auto_replaced(box)
box.margin_left, box.margin_right = computed_margins
block_level_width.without_min_max(box, containing_block)
else:
block_replaced_width(box, containing_block)
replaced_box_height(box)
return box
@handle_min_max_width
def block_level_width(box, containing_block):
"""Set the ``box`` width."""
# 'cb' stands for 'containing block'
cb_width = containing_block.width
# http://www.w3.org/TR/CSS21/visudet.html#blockwidth
# These names are waaay too long
margin_l = box.margin_left
margin_r = box.margin_right
padding_l = box.padding_left
padding_r = box.padding_right
border_l = box.border_left_width
border_r = box.border_right_width
width = box.width
# Only margin-left, margin-right and width can be 'auto'.
# We want: width of containing block ==
# margin-left + border-left-width + padding-left + width
# + padding-right + border-right-width + margin-right
paddings_plus_borders = padding_l + padding_r + border_l + border_r
if box.width != 'auto':
total = paddings_plus_borders + width
if margin_l != 'auto':
total += margin_l
if margin_r != 'auto':
total += margin_r
if total > cb_width:
if margin_l == 'auto':
margin_l = box.margin_left = 0
if margin_r == 'auto':
margin_r = box.margin_right = 0
if width != 'auto' and margin_l != 'auto' and margin_r != 'auto':
# The equation is over-constrained.
if containing_block.style['direction'] == 'rtl' and not box.is_column:
box.position_x += (
cb_width - paddings_plus_borders - width - margin_r - margin_l)
# Do nothing in ltr.
if width == 'auto':
if margin_l == 'auto':
margin_l = box.margin_left = 0
if margin_r == 'auto':
margin_r = box.margin_right = 0
width = box.width = cb_width - (
paddings_plus_borders + margin_l + margin_r)
margin_sum = cb_width - paddings_plus_borders - width
if margin_l == 'auto' and margin_r == 'auto':
box.margin_left = margin_sum / 2.
box.margin_right = margin_sum / 2.
elif margin_l == 'auto' and margin_r != 'auto':
box.margin_left = margin_sum - margin_r
elif margin_l != 'auto' and margin_r == 'auto':
box.margin_right = margin_sum - margin_l
def relative_positioning(box, containing_block):
"""Translate the ``box`` if it is relatively positioned."""
if box.style['position'] == 'relative':
resolve_position_percentages(box, containing_block)
if box.left != 'auto' and box.right != 'auto':
if box.style['direction'] == 'ltr':
translate_x = box.left
else:
translate_x = -box.right
elif box.left != 'auto':
translate_x = box.left
elif box.right != 'auto':
translate_x = -box.right
else:
translate_x = 0
if box.top != 'auto':
translate_y = box.top
elif box.style['bottom'] != 'auto':
translate_y = -box.bottom
else:
translate_y = 0
box.translate(translate_x, translate_y)
if isinstance(box, (boxes.InlineBox, boxes.LineBox)):
for child in box.children:
relative_positioning(child, containing_block)
def block_container_layout(context, box, max_position_y, skip_stack,
page_is_empty, absolute_boxes, fixed_boxes,
adjoining_margins=None):
"""Set the ``box`` height."""
# TODO: boxes.FlexBox is allowed here because flex_layout calls
# block_container_layout, there's probably a better solution.
assert isinstance(box, (boxes.BlockContainerBox, boxes.FlexBox))
# We have to work around floating point rounding errors here.
# The 1e-9 value comes from PEP 485.
allowed_max_position_y = max_position_y * (1 + 1e-9)
# See http://www.w3.org/TR/CSS21/visuren.html#block-formatting
if not isinstance(box, boxes.BlockBox):
context.create_block_formatting_context()
is_start = skip_stack is None
if box.style['box_decoration_break'] == 'slice' and not is_start:
# Remove top margin, border and padding:
box._remove_decoration(start=True, end=False)
if adjoining_margins is None:
adjoining_margins = []
if box.style['box_decoration_break'] == 'clone':
max_position_y -= (
box.padding_bottom + box.border_bottom_width +
box.margin_bottom)
adjoining_margins.append(box.margin_top)
this_box_adjoining_margins = adjoining_margins
collapsing_with_children = not (
box.border_top_width or box.padding_top or box.is_flex_item or
establishes_formatting_context(box) or box.is_for_root_element)
if collapsing_with_children:
# XXX not counting margins in adjoining_margins, if any
# (There are not padding or borders, see above.)
position_y = box.position_y
else:
box.position_y += collapse_margin(adjoining_margins) - box.margin_top
adjoining_margins = []
position_y = box.content_box_y()
position_x = box.content_box_x()
if box.style['position'] == 'relative':
# New containing block, use a new absolute list
absolute_boxes = []
new_children = []
next_page = {'break': 'any', 'page': None}
last_in_flow_child = None
if is_start:
skip = 0
first_letter_style = getattr(box, 'first_letter_style', None)
else:
skip, skip_stack = skip_stack
first_letter_style = None
for i, child in enumerate(box.children[skip:]):
index = i + skip
child.position_x = position_x
# XXX does not count margins in adjoining_margins:
child.position_y = position_y
if not child.is_in_normal_flow():
child.position_y += collapse_margin(adjoining_margins)
if child.is_absolutely_positioned():
placeholder = AbsolutePlaceholder(child)
placeholder.index = index
new_children.append(placeholder)
if child.style['position'] == 'absolute':
absolute_boxes.append(placeholder)
else:
fixed_boxes.append(placeholder)
elif child.is_floated():
new_child = float_layout(
context, child, box, absolute_boxes, fixed_boxes)
# New page if overflow
if (page_is_empty and not new_children) or not (
new_child.position_y + new_child.height >
allowed_max_position_y):
new_child.index = index
new_children.append(new_child)
else:
for previous_child in reversed(new_children):
if previous_child.is_in_normal_flow():
last_in_flow_child = previous_child
break
page_break = block_level_page_break(
last_in_flow_child, child)
if new_children and page_break in ('avoid', 'avoid-page'):
result = find_earlier_page_break(
new_children, absolute_boxes, fixed_boxes)
if result:
new_children, resume_at = result
break
resume_at = (index, None)
break
elif child.is_running():
running_name = child.style['position'][1]
page = context.current_page
context.running_elements[running_name][page].append(child)
continue
if isinstance(child, boxes.LineBox):
assert len(box.children) == 1, (
'line box with siblings before layout')
if adjoining_margins:
position_y += collapse_margin(adjoining_margins)
adjoining_margins = []
new_containing_block = box
lines_iterator = iter_line_boxes(
context, child, position_y, skip_stack,
new_containing_block, absolute_boxes, fixed_boxes,
first_letter_style)
is_page_break = False
for line, resume_at in lines_iterator:
line.resume_at = resume_at
new_position_y = line.position_y + line.height
# Add bottom padding and border to the bottom position of the
# box if needed
if resume_at is None or (
box.style['box_decoration_break'] == 'clone'):
offset_y = box.border_bottom_width + box.padding_bottom
else:
offset_y = 0
# Allow overflow if the first line of the page is higher
# than the page itself so that we put *something* on this
# page and can advance in the context.
if new_position_y + offset_y > allowed_max_position_y and (
new_children or not page_is_empty):
over_orphans = len(new_children) - box.style['orphans']
if over_orphans < 0 and not page_is_empty:
# Reached the bottom of the page before we had
# enough lines for orphans, cancel the whole box.
page = child.page_values()[0]
return (
None, None, {'break': 'any', 'page': page}, [],
False)
# How many lines we need on the next page to satisfy widows
# -1 for the current line.
needed = box.style['widows'] - 1
if needed:
for _ in lines_iterator:
needed -= 1
if needed == 0:
break
if needed > over_orphans and not page_is_empty:
# Total number of lines < orphans + widows
page = child.page_values()[0]
return (
None, None, {'break': 'any', 'page': page}, [],
False)
if needed and needed <= over_orphans:
# Remove lines to keep them for the next page
del new_children[-needed:]
# Page break here, resume before this line
resume_at = (index, skip_stack)
is_page_break = True
break
# TODO: this is incomplete.
# See http://dev.w3.org/csswg/css3-page/#allowed-pg-brk
# "When an unforced page break occurs here, both the adjoining
# margin-top and margin-bottom are set to zero."
# See https://github.com/Kozea/WeasyPrint/issues/115
elif page_is_empty and new_position_y > allowed_max_position_y:
# Remove the top border when a page is empty and the box is
# too high to be drawn in one page
new_position_y -= box.margin_top
line.translate(0, -box.margin_top)
box.margin_top = 0
new_children.append(line)
position_y = new_position_y
skip_stack = resume_at
if new_children:
resume_at = (index, new_children[-1].resume_at)
if is_page_break:
break
else:
for previous_child in reversed(new_children):
if previous_child.is_in_normal_flow():
last_in_flow_child = previous_child
break
else:
last_in_flow_child = None
if last_in_flow_child is not None:
# Between in-flow siblings
page_break = block_level_page_break(last_in_flow_child, child)
page_name = block_level_page_name(last_in_flow_child, child)
if page_name or page_break in (
'page', 'left', 'right', 'recto', 'verso'):
page_name = child.page_values()[0]
next_page = {'break': page_break, 'page': page_name}
resume_at = (index, None)
break
else:
page_break = 'auto'
new_containing_block = box
if not new_containing_block.is_table_wrapper:
resolve_percentages(child, new_containing_block)
if (child.is_in_normal_flow() and
last_in_flow_child is None and
collapsing_with_children):
# TODO: add the adjoining descendants' margin top to
# [child.margin_top]
old_collapsed_margin = collapse_margin(adjoining_margins)
if child.margin_top == 'auto':
child_margin_top = 0
else:
child_margin_top = child.margin_top
new_collapsed_margin = collapse_margin(
adjoining_margins + [child_margin_top])
collapsed_margin_difference = (
new_collapsed_margin - old_collapsed_margin)
for previous_new_child in new_children:
previous_new_child.translate(
dy=collapsed_margin_difference)
clearance = get_clearance(
context, child, new_collapsed_margin)
if clearance is not None:
for previous_new_child in new_children:
previous_new_child.translate(
dy=-collapsed_margin_difference)
collapsed_margin = collapse_margin(adjoining_margins)
box.position_y += collapsed_margin - box.margin_top
# Count box.margin_top as we emptied adjoining_margins
adjoining_margins = []
position_y = box.content_box_y()
if adjoining_margins and box.is_table_wrapper:
collapsed_margin = collapse_margin(adjoining_margins)
child.position_y += collapsed_margin
position_y += collapsed_margin
adjoining_margins = []
page_is_empty_with_no_children = page_is_empty and not any(
child for child in new_children
if not isinstance(child, AbsolutePlaceholder))
if not getattr(child, 'first_letter_style', None):
child.first_letter_style = first_letter_style
(new_child, resume_at, next_page, next_adjoining_margins,
collapsing_through) = block_level_layout(
context, child, max_position_y, skip_stack,
new_containing_block, page_is_empty_with_no_children,
absolute_boxes, fixed_boxes, adjoining_margins)
skip_stack = None
if new_child is not None:
# index in its non-laid-out parent, not in future new parent
# May be used in find_earlier_page_break()
new_child.index = index
# We need to do this after the child layout to have the
# used value for margin_top (eg. it might be a percentage.)
if not isinstance(
new_child, (boxes.BlockBox, boxes.TableBox)):
adjoining_margins.append(new_child.margin_top)
offset_y = (
collapse_margin(adjoining_margins) -
new_child.margin_top)
new_child.translate(0, offset_y)
adjoining_margins = []
# else: blocks handle that themselves.
adjoining_margins = next_adjoining_margins
adjoining_margins.append(new_child.margin_bottom)
if not collapsing_through:
new_position_y = (
new_child.border_box_y() + new_child.border_height())
if (new_position_y > allowed_max_position_y and
not page_is_empty_with_no_children):
# The child overflows the page area, put it on the
# next page. (But dont delay whole blocks if eg.
# only the bottom border overflows.)
new_child = None
else:
position_y = new_position_y
if new_child is not None and new_child.clearance is not None:
position_y = (
new_child.border_box_y() + new_child.border_height())
if new_child is None:
# Nothing fits in the remaining space of this page: break
if page_break in ('avoid', 'avoid-page'):
# TODO: fill the blank space at the bottom of the page
result = find_earlier_page_break(
new_children, absolute_boxes, fixed_boxes)
if result:
new_children, resume_at = result
break
else:
# We did not find any page break opportunity
if not page_is_empty:
# The page has content *before* this block:
# cancel the block and try to find a break
# in the parent.
page = child.page_values()[0]
return (
None, None, {'break': 'any', 'page': page}, [],
False)
# else:
# ignore this 'avoid' and break anyway.
if all(child.is_absolutely_positioned()
for child in new_children):
# This box has only rendered absolute children, keep them
# for the next page. This is for example useful for list
# markers.
remove_placeholders(
new_children, absolute_boxes, fixed_boxes)
new_children = []
if new_children:
resume_at = (index, None)
break
else:
# This was the first child of this box, cancel the box
# completly
page = child.page_values()[0]
return (
None, None, {'break': 'any', 'page': page}, [], False)
# Bottom borders may overflow here
# TODO: back-track somehow when all lines fit but not borders
new_children.append(new_child)
if resume_at is not None:
resume_at = (index, resume_at)
break
else:
resume_at = None
if (resume_at is not None and
box.style['break_inside'] in ('avoid', 'avoid-page') and
not page_is_empty):
return (
None, None, {'break': 'any', 'page': None}, [], False)
if collapsing_with_children:
box.position_y += (
collapse_margin(this_box_adjoining_margins) - box.margin_top)
for previous_child in reversed(new_children):
if previous_child.is_in_normal_flow():
last_in_flow_child = previous_child
break
else:
last_in_flow_child = None
collapsing_through = False
if last_in_flow_child is None:
collapsed_margin = collapse_margin(adjoining_margins)
# top and bottom margin of this box
if (box.height in ('auto', 0) and
get_clearance(context, box, collapsed_margin) is None and
all(v == 0 for v in [
box.min_height, box.border_top_width, box.padding_top,
box.border_bottom_width, box.padding_bottom])):
collapsing_through = True
else:
position_y += collapsed_margin
adjoining_margins = []
else:
# bottom margin of the last child and bottom margin of this box ...
if box.height != 'auto':
# not adjoining. (position_y is not used afterwards.)
adjoining_margins = []
if (box.border_bottom_width or
box.padding_bottom or
establishes_formatting_context(box) or
box.is_for_root_element or
box.is_table_wrapper):
position_y += collapse_margin(adjoining_margins)
adjoining_margins = []
new_box = box.copy_with_children(
new_children, is_start=is_start, is_end=resume_at is None)
# TODO: See corner cases in
# http://www.w3.org/TR/CSS21/visudet.html#normal-block
# TODO: See float.float_layout
if new_box.height == 'auto':
if context.excluded_shapes and new_box.style['overflow'] != 'visible':
max_float_position_y = max(
float_box.position_y + float_box.margin_height()
for float_box in context.excluded_shapes)
position_y = max(max_float_position_y, position_y)
new_box.height = position_y - new_box.content_box_y()
if new_box.style['position'] == 'relative':
# New containing block, resolve the layout of the absolute descendants
for absolute_box in absolute_boxes:
absolute_layout(context, absolute_box, new_box, fixed_boxes)
for child in new_box.children:
relative_positioning(child, (new_box.width, new_box.height))
if not isinstance(new_box, boxes.BlockBox):
context.finish_block_formatting_context(new_box)
if resume_at is None:
# After finish_block_formatting_context which may increment
# new_box.height
new_box.height = max(
min(new_box.height, new_box.max_height),
new_box.min_height)
else:
# Make the box fill the blank space at the bottom of the page
# https://www.w3.org/TR/css-break-3/#box-splitting
new_box.height = (
max_position_y - new_box.position_y -
(new_box.margin_height() - new_box.height))
if box.style['box_decoration_break'] == 'clone':
new_box.height += (
box.padding_bottom + box.border_bottom_width +
box.margin_bottom)
if next_page['page'] is None:
next_page['page'] = new_box.page_values()[1]
return new_box, resume_at, next_page, adjoining_margins, collapsing_through
def collapse_margin(adjoining_margins):
"""Return the amount of collapsed margin for a list of adjoining margins.
"""
# Add 0 to make sure that neither max() or min() get an empty list
margins = [0]
margins.extend(adjoining_margins)
positives = (m for m in margins if m >= 0)
negatives = (m for m in margins if m <= 0)
return max(positives) + min(negatives)
def establishes_formatting_context(box):
"""Return wether a box establishes a block formatting context.
See http://www.w3.org/TR/CSS2/visuren.html#block-formatting
"""
return (
box.is_floated()
) or (
box.is_absolutely_positioned()
) or (
# TODO: columns shouldn't be block boxes, this condition would then be
# useless when this is fixed
box.is_column
) or (
isinstance(box, boxes.BlockContainerBox) and
not isinstance(box, boxes.BlockBox)
) or (
isinstance(box, boxes.BlockBox) and box.style['overflow'] != 'visible'
)
def block_level_page_break(sibling_before, sibling_after):
"""Return the value of ``page-break-before`` or ``page-break-after``
that "wins" for boxes that meet at the margin between two sibling boxes.
For boxes before the margin, the 'page-break-after' value is considered;
for boxes after the margin the 'page-break-before' value is considered.
* 'avoid' takes priority over 'auto'
* 'page' takes priority over 'avoid' or 'auto'
* 'left' or 'right' take priority over 'always', 'avoid' or 'auto'
* Among 'left' and 'right', later values in the tree take priority.
See http://dev.w3.org/csswg/css3-page/#allowed-pg-brk
"""
values = []
# https://drafts.csswg.org/css-break-3/#possible-breaks
block_parallel_box_types = (
boxes.BlockLevelBox, boxes.TableRowGroupBox, boxes.TableRowBox)
box = sibling_before
while isinstance(box, block_parallel_box_types):
values.append(box.style['break_after'])
if not (isinstance(box, boxes.ParentBox) and box.children):
break
box = box.children[-1]
values.reverse() # Have them in tree order
box = sibling_after
while isinstance(box, block_parallel_box_types):
values.append(box.style['break_before'])
if not (isinstance(box, boxes.ParentBox) and box.children):
break
box = box.children[0]
result = 'auto'
for value in values:
if value in ('left', 'right', 'recto', 'verso') or (value, result) in (
('page', 'auto'),
('page', 'avoid'),
('avoid', 'auto'),
('page', 'avoid-page'),
('avoid-page', 'auto')):
result = value
return result
def block_level_page_name(sibling_before, sibling_after):
"""Return the next page name when siblings don't have the same names."""
before_page = sibling_before.page_values()[1]
after_page = sibling_after.page_values()[0]
if before_page != after_page:
return after_page
def find_earlier_page_break(children, absolute_boxes, fixed_boxes):
"""Because of a `page-break-before: avoid` or a `page-break-after: avoid`
we need to find an earlier page break opportunity inside `children`.
Absolute or fixed placeholders removed from children should also be
removed from `absolute_boxes` or `fixed_boxes`.
Return (new_children, resume_at)
"""
if children and isinstance(children[0], boxes.LineBox):
# Normally `orphans` and `widows` apply to the block container, but
# line boxes inherit them.
orphans = children[0].style['orphans']
widows = children[0].style['widows']
index = len(children) - widows # how many lines we keep
if index < orphans:
return None
new_children = children[:index]
resume_at = (0, new_children[-1].resume_at)
remove_placeholders(children[index:], absolute_boxes, fixed_boxes)
return new_children, resume_at
previous_in_flow = None
for index, child in reversed_enumerate(children):
if child.is_in_normal_flow():
if previous_in_flow is not None and (
block_level_page_break(child, previous_in_flow) not in
('avoid', 'avoid-page')):
index += 1 # break after child
new_children = children[:index]
# Get the index in the original parent
resume_at = (children[index].index, None)
break
previous_in_flow = child
if child.is_in_normal_flow() and (
child.style['break_inside'] not in ('avoid', 'avoid-page')):
breakable_box_types = (
boxes.BlockBox, boxes.TableBox, boxes.TableRowGroupBox)
if isinstance(child, breakable_box_types):
result = find_earlier_page_break(
child.children, absolute_boxes, fixed_boxes)
if result:
new_grand_children, resume_at = result
new_child = child.copy_with_children(new_grand_children)
new_children = list(children[:index]) + [new_child]
# Index in the original parent
resume_at = (new_child.index, resume_at)
index += 1 # Remove placeholders after child
break
else:
return None
remove_placeholders(children[index:], absolute_boxes, fixed_boxes)
return new_children, resume_at
def reversed_enumerate(seq):
"""Like reversed(list(enumerate(seq))) without copying the whole seq."""
return zip(reversed(range(len(seq))), reversed(seq))
def remove_placeholders(box_list, absolute_boxes, fixed_boxes):
"""For boxes that have been removed in find_earlier_page_break(),
also remove the matching placeholders in absolute_boxes and fixed_boxes.
"""
for box in box_list:
if isinstance(box, boxes.ParentBox):
remove_placeholders(box.children, absolute_boxes, fixed_boxes)
if box.style['position'] == 'absolute' and box in absolute_boxes:
# box is not in absolute_boxes if its parent has position: relative
absolute_boxes.remove(box)
elif box.style['position'] == 'fixed':
fixed_boxes.remove(box)

View File

@ -0,0 +1,301 @@
"""
weasyprint.layout.columns
-------------------------
Layout for columns.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from math import floor
from .absolute import absolute_layout
from .percentages import resolve_percentages
def columns_layout(context, box, max_position_y, skip_stack, containing_block,
page_is_empty, absolute_boxes, fixed_boxes,
adjoining_margins):
"""Lay out a multi-column ``box``."""
# Avoid circular imports
from .blocks import (
block_box_layout, block_level_layout, block_level_width,
collapse_margin)
# Implementation of the multi-column pseudo-algorithm:
# https://www.w3.org/TR/css3-multicol/#pseudo-algorithm
width = None
style = box.style
original_max_position_y = max_position_y
if box.style['position'] == 'relative':
# New containing block, use a new absolute list
absolute_boxes = []
box = box.copy_with_children(box.children)
box.position_y += collapse_margin(adjoining_margins) - box.margin_top
height = box.style['height']
if height != 'auto' and height.unit != '%':
assert height.unit == 'px'
known_height = True
max_position_y = min(
max_position_y, box.content_box_y() + height.value)
else:
known_height = False
# TODO: the available width can be unknown if the containing block needs
# the size of this block to know its own size.
block_level_width(box, containing_block)
available_width = box.width
if style['column_width'] == 'auto' and style['column_count'] != 'auto':
count = style['column_count']
width = max(
0, available_width - (count - 1) * style['column_gap']) / count
elif (style['column_width'] != 'auto' and
style['column_count'] == 'auto'):
count = max(1, int(floor(
(available_width + style['column_gap']) /
(style['column_width'] + style['column_gap']))))
width = (
(available_width + style['column_gap']) / count -
style['column_gap'])
else:
count = min(style['column_count'], int(floor(
(available_width + style['column_gap']) /
(style['column_width'] + style['column_gap']))))
width = (
(available_width + style['column_gap']) / count -
style['column_gap'])
def create_column_box(children):
column_box = box.anonymous_from(box, children=children)
resolve_percentages(column_box, containing_block)
column_box.is_column = True
column_box.width = width
column_box.position_x = box.content_box_x()
column_box.position_y = box.content_box_y()
return column_box
# Handle column-span property.
# We want to get the following structure:
# columns_and_blocks = [
# [column_child_1, column_child_2],
# spanning_block,
# …
# ]
columns_and_blocks = []
column_children = []
for child in box.children:
if child.style['column_span'] == 'all':
if column_children:
columns_and_blocks.append(column_children)
columns_and_blocks.append(child.copy())
column_children = []
continue
column_children.append(child.copy())
if column_children:
columns_and_blocks.append(column_children)
if not box.children:
next_page = {'break': 'any', 'page': None}
skip_stack = None
# Balance.
#
# The current algorithm starts from the ideal height (the total height
# divided by the number of columns). We then iterate until the last column
# is not the highest one. At the end of each loop, we add the minimal
# height needed to make one direct child at the top of one column go to the
# end of the previous column.
#
# We rely on a real rendering for each loop, and with a stupid algorithm
# like this it can last minutes…
adjoining_margins = []
current_position_y = box.content_box_y()
new_children = []
for column_children_or_block in columns_and_blocks:
if not isinstance(column_children_or_block, list):
# We get a spanning block, we display it like other blocks.
block = column_children_or_block
resolve_percentages(block, containing_block)
block.position_x = box.content_box_x()
block.position_y = current_position_y
new_child, _, _, adjoining_margins, _ = block_level_layout(
context, block, original_max_position_y, skip_stack,
containing_block, page_is_empty, absolute_boxes, fixed_boxes,
adjoining_margins)
new_children.append(new_child)
current_position_y = (
new_child.border_height() + new_child.border_box_y())
adjoining_margins.append(new_child.margin_bottom)
continue
excluded_shapes = context.excluded_shapes[:]
# We have a list of children that we have to balance between columns.
column_children = column_children_or_block
# Find the total height of the content
current_position_y += collapse_margin(adjoining_margins)
adjoining_margins = []
column_box = create_column_box(column_children)
new_child, _, _, _, _ = block_box_layout(
context, column_box, float('inf'), skip_stack, containing_block,
page_is_empty, [], [], [])
height = new_child.margin_height()
if style['column_fill'] == 'balance':
height /= count
# Try to render columns until the content fits, increase the column
# height step by step.
column_skip_stack = skip_stack
lost_space = float('inf')
while True:
# Remove extra excluded shapes introduced during previous loop
new_excluded_shapes = (
len(context.excluded_shapes) - len(excluded_shapes))
for i in range(new_excluded_shapes):
context.excluded_shapes.pop()
for i in range(count):
# Render the column
new_box, resume_at, next_page, _, _ = block_box_layout(
context, column_box, box.content_box_y() + height,
column_skip_stack, containing_block, page_is_empty,
[], [], [])
if new_box is None:
# We didn't render anything. Give up and use the max
# content height.
height *= count
continue
column_skip_stack = resume_at
in_flow_children = [
child for child in new_box.children
if child.is_in_normal_flow()]
if in_flow_children:
# Get the empty space at the bottom of the column box
empty_space = height - (
in_flow_children[-1].position_y - box.content_box_y() +
in_flow_children[-1].margin_height())
# Get the minimum size needed to render the next box
next_box, _, _, _, _ = block_box_layout(
context, column_box, box.content_box_y(),
column_skip_stack, containing_block, True, [], [], [])
for child in next_box.children:
if child.is_in_normal_flow():
next_box_size = child.margin_height()
break
else:
empty_space = next_box_size = 0
# Append the size needed to render the next box in this
# column.
#
# The next box size may be smaller than the empty space, for
# example when the next box can't be separated from its own
# next box. In this case we don't try to find the real value
# and let the workaround below fix this for us.
#
# We also want to avoid very small values that may have been
# introduced by rounding errors. As the workaround below at
# least adds 1 pixel for each loop, we can ignore lost spaces
# lower than 1px.
if next_box_size - empty_space > 1:
lost_space = min(lost_space, next_box_size - empty_space)
# Stop if we already rendered the whole content
if resume_at is None:
break
if column_skip_stack is None:
# We rendered the whole content, stop
break
else:
if lost_space == float('inf'):
# We didn't find the extra size needed to render a child in
# the previous column, increase height by the minimal
# value.
height += 1
else:
# Increase the columns heights and render them once again
height += lost_space
column_skip_stack = skip_stack
# TODO: check box.style['max']-height
max_position_y = min(max_position_y, box.content_box_y() + height)
# Replace the current box children with columns
i = 0
max_column_height = 0
columns = []
while True:
if i == count - 1:
max_position_y = original_max_position_y
column_box = create_column_box(column_children)
column_box.position_y = current_position_y
if style['direction'] == 'rtl':
column_box.position_x += (
box.width - (i + 1) * width - i * style['column_gap'])
else:
column_box.position_x += i * (width + style['column_gap'])
new_child, column_skip_stack, column_next_page, _, _ = (
block_box_layout(
context, column_box, max_position_y, skip_stack,
containing_block, page_is_empty, absolute_boxes,
fixed_boxes, None))
if new_child is None:
break
next_page = column_next_page
skip_stack = column_skip_stack
columns.append(new_child)
max_column_height = max(
max_column_height, new_child.margin_height())
if skip_stack is None:
break
i += 1
if i == count and not known_height:
# [If] a declaration that constrains the column height
# (e.g., using height or max-height). In this case,
# additional column boxes are created in the inline
# direction.
break
current_position_y += max_column_height
for column in columns:
column.height = max_column_height
new_children.append(column)
if box.children and not new_children:
# The box has children but none can be drawn, let's skip the whole box
return None, (0, None), {'break': 'any', 'page': None}, [], False
# Set the height of box and the columns
box.children = new_children
current_position_y += collapse_margin(adjoining_margins)
if box.height == 'auto':
box.height = current_position_y - box.position_y
height_difference = 0
else:
height_difference = box.height - (current_position_y - box.position_y)
if box.min_height != 'auto' and box.min_height > box.height:
height_difference += box.min_height - box.height
box.height = box.min_height
for child in new_children[::-1]:
if child.is_column:
child.height += height_difference
else:
break
if box.style['position'] == 'relative':
# New containing block, resolve the layout of the absolute descendants
for absolute_box in absolute_boxes:
absolute_layout(context, absolute_box, box, fixed_boxes)
return box, skip_stack, next_page, [], False

887
weasyprint/layout/flex.py Normal file
View File

@ -0,0 +1,887 @@
"""
weasyprint.layout.flex
------------------------
Layout for flex containers and flex-items.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import sys
from math import log10
from ..css.properties import Dimension
from ..formatting_structure import boxes
from .percentages import resolve_one_percentage, resolve_percentages
from .preferred import max_content_width, min_content_width
from .tables import find_in_flow_baseline
class FlexLine(list):
pass
def flex_layout(context, box, max_position_y, skip_stack, containing_block,
page_is_empty, absolute_boxes, fixed_boxes):
# Avoid a circular import
from . import blocks, preferred
context.create_block_formatting_context()
resume_at = None
# Step 1 is done in formatting_structure.boxes
# Step 2
if box.style['flex_direction'].startswith('row'):
axis, cross = 'width', 'height'
else:
axis, cross = 'height', 'width'
margin_left = 0 if box.margin_left == 'auto' else box.margin_left
margin_right = 0 if box.margin_right == 'auto' else box.margin_right
margin_top = 0 if box.margin_top == 'auto' else box.margin_top
margin_bottom = 0 if box.margin_bottom == 'auto' else box.margin_bottom
if getattr(box, axis) != 'auto':
available_main_space = getattr(box, axis)
else:
if axis == 'width':
available_main_space = (
containing_block.width -
margin_left - margin_right -
box.padding_left - box.padding_right -
box.border_left_width - box.border_right_width)
else:
main_space = max_position_y - box.position_y
if containing_block.height != 'auto':
if hasattr(containing_block.height, 'unit'):
assert containing_block.height.unit == 'px'
main_space = min(main_space, containing_block.height.value)
else:
main_space = min(main_space, containing_block.height)
available_main_space = (
main_space -
margin_top - margin_bottom -
box.padding_top - box.padding_bottom -
box.border_top_width - box.border_bottom_width)
if getattr(box, cross) != 'auto':
available_cross_space = getattr(box, cross)
else:
if cross == 'height':
main_space = max_position_y - box.content_box_y()
if containing_block.height != 'auto':
if hasattr(containing_block.height, 'unit'):
assert containing_block.height.unit == 'px'
main_space = min(main_space, containing_block.height.value)
else:
main_space = min(main_space, containing_block.height)
available_cross_space = (
main_space -
margin_top - margin_bottom -
box.padding_top - box.padding_bottom -
box.border_top_width - box.border_bottom_width)
else:
available_cross_space = (
containing_block.width -
margin_left - margin_right -
box.padding_left - box.padding_right -
box.border_left_width - box.border_right_width)
# Step 3
children = box.children
parent_box = box.copy_with_children(children)
resolve_percentages(parent_box, containing_block)
# TODO: removing auto margins is OK for this step, but margins should be
# calculated later.
if parent_box.margin_top == 'auto':
box.margin_top = parent_box.margin_top = 0
if parent_box.margin_bottom == 'auto':
box.margin_bottom = parent_box.margin_bottom = 0
if parent_box.margin_left == 'auto':
box.margin_left = parent_box.margin_left = 0
if parent_box.margin_right == 'auto':
box.margin_right = parent_box.margin_right = 0
if isinstance(parent_box, boxes.FlexBox):
blocks.block_level_width(parent_box, containing_block)
else:
parent_box.width = preferred.flex_max_content_width(
context, parent_box)
original_skip_stack = skip_stack
if skip_stack is not None:
if box.style['flex_direction'].endswith('-reverse'):
children = children[:skip_stack[0] + 1]
else:
children = children[skip_stack[0]:]
skip_stack = skip_stack[1]
else:
skip_stack = None
child_skip_stack = skip_stack
for child in children:
if not child.is_flex_item:
continue
# See https://www.w3.org/TR/css-flexbox-1/#min-size-auto
if child.style['overflow'] == 'visible':
main_flex_direction = axis
else:
main_flex_direction = None
resolve_percentages(child, containing_block, main_flex_direction)
child.position_x = parent_box.content_box_x()
child.position_y = parent_box.content_box_y()
if child.min_width == 'auto':
specified_size = (
child.width if child.width != 'auto' else float('inf'))
if isinstance(child, boxes.ParentBox):
new_child = child.copy_with_children(child.children)
else:
new_child = child.copy()
new_child.style = child.style.copy()
new_child.style['width'] = 'auto'
new_child.style['min_width'] = Dimension(0, 'px')
new_child.style['max_width'] = Dimension(float('inf'), 'px')
content_size = min_content_width(context, new_child, outer=False)
child.min_width = min(specified_size, content_size)
elif child.min_height == 'auto':
# TODO: find a way to get min-content-height
specified_size = (
child.height if child.height != 'auto' else float('inf'))
if isinstance(child, boxes.ParentBox):
new_child = child.copy_with_children(child.children)
else:
new_child = child.copy()
new_child.style = child.style.copy()
new_child.style['height'] = 'auto'
new_child.style['min_height'] = Dimension(0, 'px')
new_child.style['max_height'] = Dimension(float('inf'), 'px')
new_child = blocks.block_level_layout(
context, new_child, float('inf'), child_skip_stack,
parent_box, page_is_empty, [], [], [])[0]
content_size = new_child.height
child.min_height = min(specified_size, content_size)
child.style = child.style.copy()
if child.style['flex_basis'] == 'content':
flex_basis = child.flex_basis = 'content'
else:
resolve_one_percentage(child, 'flex_basis', available_main_space)
flex_basis = child.flex_basis
# "If a value would resolve to auto for width, it instead resolves
# to content for flex-basis." Let's do this for height too.
# See https://www.w3.org/TR/css-flexbox-1/#propdef-flex-basis
resolve_one_percentage(child, axis, available_main_space)
if flex_basis == 'auto':
if child.style[axis] == 'auto':
flex_basis = 'content'
else:
if axis == 'width':
flex_basis = child.border_width()
if child.margin_left != 'auto':
flex_basis += child.margin_left
if child.margin_right != 'auto':
flex_basis += child.margin_right
else:
flex_basis = child.border_height()
if child.margin_top != 'auto':
flex_basis += child.margin_top
if child.margin_bottom != 'auto':
flex_basis += child.margin_bottom
# Step 3.A
if flex_basis != 'content':
child.flex_base_size = flex_basis
# TODO: Step 3.B
# TODO: Step 3.C
# Step 3.D is useless, as we never have infinite sizes on paged media
# Step 3.E
else:
child.style[axis] = 'max-content'
# TODO: don't set style value, support *-content values instead
if child.style[axis] == 'max-content':
child.style[axis] = 'auto'
if axis == 'width':
child.flex_base_size = max_content_width(context, child)
else:
if isinstance(child, boxes.ParentBox):
new_child = child.copy_with_children(child.children)
else:
new_child = child.copy()
new_child.width = float('inf')
new_child = blocks.block_level_layout(
context, new_child, float('inf'), child_skip_stack,
parent_box, page_is_empty, absolute_boxes, fixed_boxes,
adjoining_margins=[])[0]
child.flex_base_size = new_child.margin_height()
elif child.style[axis] == 'min-content':
child.style[axis] = 'auto'
if axis == 'width':
child.flex_base_size = min_content_width(context, child)
else:
if isinstance(child, boxes.ParentBox):
new_child = child.copy_with_children(child.children)
else:
new_child = child.copy()
new_child.width = 0
new_child = blocks.block_level_layout(
context, new_child, float('inf'), child_skip_stack,
parent_box, page_is_empty, absolute_boxes, fixed_boxes,
adjoining_margins=[])[0]
child.flex_base_size = new_child.margin_height()
else:
assert child.style[axis].unit == 'px'
# TODO: should we add padding, borders and margins?
child.flex_base_size = child.style[axis].value
child.hypothetical_main_size = max(
getattr(child, 'min_%s' % axis), min(
child.flex_base_size, getattr(child, 'max_%s' % axis)))
# Skip stack is only for the first child
child_skip_stack = None
# Step 4
# TODO: the whole step has to be fixed
if axis == 'width':
blocks.block_level_width(box, containing_block)
else:
if box.style['height'] != 'auto':
box.height = box.style['height'].value
else:
box.height = 0
for i, child in enumerate(children):
if not child.is_flex_item:
continue
child_height = (
child.hypothetical_main_size +
child.border_top_width + child.border_bottom_width +
child.padding_top + child.padding_bottom)
if getattr(box, axis) == 'auto' and (
child_height + box.height > available_main_space):
resume_at = (i, None)
children = children[:i + 1]
break
box.height += child_height
# Step 5
flex_lines = []
line = []
line_size = 0
axis_size = getattr(box, axis)
for i, child in enumerate(
sorted(children, key=lambda item: item.style['order'])):
if not child.is_flex_item:
continue
line_size += child.hypothetical_main_size
if box.style['flex_wrap'] != 'nowrap' and line_size > axis_size:
if line:
flex_lines.append(FlexLine(line))
line = [(i, child)]
line_size = child.hypothetical_main_size
else:
line.append((i, child))
flex_lines.append(FlexLine(line))
line = []
line_size = 0
else:
line.append((i, child))
if line:
flex_lines.append(FlexLine(line))
# TODO: handle *-reverse using the terminology from the specification
if box.style['flex_wrap'] == 'wrap-reverse':
flex_lines.reverse()
if box.style['flex_direction'].endswith('-reverse'):
for line in flex_lines:
line.reverse()
# Step 6
# See https://www.w3.org/TR/css-flexbox-1/#resolve-flexible-lengths
for line in flex_lines:
# Step 6 - 9.7.1
hypothetical_main_size = sum(
child.hypothetical_main_size for i, child in line)
if hypothetical_main_size < available_main_space:
flex_factor_type = 'grow'
else:
flex_factor_type = 'shrink'
# Step 6 - 9.7.2
for i, child in line:
if flex_factor_type == 'grow':
child.flex_factor = child.style['flex_grow']
else:
child.flex_factor = child.style['flex_shrink']
if (child.flex_factor == 0 or
(flex_factor_type == 'grow' and
child.flex_base_size > child.hypothetical_main_size) or
(flex_factor_type == 'shrink' and
child.flex_base_size < child.hypothetical_main_size)):
child.target_main_size = child.hypothetical_main_size
child.frozen = True
else:
child.frozen = False
# Step 6 - 9.7.3
initial_free_space = available_main_space
for i, child in line:
if child.frozen:
initial_free_space -= child.target_main_size
else:
initial_free_space -= child.flex_base_size
# Step 6 - 9.7.4
while not all(child.frozen for i, child in line):
unfrozen_factor_sum = 0
remaining_free_space = available_main_space
# Step 6 - 9.7.4.b
for i, child in line:
if child.frozen:
remaining_free_space -= child.target_main_size
else:
remaining_free_space -= child.flex_base_size
unfrozen_factor_sum += child.flex_factor
if unfrozen_factor_sum < 1:
initial_free_space *= unfrozen_factor_sum
if initial_free_space == float('inf'):
initial_free_space = sys.maxsize
if remaining_free_space == float('inf'):
remaining_free_space = sys.maxsize
initial_magnitude = (
int(log10(initial_free_space)) if initial_free_space > 0
else -float('inf'))
remaining_magnitude = (
int(log10(remaining_free_space)) if remaining_free_space > 0
else -float('inf'))
if initial_magnitude < remaining_magnitude:
remaining_free_space = initial_free_space
# Step 6 - 9.7.4.c
if remaining_free_space == 0:
# "Do nothing", but we at least set the flex_base_size as
# target_main_size for next step.
for i, child in line:
if not child.frozen:
child.target_main_size = child.flex_base_size
else:
scaled_flex_shrink_factors_sum = 0
flex_grow_factors_sum = 0
for i, child in line:
if not child.frozen:
child.scaled_flex_shrink_factor = (
child.flex_base_size * child.style['flex_shrink'])
scaled_flex_shrink_factors_sum += (
child.scaled_flex_shrink_factor)
flex_grow_factors_sum += child.style['flex_grow']
for i, child in line:
if not child.frozen:
if flex_factor_type == 'grow':
ratio = (
child.style['flex_grow'] /
flex_grow_factors_sum)
child.target_main_size = (
child.flex_base_size +
remaining_free_space * ratio)
elif flex_factor_type == 'shrink':
if scaled_flex_shrink_factors_sum == 0:
child.target_main_size = child.flex_base_size
else:
ratio = (
child.scaled_flex_shrink_factor /
scaled_flex_shrink_factors_sum)
child.target_main_size = (
child.flex_base_size +
remaining_free_space * ratio)
# Step 6 - 9.7.4.d
# TODO: First part of this step is useless until 3.E is correct
for i, child in line:
child.adjustment = 0
if not child.frozen and child.target_main_size < 0:
child.adjustment = -child.target_main_size
child.target_main_size = 0
# Step 6 - 9.7.4.e
adjustments = sum(child.adjustment for i, child in line)
for i, child in line:
if adjustments == 0:
child.frozen = True
elif adjustments > 0 and child.adjustment > 0:
child.frozen = True
elif adjustments < 0 and child.adjustment < 0:
child.frozen = True
# Step 6 - 9.7.5
for i, child in line:
if axis == 'width':
child.width = (
child.target_main_size -
child.padding_left - child.padding_right -
child.border_left_width - child.border_right_width)
if child.margin_left != 'auto':
child.width -= child.margin_left
if child.margin_right != 'auto':
child.width -= child.margin_right
else:
child.height = (
child.target_main_size -
child.padding_top - child.padding_bottom -
child.border_top_width - child.border_top_width)
if child.margin_left != 'auto':
child.height -= child.margin_left
if child.margin_right != 'auto':
child.height -= child.margin_right
# Step 7
# TODO: Fix TODO in build.flex_children
# TODO: Handle breaks
new_flex_lines = []
child_skip_stack = skip_stack
for line in flex_lines:
new_flex_line = FlexLine()
for i, child in line:
# TODO: Find another way than calling block_level_layout_switch to
# get baseline and child.height
if child.margin_top == 'auto':
child.margin_top = 0
if child.margin_bottom == 'auto':
child.margin_bottom = 0
if isinstance(child, boxes.ParentBox):
child_copy = child.copy_with_children(child.children)
else:
child_copy = child.copy()
blocks.block_level_width(child_copy, parent_box)
new_child, _, _, adjoining_margins, _ = (
blocks.block_level_layout_switch(
context, child_copy, float('inf'), child_skip_stack,
parent_box, page_is_empty, absolute_boxes, fixed_boxes,
adjoining_margins=[]))
child._baseline = find_in_flow_baseline(new_child) or 0
if cross == 'height':
child.height = new_child.height
# As flex items margins never collapse (with other flex items
# or with the flex container), we can add the adjoining margins
# to the child bottom margin.
child.margin_bottom += blocks.collapse_margin(
adjoining_margins)
else:
child.width = min_content_width(context, child, outer=False)
new_flex_line.append((i, child))
# Skip stack is only for the first child
child_skip_stack = None
if new_flex_line:
new_flex_lines.append(new_flex_line)
flex_lines = new_flex_lines
# Step 8
cross_size = getattr(box, cross)
if len(flex_lines) == 1 and cross_size != 'auto':
flex_lines[0].cross_size = cross_size
else:
for line in flex_lines:
collected_items = []
not_collected_items = []
for i, child in line:
align_self = child.style['align_self']
if (box.style['flex_direction'].startswith('row') and
align_self == 'baseline' and
child.margin_top != 'auto' and
child.margin_bottom != 'auto'):
collected_items.append(child)
else:
not_collected_items.append(child)
cross_start_distance = 0
cross_end_distance = 0
for child in collected_items:
baseline = child._baseline - child.position_y
cross_start_distance = max(cross_start_distance, baseline)
cross_end_distance = max(
cross_end_distance, child.margin_height() - baseline)
collected_cross_size = cross_start_distance + cross_end_distance
non_collected_cross_size = 0
if not_collected_items:
non_collected_cross_size = float('-inf')
for child in not_collected_items:
if cross == 'height':
child_cross_size = child.border_height()
if child.margin_top != 'auto':
child_cross_size += child.margin_top
if child.margin_bottom != 'auto':
child_cross_size += child.margin_bottom
else:
child_cross_size = child.border_width()
if child.margin_left != 'auto':
child_cross_size += child.margin_left
if child.margin_right != 'auto':
child_cross_size += child.margin_right
non_collected_cross_size = max(
child_cross_size, non_collected_cross_size)
line.cross_size = max(
collected_cross_size, non_collected_cross_size)
if len(flex_lines) == 1:
line, = flex_lines
min_cross_size = getattr(box, 'min_%s' % cross)
if min_cross_size == 'auto':
min_cross_size = float('-inf')
max_cross_size = getattr(box, 'max_%s' % cross)
if max_cross_size == 'auto':
max_cross_size = float('inf')
line.cross_size = max(
min_cross_size, min(line.cross_size, max_cross_size))
# Step 9
if box.style['align_content'] == 'stretch':
definite_cross_size = None
if cross == 'height' and box.style['height'] != 'auto':
definite_cross_size = box.style['height'].value
elif cross == 'width':
if isinstance(box, boxes.FlexBox):
if box.style['width'] == 'auto':
definite_cross_size = available_cross_space
else:
definite_cross_size = box.style['width'].value
if definite_cross_size is not None:
extra_cross_size = definite_cross_size - sum(
line.cross_size for line in flex_lines)
if extra_cross_size:
for line in flex_lines:
line.cross_size += extra_cross_size / len(flex_lines)
# TODO: Step 10
# Step 11
for line in flex_lines:
for i, child in line:
align_self = child.style['align_self']
if align_self == 'auto':
align_self = box.style['align_items']
if align_self == 'stretch' and child.style[cross] == 'auto':
cross_margins = (
(child.margin_top, child.margin_bottom)
if cross == 'height'
else (child.margin_left, child.margin_right))
if child.style[cross] == 'auto':
if 'auto' not in cross_margins:
cross_size = line.cross_size
if cross == 'height':
cross_size -= (
child.margin_top + child.margin_bottom +
child.padding_top + child.padding_bottom +
child.border_top_width +
child.border_bottom_width)
else:
cross_size -= (
child.margin_left + child.margin_right +
child.padding_left + child.padding_right +
child.border_left_width +
child.border_right_width)
setattr(child, cross, cross_size)
# TODO: redo layout?
# else: Cross size has been set by step 7
# Step 12
# TODO: handle rtl
original_position_axis = (
box.content_box_x() if axis == 'width'
else box.content_box_y())
justify_content = box.style['justify_content']
if box.style['flex_direction'].endswith('-reverse'):
if justify_content == 'flex-start':
justify_content = 'flex-end'
elif justify_content == 'flex-end':
justify_content = 'flex-start'
for line in flex_lines:
position_axis = original_position_axis
if axis == 'width':
free_space = box.width
for i, child in line:
free_space -= child.border_width()
if child.margin_left != 'auto':
free_space -= child.margin_left
if child.margin_right != 'auto':
free_space -= child.margin_right
else:
free_space = box.height
for i, child in line:
free_space -= child.border_height()
if child.margin_top != 'auto':
free_space -= child.margin_top
if child.margin_bottom != 'auto':
free_space -= child.margin_bottom
margins = 0
for i, child in line:
if axis == 'width':
if child.margin_left == 'auto':
margins += 1
if child.margin_right == 'auto':
margins += 1
else:
if child.margin_top == 'auto':
margins += 1
if child.margin_bottom == 'auto':
margins += 1
if margins:
free_space /= margins
for i, child in line:
if axis == 'width':
if child.margin_left == 'auto':
child.margin_left = free_space
if child.margin_right == 'auto':
child.margin_right = free_space
else:
if child.margin_top == 'auto':
child.margin_top = free_space
if child.margin_bottom == 'auto':
child.margin_bottom = free_space
free_space = 0
if justify_content == 'flex-end':
position_axis += free_space
elif justify_content == 'center':
position_axis += free_space / 2
elif justify_content == 'space-around':
position_axis += free_space / len(line) / 2
elif justify_content == 'space-evenly':
position_axis += free_space / (len(line) + 1)
for i, child in line:
if axis == 'width':
child.position_x = position_axis
if justify_content == 'stretch':
child.width += free_space / len(line)
else:
child.position_y = position_axis
position_axis += (
child.margin_width() if axis == 'width'
else child.margin_height())
if justify_content == 'space-around':
position_axis += free_space / len(line)
elif justify_content == 'space-between':
if len(line) > 1:
position_axis += free_space / (len(line) - 1)
elif justify_content == 'space-evenly':
position_axis += free_space / (len(line) + 1)
# Step 13
position_cross = (
box.content_box_y() if cross == 'height'
else box.content_box_x())
for line in flex_lines:
line.lower_baseline = 0
# TODO: don't duplicate this loop
for i, child in line:
align_self = child.style['align_self']
if align_self == 'auto':
align_self = box.style['align_items']
if align_self == 'baseline' and axis == 'width':
# TODO: handle vertical text
child.baseline = child._baseline - position_cross
line.lower_baseline = max(line.lower_baseline, child.baseline)
for i, child in line:
cross_margins = (
(child.margin_top, child.margin_bottom) if cross == 'height'
else (child.margin_left, child.margin_right))
auto_margins = sum([margin == 'auto' for margin in cross_margins])
if auto_margins:
extra_cross = line.cross_size
if cross == 'height':
extra_cross -= child.border_height()
if child.margin_top != 'auto':
extra_cross -= child.margin_top
if child.margin_bottom != 'auto':
extra_cross -= child.margin_bottom
else:
extra_cross -= child.border_width()
if child.margin_left != 'auto':
extra_cross -= child.margin_left
if child.margin_right != 'auto':
extra_cross -= child.margin_right
if extra_cross > 0:
extra_cross /= auto_margins
if cross == 'height':
if child.margin_top == 'auto':
child.margin_top = extra_cross
if child.margin_bottom == 'auto':
child.margin_bottom = extra_cross
else:
if child.margin_left == 'auto':
child.margin_left = extra_cross
if child.margin_right == 'auto':
child.margin_right = extra_cross
else:
if cross == 'height':
if child.margin_top == 'auto':
child.margin_top = 0
child.margin_bottom = extra_cross
else:
if child.margin_left == 'auto':
child.margin_left = 0
child.margin_right = extra_cross
else:
# Step 14
align_self = child.style['align_self']
if align_self == 'auto':
align_self = box.style['align_items']
position = 'position_y' if cross == 'height' else 'position_x'
setattr(child, position, position_cross)
if align_self == 'flex-end':
if cross == 'height':
child.position_y += (
line.cross_size - child.margin_height())
else:
child.position_x += (
line.cross_size - child.margin_width())
elif align_self == 'center':
if cross == 'height':
child.position_y += (
line.cross_size - child.margin_height()) / 2
else:
child.position_x += (
line.cross_size - child.margin_width()) / 2
elif align_self == 'baseline':
if cross == 'height':
child.position_y += (
line.lower_baseline - child.baseline)
else:
# Handle vertical text
pass
elif align_self == 'stretch':
if child.style[cross] == 'auto':
if cross == 'height':
margins = child.margin_top + child.margin_bottom
else:
margins = child.margin_left + child.margin_right
if child.style['box_sizing'] == 'content-box':
if cross == 'height':
margins += (
child.border_top_width +
child.border_bottom_width +
child.padding_top + child.padding_bottom)
else:
margins += (
child.border_left_width +
child.border_right_width +
child.padding_left + child.padding_right)
# TODO: don't set style width, find a way to avoid
# width re-calculation after Step 16
child.style[cross] = Dimension(
line.cross_size - margins, 'px')
position_cross += line.cross_size
# Step 15
if box.style[cross] == 'auto':
# TODO: handle min-max
setattr(box, cross, sum(line.cross_size for line in flex_lines))
# Step 16
elif len(flex_lines) > 1:
extra_cross_size = getattr(box, cross) - sum(
line.cross_size for line in flex_lines)
direction = 'position_y' if cross == 'height' else 'position_x'
if extra_cross_size > 0:
cross_translate = 0
for line in flex_lines:
for i, child in line:
if child.is_flex_item:
current_value = getattr(child, direction)
current_value += cross_translate
setattr(child, direction, current_value)
if box.style['align_content'] == 'flex-end':
setattr(
child, direction,
current_value + extra_cross_size)
elif box.style['align_content'] == 'center':
setattr(
child, direction,
current_value + extra_cross_size / 2)
elif box.style['align_content'] == 'space-around':
setattr(
child, direction,
current_value + extra_cross_size /
len(flex_lines) / 2)
elif box.style['align_content'] == 'space-evenly':
setattr(
child, direction,
current_value + extra_cross_size /
(len(flex_lines) + 1))
if box.style['align_content'] == 'space-between':
cross_translate += extra_cross_size / (len(flex_lines) - 1)
elif box.style['align_content'] == 'space-around':
cross_translate += extra_cross_size / len(flex_lines)
elif box.style['align_content'] == 'space-evenly':
cross_translate += extra_cross_size / (len(flex_lines) + 1)
# TODO: don't use block_box_layout, see TODOs in Step 14 and
# build.flex_children.
box = box.copy()
box.children = []
child_skip_stack = skip_stack
for line in flex_lines:
for i, child in line:
if child.is_flex_item:
new_child, child_resume_at = blocks.block_level_layout_switch(
context, child, max_position_y, child_skip_stack, box,
page_is_empty, absolute_boxes, fixed_boxes,
adjoining_margins=[])[:2]
if new_child is None:
if resume_at and resume_at[0]:
resume_at = (resume_at[0] + i - 1, None)
else:
box.children.append(new_child)
if child_resume_at is not None:
if original_skip_stack:
first_level_skip = original_skip_stack[0]
else:
first_level_skip = 0
if resume_at:
first_level_skip += resume_at[0]
resume_at = (first_level_skip + i, child_resume_at)
if resume_at:
break
# Skip stack is only for the first child
child_skip_stack = None
if resume_at:
break
# Set box height
# TODO: this is probably useless because of step #15
if axis == 'width' and box.height == 'auto':
if flex_lines:
box.height = sum(line.cross_size for line in flex_lines)
else:
box.height = 0
# Set baseline
# See https://www.w3.org/TR/css-flexbox-1/#flex-baselines
# TODO: use the real algorithm
if isinstance(box, boxes.InlineFlexBox):
if axis == 'width': # and main text direction is horizontal
box.baseline = flex_lines[0].lower_baseline if flex_lines else 0
else:
box.baseline = ((
find_in_flow_baseline(box.children[0])
if box.children else 0) or 0)
context.finish_block_formatting_context(box)
# TODO: check these returned values
return box, resume_at, {'break': 'any', 'page': None}, [], False

201
weasyprint/layout/float.py Normal file
View File

@ -0,0 +1,201 @@
"""
weasyprint.float
----------------
Layout for floating boxes.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..formatting_structure import boxes
from .min_max import handle_min_max_width
from .percentages import resolve_percentages, resolve_position_percentages
from .preferred import shrink_to_fit
from .tables import table_wrapper_width
@handle_min_max_width
def float_width(box, context, containing_block):
# Check that box.width is auto even if the caller does it too, because
# the handle_min_max_width decorator can change the value
if box.width == 'auto':
box.width = shrink_to_fit(context, box, containing_block.width)
def float_layout(context, box, containing_block, absolute_boxes, fixed_boxes):
"""Set the width and position of floating ``box``."""
# Avoid circular imports
from .blocks import block_container_layout
from .flex import flex_layout
from .inlines import inline_replaced_box_width_height
cb_width, cb_height = (containing_block.width, containing_block.height)
resolve_percentages(box, (cb_width, cb_height))
# TODO: This is only handled later in blocks.block_container_layout
# http://www.w3.org/TR/CSS21/visudet.html#normal-block
if cb_height == 'auto':
cb_height = (
containing_block.position_y - containing_block.content_box_y())
resolve_position_percentages(box, (cb_width, cb_height))
if box.margin_left == 'auto':
box.margin_left = 0
if box.margin_right == 'auto':
box.margin_right = 0
if box.margin_top == 'auto':
box.margin_top = 0
if box.margin_bottom == 'auto':
box.margin_bottom = 0
clearance = get_clearance(context, box)
if clearance is not None:
box.position_y += clearance
if isinstance(box, boxes.BlockReplacedBox):
inline_replaced_box_width_height(box, containing_block)
elif box.width == 'auto':
float_width(box, context, containing_block)
if box.is_table_wrapper:
table_wrapper_width(context, box, (cb_width, cb_height))
if isinstance(box, boxes.BlockContainerBox):
context.create_block_formatting_context()
box, _, _, _, _ = block_container_layout(
context, box, max_position_y=float('inf'),
skip_stack=None, page_is_empty=False,
absolute_boxes=absolute_boxes, fixed_boxes=fixed_boxes,
adjoining_margins=None)
context.finish_block_formatting_context(box)
elif isinstance(box, boxes.FlexContainerBox):
box, _, _, _, _ = flex_layout(
context, box, max_position_y=float('inf'),
skip_stack=None, containing_block=containing_block,
page_is_empty=False, absolute_boxes=absolute_boxes,
fixed_boxes=fixed_boxes)
else:
assert isinstance(box, boxes.BlockReplacedBox)
box = find_float_position(context, box, containing_block)
context.excluded_shapes.append(box)
return box
def find_float_position(context, box, containing_block):
"""Get the right position of the float ``box``."""
# See http://www.w3.org/TR/CSS2/visuren.html#float-position
# Point 4 is already handled as box.position_y is set according to the
# containing box top position, with collapsing margins handled
# Points 5 and 6, box.position_y is set to the highest position_y possible
if context.excluded_shapes:
highest_y = context.excluded_shapes[-1].position_y
if box.position_y < highest_y:
box.translate(0, highest_y - box.position_y)
# Points 1 and 2
position_x, position_y, available_width = avoid_collisions(
context, box, containing_block)
# Point 9
# position_y is set now, let's define position_x
# for float: left elements, it's already done!
if box.style['float'] == 'right':
position_x += available_width - box.margin_width()
box.translate(position_x - box.position_x, position_y - box.position_y)
return box
def get_clearance(context, box, collapsed_margin=0):
"""Return None if there is no clearance, otherwise the clearance value."""
clearance = None
hypothetical_position = box.position_y + collapsed_margin
# Hypothetical position is the position of the top border edge
for excluded_shape in context.excluded_shapes:
if box.style['clear'] in (excluded_shape.style['float'], 'both'):
y, h = excluded_shape.position_y, excluded_shape.margin_height()
if hypothetical_position < y + h:
clearance = max(
(clearance or 0), y + h - hypothetical_position)
return clearance
def avoid_collisions(context, box, containing_block, outer=True):
excluded_shapes = context.excluded_shapes
position_y = box.position_y if outer else box.border_box_y()
box_width = box.margin_width() if outer else box.border_width()
box_height = box.margin_height() if outer else box.border_height()
if box.border_height() == 0 and box.is_floated():
return 0, 0, containing_block.width
while True:
colliding_shapes = []
for shape in excluded_shapes:
# Assign locals to avoid slow attribute lookups.
shape_position_y = shape.position_y
shape_margin_height = shape.margin_height()
if ((shape_position_y < position_y <
shape_position_y + shape_margin_height) or
(shape_position_y < position_y + box_height <
shape_position_y + shape_margin_height) or
(shape_position_y >= position_y and
shape_position_y + shape_margin_height <=
position_y + box_height)):
colliding_shapes.append(shape)
left_bounds = [
shape.position_x + shape.margin_width()
for shape in colliding_shapes
if shape.style['float'] == 'left']
right_bounds = [
shape.position_x
for shape in colliding_shapes
if shape.style['float'] == 'right']
# Set the default maximum bounds
max_left_bound = containing_block.content_box_x()
max_right_bound = \
containing_block.content_box_x() + containing_block.width
if not outer:
max_left_bound += box.margin_left
max_right_bound -= box.margin_right
# Set the real maximum bounds according to sibling float elements
if left_bounds or right_bounds:
if left_bounds:
max_left_bound = max(max(left_bounds), max_left_bound)
if right_bounds:
max_right_bound = min(min(right_bounds), max_right_bound)
# Points 3, 7 and 8
if box_width > max_right_bound - max_left_bound:
# The box does not fit here
new_positon_y = min(
shape.position_y + shape.margin_height()
for shape in colliding_shapes)
if new_positon_y > position_y:
# We can find a solution with a higher position_y
position_y = new_positon_y
continue
# No solution, we must put the box here
break
position_x = max_left_bound
available_width = max_right_bound - max_left_bound
if not outer:
position_x -= box.margin_left
position_y -= box.margin_top
return position_x, position_y, available_width

1309
weasyprint/layout/inlines.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,52 @@
"""
weasyprint.layout.min_max
-------------------------
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import functools
def handle_min_max_width(function):
"""Decorate a function that sets the used width of a box to handle
{min,max}-width.
"""
@functools.wraps(function)
def wrapper(box, *args):
computed_margins = box.margin_left, box.margin_right
result = function(box, *args)
if box.width > box.max_width:
box.width = box.max_width
box.margin_left, box.margin_right = computed_margins
result = function(box, *args)
if box.width < box.min_width:
box.width = box.min_width
box.margin_left, box.margin_right = computed_margins
result = function(box, *args)
return result
wrapper.without_min_max = function
return wrapper
def handle_min_max_height(function):
"""Decorate a function that sets the used height of a box to handle
{min,max}-height.
"""
@functools.wraps(function)
def wrapper(box, *args):
computed_margins = box.margin_top, box.margin_bottom
result = function(box, *args)
if box.height > box.max_height:
box.height = box.max_height
box.margin_top, box.margin_bottom = computed_margins
result = function(box, *args)
if box.height < box.min_height:
box.height = box.min_height
box.margin_top, box.margin_bottom = computed_margins
result = function(box, *args)
return result
wrapper.without_min_max = function
return wrapper

816
weasyprint/layout/pages.py Normal file
View File

@ -0,0 +1,816 @@
"""
weasyprint.layout.pages
-----------------------
Layout for pages and CSS3 margin boxes.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import copy
from ..css import PageType, computed_from_cascaded
from ..formatting_structure import boxes, build
from ..logger import PROGRESS_LOGGER
from .absolute import absolute_layout
from .blocks import block_container_layout, block_level_layout
from .min_max import handle_min_max_height, handle_min_max_width
from .percentages import resolve_percentages
from .preferred import max_content_width, min_content_width
class OrientedBox(object):
@property
def sugar(self):
return self.padding_plus_border + self.margin_a + self.margin_b
@property
def outer(self):
return self.sugar + self.inner
@property
def outer_min_content_size(self):
return self.sugar + (
self.min_content_size if self.inner == 'auto' else self.inner)
@property
def outer_max_content_size(self):
return self.sugar + (
self.max_content_size if self.inner == 'auto' else self.inner)
def shrink_to_fit(self, available):
self.inner = min(
max(self.min_content_size, available), self.max_content_size)
class VerticalBox(OrientedBox):
def __init__(self, context, box):
self.context = context
self.box = box
# Inner dimension: that of the content area, as opposed to the
# outer dimension: that of the margin area.
self.inner = box.height
self.margin_a = box.margin_top
self.margin_b = box.margin_bottom
self.padding_plus_border = (
box.padding_top + box.padding_bottom +
box.border_top_width + box.border_bottom_width)
def restore_box_attributes(self):
box = self.box
box.height = self.inner
box.margin_top = self.margin_a
box.margin_bottom = self.margin_b
# TODO: Define what are the min-content and max-content heights
@property
def min_content_size(self):
return 0
@property
def max_content_size(self):
return 1e6
class HorizontalBox(OrientedBox):
def __init__(self, context, box):
self.context = context
self.box = box
self.inner = box.width
self.margin_a = box.margin_left
self.margin_b = box.margin_right
self.padding_plus_border = (
box.padding_left + box.padding_right +
box.border_left_width + box.border_right_width)
self._min_content_size = None
self._max_content_size = None
def restore_box_attributes(self):
box = self.box
box.width = self.inner
box.margin_left = self.margin_a
box.margin_right = self.margin_b
@property
def min_content_size(self):
if self._min_content_size is None:
self._min_content_size = min_content_width(
self.context, self.box, outer=False)
return self._min_content_size
@property
def max_content_size(self):
if self._max_content_size is None:
self._max_content_size = max_content_width(
self.context, self.box, outer=False)
return self._max_content_size
def compute_fixed_dimension(context, box, outer, vertical, top_or_left):
"""
Compute and set a margin box fixed dimension on ``box``, as described in:
http://dev.w3.org/csswg/css3-page/#margin-constraints
:param box:
The margin box to work on
:param outer:
The target outer dimension (value of a page margin)
:param vertical:
True to set height, margin-top and margin-bottom; False for width,
margin-left and margin-right
:param top_or_left:
True if the margin box in if the top half (for vertical==True) or
left half (for vertical==False) of the page.
This determines which margin should be 'auto' if the values are
over-constrained. (Rule 3 of the algorithm.)
"""
box = (VerticalBox if vertical else HorizontalBox)(context, box)
# Rule 2
total = box.padding_plus_border + sum(
value for value in [box.margin_a, box.margin_b, box.inner]
if value != 'auto')
if total > outer:
if box.margin_a == 'auto':
box.margin_a = 0
if box.margin_b == 'auto':
box.margin_b = 0
if box.inner == 'auto':
# XXX this is not in the spec, but without it box.inner
# would end up with a negative value.
# Instead, this will trigger rule 3 below.
# http://lists.w3.org/Archives/Public/www-style/2012Jul/0006.html
box.inner = 0
# Rule 3
if 'auto' not in [box.margin_a, box.margin_b, box.inner]:
# Over-constrained
if top_or_left:
box.margin_a = 'auto'
else:
box.margin_b = 'auto'
# Rule 4
if [box.margin_a, box.margin_b, box.inner].count('auto') == 1:
if box.inner == 'auto':
box.inner = (outer - box.padding_plus_border -
box.margin_a - box.margin_b)
elif box.margin_a == 'auto':
box.margin_a = (outer - box.padding_plus_border -
box.margin_b - box.inner)
elif box.margin_b == 'auto':
box.margin_b = (outer - box.padding_plus_border -
box.margin_a - box.inner)
# Rule 5
if box.inner == 'auto':
if box.margin_a == 'auto':
box.margin_a = 0
if box.margin_b == 'auto':
box.margin_b = 0
box.inner = (outer - box.padding_plus_border -
box.margin_a - box.margin_b)
# Rule 6
if box.margin_a == 'auto' and box.margin_b == 'auto':
box.margin_a = box.margin_b = (
outer - box.padding_plus_border - box.inner) / 2
assert 'auto' not in [box.margin_a, box.margin_b, box.inner]
box.restore_box_attributes()
def compute_variable_dimension(context, side_boxes, vertical, outer_sum):
"""
Compute and set a margin box fixed dimension on ``box``, as described in:
http://dev.w3.org/csswg/css3-page/#margin-dimension
:param side_boxes: Three boxes on a same side (as opposed to a corner.)
A list of:
- A @*-left or @*-top margin box
- A @*-center or @*-middle margin box
- A @*-right or @*-bottom margin box
:param vertical:
True to set height, margin-top and margin-bottom; False for width,
margin-left and margin-right
:param outer_sum:
The target total outer dimension (max box width or height)
"""
box_class = VerticalBox if vertical else HorizontalBox
side_boxes = [box_class(context, box) for box in side_boxes]
box_a, box_b, box_c = side_boxes
for box in side_boxes:
if box.margin_a == 'auto':
box.margin_a = 0
if box.margin_b == 'auto':
box.margin_b = 0
if box_b.box.is_generated:
if box_b.inner == 'auto':
ac_max_content_size = 2 * max(
box_a.outer_max_content_size, box_c.outer_max_content_size)
if outer_sum >= (
box_b.outer_max_content_size + ac_max_content_size):
box_b.inner = box_b.max_content_size
else:
ac_min_content_size = 2 * max(
box_a.outer_min_content_size,
box_c.outer_min_content_size)
box_b.inner = box_b.min_content_size
available = outer_sum - box_b.outer - ac_min_content_size
if available > 0:
weight_ac = ac_max_content_size - ac_min_content_size
weight_b = (
box_b.max_content_size - box_b.min_content_size)
weight_sum = weight_ac + weight_b
# By definition of max_content_size and min_content_size,
# weights can not be negative. weight_sum == 0 implies that
# max_content_size == min_content_size for each box, in
# which case the sum can not be both <= and > outer_sum
# Therefore, one of the last two 'if' statements would not
# have lead us here.
assert weight_sum > 0
box_b.inner += available * weight_b / weight_sum
if box_a.inner == 'auto':
box_a.shrink_to_fit((outer_sum - box_b.outer) / 2 - box_a.sugar)
if box_c.inner == 'auto':
box_c.shrink_to_fit((outer_sum - box_b.outer) / 2 - box_c.sugar)
else:
# Non-generated boxes get zero for every box-model property
assert box_b.inner == 0
if box_a.inner == box_c.inner == 'auto':
if outer_sum >= (
box_a.outer_max_content_size +
box_c.outer_max_content_size):
box_a.inner = box_a.max_content_size
box_c.inner = box_c.max_content_size
else:
box_a.inner = box_a.min_content_size
box_c.inner = box_c.min_content_size
available = outer_sum - box_a.outer - box_c.outer
if available > 0:
weight_a = (
box_a.max_content_size - box_a.min_content_size)
weight_c = (
box_c.max_content_size - box_c.min_content_size)
weight_sum = weight_a + weight_c
# By definition of max_content_size and min_content_size,
# weights can not be negative. weight_sum == 0 implies that
# max_content_size == min_content_size for each box, in
# which case the sum can not be both <= and > outer_sum
# Therefore, one of the last two 'if' statements would not
# have lead us here.
assert weight_sum > 0
box_a.inner += available * weight_a / weight_sum
box_c.inner += available * weight_c / weight_sum
elif box_a.inner == 'auto':
box_a.shrink_to_fit(outer_sum - box_c.outer - box_a.sugar)
elif box_c.inner == 'auto':
box_c.shrink_to_fit(outer_sum - box_a.outer - box_c.sugar)
# And, were done!
assert 'auto' not in [box.inner for box in side_boxes]
# Set the actual attributes back.
for box in side_boxes:
box.restore_box_attributes()
def _standardize_page_based_counters(style, pseudo_type):
"""Drop 'pages' counter from style in @page and @margin context.
Ensure `counter-increment: page` for @page context if not otherwise
manipulated by the style.
"""
page_counter_touched = False
# XXX 'counter-set` not yet supported
for propname in ('counter_reset', 'counter_increment'):
if style[propname] == 'auto':
style[propname] = ()
continue
justified_values = []
for name, value in style[propname]:
if name == 'page':
page_counter_touched = True
if name != 'pages':
justified_values.append((name, value))
style[propname] = tuple(justified_values)
if pseudo_type is None and not page_counter_touched:
style['counter_increment'] = (
('page', 1),) + style['counter_increment']
def make_margin_boxes(context, page, state):
"""Yield laid-out margin boxes for this page.
``state`` is the actual, up-to-date page-state from
``context.page_maker[context.current_page]``.
"""
# This is a closure only to make calls shorter
def make_box(at_keyword, containing_block):
"""Return a margin box with resolved percentages.
The margin box may still have 'auto' values.
Return ``None`` if this margin box should not be generated.
:param at_keyword: which margin box to return, eg. '@top-left'
:param containing_block: as expected by :func:`resolve_percentages`.
"""
style = context.style_for(page.page_type, at_keyword)
if style is None:
# doesn't affect counters
style = computed_from_cascaded(
element=None, cascaded={}, parent_style=page.style)
_standardize_page_based_counters(style, at_keyword)
box = boxes.MarginBox(at_keyword, style)
# Empty boxes should not be generated, but they may be needed for
# the layout of their neighbors.
# TODO: should be the computed value.
box.is_generated = style['content'] not in (
'normal', 'inhibit', 'none')
# TODO: get actual counter values at the time of the last page break
if box.is_generated:
# @margins mustn't manipulate page-context counters
margin_state = copy.deepcopy(state)
quote_depth, counter_values, counter_scopes = margin_state
# TODO: check this, probably useless
counter_scopes.append(set())
build.update_counters(margin_state, box.style)
box.children = build.content_to_boxes(
box.style, box, quote_depth, counter_values,
context.get_image_from_uri, context.target_collector, context,
page)
build.process_whitespace(box)
box = build.anonymous_table_boxes(box)
box = build.flex_boxes(box)
box = build.inline_in_block(box)
box = build.block_in_inline(box)
resolve_percentages(box, containing_block)
if not box.is_generated:
box.width = box.height = 0
for side in ('top', 'right', 'bottom', 'left'):
box._reset_spacing(side)
return box
margin_top = page.margin_top
margin_bottom = page.margin_bottom
margin_left = page.margin_left
margin_right = page.margin_right
max_box_width = page.border_width()
max_box_height = page.border_height()
# bottom right corner of the border box
page_end_x = margin_left + max_box_width
page_end_y = margin_top + max_box_height
# Margin box dimensions, described in
# http://dev.w3.org/csswg/css3-page/#margin-box-dimensions
generated_boxes = []
for prefix, vertical, containing_block, position_x, position_y in [
('top', False, (max_box_width, margin_top),
margin_left, 0),
('bottom', False, (max_box_width, margin_bottom),
margin_left, page_end_y),
('left', True, (margin_left, max_box_height),
0, margin_top),
('right', True, (margin_right, max_box_height),
page_end_x, margin_top),
]:
if vertical:
suffixes = ['top', 'middle', 'bottom']
fixed_outer, variable_outer = containing_block
else:
suffixes = ['left', 'center', 'right']
variable_outer, fixed_outer = containing_block
side_boxes = [make_box('@%s-%s' % (prefix, suffix), containing_block)
for suffix in suffixes]
if not any(box.is_generated for box in side_boxes):
continue
# We need the three boxes together for the variable dimension:
compute_variable_dimension(
context, side_boxes, vertical, variable_outer)
for box, offset in zip(side_boxes, [0, 0.5, 1]):
if not box.is_generated:
continue
box.position_x = position_x
box.position_y = position_y
if vertical:
box.position_y += offset * (
variable_outer - box.margin_height())
else:
box.position_x += offset * (
variable_outer - box.margin_width())
compute_fixed_dimension(
context, box, fixed_outer, not vertical,
prefix in ['top', 'left'])
generated_boxes.append(box)
# Corner boxes
for at_keyword, cb_width, cb_height, position_x, position_y in [
('@top-left-corner', margin_left, margin_top, 0, 0),
('@top-right-corner', margin_right, margin_top, page_end_x, 0),
('@bottom-left-corner', margin_left, margin_bottom, 0, page_end_y),
('@bottom-right-corner', margin_right, margin_bottom,
page_end_x, page_end_y),
]:
box = make_box(at_keyword, (cb_width, cb_height))
if not box.is_generated:
continue
box.position_x = position_x
box.position_y = position_y
compute_fixed_dimension(
context, box, cb_height, True, 'top' in at_keyword)
compute_fixed_dimension(
context, box, cb_width, False, 'left' in at_keyword)
generated_boxes.append(box)
for box in generated_boxes:
yield margin_box_content_layout(context, page, box)
def margin_box_content_layout(context, page, box):
"""Layout a margin boxs content once the box has dimensions."""
box, resume_at, next_page, _, _ = block_container_layout(
context, box,
max_position_y=float('inf'), skip_stack=None,
page_is_empty=True, absolute_boxes=[], fixed_boxes=[])
assert resume_at is None
vertical_align = box.style['vertical_align']
# Every other value is read as 'top', ie. no change.
if vertical_align in ('middle', 'bottom') and box.children:
first_child = box.children[0]
last_child = box.children[-1]
top = first_child.position_y
# Not always exact because floating point errors
# assert top == box.content_box_y()
bottom = last_child.position_y + last_child.margin_height()
content_height = bottom - top
offset = box.height - content_height
if vertical_align == 'middle':
offset /= 2
for child in box.children:
child.translate(0, offset)
return box
def page_width_or_height(box, containing_block_size):
"""Take a :class:`OrientedBox` object and set either width, margin-left
and margin-right; or height, margin-top and margin-bottom.
"The width and horizontal margins of the page box are then calculated
exactly as for a non-replaced block element in normal flow. The height
and vertical margins of the page box are calculated analogously (instead
of using the block height formulas). In both cases if the values are
over-constrained, instead of ignoring any margins, the containing block
is resized to coincide with the margin edges of the page box."
http://dev.w3.org/csswg/css3-page/#page-box-page-rule
http://www.w3.org/TR/CSS21/visudet.html#blockwidth
"""
remaining = containing_block_size - box.padding_plus_border
if box.inner == 'auto':
if box.margin_a == 'auto':
box.margin_a = 0
if box.margin_b == 'auto':
box.margin_b = 0
box.inner = remaining - box.margin_a - box.margin_b
elif box.margin_a == box.margin_b == 'auto':
box.margin_a = box.margin_b = (remaining - box.inner) / 2
elif box.margin_a == 'auto':
box.margin_a = remaining - box.inner - box.margin_b
elif box.margin_b == 'auto':
box.margin_b = remaining - box.inner - box.margin_a
box.restore_box_attributes()
@handle_min_max_width
def page_width(box, context, containing_block_width):
page_width_or_height(HorizontalBox(context, box), containing_block_width)
@handle_min_max_height
def page_height(box, context, containing_block_height):
page_width_or_height(VerticalBox(context, box), containing_block_height)
def make_page(context, root_box, page_type, resume_at, page_number,
page_state):
"""Take just enough content from the beginning to fill one page.
Return ``(page, finished)``. ``page`` is a laid out PageBox object
and ``resume_at`` indicates where in the document to start the next page,
or is ``None`` if this was the last page.
:param page_number: integer, start at 1 for the first page
:param resume_at: as returned by ``make_page()`` for the previous page,
or ``None`` for the first page.
"""
style = context.style_for(page_type)
# Propagated from the root or <body>.
style['overflow'] = root_box.viewport_overflow
page = boxes.PageBox(page_type, style)
device_size = page.style['size']
resolve_percentages(page, device_size)
page.position_x = 0
page.position_y = 0
cb_width, cb_height = device_size
page_width(page, context, cb_width)
page_height(page, context, cb_height)
root_box.position_x = page.content_box_x()
root_box.position_y = page.content_box_y()
page_content_bottom = root_box.position_y + page.height
initial_containing_block = page
if page_type.blank:
previous_resume_at = resume_at
root_box = root_box.copy_with_children([])
# TODO: handle cases where the root element is something else.
# See http://www.w3.org/TR/CSS21/visuren.html#dis-pos-flo
assert isinstance(root_box, (boxes.BlockBox, boxes.FlexContainerBox))
context.create_block_formatting_context()
context.current_page = page_number
page_is_empty = True
adjoining_margins = []
positioned_boxes = [] # Mixed absolute and fixed
root_box, resume_at, next_page, _, _ = block_level_layout(
context, root_box, page_content_bottom, resume_at,
initial_containing_block, page_is_empty, positioned_boxes,
positioned_boxes, adjoining_margins)
assert root_box
page.fixed_boxes = [
placeholder._box for placeholder in positioned_boxes
if placeholder._box.style['position'] == 'fixed']
for absolute_box in positioned_boxes:
absolute_layout(context, absolute_box, page, positioned_boxes)
context.finish_block_formatting_context(root_box)
page.children = [root_box]
descendants = page.descendants()
# Update page counter values
_standardize_page_based_counters(style, None)
build.update_counters(page_state, style)
page_counter_values = page_state[1]
# page_counter_values will be cached in the page_maker
target_collector = context.target_collector
page_maker = context.page_maker
# remake_state tells the make_all_pages-loop in layout_document()
# whether and what to re-make.
remake_state = page_maker[page_number - 1][-1]
# Evaluate and cache page values only once (for the first LineBox)
# otherwise we suffer endless loops when the target/pseudo-element
# spans across multiple pages
cached_anchors = []
cached_lookups = []
for (_, _, _, _, x_remake_state) in page_maker[:page_number - 1]:
cached_anchors.extend(x_remake_state.get('anchors', []))
cached_lookups.extend(x_remake_state.get('content_lookups', []))
for child in descendants:
# Cache target's page counters
anchor = child.style['anchor']
if anchor and anchor not in cached_anchors:
remake_state['anchors'].append(anchor)
cached_anchors.append(anchor)
# Re-make of affected targeting boxes is inclusive
target_collector.cache_target_page_counters(
anchor, page_counter_values, page_number - 1, page_maker)
# string-set and bookmark-labels don't create boxes, only `content`
# requires another call to make_page. There is maximum one 'content'
# item per box.
# TODO: remove attribute or set a default value in Box class
if hasattr(child, 'missing_link'):
# A CounterLookupItem exists for the css-token 'content'
counter_lookup = target_collector.counter_lookup_items.get(
(child.missing_link, 'content'))
else:
counter_lookup = None
# Resolve missing (page based) counters
if counter_lookup is not None:
call_parse_again = False
# Prevent endless loops
counter_lookup_id = id(counter_lookup)
refresh_missing_counters = counter_lookup_id not in cached_lookups
if refresh_missing_counters:
remake_state['content_lookups'].append(counter_lookup_id)
cached_lookups.append(counter_lookup_id)
counter_lookup.page_maker_index = page_number - 1
# Step 1: page based back-references
# Marked as pending by target_collector.cache_target_page_counters
if counter_lookup.pending:
if (page_counter_values !=
counter_lookup.cached_page_counter_values):
counter_lookup.cached_page_counter_values = copy.deepcopy(
page_counter_values)
counter_lookup.pending = False
call_parse_again = True
# Step 2: local counters
# If the box mixed-in page counters changed, update the content
# and cache the new values.
missing_counters = counter_lookup.missing_counters
if missing_counters:
if 'pages' in missing_counters:
remake_state['pages_wanted'] = True
if refresh_missing_counters and page_counter_values != \
counter_lookup.cached_page_counter_values:
counter_lookup.cached_page_counter_values = \
copy.deepcopy(page_counter_values)
for counter_name in missing_counters:
counter_value = page_counter_values.get(
counter_name, None)
if counter_value is not None:
call_parse_again = True
# no need to loop them all
break
# Step 3: targeted counters
target_missing = counter_lookup.missing_target_counters
for anchor_name, missed_counters in target_missing.items():
if 'pages' not in missed_counters:
continue
# Adjust 'pages_wanted'
item = target_collector.target_lookup_items.get(
anchor_name, None)
page_maker_index = item.page_maker_index
if page_maker_index >= 0 and anchor_name in cached_anchors:
page_maker[page_maker_index][-1]['pages_wanted'] = True
# 'content_changed' is triggered in
# targets.cache_target_page_counters()
if call_parse_again:
remake_state['content_changed'] = True
counter_lookup.parse_again(page_counter_values)
if page_type.blank:
resume_at = previous_resume_at
return page, resume_at, next_page
def set_page_type_computed_styles(page_type, html, style_for):
"""Set style for page types and pseudo-types matching ``page_type``."""
style_for.add_page_declarations(page_type)
# Apply style for page
style_for.set_computed_styles(
page_type,
# @page inherits from the root element:
# http://lists.w3.org/Archives/Public/www-style/2012Jan/1164.html
root=html.etree_element, parent=html.etree_element,
base_url=html.base_url)
# Apply style for page pseudo-elements (margin boxes)
for element, pseudo_type in style_for.get_cascaded_styles():
if pseudo_type and element == page_type:
style_for.set_computed_styles(
element, pseudo_type=pseudo_type,
# The pseudo-element inherits from the element.
root=html.etree_element, parent=element,
base_url=html.base_url)
def remake_page(index, context, root_box, html):
"""Return one laid out page without margin boxes.
Start with the initial values from ``context.page_maker[index]``.
The resulting values / initial values for the next page are stored in
the ``page_maker``.
As the function's name suggests: the plan is not to make all pages
repeatedly when a missing counter was resolved, but rather re-make the
single page where the ``content_changed`` happened.
"""
page_maker = context.page_maker
(initial_resume_at, initial_next_page, right_page, initial_page_state,
remake_state) = page_maker[index]
# PageType for current page, values for page_maker[index + 1].
# Don't modify actual page_maker[index] values!
# TODO: should we store (and reuse) page_type in the page_maker?
page_state = copy.deepcopy(initial_page_state)
next_page_name = initial_next_page['page']
first = index == 0
if initial_next_page['break'] in ('left', 'right'):
next_page_side = initial_next_page['break']
elif initial_next_page['break'] in ('recto', 'verso'):
direction_ltr = root_box.style['direction'] == 'ltr'
break_verso = initial_next_page['break'] == 'verso'
next_page_side = 'right' if direction_ltr ^ break_verso else 'left'
else:
next_page_side = None
blank = ((next_page_side == 'left' and right_page) or
(next_page_side == 'right' and not right_page))
if blank:
next_page_name = ''
side = 'right' if right_page else 'left'
page_type = PageType(side, blank, first, index, name=next_page_name)
set_page_type_computed_styles(page_type, html, context.style_for)
context.forced_break = (
initial_next_page['break'] != 'any' or initial_next_page['page'])
context.margin_clearance = False
# make_page wants a page_number of index + 1
page_number = index + 1
page, resume_at, next_page = make_page(
context, root_box, page_type, initial_resume_at,
page_number, page_state)
assert next_page
if blank:
next_page['page'] = initial_next_page['page']
right_page = not right_page
# Check whether we need to append or update the next page_maker item
if index + 1 >= len(page_maker):
# New page
page_maker_next_changed = True
else:
# Check whether something changed
# TODO: Find what we need to compare. Is resume_at enough?
(next_resume_at, next_next_page, next_right_page,
next_page_state, _) = page_maker[index + 1]
page_maker_next_changed = (
next_resume_at != resume_at or
next_next_page != next_page or
next_right_page != right_page or
next_page_state != page_state)
if page_maker_next_changed:
# Reset remake_state
remake_state = {
'content_changed': False,
'pages_wanted': False,
'anchors': [],
'content_lookups': [],
}
# Setting content_changed to True ensures remake.
# If resume_at is None (last page) it must be False to prevent endless
# loops and list index out of range (see #794).
remake_state['content_changed'] = resume_at is not None
# page_state is already a deepcopy
item = resume_at, next_page, right_page, page_state, remake_state
if index + 1 >= len(page_maker):
page_maker.append(item)
else:
page_maker[index + 1] = item
return page, resume_at
def make_all_pages(context, root_box, html, pages):
"""Return a list of laid out pages without margin boxes.
Re-make pages only if necessary.
"""
i = 0
while True:
remake_state = context.page_maker[i][-1]
if (len(pages) == 0 or
remake_state['content_changed'] or
remake_state['pages_wanted']):
PROGRESS_LOGGER.info('Step 5 - Creating layout - Page %i', i + 1)
# Reset remake_state
remake_state['content_changed'] = False
remake_state['pages_wanted'] = False
remake_state['anchors'] = []
remake_state['content_lookups'] = []
page, resume_at = remake_page(i, context, root_box, html)
yield page
else:
PROGRESS_LOGGER.info(
'Step 5 - Creating layout - Page %i (up-to-date)', i + 1)
resume_at = context.page_maker[i + 1][0]
yield pages[i]
i += 1
if resume_at is None:
# Throw away obsolete pages
context.page_maker = context.page_maker[:i + 1]
return

View File

@ -0,0 +1,150 @@
"""
weasyprint.layout.percentages
-----------------------------
Resolve percentages into fixed values.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..formatting_structure import boxes
def percentage(value, refer_to):
"""Return the percentage of the reference value, or the value unchanged.
``refer_to`` is the length for 100%. If ``refer_to`` is not a number, it
just replaces percentages.
"""
if value is None or value == 'auto':
return value
elif value.unit == 'px':
return value.value
else:
assert value.unit == '%'
return refer_to * value.value / 100.
def resolve_one_percentage(box, property_name, refer_to,
main_flex_direction=None):
"""Set a used length value from a computed length value.
``refer_to`` is the length for 100%. If ``refer_to`` is not a number, it
just replaces percentages.
"""
# box.style has computed values
value = box.style[property_name]
# box attributes are used values
percent = percentage(value, refer_to)
setattr(box, property_name, percent)
if property_name in ('min_width', 'min_height') and percent == 'auto':
if (main_flex_direction is None or
property_name != ('min_%s' % main_flex_direction)):
setattr(box, property_name, 0)
def resolve_position_percentages(box, containing_block):
cb_width, cb_height = containing_block
resolve_one_percentage(box, 'left', cb_width)
resolve_one_percentage(box, 'right', cb_width)
resolve_one_percentage(box, 'top', cb_height)
resolve_one_percentage(box, 'bottom', cb_height)
def resolve_percentages(box, containing_block, main_flex_direction=None):
"""Set used values as attributes of the box object."""
if isinstance(containing_block, boxes.Box):
# cb is short for containing block
cb_width = containing_block.width
cb_height = containing_block.height
else:
cb_width, cb_height = containing_block
if isinstance(box, boxes.PageBox):
maybe_height = cb_height
else:
maybe_height = cb_width
resolve_one_percentage(box, 'margin_left', cb_width)
resolve_one_percentage(box, 'margin_right', cb_width)
resolve_one_percentage(box, 'margin_top', maybe_height)
resolve_one_percentage(box, 'margin_bottom', maybe_height)
resolve_one_percentage(box, 'padding_left', cb_width)
resolve_one_percentage(box, 'padding_right', cb_width)
resolve_one_percentage(box, 'padding_top', maybe_height)
resolve_one_percentage(box, 'padding_bottom', maybe_height)
resolve_one_percentage(box, 'width', cb_width)
resolve_one_percentage(box, 'min_width', cb_width, main_flex_direction)
resolve_one_percentage(box, 'max_width', cb_width, main_flex_direction)
# XXX later: top, bottom, left and right on positioned elements
if cb_height == 'auto':
# Special handling when the height of the containing block
# depends on its content.
height = box.style['height']
if height == 'auto' or height.unit == '%':
box.height = 'auto'
else:
assert height.unit == 'px'
box.height = height.value
resolve_one_percentage(box, 'min_height', 0, main_flex_direction)
resolve_one_percentage(
box, 'max_height', float('inf'), main_flex_direction)
else:
resolve_one_percentage(box, 'height', cb_height)
resolve_one_percentage(
box, 'min_height', cb_height, main_flex_direction)
resolve_one_percentage(
box, 'max_height', cb_height, main_flex_direction)
# Used value == computed value
for side in ['top', 'right', 'bottom', 'left']:
prop = 'border_{0}_width'.format(side)
setattr(box, prop, box.style[prop])
# Shrink *content* widths and heights according to box-sizing
# Thanks heavens and the spec: Our validator rejects negative values
# for padding and border-width
if box.style['box_sizing'] == 'border-box':
horizontal_delta = (
box.padding_left + box.padding_right +
box.border_left_width + box.border_right_width)
vertical_delta = (
box.padding_top + box.padding_bottom +
box.border_top_width + box.border_bottom_width)
elif box.style['box_sizing'] == 'padding-box':
horizontal_delta = box.padding_left + box.padding_right
vertical_delta = box.padding_top + box.padding_bottom
else:
assert box.style['box_sizing'] == 'content-box'
horizontal_delta = 0
vertical_delta = 0
# Keep at least min_* >= 0 to prevent funny output in case box.width or
# box.height become negative.
# Restricting max_* seems reasonable, too.
if horizontal_delta > 0:
if box.width != 'auto':
box.width = max(0, box.width - horizontal_delta)
box.max_width = max(0, box.max_width - horizontal_delta)
if box.min_width != 'auto':
box.min_width = max(0, box.min_width - horizontal_delta)
if vertical_delta > 0:
if box.height != 'auto':
box.height = max(0, box.height - vertical_delta)
box.max_height = max(0, box.max_height - vertical_delta)
if box.min_height != 'auto':
box.min_height = max(0, box.min_height - vertical_delta)
def resolve_radii_percentages(box):
corners = ('top_left', 'top_right', 'bottom_right', 'bottom_left')
for corner in corners:
property_name = 'border_%s_radius' % corner
rx, ry = box.style[property_name]
rx = percentage(rx, box.border_width())
ry = percentage(ry, box.border_height())
setattr(box, property_name, (rx, ry))

View File

@ -0,0 +1,747 @@
"""
weasyprint.layout.preferred
---------------------------
Preferred and minimum preferred width, aka. max-content and min-content
width, aka. the shrink-to-fit algorithm.
Terms used (max-content width, min-content width) are defined in David
Baron's unofficial draft (http://dbaron.org/css/intrinsic/).
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import sys
from .. import text
from ..formatting_structure import boxes
from .replaced import default_image_sizing
def shrink_to_fit(context, box, available_width):
"""Return the shrink-to-fit width of ``box``.
*Warning:* both available_outer_width and the return value are
for width of the *content area*, not margin area.
http://www.w3.org/TR/CSS21/visudet.html#float-width
"""
return min(
max(
min_content_width(context, box, outer=False),
available_width),
max_content_width(context, box, outer=False))
def min_content_width(context, box, outer=True):
"""Return the min-content width for ``box``.
This is the width by breaking at every line-break opportunity.
"""
if box.is_table_wrapper:
return table_and_columns_preferred_widths(context, box, outer)[0]
elif isinstance(box, boxes.TableCellBox):
return table_cell_min_content_width(context, box, outer)
elif isinstance(box, (
boxes.BlockContainerBox, boxes.TableColumnBox, boxes.FlexBox)):
return block_min_content_width(context, box, outer)
elif isinstance(box, boxes.TableColumnGroupBox):
return column_group_content_width(context, box)
elif isinstance(box, (boxes.InlineBox, boxes.LineBox)):
return inline_min_content_width(
context, box, outer, is_line_start=True)
elif isinstance(box, boxes.ReplacedBox):
return replaced_min_content_width(box, outer)
elif isinstance(box, boxes.FlexContainerBox):
return flex_min_content_width(context, box, outer)
else:
raise TypeError(
'min-content width for %s not handled yet' %
type(box).__name__)
def max_content_width(context, box, outer=True):
"""Return the max-content width for ``box``.
This is the width by only breaking at forced line breaks.
"""
if box.is_table_wrapper:
return table_and_columns_preferred_widths(context, box, outer)[1]
elif isinstance(box, boxes.TableCellBox):
return table_cell_max_content_width(context, box, outer)
elif isinstance(box, (
boxes.BlockContainerBox, boxes.TableColumnBox, boxes.FlexBox)):
return block_max_content_width(context, box, outer)
elif isinstance(box, boxes.TableColumnGroupBox):
return column_group_content_width(context, box)
elif isinstance(box, (boxes.InlineBox, boxes.LineBox)):
return inline_max_content_width(
context, box, outer, is_line_start=True)
elif isinstance(box, boxes.ReplacedBox):
return replaced_max_content_width(box, outer)
elif isinstance(box, boxes.FlexContainerBox):
return flex_max_content_width(context, box, outer)
else:
raise TypeError(
'max-content width for %s not handled yet' % type(box).__name__)
def _block_content_width(context, box, function, outer):
"""Helper to create ``block_*_content_width.``"""
width = box.style['width']
if width == 'auto' or width.unit == '%':
# "percentages on the following properties are treated instead as
# though they were the following: width: auto"
# http://dbaron.org/css/intrinsic/#outer-intrinsic
children_widths = [
function(context, child, outer=True) for child in box.children
if not child.is_absolutely_positioned()]
width = max(children_widths) if children_widths else 0
else:
assert width.unit == 'px'
width = width.value
return adjust(box, outer, width)
def min_max(box, width):
"""Get box width from given width and box min- and max-widths."""
min_width = box.style['min_width']
max_width = box.style['max_width']
if min_width == 'auto' or min_width.unit == '%':
min_width = 0
else:
min_width = min_width.value
if max_width == 'auto' or max_width.unit == '%':
max_width = float('inf')
else:
max_width = max_width.value
return max(min_width, min(width, max_width))
def margin_width(box, width, left=True, right=True):
"""Add box paddings, borders and margins to ``width``."""
percentages = 0
for value in (
(['margin_left', 'padding_left'] if left else []) +
(['margin_right', 'padding_right'] if right else [])
):
style_value = box.style[value]
if style_value != 'auto':
if style_value.unit == 'px':
width += style_value.value
else:
assert style_value.unit == '%'
percentages += style_value.value
if left:
width += box.style['border_left_width']
if right:
width += box.style['border_right_width']
if percentages < 100:
return width / (1 - percentages / 100.)
else:
# Pathological case, ignore
return 0
def adjust(box, outer, width, left=True, right=True):
"""Respect min/max and adjust width depending on ``outer``.
If ``outer`` is set to ``True``, return margin width, else return content
width.
"""
fixed = min_max(box, width)
if outer:
return margin_width(box, fixed, left, right)
else:
return fixed
def block_min_content_width(context, box, outer=True):
"""Return the min-content width for a ``BlockBox``."""
return _block_content_width(
context, box, min_content_width, outer)
def block_max_content_width(context, box, outer=True):
"""Return the max-content width for a ``BlockBox``."""
return _block_content_width(context, box, max_content_width, outer)
def inline_min_content_width(context, box, outer=True, skip_stack=None,
first_line=False, is_line_start=False):
"""Return the min-content width for an ``InlineBox``.
The width is calculated from the lines from ``skip_stack``. If
``first_line`` is ``True``, only the first line minimum width is
calculated.
"""
widths = inline_line_widths(
context, box, outer, is_line_start, minimum=True,
skip_stack=skip_stack, first_line=first_line)
if first_line:
widths = [next(widths)]
else:
widths = list(widths)
widths[-1] -= trailing_whitespace_size(context, box)
return adjust(box, outer, max(widths))
def inline_max_content_width(context, box, outer=True, is_line_start=False):
"""Return the max-content width for an ``InlineBox``."""
widths = list(
inline_line_widths(context, box, outer, is_line_start, minimum=False))
widths[-1] -= trailing_whitespace_size(context, box)
return adjust(box, outer, max(widths))
def column_group_content_width(context, box):
"""Return the *-content width for a ``TableColumnGroupBox``."""
width = box.style['width']
if width == 'auto' or width.unit == '%':
width = 0
else:
assert width.unit == 'px'
width = width.value
return adjust(box, False, width)
def table_cell_min_content_width(context, box, outer):
"""Return the min-content width for a ``TableCellBox``."""
children_widths = [
min_content_width(context, child, outer=True)
for child in box.children
if not child.is_absolutely_positioned()]
children_min_width = margin_width(
box, max(children_widths) if children_widths else 0)
width = box.style['width']
if width != 'auto' and width.unit == 'px':
cell_min_width = adjust(box, outer, width.value)
else:
cell_min_width = 0
return max(children_min_width, cell_min_width)
def table_cell_max_content_width(context, box, outer):
"""Return the max-content width for a ``TableCellBox``."""
return max(
table_cell_min_content_width(context, box, outer),
block_max_content_width(context, box, outer))
def inline_line_widths(context, box, outer, is_line_start, minimum,
skip_stack=None, first_line=False):
if box.style['text_indent'].unit == '%':
# TODO: this is wrong, text-indent percentages should be resolved
# before calling this function.
text_indent = 0
else:
text_indent = box.style['text_indent'].value
current_line = 0
if skip_stack is None:
skip = 0
else:
skip, skip_stack = skip_stack
for child in box.children[skip:]:
if child.is_absolutely_positioned():
continue # Skip
if isinstance(child, boxes.InlineBox):
lines = inline_line_widths(
context, child, outer, is_line_start, minimum, skip_stack,
first_line)
if first_line:
lines = [next(lines)]
else:
lines = list(lines)
if len(lines) == 1:
lines[0] = adjust(child, outer, lines[0])
else:
lines[0] = adjust(child, outer, lines[0], right=False)
lines[-1] = adjust(child, outer, lines[-1], left=False)
elif isinstance(child, boxes.TextBox):
space_collapse = child.style['white_space'] in (
'normal', 'nowrap', 'pre-line')
if skip_stack is None:
skip = 0
else:
skip, skip_stack = skip_stack
assert skip_stack is None
child_text = child.text[(skip or 0):]
if is_line_start and space_collapse:
child_text = child_text.lstrip(' ')
if minimum and child_text == ' ':
lines = [0, 0]
else:
max_width = 0 if minimum else None
lines = []
resume_at = new_resume_at = 0
while new_resume_at is not None:
resume_at += new_resume_at
_, _, new_resume_at, width, _, _ = (
text.split_first_line(
child_text[resume_at:], child.style, context,
max_width, child.justification_spacing,
minimum=True))
lines.append(width)
if first_line:
break
if first_line and new_resume_at:
current_line += lines[0]
break
else:
# http://www.w3.org/TR/css3-text/#line-break-details
# "The line breaking behavior of a replaced element
# or other atomic inline is equivalent to that
# of the Object Replacement Character (U+FFFC)."
# http://www.unicode.org/reports/tr14/#DescriptionOfProperties
# "By default, there is a break opportunity
# both before and after any inline object."
if minimum:
lines = [0, max_content_width(context, child), 0]
else:
lines = [max_content_width(context, child)]
# The first text line goes on the current line
current_line += lines[0]
if len(lines) > 1:
# Forced line break
yield current_line + text_indent
text_indent = 0
if len(lines) > 2:
for line in lines[1:-1]:
yield line
current_line = lines[-1]
is_line_start = lines[-1] == 0
skip_stack = None
yield current_line + text_indent
def _percentage_contribution(box):
"""Return the percentage contribution of a cell, column or column group.
http://dbaron.org/css/intrinsic/#pct-contrib
"""
min_width = (
box.style['min_width'].value if box.style['min_width'] != 'auto' and
box.style['min_width'].unit == '%' else 0)
max_width = (
box.style['max_width'].value if box.style['max_width'] != 'auto' and
box.style['max_width'].unit == '%' else float('inf'))
width = (
box.style['width'].value if box.style['width'] != 'auto' and
box.style['width'].unit == '%' else 0)
return max(min_width, min(width, max_width))
def table_and_columns_preferred_widths(context, box, outer=True):
"""Return content widths for the auto layout table and its columns.
The tuple returned is
``(table_min_content_width, table_max_content_width,
column_min_content_widths, column_max_content_widths,
column_intrinsic_percentages, constrainedness,
total_horizontal_border_spacing, grid)``
http://dbaron.org/css/intrinsic/
"""
# Avoid a circular import
from .tables import distribute_excess_width
table = box.get_wrapped_table()
result = context.tables.get(table)
if result:
return result[outer]
# Create the grid
grid_width, grid_height = 0, 0
row_number = 0
for row_group in table.children:
for row in row_group.children:
for cell in row.children:
grid_width = max(cell.grid_x + cell.colspan, grid_width)
grid_height = max(row_number + cell.rowspan, grid_height)
row_number += 1
grid = [[None] * grid_width for i in range(grid_height)]
row_number = 0
for row_group in table.children:
for row in row_group.children:
for cell in row.children:
grid[row_number][cell.grid_x] = cell
row_number += 1
zipped_grid = list(zip(*grid))
# Define the total horizontal border spacing
if table.style['border_collapse'] == 'separate' and grid_width > 0:
total_horizontal_border_spacing = (
table.style['border_spacing'][0] *
(1 + len([column for column in zipped_grid if any(column)])))
else:
total_horizontal_border_spacing = 0
if grid_width == 0 or grid_height == 0:
table.children = []
min_width = block_min_content_width(context, table, outer=False)
max_width = block_max_content_width(context, table, outer=False)
outer_min_width = adjust(
box, outer=True, width=block_min_content_width(
context, table, outer=True))
outer_max_width = adjust(
box, outer=True, width=block_max_content_width(
context, table, outer=True))
result = ([], [], [], [], total_horizontal_border_spacing, [])
context.tables[table] = result = {
False: (min_width, max_width) + result,
True: (outer_min_width, outer_max_width) + result,
}
return result[outer]
column_groups = [None] * grid_width
columns = [None] * grid_width
column_number = 0
for column_group in table.column_groups:
for column in column_group.children:
column_groups[column_number] = column_group
columns[column_number] = column
column_number += 1
if column_number == grid_width:
break
else:
continue
break
colspan_cells = []
# Define the intermediate content widths
min_content_widths = [0 for i in range(grid_width)]
max_content_widths = [0 for i in range(grid_width)]
intrinsic_percentages = [0 for i in range(grid_width)]
# Intermediate content widths for span 1
for i in range(grid_width):
for groups in (column_groups, columns):
if groups[i]:
min_content_widths[i] = max(
min_content_widths[i],
min_content_width(context, groups[i]))
max_content_widths[i] = max(
max_content_widths[i],
max_content_width(context, groups[i]))
intrinsic_percentages[i] = max(
intrinsic_percentages[i],
_percentage_contribution(groups[i]))
for cell in zipped_grid[i]:
if cell:
if cell.colspan == 1:
min_content_widths[i] = max(
min_content_widths[i],
min_content_width(context, cell))
max_content_widths[i] = max(
max_content_widths[i],
max_content_width(context, cell))
intrinsic_percentages[i] = max(
intrinsic_percentages[i],
_percentage_contribution(cell))
else:
colspan_cells.append(cell)
# Intermediate content widths for span > 1 is wrong in the 4.1 section, as
# explained in its third issue. Min- and max-content widths are handled by
# the excess width distribution method, and percentages do not distribute
# widths to columns that have originating cells.
# Intermediate intrinsic percentage widths for span > 1
for span in range(1, grid_width):
percentage_contributions = []
for i in range(grid_width):
percentage_contribution = intrinsic_percentages[i]
for j, cell in enumerate(zipped_grid[i]):
indexes = [k for k in range(i + 1) if grid[j][k]]
if not indexes:
continue
origin = max(indexes)
origin_cell = grid[j][origin]
if origin_cell.colspan - 1 != span:
continue
cell_slice = slice(origin, origin + origin_cell.colspan)
baseline_percentage = sum(intrinsic_percentages[cell_slice])
# Cell contribution to intrinsic percentage width
if intrinsic_percentages[i] == 0:
diff = max(
0,
_percentage_contribution(origin_cell) -
baseline_percentage)
other_columns_contributions = [
max_content_widths[j]
for j in range(
origin, origin + origin_cell.colspan)
if intrinsic_percentages[j] == 0]
other_columns_contributions_sum = sum(
other_columns_contributions)
if other_columns_contributions_sum == 0:
if other_columns_contributions:
ratio = 1 / len(other_columns_contributions)
else:
ratio = 1
else:
ratio = (
max_content_widths[i] /
other_columns_contributions_sum)
percentage_contribution = max(
percentage_contribution,
diff * ratio)
percentage_contributions.append(percentage_contribution)
intrinsic_percentages = percentage_contributions
# Define constrainedness
constrainedness = [False for i in range(grid_width)]
for i in range(grid_width):
if (column_groups[i] and column_groups[i].style['width'] != 'auto' and
column_groups[i].style['width'].unit != '%'):
constrainedness[i] = True
continue
if (columns[i] and columns[i].style['width'] != 'auto' and
columns[i].style['width'].unit != '%'):
constrainedness[i] = True
continue
for cell in zipped_grid[i]:
if (cell and cell.colspan == 1 and
cell.style['width'] != 'auto' and
cell.style['width'].unit != '%'):
constrainedness[i] = True
break
intrinsic_percentages = [
min(percentage, 100 - sum(intrinsic_percentages[:i]))
for i, percentage in enumerate(intrinsic_percentages)]
# Max- and min-content widths for span > 1
for cell in colspan_cells:
min_content = min_content_width(context, cell)
max_content = max_content_width(context, cell)
column_slice = slice(cell.grid_x, cell.grid_x + cell.colspan)
columns_min_content = sum(min_content_widths[column_slice])
columns_max_content = sum(max_content_widths[column_slice])
if table.style['border_collapse'] == 'separate':
spacing = (cell.colspan - 1) * table.style['border_spacing'][0]
else:
spacing = 0
if min_content > columns_min_content + spacing:
excess_width = min_content - (columns_min_content + spacing)
distribute_excess_width(
context, zipped_grid, excess_width, min_content_widths,
constrainedness, intrinsic_percentages, max_content_widths,
column_slice)
if max_content > columns_max_content + spacing:
excess_width = max_content - (columns_max_content + spacing)
distribute_excess_width(
context, zipped_grid, excess_width, max_content_widths,
constrainedness, intrinsic_percentages, max_content_widths,
column_slice)
# Calculate the max- and min-content widths of table and columns
small_percentage_contributions = [
max_content_widths[i] / (intrinsic_percentages[i] / 100.)
for i in range(grid_width)
if intrinsic_percentages[i]]
large_percentage_contribution_numerator = sum(
max_content_widths[i] for i in range(grid_width)
if intrinsic_percentages[i] == 0)
large_percentage_contribution_denominator = (
(100 - sum(intrinsic_percentages)) / 100.)
if large_percentage_contribution_denominator == 0:
if large_percentage_contribution_numerator == 0:
large_percentage_contribution = 0
else:
# "the large percentage contribution of the table [is] an
# infinitely large number if the numerator is nonzero [and] the
# denominator of that ratio is 0."
#
# http://dbaron.org/css/intrinsic/#autotableintrinsic
#
# Please note that "an infinitely large number" is not "infinite",
# and that's probably not a coincindence: putting 'inf' here breaks
# some cases (see #305).
large_percentage_contribution = sys.maxsize
else:
large_percentage_contribution = (
large_percentage_contribution_numerator /
large_percentage_contribution_denominator)
table_min_content_width = (
total_horizontal_border_spacing + sum(min_content_widths))
table_max_content_width = (
total_horizontal_border_spacing + max(
[sum(max_content_widths), large_percentage_contribution] +
small_percentage_contributions))
if table.style['width'] != 'auto' and table.style['width'].unit == 'px':
# "percentages on the following properties are treated instead as
# though they were the following: width: auto"
# http://dbaron.org/css/intrinsic/#outer-intrinsic
table_min_width = table_max_width = table.style['width'].value
else:
table_min_width = table_min_content_width
table_max_width = table_max_content_width
table_min_content_width = max(
table_min_content_width, adjust(
table, outer=False, width=table_min_width))
table_max_content_width = max(
table_max_content_width, adjust(
table, outer=False, width=table_max_width))
table_outer_min_content_width = margin_width(
table, margin_width(box, table_min_content_width))
table_outer_max_content_width = margin_width(
table, margin_width(box, table_max_content_width))
result = (
min_content_widths, max_content_widths, intrinsic_percentages,
constrainedness, total_horizontal_border_spacing, zipped_grid)
context.tables[table] = result = {
False: (table_min_content_width, table_max_content_width) + result,
True: (
(table_outer_min_content_width, table_outer_max_content_width) +
result),
}
return result[outer]
def replaced_min_content_width(box, outer=True):
"""Return the min-content width for an ``InlineReplacedBox``."""
width = box.style['width']
if width == 'auto':
height = box.style['height']
if height == 'auto' or height.unit == '%':
height = 'auto'
else:
assert height.unit == 'px'
height = height.value
if (box.style['max_width'] != 'auto' and
box.style['max_width'].unit == '%'):
# See https://drafts.csswg.org/css-sizing/#intrinsic-contribution
width = 0
else:
image = box.replacement
iwidth, iheight = image.get_intrinsic_size(
box.style['image_resolution'], box.style['font_size'])
width, _ = default_image_sizing(
iwidth, iheight, image.intrinsic_ratio, 'auto', height,
default_width=300, default_height=150)
elif box.style['width'].unit == '%':
# See https://drafts.csswg.org/css-sizing/#intrinsic-contribution
width = 0
else:
assert width.unit == 'px'
width = width.value
return adjust(box, outer, width)
def replaced_max_content_width(box, outer=True):
"""Return the max-content width for an ``InlineReplacedBox``."""
width = box.style['width']
if width == 'auto':
height = box.style['height']
if height == 'auto' or height.unit == '%':
height = 'auto'
else:
assert height.unit == 'px'
height = height.value
image = box.replacement
iwidth, iheight = image.get_intrinsic_size(
box.style['image_resolution'], box.style['font_size'])
width, _ = default_image_sizing(
iwidth, iheight, image.intrinsic_ratio, 'auto', height,
default_width=300, default_height=150)
elif box.style['width'].unit == '%':
# See https://drafts.csswg.org/css-sizing/#intrinsic-contribution
width = 0
else:
assert width.unit == 'px'
width = width.value
return adjust(box, outer, width)
def flex_min_content_width(context, box, outer=True):
"""Return the min-content width for an ``FlexContainerBox``."""
# TODO: use real values, see
# https://www.w3.org/TR/css-flexbox-1/#intrinsic-sizes
min_contents = [
min_content_width(context, child, outer=True)
for child in box.children if child.is_flex_item]
if not min_contents:
return adjust(box, outer, 0)
if (box.style['flex_direction'].startswith('row') and
box.style['flex_wrap'] == 'nowrap'):
return adjust(box, outer, sum(min_contents))
else:
return adjust(box, outer, max(min_contents))
def flex_max_content_width(context, box, outer=True):
"""Return the max-content width for an ``FlexContainerBox``."""
# TODO: use real values, see
# https://www.w3.org/TR/css-flexbox-1/#intrinsic-sizes
max_contents = [
max_content_width(context, child, outer=True)
for child in box.children if child.is_flex_item]
if not max_contents:
return adjust(box, outer, 0)
if box.style['flex_direction'].startswith('row'):
return adjust(box, outer, sum(max_contents))
else:
return adjust(box, outer, max(max_contents))
def trailing_whitespace_size(context, box):
"""Return the size of the trailing whitespace of ``box``."""
from .inlines import split_text_box, split_first_line
while isinstance(box, (boxes.InlineBox, boxes.LineBox)):
if not box.children:
return 0
box = box.children[-1]
if not (isinstance(box, boxes.TextBox) and box.text and
box.style['white_space'] in ('normal', 'nowrap', 'pre-line')):
return 0
stripped_text = box.text.rstrip(' ')
if box.style['font_size'] == 0 or len(stripped_text) == len(box.text):
return 0
if stripped_text:
resume = 0
while resume is not None:
old_resume = resume
old_box, resume, _ = split_text_box(context, box, None, resume)
assert old_box
stripped_box = box.copy_with_text(stripped_text)
stripped_box, resume, _ = split_text_box(
context, stripped_box, None, old_resume)
assert stripped_box is not None
assert resume is None
return old_box.width - stripped_box.width
else:
_, _, _, width, _, _ = split_first_line(
box.text, box.style, context, None, box.justification_spacing)
return width

View File

@ -0,0 +1,131 @@
"""
weasyprint.layout.replaced
--------------------------
Layout for images and other replaced elements.
http://dev.w3.org/csswg/css-images-3/#sizing
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from .percentages import percentage
def default_image_sizing(intrinsic_width, intrinsic_height, intrinsic_ratio,
specified_width, specified_height,
default_width, default_height):
"""Default sizing algorithm for the concrete object size.
http://dev.w3.org/csswg/css-images-3/#default-sizing
Return a ``(concrete_width, concrete_height)`` tuple.
"""
if specified_width == 'auto':
specified_width = None
if specified_height == 'auto':
specified_height = None
if specified_width is not None and specified_height is not None:
return specified_width, specified_height
elif specified_width is not None:
return specified_width, (
specified_width / intrinsic_ratio if intrinsic_ratio is not None
else intrinsic_height if intrinsic_height is not None
else default_height)
elif specified_height is not None:
return (
specified_height * intrinsic_ratio if intrinsic_ratio is not None
else intrinsic_width if intrinsic_width is not None
else default_width
), specified_height
else:
if intrinsic_width is not None or intrinsic_height is not None:
return default_image_sizing(
intrinsic_width, intrinsic_height, intrinsic_ratio,
intrinsic_width, intrinsic_height, default_width,
default_height)
else:
return contain_constraint_image_sizing(
default_width, default_height, intrinsic_ratio)
def contain_constraint_image_sizing(
constraint_width, constraint_height, intrinsic_ratio):
"""Cover constraint sizing algorithm for the concrete object size.
http://dev.w3.org/csswg/css-images-3/#contain-constraint
Return a ``(concrete_width, concrete_height)`` tuple.
"""
return _constraint_image_sizing(
constraint_width, constraint_height, intrinsic_ratio, cover=False)
def cover_constraint_image_sizing(
constraint_width, constraint_height, intrinsic_ratio):
"""Cover constraint sizing algorithm for the concrete object size.
http://dev.w3.org/csswg/css-images-3/#cover-constraint
Return a ``(concrete_width, concrete_height)`` tuple.
"""
return _constraint_image_sizing(
constraint_width, constraint_height, intrinsic_ratio, cover=True)
def _constraint_image_sizing(
constraint_width, constraint_height, intrinsic_ratio, cover):
if intrinsic_ratio is None:
return constraint_width, constraint_height
elif cover ^ (constraint_width > constraint_height * intrinsic_ratio):
return constraint_height * intrinsic_ratio, constraint_height
else:
return constraint_width, constraint_width / intrinsic_ratio
def replacedbox_layout(box):
# TODO: respect box-sizing ?
object_fit = box.style['object_fit']
position = box.style['object_position']
image = box.replacement
intrinsic_width, intrinsic_height = image.get_intrinsic_size(
box.style['image_resolution'], box.style['font_size'])
if None in (intrinsic_width, intrinsic_height):
intrinsic_width, intrinsic_height = contain_constraint_image_sizing(
box.width, box.height, box.replacement.intrinsic_ratio)
if object_fit == 'fill':
draw_width, draw_height = box.width, box.height
else:
if object_fit == 'contain' or object_fit == 'scale-down':
draw_width, draw_height = contain_constraint_image_sizing(
box.width, box.height, box.replacement.intrinsic_ratio)
elif object_fit == 'cover':
draw_width, draw_height = cover_constraint_image_sizing(
box.width, box.height, box.replacement.intrinsic_ratio)
else:
assert object_fit == 'none', object_fit
draw_width, draw_height = intrinsic_width, intrinsic_height
if object_fit == 'scale-down':
draw_width = min(draw_width, intrinsic_width)
draw_height = min(draw_height, intrinsic_height)
origin_x, position_x, origin_y, position_y = position[0]
ref_x = box.width - draw_width
ref_y = box.height - draw_height
position_x = percentage(position_x, ref_x)
position_y = percentage(position_y, ref_y)
if origin_x == 'right':
position_x = ref_x - position_x
if origin_y == 'bottom':
position_y = ref_y - position_y
position_x += box.content_box_x()
position_y += box.content_box_y()
return draw_width, draw_height, position_x, position_y

889
weasyprint/layout/tables.py Normal file
View File

@ -0,0 +1,889 @@
"""
weasyprint.layout.tables
------------------------
Layout for tables and internal table boxes.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..formatting_structure import boxes
from ..logger import LOGGER
from .percentages import resolve_one_percentage, resolve_percentages
from .preferred import max_content_width, table_and_columns_preferred_widths
def table_layout(context, table, max_position_y, skip_stack, containing_block,
page_is_empty, absolute_boxes, fixed_boxes):
"""Layout for a table box."""
# Avoid a circular import
from .blocks import (
block_container_layout, block_level_page_break,
find_earlier_page_break)
column_widths = table.column_widths
if table.style['border_collapse'] == 'separate':
border_spacing_x, border_spacing_y = table.style['border_spacing']
else:
border_spacing_x = 0
border_spacing_y = 0
# TODO: reverse this for direction: rtl
column_positions = table.column_positions = []
position_x = table.content_box_x()
rows_x = position_x + border_spacing_x
for width in column_widths:
position_x += border_spacing_x
column_positions.append(position_x)
position_x += width
rows_width = position_x - rows_x
if table.style['border_collapse'] == 'collapse':
if skip_stack:
skipped_groups, group_skip_stack = skip_stack
if group_skip_stack:
skipped_rows, _ = group_skip_stack
else:
skipped_rows = 0
for group in table.children[:skipped_groups]:
skipped_rows += len(group.children)
else:
skipped_rows = 0
_, horizontal_borders = table.collapsed_border_grid
if horizontal_borders:
table.border_top_width = max(
width for _, (_, width, _)
in horizontal_borders[skipped_rows]) / 2
# Make this a sub-function so that many local variables like rows_x
# don't need to be passed as parameters.
def group_layout(group, position_y, max_position_y,
page_is_empty, skip_stack):
resume_at = None
next_page = {'break': 'any', 'page': None}
original_page_is_empty = page_is_empty
resolve_percentages(group, containing_block=table)
group.position_x = rows_x
group.position_y = position_y
group.width = rows_width
new_group_children = []
# For each rows, cells for which this is the last row (with rowspan)
ending_cells_by_row = [[] for row in group.children]
is_group_start = skip_stack is None
if is_group_start:
skip = 0
else:
skip, skip_stack = skip_stack
assert not skip_stack # No breaks inside rows for now
for i, row in enumerate(group.children[skip:]):
index_row = i + skip
row.index = index_row
if new_group_children:
page_break = block_level_page_break(
new_group_children[-1], row)
if page_break in ('page', 'recto', 'verso', 'left', 'right'):
next_page['break'] = page_break
resume_at = (index_row, None)
break
resolve_percentages(row, containing_block=table)
row.position_x = rows_x
row.position_y = position_y
row.width = rows_width
# Place cells at the top of the row and layout their content
new_row_children = []
for cell in row.children:
spanned_widths = column_widths[cell.grid_x:][:cell.colspan]
# In the fixed layout the grid width is set by cells in
# the first row and column elements.
# This may be less than the previous value of cell.colspan
# if that would bring the cell beyond the grid width.
cell.colspan = len(spanned_widths)
if cell.colspan == 0:
# The cell is entierly beyond the grid width, remove it
# entierly. Subsequent cells in the same row have greater
# grid_x, so they are beyond too.
cell_index = row.children.index(cell)
ignored_cells = row.children[cell_index:]
LOGGER.warning('This table row has more columns than '
'the table, ignored %i cells: %r',
len(ignored_cells), ignored_cells)
break
resolve_percentages(cell, containing_block=table)
cell.position_x = column_positions[cell.grid_x]
cell.position_y = row.position_y
cell.margin_top = 0
cell.margin_left = 0
cell.width = 0
borders_plus_padding = cell.border_width() # with width==0
# TODO: we should remove the number of columns with no
# originating cells to cell.colspan, see
# test_layout_table_auto_49
cell.width = (
sum(spanned_widths) +
border_spacing_x * (cell.colspan - 1) -
borders_plus_padding)
# The computed height is a minimum
cell.computed_height = cell.height
cell.height = 'auto'
cell, _, _, _, _ = block_container_layout(
context, cell,
max_position_y=float('inf'),
skip_stack=None,
page_is_empty=True,
absolute_boxes=absolute_boxes,
fixed_boxes=fixed_boxes)
cell.empty = not any(
child.is_floated() or child.is_in_normal_flow()
for child in cell.children)
cell.content_height = cell.height
if cell.computed_height != 'auto':
cell.height = max(cell.height, cell.computed_height)
new_row_children.append(cell)
row = row.copy_with_children(new_row_children)
# Table height algorithm
# http://www.w3.org/TR/CSS21/tables.html#height-layout
# cells with vertical-align: baseline
baseline_cells = []
for cell in row.children:
vertical_align = cell.style['vertical_align']
if vertical_align in ('top', 'middle', 'bottom'):
cell.vertical_align = vertical_align
else:
# Assume 'baseline' for any other value
cell.vertical_align = 'baseline'
cell.baseline = cell_baseline(cell)
baseline_cells.append(cell)
if baseline_cells:
row.baseline = max(cell.baseline for cell in baseline_cells)
for cell in baseline_cells:
extra = row.baseline - cell.baseline
if cell.baseline != row.baseline and extra:
add_top_padding(cell, extra)
# row height
for cell in row.children:
ending_cells_by_row[cell.rowspan - 1].append(cell)
ending_cells = ending_cells_by_row.pop(0)
if ending_cells: # in this row
if row.height == 'auto':
row_bottom_y = max(
cell.position_y + cell.border_height()
for cell in ending_cells)
row.height = max(row_bottom_y - row.position_y, 0)
else:
row.height = max(row.height, max(
row_cell.height for row_cell in ending_cells))
row_bottom_y = row.position_y + row.height
else:
row_bottom_y = row.position_y
row.height = 0
if not baseline_cells:
row.baseline = row_bottom_y
# Add extra padding to make the cells the same height as the row
# and honor vertical-align
for cell in ending_cells:
cell_bottom_y = cell.position_y + cell.border_height()
extra = row_bottom_y - cell_bottom_y
if extra:
if cell.vertical_align == 'bottom':
add_top_padding(cell, extra)
elif cell.vertical_align == 'middle':
extra /= 2.
add_top_padding(cell, extra)
cell.padding_bottom += extra
else:
cell.padding_bottom += extra
if cell.computed_height != 'auto':
vertical_align_shift = 0
if cell.vertical_align == 'middle':
vertical_align_shift = (
cell.computed_height - cell.content_height) / 2
elif cell.vertical_align == 'bottom':
vertical_align_shift = (
cell.computed_height - cell.content_height)
if vertical_align_shift > 0:
for child in cell.children:
child.translate(dy=vertical_align_shift)
next_position_y = row.position_y + row.height + border_spacing_y
# Break if this row overflows the page, unless there is no
# other content on the page.
if next_position_y > max_position_y and not page_is_empty:
if new_group_children:
previous_row = new_group_children[-1]
page_break = block_level_page_break(previous_row, row)
if page_break == 'avoid':
earlier_page_break = find_earlier_page_break(
new_group_children, absolute_boxes, fixed_boxes)
if earlier_page_break:
new_group_children, resume_at = earlier_page_break
break
else:
resume_at = (index_row, None)
break
if original_page_is_empty:
resume_at = (index_row, None)
else:
return None, None, next_page
break
position_y = next_position_y
new_group_children.append(row)
page_is_empty = False
# Do not keep the row group if we made a page break
# before any of its rows or with 'avoid'
if resume_at and not original_page_is_empty and (
group.style['break_inside'] in ('avoid', 'avoid-page') or
not new_group_children):
return None, None, next_page
group = group.copy_with_children(
new_group_children,
is_start=is_group_start, is_end=resume_at is None)
# Set missing baselines in a second loop because of rowspan
for row in group.children:
if row.baseline is None:
if row.children:
# lowest bottom content edge
row.baseline = max(
cell.content_box_y() + cell.height
for cell in row.children) - row.position_y
else:
row.baseline = 0
group.height = position_y - group.position_y
if group.children:
# The last border spacing is outside of the group.
group.height -= border_spacing_y
return group, resume_at, next_page
def body_groups_layout(skip_stack, position_y, max_position_y,
page_is_empty):
if skip_stack is None:
skip = 0
else:
skip, skip_stack = skip_stack
new_table_children = []
resume_at = None
next_page = {'break': 'any', 'page': None}
for i, group in enumerate(table.children[skip:]):
index_group = i + skip
group.index = index_group
if group.is_header or group.is_footer:
continue
if new_table_children:
page_break = block_level_page_break(
new_table_children[-1], group)
if page_break in ('page', 'recto', 'verso', 'left', 'right'):
next_page['break'] = page_break
resume_at = (index_group, None)
break
new_group, resume_at, next_page = group_layout(
group, position_y, max_position_y, page_is_empty, skip_stack)
skip_stack = None
if new_group is None:
if new_table_children:
previous_group = new_table_children[-1]
page_break = block_level_page_break(previous_group, group)
if page_break == 'avoid':
earlier_page_break = find_earlier_page_break(
new_table_children, absolute_boxes, fixed_boxes)
if earlier_page_break is not None:
new_table_children, resume_at = earlier_page_break
break
resume_at = (index_group, None)
else:
return None, None, next_page, position_y
break
new_table_children.append(new_group)
position_y += new_group.height + border_spacing_y
page_is_empty = False
if resume_at:
resume_at = (index_group, resume_at)
break
return new_table_children, resume_at, next_page, position_y
# Layout for row groups, rows and cells
position_y = table.content_box_y() + border_spacing_y
initial_position_y = position_y
table_rows = [
child for child in table.children
if not child.is_header and not child.is_footer]
def all_groups_layout():
# If the page is not empty, we try to render the header and the footer
# on it. If the table does not fit on the page, we try to render it on
# the next page.
# If the page is empty and the header and footer are too big, there
# are not rendered. If no row can be rendered because of the header and
# the footer, the header and/or the footer are not rendered.
if page_is_empty:
header_footer_max_position_y = max_position_y
else:
header_footer_max_position_y = float('inf')
if table.children and table.children[0].is_header:
header = table.children[0]
header, resume_at, next_page = group_layout(
header, position_y, header_footer_max_position_y,
skip_stack=None, page_is_empty=False)
if header and not resume_at:
header_height = header.height + border_spacing_y
else: # Header too big for the page
header = None
else:
header = None
if table.children and table.children[-1].is_footer:
footer = table.children[-1]
footer, resume_at, next_page = group_layout(
footer, position_y, header_footer_max_position_y,
skip_stack=None, page_is_empty=False)
if footer and not resume_at:
footer_height = footer.height + border_spacing_y
else: # Footer too big for the page
footer = None
else:
footer = None
# Don't remove headers and footers if breaks are avoided in line groups
skip = skip_stack[0] if skip_stack else 0
avoid_breaks = False
for group in table.children[skip:]:
if not group.is_header and not group.is_footer:
avoid_breaks = (
group.style['break_inside'] in ('avoid', 'avoid-page'))
break
if header and footer:
# Try with both the header and footer
new_table_children, resume_at, next_page, end_position_y = (
body_groups_layout(
skip_stack,
position_y=position_y + header_height,
max_position_y=max_position_y - footer_height,
page_is_empty=avoid_breaks))
if new_table_children or not table_rows or not page_is_empty:
footer.translate(dy=end_position_y - footer.position_y)
end_position_y += footer_height
return (header, new_table_children, footer,
end_position_y, resume_at, next_page)
else:
# We could not fit any content, drop the footer
footer = None
if header and not footer:
# Try with just the header
new_table_children, resume_at, next_page, end_position_y = (
body_groups_layout(
skip_stack,
position_y=position_y + header_height,
max_position_y=max_position_y,
page_is_empty=avoid_breaks))
if new_table_children or not table_rows or not page_is_empty:
return (header, new_table_children, footer,
end_position_y, resume_at, next_page)
else:
# We could not fit any content, drop the header
header = None
if footer and not header:
# Try with just the footer
new_table_children, resume_at, next_page, end_position_y = (
body_groups_layout(
skip_stack,
position_y=position_y,
max_position_y=max_position_y - footer_height,
page_is_empty=avoid_breaks))
if new_table_children or not table_rows or not page_is_empty:
footer.translate(dy=end_position_y - footer.position_y)
end_position_y += footer_height
return (header, new_table_children, footer,
end_position_y, resume_at, next_page)
else:
# We could not fit any content, drop the footer
footer = None
assert not (header or footer)
new_table_children, resume_at, next_page, end_position_y = (
body_groups_layout(
skip_stack, position_y, max_position_y, page_is_empty))
return (
header, new_table_children, footer, end_position_y, resume_at,
next_page)
def get_column_cells(table, column):
"""Closure getting the column cells."""
return lambda: [
cell
for row_group in table.children
for row in row_group.children
for cell in row.children
if cell.grid_x == column.grid_x]
header, new_table_children, footer, position_y, resume_at, next_page = \
all_groups_layout()
if new_table_children is None:
assert resume_at is None
table = None
adjoining_margins = []
collapsing_through = False
return (
table, resume_at, next_page, adjoining_margins, collapsing_through)
table = table.copy_with_children(
([header] if header is not None else []) +
new_table_children +
([footer] if footer is not None else []),
is_start=skip_stack is None, is_end=resume_at is None)
if table.style['border_collapse'] == 'collapse':
table.skipped_rows = skipped_rows
# If the height property has a bigger value, just add blank space
# below the last row group.
table.height = max(
table.height if table.height != 'auto' else 0,
position_y - table.content_box_y())
# Layout for column groups and columns
columns_height = position_y - initial_position_y
if table.children:
# The last border spacing is below the columns.
columns_height -= border_spacing_y
for group in table.column_groups:
for column in group.children:
resolve_percentages(column, containing_block=table)
if column.grid_x < len(column_positions):
column.position_x = column_positions[column.grid_x]
column.position_y = initial_position_y
column.width = column_widths[column.grid_x]
column.height = columns_height
else:
# Ignore extra empty columns
column.position_x = 0
column.position_y = 0
column.width = 0
column.height = 0
resolve_percentages(group, containing_block=table)
column.get_cells = get_column_cells(table, column)
first = group.children[0]
last = group.children[-1]
group.position_x = first.position_x
group.position_y = initial_position_y
group.width = last.position_x + last.width - first.position_x
group.height = columns_height
if resume_at and not page_is_empty and (
table.style['break_inside'] in ('avoid', 'avoid-page')):
table = None
resume_at = None
adjoining_margins = []
collapsing_through = False
return table, resume_at, next_page, adjoining_margins, collapsing_through
def add_top_padding(box, extra_padding):
"""Increase the top padding of a box. This also translates the children.
"""
box.padding_top += extra_padding
for child in box.children:
child.translate(dy=extra_padding)
def fixed_table_layout(box):
"""Run the fixed table layout and return a list of column widths
http://www.w3.org/TR/CSS21/tables.html#fixed-table-layout
"""
table = box.get_wrapped_table()
assert table.width != 'auto'
all_columns = [column for column_group in table.column_groups
for column in column_group.children]
if table.children and table.children[0].children:
first_rowgroup = table.children[0]
first_row_cells = first_rowgroup.children[0].children
else:
first_row_cells = []
num_columns = max(
len(all_columns),
sum(cell.colspan for cell in first_row_cells)
)
# ``None`` means not know yet.
column_widths = [None] * num_columns
# `width` on column boxes
for i, column in enumerate(all_columns):
resolve_one_percentage(column, 'width', table.width)
if column.width != 'auto':
column_widths[i] = column.width
if table.style['border_collapse'] == 'separate':
border_spacing_x, _ = table.style['border_spacing']
else:
border_spacing_x = 0
# `width` on cells of the first row.
i = 0
for cell in first_row_cells:
resolve_percentages(cell, table)
if cell.width != 'auto':
width = cell.border_width()
width -= border_spacing_x * (cell.colspan - 1)
# In the general case, this width affects several columns (through
# colspan) some of which already have a width. Subtract these
# known widths and divide among remaining columns.
columns_without_width = [] # and occupied by this cell
for j in range(i, i + cell.colspan):
if column_widths[j] is None:
columns_without_width.append(j)
else:
width -= column_widths[j]
if columns_without_width:
width_per_column = width / len(columns_without_width)
for j in columns_without_width:
column_widths[j] = width_per_column
del width
i += cell.colspan
del i
# Distribute the remaining space equally on columns that do not have
# a width yet.
all_border_spacing = border_spacing_x * (num_columns + 1)
min_table_width = (sum(w for w in column_widths if w is not None) +
all_border_spacing)
columns_without_width = [i for i, w in enumerate(column_widths)
if w is None]
if columns_without_width and table.width >= min_table_width:
remaining_width = table.width - min_table_width
width_per_column = remaining_width / len(columns_without_width)
for i in columns_without_width:
column_widths[i] = width_per_column
else:
# XXX this is bad, but we were given a broken table to work with...
for i in columns_without_width:
column_widths[i] = 0
# If the sum is less than the table width,
# distribute the remaining space equally
extra_width = table.width - sum(column_widths) - all_border_spacing
if extra_width <= 0:
# substract a negative: widen the table
table.width -= extra_width
elif num_columns:
extra_per_column = extra_width / num_columns
column_widths = [w + extra_per_column for w in column_widths]
# Now we have table.width == sum(column_widths) + all_border_spacing
# with possible floating point rounding errors.
# (unless there is zero column)
table.column_widths = column_widths
def auto_table_layout(context, box, containing_block):
"""Run the auto table layout and return a list of column widths.
http://www.w3.org/TR/CSS21/tables.html#auto-table-layout
"""
table = box.get_wrapped_table()
(table_min_content_width, table_max_content_width,
column_min_content_widths, column_max_content_widths,
column_intrinsic_percentages, constrainedness,
total_horizontal_border_spacing, grid) = \
table_and_columns_preferred_widths(context, box, outer=False)
margins = 0
if box.margin_left != 'auto':
margins += box.margin_left
if box.margin_right != 'auto':
margins += box.margin_right
paddings = table.padding_left + table.padding_right
cb_width, _ = containing_block
available_width = cb_width - margins - paddings
if table.style['border_collapse'] == 'collapse':
available_width -= (
table.border_left_width + table.border_right_width)
if table.width == 'auto':
if available_width <= table_min_content_width:
table.width = table_min_content_width
elif available_width < table_max_content_width:
table.width = available_width
else:
table.width = table_max_content_width
else:
if table.width < table_min_content_width:
table.width = table_min_content_width
if not grid:
table.column_widths = []
return
assignable_width = table.width - total_horizontal_border_spacing
min_content_guess = column_min_content_widths[:]
min_content_percentage_guess = column_min_content_widths[:]
min_content_specified_guess = column_min_content_widths[:]
max_content_guess = column_max_content_widths[:]
guesses = (
min_content_guess, min_content_percentage_guess,
min_content_specified_guess, max_content_guess)
for i in range(len(grid)):
if column_intrinsic_percentages[i]:
min_content_percentage_guess[i] = max(
column_intrinsic_percentages[i] / 100 * assignable_width,
column_min_content_widths[i])
min_content_specified_guess[i] = min_content_percentage_guess[i]
max_content_guess[i] = min_content_percentage_guess[i]
elif constrainedness[i]:
min_content_specified_guess[i] = column_min_content_widths[i]
if assignable_width <= sum(max_content_guess):
# Default values shouldn't be used, but we never know.
# See https://github.com/Kozea/WeasyPrint/issues/770
lower_guess = guesses[0]
upper_guess = guesses[-1]
# We have to work around floating point rounding errors here.
# The 1e-9 value comes from PEP 485.
for guess in guesses:
if sum(guess) <= assignable_width * (1 + 1e-9):
lower_guess = guess
else:
break
for guess in guesses[::-1]:
if sum(guess) >= assignable_width * (1 - 1e-9):
upper_guess = guess
else:
break
if upper_guess == lower_guess:
# TODO: Uncomment the assert when bugs #770 and #628 are closed
# Equivalent to "assert assignable_width == sum(upper_guess)"
# assert abs(assignable_width - sum(upper_guess)) <= (
# assignable_width * 1e-9)
table.column_widths = upper_guess
else:
added_widths = [
upper_guess[i] - lower_guess[i] for i in range(len(grid))]
available_ratio = (
(assignable_width - sum(lower_guess)) / sum(added_widths))
table.column_widths = [
lower_guess[i] + added_widths[i] * available_ratio
for i in range(len(grid))]
else:
table.column_widths = max_content_guess
excess_width = assignable_width - sum(max_content_guess)
excess_width = distribute_excess_width(
context, grid, excess_width, table.column_widths, constrainedness,
column_intrinsic_percentages, column_max_content_widths)
if excess_width:
if table_min_content_width < table.width - excess_width:
# Reduce the width of the size from the excess width that has
# not been distributed.
table.width -= excess_width
else:
# Break rules
columns = [i for i, column in enumerate(grid) if any(column)]
for i in columns:
table.column_widths[i] += excess_width / len(columns)
def table_wrapper_width(context, wrapper, containing_block):
"""Find the width of each column and derive the wrapper width."""
table = wrapper.get_wrapped_table()
resolve_percentages(table, containing_block)
if table.style['table_layout'] == 'fixed' and table.width != 'auto':
fixed_table_layout(wrapper)
else:
auto_table_layout(context, wrapper, containing_block)
wrapper.width = table.border_width()
def cell_baseline(cell):
"""
Return the y position of a cells baseline from the top of its border box.
See http://www.w3.org/TR/CSS21/tables.html#height-layout
"""
result = find_in_flow_baseline(
cell, baseline_types=(boxes.LineBox, boxes.TableRowBox))
if result is not None:
return result - cell.position_y
else:
# Default to the bottom of the content area.
return cell.border_top_width + cell.padding_top + cell.height
def find_in_flow_baseline(box, last=False, baseline_types=(boxes.LineBox,)):
"""
Return the absolute Y position for the first (or last) in-flow baseline
if any, or None.
"""
# TODO: synthetize baseline when needed
# See https://www.w3.org/TR/css-align-3/#synthesize-baseline
if isinstance(box, baseline_types):
return box.position_y + box.baseline
if isinstance(box, boxes.ParentBox) and not isinstance(
box, boxes.TableCaptionBox):
children = reversed(box.children) if last else box.children
for child in children:
if child.is_in_normal_flow():
result = find_in_flow_baseline(child, last, baseline_types)
if result is not None:
return result
def distribute_excess_width(context, grid, excess_width, column_widths,
constrainedness, column_intrinsic_percentages,
column_max_content_widths,
column_slice=slice(0, None)):
"""Distribute available width to columns.
Return excess width left when it's impossible without breaking rules.
See http://dbaron.org/css/intrinsic/#distributetocols
"""
# First group
columns = [
(i + column_slice.start, column)
for i, column in enumerate(grid[column_slice])
if not constrainedness[i + column_slice.start] and
column_intrinsic_percentages[i + column_slice.start] == 0 and
column_max_content_widths[i + column_slice.start] > 0]
if columns:
current_widths = [column_widths[i] for i, column in columns]
differences = [
max(0, width[0] - width[1])
for width in zip(column_max_content_widths, current_widths)]
if sum(differences) > excess_width:
differences = [
difference / sum(differences) * excess_width
for difference in differences]
excess_width -= sum(differences)
for i, difference in enumerate(differences):
column_widths[columns[i][0]] += difference
if excess_width <= 0:
return
# Second group
columns = [
i + column_slice.start for i, column in enumerate(grid[column_slice])
if not constrainedness[i + column_slice.start] and
column_intrinsic_percentages[i + column_slice.start] == 0]
if columns:
for i in columns:
column_widths[i] += excess_width / len(columns)
return
# Third group
columns = [
(i + column_slice.start, column)
for i, column in enumerate(grid[column_slice])
if constrainedness[i + column_slice.start] and
column_intrinsic_percentages[i + column_slice.start] == 0 and
column_max_content_widths[i + column_slice.start] > 0]
if columns:
current_widths = [column_widths[i] for i, column in columns]
differences = [
max(0, width[0] - width[1])
for width in zip(column_max_content_widths, current_widths)]
if sum(differences) > excess_width:
differences = [
difference / sum(differences) * excess_width
for difference in differences]
excess_width -= sum(differences)
for i, difference in enumerate(differences):
column_widths[columns[i][0]] += difference
if excess_width <= 0:
return
# Fourth group
columns = [
(i + column_slice.start, column)
for i, column in enumerate(grid[column_slice])
if column_intrinsic_percentages[i + column_slice.start] > 0]
if columns:
fixed_width = sum(
column_widths[j] for j in range(len(grid))
if j not in [i for i, column in columns])
percentage_width = sum(
column_intrinsic_percentages[i]
for i, column in columns)
if fixed_width and percentage_width >= 100:
# Sum of the percentages are greater than 100%
ratio = excess_width
elif fixed_width == 0:
# No fixed width, let's take the whole excess width
ratio = excess_width
else:
ratio = fixed_width / (100 - percentage_width)
widths = [
column_intrinsic_percentages[i] * ratio for i, column in columns]
current_widths = [column_widths[i] for i, column in columns]
# Allow to reduce the size of the columns to respect the percentage
differences = [
width[0] - width[1]
for width in zip(widths, current_widths)]
if sum(differences) > excess_width:
differences = [
difference / sum(differences) * excess_width
for difference in differences]
excess_width -= sum(differences)
for i, difference in enumerate(differences):
column_widths[columns[i][0]] += difference
if excess_width <= 0:
return
# Bonus: we've tried our best to distribute the extra size, but we
# failed. Instead of blindly distributing the size among all the colums
# and breaking all the rules (as said in the draft), let's try to
# change the columns with no constraint at all, then resize the table,
# and at least break the rules to make the columns fill the table.
# Fifth group, part 1
columns = [
i + column_slice.start for i, column in enumerate(grid[column_slice])
if any(column) and
column_intrinsic_percentages[i + column_slice.start] == 0 and
not any(
max_content_width(context, cell)
for cell in column if cell)]
if columns:
for i in columns:
column_widths[i] += excess_width / len(columns)
return
# Fifth group, part 2, aka abort
return excess_width

30
weasyprint/logger.py Normal file
View File

@ -0,0 +1,30 @@
"""
weasyprint.logging
------------------
Logging setup.
The rest of the code gets the logger through this module rather than
``logging.getLogger`` to make sure that it is configured.
Logging levels are used for specific purposes:
- errors are used for unreachable or unusable external resources, including
unreachable stylesheets, unreachables images and unreadable images;
- warnings are used for unknown or bad HTML/CSS syntaxes, unreachable local
fonts and various non-fatal problems;
- infos are used to advertise rendering steps.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import logging
LOGGER = logging.getLogger('weasyprint')
if not LOGGER.handlers:
LOGGER.setLevel(logging.WARNING)
LOGGER.addHandler(logging.NullHandler())
PROGRESS_LOGGER = logging.getLogger('weasyprint.progress')

591
weasyprint/pdf.py Normal file
View File

@ -0,0 +1,591 @@
"""
weasyprint.pdf
--------------
Post-process the PDF files created by cairo and extra metadata (including
attachments, embedded files, trim & bleed boxes).
Rather than trying to parse any valid PDF, we make some assumptions
that hold for cairo in order to simplify the code:
* All newlines are '\n', not '\r' or '\r\n'
* Except for number 0 (which is always free) there is no "free" object.
* Most white space separators are made of a single 0x20 space.
* Indirect dictionary objects do not contain '>>' at the start of a line
except to mark the end of the object, followed by 'endobj'.
(In other words, '>>' markers for sub-dictionaries are indented.)
* The Page Tree is flat: all kids of the root page node are page objects,
not page tree nodes.
However the code uses a lot of assert statements so that if an assumptions
is not true anymore, the code should (hopefully) fail with an exception
rather than silently behave incorrectly.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import hashlib
import io
import mimetypes
import os
import re
import string
import zlib
from urllib.parse import unquote, urlsplit
import cairocffi as cairo
from . import Attachment
from .logger import LOGGER
from .urls import URLFetchingError
def pdf_escape(value):
"""Escape parentheses and backslashes in ``value``.
``value`` must be unicode, or latin1 bytestring.
"""
if isinstance(value, bytes):
value = value.decode('latin1')
return value.translate({40: r'\(', 41: r'\)', 92: r'\\'})
class PDFFormatter(string.Formatter):
"""Like str.format except:
* Results are byte strings
* The new !P conversion flags encodes a PDF string.
(UTF-16 BE with a BOM, then backslash-escape parentheses.)
Except for fields marked !P, everything should be ASCII-only.
"""
def convert_field(self, value, conversion):
if conversion == 'P':
# Make a round-trip back through Unicode for the .translate()
# method. (bytes.translate only maps to single bytes.)
# Use latin1 to map all byte values.
return '({0})'.format(pdf_escape(
('\ufeff' + value).encode('utf-16-be').decode('latin1')))
else:
return super().convert_field(value, conversion)
def vformat(self, format_string, args, kwargs):
result = super().vformat(format_string, args, kwargs)
return result.encode('latin1')
pdf_format = PDFFormatter().format
class PDFDictionary(object):
def __init__(self, object_number, byte_string):
self.object_number = object_number
self.byte_string = byte_string
def __repr__(self):
return self.__class__.__name__ + repr(
(self.object_number, self.byte_string))
_re_cache = {}
def get_value(self, key, value_re):
regex = self._re_cache.get((key, value_re))
if not regex:
regex = re.compile(pdf_format('/{0} {1}', key, value_re))
self._re_cache[key, value_re] = regex
return regex.search(self.byte_string).group(1)
def get_type(self):
"""Get dictionary type.
:returns: the value for the /Type key.
"""
# No end delimiter, + defaults to greedy
return self.get_value('Type', '/(\\w+)').decode('ascii')
def get_indirect_dict(self, key, pdf_file):
"""Read the value for `key` and follow the reference.
We assume that it is an indirect dictionary object.
:return: a new PDFDictionary instance.
"""
object_number = int(self.get_value(key, '(\\d+) 0 R'))
return type(self)(object_number, pdf_file.read_object(object_number))
def get_indirect_dict_array(self, key, pdf_file):
"""Read the value for `key` and follow the references.
We assume that it is an array of indirect dictionary objects.
:return: a list of new PDFDictionary instance.
"""
parts = self.get_value(key, '\\[(.+?)\\]').split(b' 0 R')
# The array looks like this: ' <a> 0 R <b> 0 R <c> 0 R '
# so `parts` ends up like this [' <a>', ' <b>', ' <c>', ' ']
# With the trailing white space in the list.
trail = parts.pop()
assert not trail.strip()
class_ = type(self)
read = pdf_file.read_object
return [class_(n, read(n)) for n in map(int, parts)]
class PDFFile(object):
trailer_re = re.compile(
b'\ntrailer\n(.+)\nstartxref\n(\\d+)\n%%EOF\n$', re.DOTALL)
def __init__(self, fileobj):
# cairos trailer only has Size, Root and Info.
# The trailer + startxref + EOF is typically under 100 bytes
fileobj.seek(-200, os.SEEK_END)
trailer, startxref = self.trailer_re.search(fileobj.read()).groups()
trailer = PDFDictionary(None, trailer)
startxref = int(startxref)
fileobj.seek(startxref)
line = next(fileobj)
assert line == b'xref\n'
line = next(fileobj)
first_object, total_objects = line.split()
assert first_object == b'0'
total_objects = int(total_objects)
line = next(fileobj)
assert line == b'0000000000 65535 f \n'
objects_offsets = [None]
for object_number in range(1, total_objects):
line = next(fileobj)
assert line[10:] == b' 00000 n \n'
objects_offsets.append(int(line[:10]))
self.fileobj = fileobj
#: Maps object number -> bytes from the start of the file
self.objects_offsets = objects_offsets
info = trailer.get_indirect_dict('Info', self)
catalog = trailer.get_indirect_dict('Root', self)
page_tree = catalog.get_indirect_dict('Pages', self)
pages = page_tree.get_indirect_dict_array('Kids', self)
# Check that the tree is flat
assert all(p.get_type() == 'Page' for p in pages)
self.startxref = startxref
self.info = info
self.catalog = catalog
self.page_tree = page_tree
self.pages = pages
self.finished = False
self.overwritten_objects_offsets = {}
self.new_objects_offsets = []
def read_object(self, object_number):
"""
:param object_number:
An integer N so that 1 <= N < len(self.objects_offsets)
:returns:
The object content as a byte string.
"""
fileobj = self.fileobj
fileobj.seek(self.objects_offsets[object_number])
line = next(fileobj)
assert line.endswith(b' 0 obj\n')
assert int(line[:-7]) == object_number # len(b' 0 obj\n') == 7
object_lines = []
for line in fileobj:
if line == b'>>\n':
assert next(fileobj) == b'endobj\n'
# No newline, well add it when writing.
object_lines.append(b'>>')
return b''.join(object_lines)
object_lines.append(line)
def overwrite_object(self, object_number, byte_string):
"""Write the new content for an existing object at the end of the file.
:param object_number:
An integer N so that 1 <= N < len(self.objects_offsets)
:param byte_string:
The new object content as a byte string.
"""
self.overwritten_objects_offsets[object_number] = (
self._write_object(object_number, byte_string))
def extend_dict(self, dictionary, new_content):
"""Overwrite a dictionary object.
Content is added inside the << >> delimiters.
"""
assert dictionary.byte_string.endswith(b'>>')
self.overwrite_object(
dictionary.object_number,
dictionary.byte_string[:-2] + new_content + b'\n>>')
def next_object_number(self):
"""Return object number that would be used by write_new_object()."""
return len(self.objects_offsets) + len(self.new_objects_offsets)
def write_new_object(self, byte_string):
"""Write a new object at the end of the file.
:param byte_string:
The object content as a byte string.
:return:
The new object number.
"""
object_number = self.next_object_number()
self.new_objects_offsets.append(
self._write_object(object_number, byte_string))
return object_number
def finish(self):
"""Write cross-ref table and trailer for new and overwritten objects.
This makes `fileobj` a valid (updated) PDF file.
"""
new_startxref, write = self._start_writing()
self.finished = True
write(b'xref\n')
# Dont bother sorting or finding contiguous numbers,
# just write a new sub-section for each overwritten object.
for object_number, offset in self.overwritten_objects_offsets.items():
write(pdf_format(
'{0} 1\n{1:010} 00000 n \n', object_number, offset))
if self.new_objects_offsets:
first_new_object = len(self.objects_offsets)
write(pdf_format(
'{0} {1}\n', first_new_object, len(self.new_objects_offsets)))
for object_number, offset in enumerate(
self.new_objects_offsets, start=first_new_object):
write(pdf_format('{0:010} 00000 n \n', offset))
write(pdf_format(
'trailer\n<< '
'/Size {size} /Root {root} 0 R /Info {info} 0 R /Prev {prev}'
' >>\nstartxref\n{startxref}\n%%EOF\n',
size=self.next_object_number(),
root=self.catalog.object_number,
info=self.info.object_number,
prev=self.startxref,
startxref=new_startxref))
def _write_object(self, object_number, byte_string):
offset, write = self._start_writing()
write(pdf_format('{0} 0 obj\n', object_number))
write(byte_string)
write(b'\nendobj\n')
return offset
def _start_writing(self):
assert not self.finished
fileobj = self.fileobj
fileobj.seek(0, os.SEEK_END)
return fileobj.tell(), fileobj.write
def _write_compressed_file_object(pdf, file):
"""Write a compressed file like object as ``/EmbeddedFile``.
Compressing is done with deflate. In fact, this method writes multiple PDF
objects to include length, compressed length and MD5 checksum.
:return:
the object number of the compressed file stream object
"""
object_number = pdf.next_object_number()
# Make sure we stay in sync with our object numbers
expected_next_object_number = object_number + 4
length_number = object_number + 1
md5_number = object_number + 2
uncompressed_length_number = object_number + 3
offset, write = pdf._start_writing()
write(pdf_format('{0} 0 obj\n', object_number))
write(pdf_format(
'<< /Type /EmbeddedFile /Length {0} 0 R /Filter '
'/FlateDecode /Params << /CheckSum {1} 0 R /Size {2} 0 R >> >>\n',
length_number, md5_number, uncompressed_length_number))
write(b'stream\n')
uncompressed_length = 0
compressed_length = 0
md5 = hashlib.md5()
compress = zlib.compressobj()
for data in iter(lambda: file.read(4096), b''):
uncompressed_length += len(data)
md5.update(data)
compressed = compress.compress(data)
compressed_length += len(compressed)
write(compressed)
compressed = compress.flush(zlib.Z_FINISH)
compressed_length += len(compressed)
write(compressed)
write(b'\nendstream\n')
write(b'endobj\n')
pdf.new_objects_offsets.append(offset)
pdf.write_new_object(pdf_format("{0}", compressed_length))
pdf.write_new_object(pdf_format("<{0}>", md5.hexdigest()))
pdf.write_new_object(pdf_format("{0}", uncompressed_length))
assert pdf.next_object_number() == expected_next_object_number
return object_number
def _get_filename_from_result(url, result):
"""Derive a filename from a fetched resource.
This is either the filename returned by the URL fetcher, the last URL path
component or a synthetic name if the URL has no path.
"""
filename = None
# A given filename will always take precedence
if result:
filename = result.get('filename')
if filename:
return filename
# The URL path likely contains a filename, which is a good second guess
if url:
split = urlsplit(url)
if split.scheme != 'data':
filename = split.path.split("/")[-1]
if filename == '':
filename = None
if filename is None:
# The URL lacks a path altogether. Use a synthetic name.
# Using guess_extension is a great idea, but sadly the extension is
# probably random, depending on the alignment of the stars, which car
# you're driving and which software has been installed on your machine.
#
# Unfortuneatly this isn't even imdepodent on one machine, because the
# extension can depend on PYTHONHASHSEED if mimetypes has multiple
# extensions to offer
extension = None
if result:
mime_type = result.get('mime_type')
if mime_type == 'text/plain':
# text/plain has a phletora of extensions - all garbage
extension = '.txt'
else:
extension = mimetypes.guess_extension(mime_type) or '.bin'
else:
extension = '.bin'
filename = 'attachment' + extension
else:
filename = unquote(filename)
return filename
def _write_pdf_embedded_files(pdf, attachments, url_fetcher):
"""Write attachments as embedded files (document attachments).
:return:
the object number of the name dictionary or :obj:`None`
"""
file_spec_ids = []
for attachment in attachments:
file_spec_id = _write_pdf_attachment(pdf, attachment, url_fetcher)
if file_spec_id is not None:
file_spec_ids.append(file_spec_id)
# We might have failed to write any attachment at all
if len(file_spec_ids) == 0:
return None
content = [b'<< /Names [']
for fs in file_spec_ids:
content.append(pdf_format('\n(attachment{0}) {0} 0 R ',
fs))
content.append(b'\n] >>')
return pdf.write_new_object(b''.join(content))
def _write_pdf_attachment(pdf, attachment, url_fetcher):
"""Write an attachment to the PDF stream.
:return:
the object number of the ``/Filespec`` object or :obj:`None` if the
attachment couldn't be read.
"""
try:
# Attachments from document links like <link> or <a> can only be URLs.
# They're passed in as tuples
if isinstance(attachment, tuple):
url, description = attachment
attachment = Attachment(
url=url, url_fetcher=url_fetcher, description=description)
elif not isinstance(attachment, Attachment):
attachment = Attachment(guess=attachment, url_fetcher=url_fetcher)
with attachment.source as (source_type, source, url, _):
if isinstance(source, bytes):
source = io.BytesIO(source)
file_stream_id = _write_compressed_file_object(pdf, source)
except URLFetchingError as exc:
LOGGER.error('Failed to load attachment: %s', exc)
return None
# TODO: Use the result object from a URL fetch operation to provide more
# details on the possible filename
filename = _get_filename_from_result(url, None)
return pdf.write_new_object(pdf_format(
'<< /Type /Filespec /F () /UF {0!P} /EF << /F {1} 0 R >> '
'/Desc {2!P}\n>>',
filename,
file_stream_id,
attachment.description or ''))
def write_pdf_metadata(fileobj, scale, url_fetcher, attachments,
attachment_links, pages):
"""Add PDF metadata that are not handled by cairo.
Includes:
- attachments
- embedded files
- trim box
- bleed box
"""
pdf = PDFFile(fileobj)
# Add embedded files
embedded_files_id = _write_pdf_embedded_files(
pdf, attachments, url_fetcher)
if embedded_files_id is not None:
params = b''
if embedded_files_id is not None:
params += pdf_format(' /Names << /EmbeddedFiles {0} 0 R >>',
embedded_files_id)
pdf.extend_dict(pdf.catalog, params)
# Add attachments
# A single link can be split in multiple regions. We don't want to embed
# a file multiple times of course, so keep a reference to every embedded
# URL and reuse the object number.
# TODO: If we add support for descriptions this won't always be correct,
# because two links might have the same href, but different titles.
annot_files = {}
for page_links in attachment_links:
for link_type, target, rectangle in page_links:
if link_type == 'attachment' and target not in annot_files:
# TODO: use the title attribute as description
annot_files[target] = _write_pdf_attachment(
pdf, (target, None), url_fetcher)
for pdf_page, document_page, page_links in zip(
pdf.pages, pages, attachment_links):
# Add bleed box
media_box = pdf_page.get_value(
'MediaBox', '\\[(.+?)\\]').decode('ascii').strip()
left, top, right, bottom = (
float(value) for value in media_box.split(' '))
# Convert pixels into points
bleed = {
key: value * 0.75 for key, value in document_page.bleed.items()}
trim_left = left + bleed['left']
trim_top = top + bleed['top']
trim_right = right - bleed['right']
trim_bottom = bottom - bleed['bottom']
# Arbitrarly set PDF BleedBox between CSS bleed box (PDF MediaBox) and
# CSS page box (PDF TrimBox), at most 10 points from the TrimBox.
bleed_left = trim_left - min(10, bleed['left'])
bleed_top = trim_top - min(10, bleed['top'])
bleed_right = trim_right + min(10, bleed['right'])
bleed_bottom = trim_bottom + min(10, bleed['bottom'])
pdf.extend_dict(pdf_page, pdf_format(
'/TrimBox [ {} {} {} {} ] /BleedBox [ {} {} {} {} ]'.format(
trim_left, trim_top, trim_right, trim_bottom,
bleed_left, bleed_top, bleed_right, bleed_bottom)))
# Add links to attachments
# TODO: splitting a link into multiple independent rectangular
# annotations works well for pure links, but rather mediocre for other
# annotations and fails completely for transformed (CSS) or complex
# link shapes (area). It would be better to use /AP for all links and
# coalesce link shapes that originate from the same HTML link. This
# would give a feeling similiar to what browsers do with links that
# span multiple lines.
annotations = []
for link_type, target, rectangle in page_links:
if link_type == 'attachment' and annot_files[target] is not None:
matrix = cairo.Matrix(
xx=scale, yy=-scale, y0=document_page.height * scale)
rect_x, rect_y, width, height = rectangle
rect_x, rect_y = matrix.transform_point(rect_x, rect_y)
width, height = matrix.transform_distance(width, height)
# x, y, w, h => x0, y0, x1, y1
rectangle = rect_x, rect_y, rect_x + width, rect_y + height
content = [pdf_format(
'<< /Type /Annot '
'/Rect [{0:f} {1:f} {2:f} {3:f}] /Border [0 0 0]\n',
*rectangle)]
link_ap = pdf.write_new_object(pdf_format(
'<< /Type /XObject /Subtype /Form '
'/BBox [{0:f} {1:f} {2:f} {3:f}] /Length 0 >>\n'
'stream\n'
'endstream',
*rectangle))
content.append(b'/Subtype /FileAttachment ')
# evince needs /T or fails on an internal assertion. PDF
# doesn't require it.
content.append(pdf_format(
'/T () /FS {0} 0 R /AP << /N {1} 0 R >>',
annot_files[target], link_ap))
content.append(b'>>')
annotations.append(pdf.write_new_object(b''.join(content)))
if annotations:
pdf.extend_dict(pdf_page, pdf_format(
'/Annots [{0}]', ' '.join(
'{0} 0 R'.format(n) for n in annotations)))
pdf.finish()

145
weasyprint/stacking.py Normal file
View File

@ -0,0 +1,145 @@
"""
weasyprint.stacking
-------------------
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import operator
from .formatting_structure import boxes
from .layout.absolute import AbsolutePlaceholder
_Z_INDEX_GETTER = operator.attrgetter('z_index')
class StackingContext(object):
"""Stacking contexts define the paint order of all pieces of a document.
http://www.w3.org/TR/CSS21/visuren.html#x43
http://www.w3.org/TR/CSS21/zindex.html
"""
def __init__(self, box, child_contexts, blocks, floats, blocks_and_cells,
page):
self.box = box
self.page = page
self.block_level_boxes = blocks # 4: In flow, non positioned
self.float_contexts = floats # 5: Non positioned
self.negative_z_contexts = [] # 3: Child contexts, z-index < 0
self.zero_z_contexts = [] # 8: Child contexts, z-index = 0
self.positive_z_contexts = [] # 9: Child contexts, z-index > 0
self.blocks_and_cells = blocks_and_cells # 7: Non positioned
for context in child_contexts:
if context.z_index < 0:
self.negative_z_contexts.append(context)
elif context.z_index == 0:
self.zero_z_contexts.append(context)
else: # context.z_index > 0
self.positive_z_contexts.append(context)
self.negative_z_contexts.sort(key=_Z_INDEX_GETTER)
self.positive_z_contexts.sort(key=_Z_INDEX_GETTER)
# sort() is stable, so the lists are now storted
# by z-index, then tree order.
self.z_index = box.style['z_index']
if self.z_index == 'auto':
self.z_index = 0
@classmethod
def from_page(cls, page):
# Page children (the box for the root element and margin boxes)
# as well as the page box itself are unconditionally stacking contexts.
child_contexts = [cls.from_box(child, page) for child in page.children]
# Children are sub-contexts, remove them from the "normal" tree.
page = page.copy_with_children([])
return cls(page, child_contexts, [], [], [], page)
@classmethod
def from_box(cls, box, page, child_contexts=None):
children = [] # What will be passed to this box
if child_contexts is None:
child_contexts = children
# child_contexts: where to put sub-contexts that we find here.
# May not be the same as children for:
# "treat the element as if it created a new stacking context,
# but any positioned descendants and descendants which actually
# create a new stacking context should be considered part of the
# parent stacking context, not this new one."
blocks = []
floats = []
blocks_and_cells = []
def dispatch(box):
if isinstance(box, AbsolutePlaceholder):
box = box._box
style = box.style
absolute_and_z_index = (
style['position'] != 'static' and style['z_index'] != 'auto')
if (absolute_and_z_index or
style['opacity'] < 1 or
# 'transform: none' gives a "falsy" empty list here
style['transform'] or
style['overflow'] != 'visible'):
# This box defines a new stacking context, remove it
# from the "normal" children list.
child_contexts.append(
StackingContext.from_box(box, page))
else:
if style['position'] != 'static':
assert style['z_index'] == 'auto'
# "Fake" context: sub-contexts will go in this
# `child_contexts` list.
# Insert at the position before creating the sub-context.
index = len(child_contexts)
child_contexts.insert(
index,
StackingContext.from_box(box, page, child_contexts))
elif box.is_floated():
floats.append(StackingContext.from_box(
box, page, child_contexts))
elif isinstance(
box, (boxes.InlineBlockBox, boxes.InlineFlexBox)):
# Have this fake stacking context be part of the "normal"
# box tree, because we need its position in the middle
# of a tree of inline boxes.
return StackingContext.from_box(box, page, child_contexts)
else:
if isinstance(box, boxes.BlockLevelBox):
blocks_index = len(blocks)
blocks_and_cells_index = len(blocks_and_cells)
elif isinstance(box, boxes.TableCellBox):
blocks_index = None
blocks_and_cells_index = len(blocks_and_cells)
else:
blocks_index = None
blocks_and_cells_index = None
box = dispatch_children(box)
# Insert at the positions before dispatch the children.
if blocks_index is not None:
blocks.insert(blocks_index, box)
if blocks_and_cells_index is not None:
blocks_and_cells.insert(blocks_and_cells_index, box)
return box
def dispatch_children(box):
if not isinstance(box, boxes.ParentBox):
return box
new_children = []
for child in box.children:
result = dispatch(child)
if result is not None:
new_children.append(result)
box.children = new_children
return box
box = dispatch_children(box)
return cls(box, children, blocks, floats, blocks_and_cells, page)

View File

@ -0,0 +1,10 @@
"""
weasyprint.tests
----------------
The Weasyprint test suite.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""

Binary file not shown.

View File

@ -0,0 +1,17 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN">
<html>
<head>
<title>The Second Acid Test (Reference Rendering)</title>
<style type="text/css">
html { margin: 0; padding: 0; border: 0; overflow: hidden; background: white; }
body { margin: 0; padding: 0; border: 0; }
h2 { margin: 0; padding: 48px 0 36px 84px; border: 0; font: 24px/24px sans-serif; color: navy; }
p { margin: 0; padding: 0 0 0 72px; border: 0; }
img { vertical-align: top; margin: 0; padding: 0; border: 0; }
</style>
</head>
<body>
<h2>Hello&nbsp;World!</h2>
<p><a href="reference.png"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAACoCAMAAABDlVWGAAABUFBMVEUAAAABAQADAwAICAAIpQQOpwodHQAhISAmryIsLAAwsy1AQEBDQwBSUgBTwFBUVAB1dQCAgACBgQCCggCDgwCEhACFhQCGhgCJiQCJ1IeKigCLiwCMjACOjgCPjwCPj4+SkgCTkwCVlQCXlwCYmACbmwCdnQCdnZ2fnwCgoACiogCi3aClpQCmpgCpqQCrqwCsrACtrQCvrwCwsLCysgCzswC2tgC8vAC9vQC+vgDBwQDCwgDDwwDExADIyADJyQDKygDKysrLywDNzQDOzgDO7c3Q0ADS0gDT0wDU1ADW1gDX1wDa2gDb29vd3QDf3wDg4ADh4QDk5ADl5QDo6ADo9ujp6QDq6gDq6urr6wDs7ADt7QDv7wDw8ADx8QDy8gDz8wDz+vP19QD29gD39wD4+AD5+QD7+wD7+/v8/AD9/QD9/f3+/gD+/v7//wD///+VIlNwAAADWklEQVR42u3d6VPaQBgGcBBBkDNciiCIgKgRTxTPqijeiCeioohcAiH9/7+VduqMbVNIJruMGZ/96Mz77C/AZt/VDKq+yxgqiUPWXIACCiiggAIKKDXo/ybmJQ45FwAooIACCiiggJKFygHJuQBAAQUUUEABBZQeVBTuuVCTC6sVniWjJULLmeT6YUUutHK4nsyUKUJLmY0Zv2+t/Yo+xm1mk8lsiz+KxX2sqK35/DMbmRItaPVs0euw+4/rfMKq06hVKrVGZ02Ic/5RUT/22x3exbMqHWj1bH7U7po8Kb9EDH3G6G46vRs19hkiL92Zf1eUTyZd9tF5ISkBaHZlmGHCpw0+ohsI5Jqt9mjmAgO6SHfoPxWN0zDDDK9kaUArB6F29naFTxgGlt6KKdbjYVPFt6UBQ9d3X6Cist2+6tBBhQL0YXmEcS8U+EdrX+DtlrXotVq9hb19C/RZu6wowYrCgpsZWX4gD62fTzsZ7z7Hx3XGXJEd7P81BtlizqiLd4YKVnD7XsY5fV4nDq0d+e1M6JLjbZpoM2Xp/z0sqWZUY+sMFazgLkOM3X9UIw6tJscY11ye583q3Rarf59Wz7Z21ebOUOGK/JyLGUtWiUNLe0HGNdvONanSLY/2fVqtp5VWmTpDhSuqsy4muFciv5iOxx1DPz/8pKAPy0OO8WMKq/4iZnfHnsi99U8xtz12QQH6mnAwwWtyi+k6yDgSrzR2ppOpId9mg9TtqbHpG5o6obKF5rdCzokyR+aGz5UnnKGtPJ3uKbsajl1xZLZQ7ioWXs1SavNqNzvfCkIthvSmhOcL33ZuarQaZ+71riHQtElv89o/aty9chSPIg2BNlh64/wxihL0fdy/HyzuxR5FxFYQhvb+6Kwc6GfASf3jBKCAAgoooIACCqi4fGVAySbRgxKPogSlkUUDSimMOJReGlko1TiCybTzSAX3IJBIbm8S5cf2LFJmai8zv8YrqpzPqHJWvXLuo8rZmZSz1yune1JOP6qcDl85ZyblnELxCwhAAQUUUEAB/UxQMQ8W0LgYMXNJfgICUEABBRRQQAGV/RChitAQkw8ooIACCiiggJKF4hsLAAUUUEABBfTrQPFNhIACCiiggAL6daD4DwOAAgoooIACqljoDwseYUYsza58AAAAAElFTkSuQmCC" alt="Follow this link to view the reference image, which should be rendered below the text &quot;Hello World!&quot; on the test page in the same way that this paragraph is rendered below that text on this page."></a></p>
</body>
</html>

View File

@ -0,0 +1,148 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN">
<html>
<head>
<title>The Second Acid Test</title>
<style type="text/css">
/* section numbers refer to CSS2.1 */
/* page setup */
html { font: 12px sans-serif; margin: 0; padding: 0; overflow: hidden; /* hides scrollbars on viewport, see 11.1.1:3 */ background: white; color: red; }
body { margin: 0; padding: 0; }
/* introduction message */
.intro { font: 2em sans-serif; margin: 3.5em 2em; padding: 0.5em; border: solid thin; background: white; color: black; position: relative; z-index: 2; /* should cover the black and red bars that are fixed-positioned */ }
.intro * { font: inherit; margin: 0; padding: 0; }
.intro h1 { font-size: 1em; font-weight: bolder; margin: 0; padding: 0; }
.intro :link { color: blue; }
.intro :visited { color: purple; }
/* picture setup */
#top { margin: 100em 3em 0; padding: 2em 0 0 .5em; text-align: left; font: 2em/24px sans-serif; color: navy; white-space: pre; } /* "Hello World!" text */
.picture { position: relative; border: 1em solid transparent; margin: 0 0 100em 3em; } /* containing block for face */
.picture { background: red; } /* overriden by preferred stylesheet below */
/* top line of face (scalp): fixed positioning and min/max height/width */
.picture p { position: fixed; margin: 0; padding: 0; border: 0; top: 9em; left: 11em; width: 140%; max-width: 4em; height: 8px; min-height: 1em; max-height: 2mm; /* min-height overrides max-height, see 10.7 */ background: black; border-bottom: 0.5em yellow solid; }
/* bits that shouldn't be part of the top line (and shouldn't be visible at all): HTML parsing, "+" combinator, stacking order */
.picture p.bad { border-bottom: red solid; /* shouldn't matter, because the "p + table + p" rule below should match it too, thus hiding it */ }
.picture p + p { background: maroon; z-index: 1; } /* shouldn't match anything */
.picture p + table + p { margin-top: 3em; /* should end up under the absolutely positioned table below, and thus not be visible */ }
/* second line of face: attribute selectors, float positioning */
[class~=one].first.one { position: absolute; top: 0; margin: 36px 0 0 60px; padding: 0; border: black 2em; border-style: none solid; /* shrink wraps around float */ }
[class~=one][class~=first] [class=second\ two][class="second two"] { float: right; width: 48px; height: 12px; background: yellow; margin: 0; padding: 0; } /* only content of abs pos block */
/* third line of face: width and overflow */
.forehead { margin: 4em; width: 8em; border-left: solid black 1em; border-right: solid black 1em; background: red url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mP4%2F58BAAT%2FAf9jgNErAAAAAElFTkSuQmCC); /* that's a 1x1 yellow pixel PNG */ }
.forehead * { width: 12em; line-height: 1em; }
/* class selectors headache */
.two.error.two { background: maroon; } /* shouldn't match */
.forehead.error.forehead { background: red; } /* shouldn't match */
[class=second two] { background: red; } /* this should be ignored (invalid selector -- grammar says it only accepts IDENTs or STRINGs) */
/* fourth and fifth lines of face, with eyes: paint order test (see appendix E) and fixed backgrounds */
/* the two images are identical: 2-by-2 squares with the top left
and bottom right pixels set to yellow and the other two set to
transparent. Since they are offset by one pixel from each other,
the second one paints exactly over the transparent parts of the
first one, thus creating a solid yellow block. */
.eyes { position: absolute; top: 5em; left: 3em; margin: 0; padding: 0; background: red; }
#eyes-a { height: 0; line-height: 2em; text-align: right; } /* contents should paint top-most because they're inline */
#eyes-a object { display: inline; vertical-align: bottom; }
#eyes-a object[type] { width: 7.5em; height: 2.5em; } /* should have no effect since that object should fallback to being inline (height/width don't apply to inlines) */
#eyes-a object object object { border-right: solid 1em black; padding: 0 12px 0 11px; background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAABnRSTlMAAAAAAABupgeRAAAABmJLR0QA%2FwD%2FAP%2BgvaeTAAAAEUlEQVR42mP4%2F58BCv7%2FZwAAHfAD%2FabwPj4AAAAASUVORK5CYII%3D) fixed 1px 0; }
#eyes-b { float: left; width: 10em; height: 2em; background: fixed url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAABnRSTlMAAAAAAABupgeRAAAABmJLR0QA%2FwD%2FAP%2BgvaeTAAAAEUlEQVR42mP4%2F58BCv7%2FZwAAHfAD%2FabwPj4AAAAASUVORK5CYII%3D); border-left: solid 1em black; border-right: solid 1em red; } /* should paint in the middle layer because it is a float */
#eyes-c { display: block; background: red; border-left: 2em solid yellow; width: 10em; height: 2em; } /* should paint bottom most because it is a block */
/* lines six to nine, with nose: auto margins */
.nose { float: left; margin: -2em 2em -1em; border: solid 1em black; border-top: 0; min-height: 80%; height: 60%; max-height: 3em; /* percentages become auto (see 10.5 and 10.7) and intrinsic height is more than 3em, so 3em wins */ padding: 0; width: 12em; }
.nose > div { padding: 1em 1em 3em; height: 0; background: yellow; }
.nose div div { width: 2em; height: 2em; background: red; margin: auto; }
.nose :hover div { border-color: blue; }
.nose div:hover :before { border-bottom-color: inherit; }
.nose div:hover :after { border-top-color: inherit; }
.nose div div:before { display: block; border-style: none solid solid; border-color: red yellow black yellow; border-width: 1em; content: ''; height: 0; }
.nose div :after { display: block; border-style: solid solid none; border-color: black yellow red yellow; border-width: 1em; content: ''; height: 0; }
/* between lines nine and ten: margin collapsing with 'float' and 'clear' */
.empty { margin: 6.25em; height: 10%; /* computes to auto which makes it empty per 8.3.1:7 (own margins) */ }
.empty div { margin: 0 2em -6em 4em; }
.smile { margin: 5em 3em; clear: both; /* clearance is negative (see 8.3.1 and 9.5.1) */ }
/* line ten and eleven: containing block for abs pos */
.smile div { margin-top: 0.25em; background: black; width: 12em; height: 2em; position: relative; bottom: -1em; }
.smile div div { position: absolute; top: 0; right: 1em; width: auto; height: 0; margin: 0; border: yellow solid 1em; }
/* smile (over lines ten and eleven): backgrounds behind borders, inheritance of 'float', nested floats, negative heights */
.smile div div span { display: inline; margin: -1em 0 0 0; border: solid 1em transparent; border-style: none solid; float: right; background: black; height: 1em; }
.smile div div span em { float: inherit; border-top: solid yellow 1em; border-bottom: solid black 1em; } /* zero-height block; width comes from (zero-height) child. */
.smile div div span em strong { width: 6em; display: block; margin-bottom: -1em; /* should have no effect, since parent has top&bottom borders, so this margin doesn't collapse */ }
/* line twelve: line-height */
.chin { margin: -4em 4em 0; width: 8em; line-height: 1em; border-left: solid 1em black; border-right: solid 1em black; background: yellow url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAFSDNYfAAAAaklEQVR42u3XQQrAIAwAQeP%2F%2F6wf8CJBJTK9lnQ7FpHGaOurt1I34nfH9pMMZAZ8BwMGEvvh%2BBsJCAgICLwIOA8EBAQEBAQEBAQEBK79H5RfIQAAAAAAAAAAAAAAAAAAAAAAAAAAAID%2FABMSqAfj%2FsLmvAAAAABJRU5ErkJggg%3D%3D) /* 64x64 red square */ no-repeat fixed /* shouldn't be visible unless the smiley is moved to the top left of the viewport */; }
.chin div { display: inline; font: 2px/4px serif; }
/* line thirteen: cascade and selector tests */
.parser-container div { color: maroon; border: solid; color: orange; } /* setup */
div.parser-container * { border-color: black; /* overrides (implied) border-color on previous line */ } /* setup */
* div.parser { border-width: 0 2em; /* overrides (implied) declarations on earlier line */ } /* setup */
/* line thirteen continued: parser tests */
.parser { /* comment parsing test -- comment ends before the end of this line, the backslash should have no effect: \*/ }
.parser { margin: 0 5em 1em; padding: 0 1em; width: 2em; height: 1em; error: \}; background: yellow; } /* setup with parsing test */
* html .parser { background: gray; }
\.parser { padding: 2em; }
.parser { m\argin: 2em; };
.parser { height: 3em; }
.parser { width: 200; }
.parser { border: 5em solid red ! error; }
.parser { background: red pink; }
/* line fourteen (last line of face): table */
ul { display: table; padding: 0; margin: -1em 7em 0; background: red; }
ul li { padding: 0; margin: 0; }
ul li.first-part { display: table-cell; height: 1em; width: 1em; background: black; }
ul li.second-part { display: table; height: 1em; width: 1em; background: black; } /* anonymous table cell wraps around this */
ul li.third-part { display: table-cell; height: 0.5em; /* gets stretched to fit row */ width: 1em; background: black; }
ul li.fourth-part { list-style: none; height: 1em; width: 1em; background: black; } /* anonymous table cell wraps around this */
/* bits that shouldn't appear: inline alignment in cells */
.image-height-test { height: 10px; overflow: hidden; font: 20em serif; } /* only the area between the top of the line box and the top of the image should be visible */
table { margin: 0; border-spacing: 0; }
td { padding: 0; }
</style>
<link rel="appendix stylesheet" href="data:text/css,.picture%20%7B%20background%3A%20none%3B%20%7D"> <!-- this stylesheet should be applied by default -->
</head>
<body>
<div class="intro">
<h1>Standards compliant?</h1>
<p><a href="#top">Take The Acid2 Test</a> and compare it to <a href="reference.html">the reference rendering</a>.</p>
</div>
<h2 id="top">Hello World!</h2>
<div class="picture">
<p><table><tr><td></table><p class="bad"> <!-- <table> closes <p> per the HTML4 DTD -->
<blockquote class="first one"><address class="second two"></address></blockquote>
<div class="forehead"><div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</div></div>
<div class="eyes"><div id="eyes-a"><object data="data:application/x-unknown,ERROR"><object data="./404" type="text/html"><object data="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAYCAYAAAFy7sgCAAAGsUlEQVRo3u2ZbWwcZxHHf3s%2B7LNbO3ZjXBtowprGODRX0qpNQCjmJKuVKhMl1P2AkCwhFOIKkCBSm9IXavGFKAixIAECwkmWo5MrhRI3Ub40IEwQgp6aIDg3Cd6eEqyIHEteah%2B1E69vhw%2BZtTaX8704ZzkKjHS6271nZ56ZZ%2BY%2F%2F%2BdZKF%2FCwYshx3EkkggLsD1v4FQkEZZYLCbAKyG9%2Ba9EIsG6hnUAf8x74K3aUC3j4%2BM54HcsR2oAIomwZOezkv%2FnSHpYNh%2BNCmAE7xv94zvFdd1bHsjMZmQkPSxAJP%2B%2FfuBLwK54PC7JZFKAVJmzXLBt2w%2FMvcDLwIb8QS8CeJ4nkURYIomw7J%2FYJ8BvSiiXptGGxWds2%2Fa9%2Bnaxh%2BYAD%2Bgt04NDgABTpQY2cvvSFLzw86gWeBVwC8SzlOSv2YeBPfmDBoBHgKmR9LBEEmHZfDTqGykqfkUE0nA78BzQGfSgUeP3wNeTXwXg7MwZDhw4UHL6ra2ti79%2FOvljgG8AZ4H64Lhm4MvAocxsRppGG%2FxcXihlwLIs6R%2FfKV2HO%2F26uA94pdDYUKUZUU7W1RQYXA98Gnhaf5%2FXWX0HeAHYoQonqa4sZSOsSWMCWeC9Yko%2BCQwBe4E6oNc0Tc91XTl1%2BaTsn9gnI%2Blhyc5nZWxsrBIkKSbl2tiic3tW53YDEwOKaoFBrcOfqKee53lG9xsPMjV784r%2F4lO%2FpPvyJ9iyZcuvFSaXK5XYeAZ4CDgGvB3MS4B54LQuWYPeuy4iRFsevsXqpuYoqVQKIH2bK1CuDQNo11o4XUzh%2FcDWYIe1LEtyuZx4niee54njOGKapgfsqlL%2Bl2OjEXg8nxrc1dJ0h3hbtL%2BGCtz7KPBF4CuBe9uB15VafE8hr9qylI3HgG8C2%2FK7VyHZoJj7MrBRm30qFotJMpkU27YlHo%2F7Ha5a%2BV%2FKRkSJ4KuKRLVLKapTjB1SzAVIjY2NSXY%2BKyPpYdk%2FsU9OXT4pruv6BdZbBQfKsVGnvWlIe1VB6VQO8JxC1vZYLCbZ%2BaxsPhpdZDyRRFhG0sPiOE6ldKBg2lRg4xF1YCDIIIKN7DGgD3gH%2BBXwejKZfPrs2tPs%2FvPN2bKuYR1nd7xLKBSSJeqoXKnERjPwNWAG%2BLn2rZuM%2B4Tpml6vaWlp4eLcxVusZq5lCgVgOVKJjRqdX86ffL4D5wIoZACnTpw4wRMdT96i%2FImOJxERAs4uVyqxUacF%2FPdiCj%2BjdRBRGFtwXVdG0sPSdbhTmkYbpH98p2RmM2JZlig1vl0GWo4NQ%2Fn%2Bs5pKRXfwjweaxy7TND3HcRZbfC6X8xVPVQlGy7WxVWlO5XRXFXm6EZmrQuSXYyPE3SiVoEhE6Wyr0u2rumO6zv%2B21AFdQAswC1wCMuUCXCmyWQus103Qg8qlDO0lxwOb%2Fl4FiK3AB3VS%2FuKKLtK%2FgbeAnwG%2FvUODuRw%2FFrR0H1UC75fwu8oJ%2FhFsW5VIG%2FBUgEIN6Y65O4AHu4Ap0zQ9y7LEcZyb9lRBUHQcRyzL8unZVBW5bFWAvAp%2BhDQ2g4F47dUYtlU6obXA54DnVdFLekjUGGifh4AFy7LEdV3xj3X9I66m0QZpGm2QrsOd0j%2B%2BU0bSw5KZzYjrun6HWlAd961i4FfCj0aN1Usau%2Bc1lmuXPFwvAEumUut7tQQvAb%2FXb%2FT0bCAej9cODg7yt%2Bm%2F8q2%2F7OUHZ76PnZ1k2p0mJzlykmPancbOTnL0whHs7CQfb%2B5mx2d3sH79%2BtCRI0c6FeaOr9ICrIQfLvA%2B8BGNXxi4R6HrisJVUWrxAVW2oMFf0Aczim8o3kV6enowDIPjF9%2Fk%2BMU3S3rrjzMMg56eHr%2BxP7qKFbASfojG6kpeDGs1tiW53RxwWT%2Bin5q8w4xpQK5evQpAR30H7ZH2khNvj7TTUd8BgD4rqmu1ZKX8qNeY%2BfHz4zlXDgT5E8tpCTUq7XSBC4Euv8227TV9fX1E73%2BYtvo27BmbS9cvFVTY3bSRFza9yOcf6Gfmygy7d%2B%2Fm%2FPnzF4DvrsBLhnJlJfwIKXxv1PheAE4qK6p4H9AGbNKTuhngBPBPXYRe4IemaT5kWZbR19fHNbmGnZ1k4r3U4glDR30Hm5qjbGjsImJEOHbsGHv27JFz5869o0eFq01Jq%2BmHAXwI6FFKagMTgHM7GzFDS%2BoeLSMv7zjzC9x4Y7gxFovVDAwMEI1GaWlpWSzRVCrFwYMH%2FXfxZ4AfAa8B%2F7lDaGg1%2FQgp43lfK0yqtRMuJa3ceKe5DfgYsCYAZ2ngD8CfAkzqTpW7xY%2F%2FSznyX%2FVeUb2kVmX4AAAAAElFTkSuQmCC">ERROR</object></object></object></div><div id="eyes-b"></div><div id="eyes-c"></div></div> <!-- that's a PNG with 8bit alpha containing two eyes -->
<div class="nose"><div><div></div></div></div>
<div class="empty"><div></div></div>
<div class="smile"><div><div><span><em><strong></strong></em></span></div></div></div>
<div class="chin"><div>&nbsp;</div></div>
<div class="parser-container"><div class="parser"><!-- ->ERROR<!- --></div></div> <!-- two dashes is what delimits a comment, so the text "->ERROR<!-" earlier on this line is actually part of a comment -->
<ul>
<li class="first-part"></li>
<li class="second-part"></li>
<li class="third-part"></li>
<li class="fourth-part"></li>
</ul>
<div class="image-height-test"><table><tr><td><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAFSDNYfAAAAaklEQVR42u3XQQrAIAwAQeP%2F%2F6wf8CJBJTK9lnQ7FpHGaOurt1I34nfH9pMMZAZ8BwMGEvvh%2BBsJCAgICLwIOA8EBAQEBAQEBAQEBK79H5RfIQAAAAAAAAAAAAAAAAAAAAAAAAAAAID%2FABMSqAfj%2FsLmvAAAAABJRU5ErkJggg%3D%3D" alt=""></td></tr></table></div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

View File

@ -0,0 +1,58 @@
<html>
<head>
<meta http-equiv=Content-Type content=text/html;charset=utf8>
<!-- currentColor means 'inherit' on color itself. -->
<link rel=stylesheet href="data:text/css;charset=ASCII,a%7Bcolor%3AcurrentColor%7D">
<style media=print>
@import url(sub_directory/sheet1.css);
@import "data:text/css;charset=utf-16le;base64,\
bABpAHsAYwBvAGwAbwByADoAcgBlAGQAfQA=";
@import "data:,ul {border-width: 1000px !important}" screen;
@font-face { src: url(AHEM____.TTF); font-family: ahem }
a:after {
content: " [" attr(href) "]";
border-style: solid;
border-top-width: inherit;
border-bottom-width: initial;
}
@page :first { margin-top: 5px }
ul {
border-style: none solid hidden;
border-width: thin thick 4px .25in;
}
body > h1:first-child {
background-image: url(logo_small.png);
}
h1 ~ p ~ ul a:after {
background: red;
}
</style>
<style type=text/NOT-css>
ul {
border-width: 1000px !important;
}
</style>
<style media=screen>
ul {
border-width: 1000px !important;
}
</style>
<link rel=not_stylesheet href="data:,ul {border-width: 1000px !important}">
</head>
<body style="font-size: 20px">
<h1 style="font-size: 2em">WeasyPrint test document (with Ünicōde)</h1>
<p style="color: blue; font-size: x-large;
-weasy-link: attr(foo-link) /* no such attribute */">Hello</p>
<ul style="font-family: ahem; font-size: 1.25ex">
<li style="font-size: 6pt; font-weight: bold">
<a href=home.html
style="padding: 1px 2px 3px 4px; border: 42px solid;
font-size: 300%; font-weight: bolder">Home</a>
<li>
</ul>
<div style="font-size: 2em">
<span style="display: block; width: 10rem; height: 10em">
<span style="font-size: 2rem">WeasyPrint</span>
</span>
</div>
</body>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

View File

@ -0,0 +1,4 @@
h1::before {
content: "I løvë Unicode";
background-image: url(pattern.png)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -0,0 +1,4 @@
/* Minimal user-agent stylesheet */
p { margin: 1em 0px } /* 0px should be translated to 0*/
a { text-decoration: underline }
h1 { font-weight: bolder }

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

View File

@ -0,0 +1,6 @@
<svg width="4px" height="4px" viewbox="0 0 4 4" id="root">
<!-- Overflow the veiwbox -->
<rect width="10pt" height="10pt" fill="#00f" />
<rect width="1px" height="1px" fill="#f00" />
</svg>

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

View File

@ -0,0 +1,6 @@
<svg width="4px" height="4px" viewbox="0 0 4 4" id="root">
<!-- Overflow the veiwbox -->
<rect width="10pt" height="10pt" fill="#00f" />
<rect width="1px" height="1px" fill="#f00" />
</svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@ -0,0 +1,5 @@
li {
margin-bottom: 3em; /* Should be masked*/
margin: 2em 0;
margin-left: 4em; /* Should not be masked*/
}

View File

@ -0,0 +1,16 @@
@import url(../sheet2.css) all;
p {
background: currentColor;
}
@media print {
ul {
/* 1ex == 0.8em for ahem. */
margin: 2em 2.5ex;
}
}
@media screen {
ul {
border-width: 1000px !important;
}
}

View File

@ -0,0 +1,5 @@
html {
/* Reversed contrast */
color: white;
background-color: black;
}

View File

@ -0,0 +1,4 @@
h1::before {
content: "I løvë Unicode";
background-image: url(pattern.png)
}

Binary file not shown.

View File

@ -0,0 +1,39 @@
"""
weasyprint.tests.test_draw.test_acid2
-------------------------------------
Check the famous Acid2 test.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from .. import HTML
from .test_draw import assert_pixels_equal, image_to_pixels
from .testing_utils import (
assert_no_logs, capture_logs, requires, resource_filename)
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_acid2():
def render(filename):
return HTML(resource_filename(filename)).render(enable_hinting=True)
with capture_logs():
# This is a copy of http://www.webstandards.org/files/acid2/test.html
document = render('acid2-test.html')
intro_page, test_page = document.pages
# Ignore the intro page: it is not in the reference
test_image, width, height = document.copy(
[test_page]).write_image_surface()
# This is a copy of http://www.webstandards.org/files/acid2/reference.html
ref_image, ref_width, ref_height = render(
'acid2-reference.html').write_image_surface()
assert (width, height) == (ref_width, ref_height)
assert_pixels_equal(
'acid2', width, height, image_to_pixels(test_image, width, height),
image_to_pixels(ref_image, width, height), tolerance=2)

View File

@ -0,0 +1,951 @@
"""
weasyprint.tests.test_api
-------------------------
Test the public API.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import gzip
import io
import math
import os
import sys
import unicodedata
import zlib
from pathlib import Path
from urllib.parse import urljoin, uses_relative
import cairocffi as cairo
import py
import pytest
from .. import CSS, HTML, __main__, default_url_fetcher
from ..urls import path2url
from .test_draw import assert_pixels_equal, image_to_pixels, parse_pixels
from .testing_utils import (
FakeHTML, assert_no_logs, capture_logs, http_server, resource_filename)
def _test_resource(class_, basename, check, **kwargs):
"""Common code for testing the HTML and CSS classes."""
absolute_filename = resource_filename(basename)
absolute_path = Path(absolute_filename)
url = path2url(absolute_filename)
check(class_(absolute_filename, **kwargs))
check(class_(absolute_path, **kwargs))
check(class_(guess=absolute_filename, **kwargs))
check(class_(guess=absolute_path, **kwargs))
check(class_(filename=absolute_filename, **kwargs))
check(class_(filename=absolute_path, **kwargs))
check(class_(url, **kwargs))
check(class_(guess=url, **kwargs))
check(class_(url=url, **kwargs))
with open(absolute_filename, 'rb') as fd:
check(class_(fd, **kwargs))
with open(absolute_filename, 'rb') as fd:
check(class_(guess=fd, **kwargs))
with open(absolute_filename, 'rb') as fd:
check(class_(file_obj=fd, **kwargs))
with open(absolute_filename, 'rb') as fd:
content = fd.read()
py.path.local(os.path.dirname(__file__)).chdir()
relative_filename = os.path.join('resources', basename)
relative_path = Path(relative_filename)
check(class_(relative_filename, **kwargs))
check(class_(relative_path, **kwargs))
check(class_(string=content, base_url=relative_filename, **kwargs))
encoding = kwargs.get('encoding') or 'utf8'
check(class_(string=content.decode(encoding), # unicode
base_url=relative_filename, **kwargs))
with pytest.raises(TypeError):
class_(filename='foo', url='bar')
def _check_doc1(html, has_base_url=True):
"""Check that a parsed HTML document looks like resources/doc1.html"""
root = html.etree_element
assert root.tag == 'html'
assert [child.tag for child in root] == ['head', 'body']
_head, body = root
assert [child.tag for child in body] == ['h1', 'p', 'ul', 'div']
h1, p, ul, div = body
assert h1.text == 'WeasyPrint test document (with Ünicōde)'
if has_base_url:
url = urljoin(html.base_url, 'pattern.png')
assert url.startswith('file:')
assert url.endswith('weasyprint/tests/resources/pattern.png')
else:
assert html.base_url is None
def _run(args, stdin=b''):
stdin = io.BytesIO(stdin)
stdout = io.BytesIO()
try:
__main__.HTML = FakeHTML
__main__.main(args.split(), stdin=stdin, stdout=stdout)
finally:
__main__.HTML = HTML
return stdout.getvalue()
class _fake_file(object):
def __init__(self):
self.chunks = []
def write(self, data):
self.chunks.append(bytes(data[:]))
def getvalue(self):
return b''.join(self.chunks)
def _png_size(result):
png_bytes, width, height = result
surface = cairo.ImageSurface.create_from_png(io.BytesIO(png_bytes))
assert (surface.get_width(), surface.get_height()) == (width, height)
return width, height
def _round_meta(pages):
"""Eliminate errors of floating point arithmetic for metadata.
(eg. 49.99999999999994 instead of 50)
"""
for page in pages:
anchors = page.anchors
for anchor_name, (pos_x, pos_y) in anchors.items():
anchors[anchor_name] = round(pos_x, 6), round(pos_y, 6)
links = page.links
for i, link in enumerate(links):
link_type, target, (pos_x, pos_y, width, height) = link
link = (
link_type, target, (round(pos_x, 6), round(pos_y, 6),
round(width, 6), round(height, 6)))
links[i] = link
bookmarks = page.bookmarks
for i, (level, label, (pos_x, pos_y), state) in enumerate(bookmarks):
bookmarks[i] = (level, label,
(round(pos_x, 6), round(pos_y, 6)), state)
@assert_no_logs
def test_html_parsing():
"""Test the constructor for the HTML class."""
_test_resource(FakeHTML, 'doc1.html', _check_doc1)
_test_resource(FakeHTML, 'doc1_UTF-16BE.html', _check_doc1,
encoding='UTF-16BE')
py.path.local(os.path.dirname(__file__)).chdir()
filename = os.path.join('resources', 'doc1.html')
with open(filename, encoding='utf-8') as fd:
string = fd.read()
_check_doc1(FakeHTML(string=string, base_url=filename))
_check_doc1(FakeHTML(string=string), has_base_url=False)
string_with_meta = string.replace(
'<meta', '<base href="resources/"><meta')
_check_doc1(FakeHTML(string=string_with_meta, base_url='.'))
@assert_no_logs
def test_css_parsing():
"""Test the constructor for the CSS class."""
def check_css(css):
"""Check that a parsed stylsheet looks like resources/utf8-test.css"""
# Using 'encoding' adds a CSSCharsetRule
h1_rule, = css.matcher.lower_local_name_selectors['h1']
assert h1_rule[3] == 'before'
assert h1_rule[4][0][0] == 'content'
assert h1_rule[4][0][1][0][1] == 'I løvë Unicode'
assert h1_rule[4][1][0] == 'background_image'
assert h1_rule[4][1][1][0][0] == 'url'
assert h1_rule[4][1][1][0][1].startswith('file:')
assert h1_rule[4][1][1][0][1].endswith(
'weasyprint/tests/resources/pattern.png')
_test_resource(CSS, 'utf8-test.css', check_css)
_test_resource(CSS, 'latin1-test.css', check_css, encoding='latin1')
def check_png_pattern(png_bytes, x2=False, blank=False, rotated=False):
if blank:
expected_pixels = '''
________
________
________
________
________
________
________
________
'''
size = 8
elif x2:
expected_pixels = '''
________________
________________
________________
________________
____rrBBBBBB____
____rrBBBBBB____
____BBBBBBBB____
____BBBBBBBB____
____BBBBBBBB____
____BBBBBBBB____
____BBBBBBBB____
____BBBBBBBB____
________________
________________
________________
________________
'''
size = 16
elif rotated:
expected_pixels = '''
________
________
__BBBB__
__BBBB__
__BBBB__
__rBBB__
________
________
'''
size = 8
else:
expected_pixels = '''
________
________
__rBBB__
__BBBB__
__BBBB__
__BBBB__
________
________
'''
size = 8
surface = cairo.ImageSurface.create_from_png(io.BytesIO(png_bytes))
assert_pixels_equal('api_png', size, size,
image_to_pixels(surface, size, size),
b"".join(parse_pixels(expected_pixels)))
@assert_no_logs
def test_python_render(tmpdir):
"""Test rendering with the Python API."""
base_url = resource_filename('dummy.html')
html_string = '<body><img src=pattern.png>'
css_string = '''
@page { margin: 2px; size: 8px; background: #fff }
body { margin: 0; font-size: 0 }
img { image-rendering: pixelated }
@media screen { img { transform: rotate(-90deg) } }
'''
html = FakeHTML(string=html_string, base_url=base_url)
css = CSS(string=css_string)
png_bytes = html.write_png(stylesheets=[css])
pdf_bytes = html.write_pdf(stylesheets=[css])
assert png_bytes.startswith(b'\211PNG\r\n\032\n')
assert pdf_bytes.startswith(b'%PDF')
check_png_pattern(png_bytes)
# TODO: check PDF content? How?
png_file = _fake_file()
html.write_png(png_file, stylesheets=[css])
assert png_file.getvalue() == png_bytes
pdf_file = _fake_file()
html.write_pdf(pdf_file, stylesheets=[css])
# assert pdf_file.read_binary() == pdf_bytes
png_file = tmpdir.join('1.png')
pdf_file = tmpdir.join('1.pdf')
html.write_png(png_file.strpath, stylesheets=[css])
html.write_pdf(pdf_file.strpath, stylesheets=[css])
assert png_file.read_binary() == png_bytes
# assert pdf_file.read_binary() == pdf_bytes
png_file = tmpdir.join('2.png')
pdf_file = tmpdir.join('2.pdf')
with open(png_file.strpath, 'wb') as png_fd:
html.write_png(png_fd, stylesheets=[css])
with open(pdf_file.strpath, 'wb') as pdf_fd:
html.write_pdf(pdf_fd, stylesheets=[css])
assert png_file.read_binary() == png_bytes
# assert pdf_file.read_binary() == pdf_bytes
x2_png_bytes = html.write_png(stylesheets=[css], resolution=192)
check_png_pattern(x2_png_bytes, x2=True)
screen_css = CSS(string=css_string, media_type='screen')
rotated_png_bytes = html.write_png(stylesheets=[screen_css])
check_png_pattern(rotated_png_bytes, rotated=True)
assert FakeHTML(
string=html_string, base_url=base_url, media_type='screen'
).write_png(
stylesheets=[io.BytesIO(css_string.encode('utf8'))]
) == rotated_png_bytes
assert FakeHTML(
string='<style>%s</style>%s' % (css_string, html_string),
base_url=base_url, media_type='screen'
).write_png() == rotated_png_bytes
@assert_no_logs
def test_command_line_render(tmpdir):
css = b'''
@page { margin: 2px; size: 8px; background: #fff }
@media screen { img { transform: rotate(-90deg) } }
body { margin: 0; font-size: 0 }
'''
html = b'<body><img src=pattern.png>'
combined = b'<style>' + css + b'</style>' + html
linked = b'<link rel=stylesheet href=style.css>' + html
py.path.local(resource_filename('')).chdir()
# Reference
html_obj = FakeHTML(string=combined, base_url='dummy.html')
# pdf_bytes = html_obj.write_pdf()
png_bytes = html_obj.write_png()
x2_png_bytes = html_obj.write_png(resolution=192)
rotated_png_bytes = FakeHTML(string=combined, base_url='dummy.html',
media_type='screen').write_png()
empty_png_bytes = FakeHTML(
string=b'<style>' + css + b'</style>').write_png()
check_png_pattern(png_bytes)
check_png_pattern(rotated_png_bytes, rotated=True)
check_png_pattern(empty_png_bytes, blank=True)
tmpdir.chdir()
with open(resource_filename('pattern.png'), 'rb') as pattern_fd:
pattern_bytes = pattern_fd.read()
tmpdir.join('pattern.png').write_binary(pattern_bytes)
tmpdir.join('no_css.html').write_binary(html)
tmpdir.join('combined.html').write_binary(combined)
tmpdir.join('combined-UTF-16BE.html').write_binary(
combined.decode('ascii').encode('UTF-16BE'))
tmpdir.join('linked.html').write_binary(linked)
tmpdir.join('style.css').write_binary(css)
_run('combined.html out1.png')
_run('combined.html out2.pdf')
assert tmpdir.join('out1.png').read_binary() == png_bytes
# TODO: check PDF content? How?
# assert tmpdir.join('out2.pdf').read_binary() == pdf_bytes
_run('combined-UTF-16BE.html out3.png --encoding UTF-16BE')
assert tmpdir.join('out3.png').read_binary() == png_bytes
_run(tmpdir.join('combined.html').strpath + ' out4.png')
assert tmpdir.join('out4.png').read_binary() == png_bytes
_run(path2url(tmpdir.join('combined.html').strpath) + ' out5.png')
assert tmpdir.join('out5.png').read_binary() == png_bytes
_run('linked.html out6.png') # test relative URLs
assert tmpdir.join('out6.png').read_binary() == png_bytes
_run('combined.html out7 -f png')
_run('combined.html out8 --format pdf')
assert tmpdir.join('out7').read_binary() == png_bytes
# assert tmpdir.join('out8').read_binary(), pdf_bytes
_run('no_css.html out9.png')
_run('no_css.html out10.png -s style.css')
assert tmpdir.join('out9.png').read_binary() != png_bytes
# assert tmpdir.join('out10.png').read_binary() == png_bytes
stdout = _run('--format png combined.html -')
assert stdout == png_bytes
_run('- out11.png', stdin=combined)
check_png_pattern(tmpdir.join('out11.png').read_binary())
assert tmpdir.join('out11.png').read_binary() == png_bytes
stdout = _run('--format png - -', stdin=combined)
assert stdout == png_bytes
_run('combined.html out13.png --media-type screen')
_run('combined.html out12.png -m screen')
_run('linked.html out14.png -m screen')
assert tmpdir.join('out12.png').read_binary() == rotated_png_bytes
assert tmpdir.join('out13.png').read_binary() == rotated_png_bytes
assert tmpdir.join('out14.png').read_binary() == rotated_png_bytes
stdout = _run('-f pdf combined.html -')
assert stdout.count(b'attachment') == 0
stdout = _run('-f pdf -a pattern.png combined.html -')
assert stdout.count(b'attachment') == 1
stdout = _run('-f pdf -a style.css -a pattern.png combined.html -')
assert stdout.count(b'attachment') == 2
stdout = _run('-f png -r 192 linked.html -')
assert stdout == x2_png_bytes
stdout = _run('-f png --resolution 192 linked.html -')
assert _run('linked.html - -f png --resolution 192') == x2_png_bytes
assert stdout == x2_png_bytes
os.mkdir('subdirectory')
py.path.local('subdirectory').chdir()
with capture_logs() as logs:
stdout = _run('--format png - -', stdin=combined)
assert len(logs) == 1
assert logs[0].startswith('ERROR: Failed to load image')
assert stdout == empty_png_bytes
stdout = _run('--format png --base-url .. - -', stdin=combined)
assert stdout == png_bytes
@assert_no_logs
def test_unicode_filenames(tmpdir):
"""Test non-ASCII filenames both in Unicode or bytes form."""
# Replicate pattern.png in CSS so that base_url does not matter.
html = b'''
<style>
@page { margin: 2px; size: 8px; background: #fff }
html { background: #00f; }
body { background: #f00; width: 1px; height: 1px }
</style>
<body>
'''
png_bytes = FakeHTML(string=html).write_png()
check_png_pattern(png_bytes)
unicode_filename = 'Unicödé'
if sys.platform.startswith('darwin'):
unicode_filename = unicodedata.normalize('NFD', unicode_filename)
tmpdir.chdir()
tmpdir.join(unicode_filename).write(html)
bytes_file, = tmpdir.listdir()
assert bytes_file.basename == unicode_filename
assert FakeHTML(unicode_filename).write_png() == png_bytes
assert FakeHTML(bytes_file.strpath).write_png() == png_bytes
os.remove(unicode_filename)
assert tmpdir.listdir() == []
FakeHTML(string=html).write_png(unicode_filename)
assert bytes_file.read_binary() == png_bytes
@assert_no_logs
def test_low_level_api():
html = FakeHTML(string='<body>')
css = CSS(string='''
@page { margin: 2px; size: 8px; background: #fff }
html { background: #00f; }
body { background: #f00; width: 1px; height: 1px }
''')
pdf_bytes = html.write_pdf(stylesheets=[css])
assert pdf_bytes.startswith(b'%PDF')
# TODO: check PDF content? How?
# assert html.render([css]).write_pdf() == pdf_bytes
png_bytes = html.write_png(stylesheets=[css])
document = html.render([css], enable_hinting=True)
page, = document.pages
assert page.width == 8
assert page.height == 8
assert document.write_png() == (png_bytes, 8, 8)
assert document.copy([page]).write_png() == (png_bytes, 8, 8)
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 8, 8)
page.paint(cairo.Context(surface))
file_obj = io.BytesIO()
surface.write_to_png(file_obj)
check_png_pattern(file_obj.getvalue())
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 8, 8)
context = cairo.Context(surface)
# Rotate at the center
context.translate(4, 4)
context.rotate(-math.pi / 2)
context.translate(-4, -4)
page.paint(context)
file_obj = io.BytesIO()
surface.write_to_png(file_obj)
check_png_pattern(file_obj.getvalue(), rotated=True)
document = html.render([css], enable_hinting=True)
page, = document.pages
assert (page.width, page.height) == (8, 8)
png_bytes, width, height = document.write_png(resolution=192)
assert (width, height) == (16, 16)
check_png_pattern(png_bytes, x2=True)
document = html.render([css], enable_hinting=True)
page, = document.pages
assert (page.width, page.height) == (8, 8)
# A resolution that is not multiple of 96:
assert _png_size(document.write_png(resolution=145.2)) == (13, 13)
document = FakeHTML(string='''
<style>
@page:first { size: 5px 10px } @page { size: 6px 4px }
p { page-break-before: always }
</style>
<p></p>
<p></p>
''').render()
page_1, page_2 = document.pages
assert (page_1.width, page_1.height) == (5, 10)
assert (page_2.width, page_2.height) == (6, 4)
result = document.write_png()
# (Max of both widths, Sum of both heights)
assert _png_size(result) == (6, 14)
assert document.copy([page_1, page_2]).write_png() == result
assert _png_size(document.copy([page_1]).write_png()) == (5, 10)
assert _png_size(document.copy([page_2]).write_png()) == (6, 4)
@pytest.mark.parametrize('html, expected_by_page, expected_tree, round', (
('''
<style>* { height: 10px }</style>
<h1>a</h1>
<h4 style="page-break-after: always">b</h4>
<h3 style="position: relative; top: 2px; left: 3px">c</h3>
<h2>d</h2>
<h1>e</h1>
''', [
[(1, 'a', (0, 0), 'open'), (4, 'b', (0, 10), 'open')],
[(3, 'c', (3, 2), 'open'), (2, 'd', (0, 10), 'open'),
(1, 'e', (0, 20), 'open')],
], [
('a', (0, 0, 0), [
('b', (0, 0, 10), [], 'open'),
('c', (1, 3, 2), [], 'open'),
('d', (1, 0, 10), [], 'open')], 'open'),
('e', (1, 0, 20), [], 'open'),
], False),
('''
<style>
* { height: 90px; margin: 0 0 10px 0 }
</style>
<h1>Title 1</h1>
<h1>Title 2</h1>
<h2 style="position: relative; left: 20px">Title 3</h2>
<h2>Title 4</h2>
<h3>Title 5</h3>
<span style="display: block; page-break-before: always"></span>
<h2>Title 6</h2>
<h1>Title 7</h1>
<h2>Title 8</h2>
<h3>Title 9</h3>
<h1>Title 10</h1>
<h2>Title 11</h2>
''', [
[
(1, 'Title 1', (0, 0), 'open'),
(1, 'Title 2', (0, 100), 'open'),
(2, 'Title 3', (20, 200), 'open'),
(2, 'Title 4', (0, 300), 'open'),
(3, 'Title 5', (0, 400), 'open')
], [
(2, 'Title 6', (0, 100), 'open'),
(1, 'Title 7', (0, 200), 'open'),
(2, 'Title 8', (0, 300), 'open'),
(3, 'Title 9', (0, 400), 'open'),
(1, 'Title 10', (0, 500), 'open'),
(2, 'Title 11', (0, 600), 'open')
],
], [
('Title 1', (0, 0, 0), [], 'open'),
('Title 2', (0, 0, 100), [
('Title 3', (0, 20, 200), [], 'open'),
('Title 4', (0, 0, 300), [
('Title 5', (0, 0, 400), [], 'open')], 'open'),
('Title 6', (1, 0, 100), [], 'open')], 'open'),
('Title 7', (1, 0, 200), [
('Title 8', (1, 0, 300), [
('Title 9', (1, 0, 400), [], 'open')], 'open')], 'open'),
('Title 10', (1, 0, 500), [
('Title 11', (1, 0, 600), [], 'open')], 'open'),
], False),
('''
<style>* { height: 10px }</style>
<h2>A</h2> <p>depth 1</p>
<h4>B</h4> <p>depth 2</p>
<h2>C</h2> <p>depth 1</p>
<h3>D</h3> <p>depth 2</p>
<h4>E</h4> <p>depth 3</p>
''', [[
(2, 'A', (0, 0), 'open'),
(4, 'B', (0, 20), 'open'),
(2, 'C', (0, 40), 'open'),
(3, 'D', (0, 60), 'open'),
(4, 'E', (0, 80), 'open'),
]], [
('A', (0, 0, 0), [
('B', (0, 0, 20), [], 'open')], 'open'),
('C', (0, 0, 40), [
('D', (0, 0, 60), [
('E', (0, 0, 80), [], 'open')], 'open')], 'open'),
], False),
('''
<style>* { height: 10px; font-size: 0 }</style>
<h2>A</h2> <p>h2 depth 1</p>
<h4>B</h4> <p>h4 depth 2</p>
<h3>C</h3> <p>h3 depth 2</p>
<h5>D</h5> <p>h5 depth 3</p>
<h1>E</h1> <p>h1 depth 1</p>
<h2>F</h2> <p>h2 depth 2</p>
<h2>G</h2> <p>h2 depth 2</p>
<h4>H</h4> <p>h4 depth 3</p>
<h1>I</h1> <p>h1 depth 1</p>
''', [[
(2, 'A', (0, 0), 'open'),
(4, 'B', (0, 20), 'open'),
(3, 'C', (0, 40), 'open'),
(5, 'D', (0, 60), 'open'),
(1, 'E', (0, 70), 'open'),
(2, 'F', (0, 90), 'open'),
(2, 'G', (0, 110), 'open'),
(4, 'H', (0, 130), 'open'),
(1, 'I', (0, 150), 'open'),
]], [
('A', (0, 0, 0), [
('B', (0, 0, 20), [], 'open'),
('C', (0, 0, 40), [
('D', (0, 0, 60), [], 'open')], 'open')], 'open'),
('E', (0, 0, 70), [
('F', (0, 0, 90), [], 'open'),
('G', (0, 0, 110), [
('H', (0, 0, 130), [], 'open')], 'open')], 'open'),
('I', (0, 0, 150), [], 'open'),
], False),
('<h1>é', [
[(1, 'é', (0, 0), 'open')]
], [
('é', (0, 0, 0), [], 'open')
], False),
('''
<h1 style="transform: translateX(50px)">!
''', [
[(1, '!', (50, 0), 'open')]
], [
('!', (0, 50, 0), [], 'open')
], False),
('''
<style>
img { display: block; bookmark-label: attr(alt); bookmark-level: 1 }
</style>
<img src="%s" alt="Chocolate" />
''' % path2url(resource_filename('pattern.png')),
[[(1, 'Chocolate', (0, 0), 'open')]],
[('Chocolate', (0, 0, 0), [], 'open')], False),
('''
<h1 style="transform-origin: 0 0;
transform: rotate(90deg) translateX(50px)">!
''', [[(1, '!', (0, 50), 'open')]], [('!', (0, 0, 50), [], 'open')], True),
('''
<body style="transform-origin: 0 0; transform: rotate(90deg)">
<h1 style="transform: translateX(50px)">!
''', [[(1, '!', (0, 50), 'open')]], [('!', (0, 0, 50), [], 'open')], True),
))
@assert_no_logs
def test_assert_bookmarks(html, expected_by_page, expected_tree, round):
document = FakeHTML(string=html).render()
if round:
_round_meta(document.pages)
assert [p.bookmarks for p in document.pages] == expected_by_page
assert document.make_bookmark_tree() == expected_tree
@assert_no_logs
def test_links():
def assert_links(html, expected_links_by_page, expected_anchors_by_page,
expected_resolved_links,
base_url=resource_filename('<inline HTML>'),
warnings=(), round=False):
with capture_logs() as logs:
document = FakeHTML(string=html, base_url=base_url).render()
if round:
_round_meta(document.pages)
resolved_links = list(document.resolve_links())
assert len(logs) == len(warnings)
for message, expected in zip(logs, warnings):
assert expected in message
assert [p.links for p in document.pages] == expected_links_by_page
assert [p.anchors for p in document.pages] == expected_anchors_by_page
assert resolved_links == expected_resolved_links
assert_links('''
<style>
body { font-size: 10px; line-height: 2; width: 200px }
p { height: 90px; margin: 0 0 10px 0 }
img { width: 30px; vertical-align: top }
</style>
<p><a href="http://weasyprint.org"><img src=pattern.png></a></p>
<p style="padding: 0 10px"><a
href="#lipsum"><img style="border: solid 1px"
src=pattern.png></a></p>
<p id=hello>Hello, World</p>
<p id=lipsum>
<a style="display: block; page-break-before: always; height: 30px"
href="#hel%6Co"></a>
</p>
''', [
[
('external', 'http://weasyprint.org', (0, 0, 30, 20)),
('external', 'http://weasyprint.org', (0, 0, 30, 30)),
('internal', 'lipsum', (10, 100, 32, 20)),
('internal', 'lipsum', (10, 100, 32, 32))
],
[('internal', 'hello', (0, 0, 200, 30))],
], [
{'hello': (0, 200)},
{'lipsum': (0, 0)}
], [
(
[
('external', 'http://weasyprint.org', (0, 0, 30, 20)),
('external', 'http://weasyprint.org', (0, 0, 30, 30)),
('internal', 'lipsum', (10, 100, 32, 20)),
('internal', 'lipsum', (10, 100, 32, 32))
],
[('hello', 0, 200)],
),
(
[
('internal', 'hello', (0, 0, 200, 30))
],
[('lipsum', 0, 0)]),
])
assert_links(
'''
<body style="width: 200px">
<a href="../lipsum/é_%E9" style="display: block; margin: 10px 5px">
''', [[('external', 'http://weasyprint.org/foo/lipsum/%C3%A9_%E9',
(5, 10, 190, 0))]],
[{}], [([('external', 'http://weasyprint.org/foo/lipsum/%C3%A9_%E9',
(5, 10, 190, 0))], [])],
base_url='http://weasyprint.org/foo/bar/')
assert_links(
'''
<body style="width: 200px">
<div style="display: block; margin: 10px 5px;
-weasy-link: url(../lipsum/é_%E9)">
''', [[('external', 'http://weasyprint.org/foo/lipsum/%C3%A9_%E9',
(5, 10, 190, 0))]],
[{}], [([('external', 'http://weasyprint.org/foo/lipsum/%C3%A9_%E9',
(5, 10, 190, 0))], [])],
base_url='http://weasyprint.org/foo/bar/')
# Relative URI reference without a base URI: allowed for links
assert_links(
'''
<body style="width: 200px">
<a href="../lipsum" style="display: block; margin: 10px 5px">
''', [[('external', '../lipsum', (5, 10, 190, 0))]], [{}],
[([('external', '../lipsum', (5, 10, 190, 0))], [])], base_url=None)
# Relative URI reference without a base URI: not supported for -weasy-link
assert_links(
'''
<body style="width: 200px">
<div style="-weasy-link: url(../lipsum);
display: block; margin: 10px 5px">
''', [[]], [{}], [([], [])], base_url=None, warnings=[
'WARNING: Ignored `-weasy-link: url("../lipsum")` at 1:1, '
'Relative URI reference without a base URI'])
# Internal or absolute URI reference without a base URI: OK
assert_links(
'''
<body style="width: 200px">
<a href="#lipsum" id="lipsum"
style="display: block; margin: 10px 5px"></a>
<a href="http://weasyprint.org/" style="display: block"></a>
''', [[('internal', 'lipsum', (5, 10, 190, 0)),
('external', 'http://weasyprint.org/', (0, 10, 200, 0))]],
[{'lipsum': (5, 10)}],
[([('internal', 'lipsum', (5, 10, 190, 0)),
('external', 'http://weasyprint.org/', (0, 10, 200, 0))],
[('lipsum', 5, 10)])],
base_url=None)
assert_links(
'''
<body style="width: 200px">
<div style="-weasy-link: url(#lipsum);
margin: 10px 5px" id="lipsum">
''',
[[('internal', 'lipsum', (5, 10, 190, 0))]],
[{'lipsum': (5, 10)}],
[([('internal', 'lipsum', (5, 10, 190, 0))], [('lipsum', 5, 10)])],
base_url=None)
assert_links(
'''
<style> a { display: block; height: 15px } </style>
<body style="width: 200px">
<a href="#lipsum"></a>
<a href="#missing" id="lipsum"></a>
''',
[[('internal', 'lipsum', (0, 0, 200, 15)),
('internal', 'missing', (0, 15, 200, 15))]],
[{'lipsum': (0, 15)}],
[([('internal', 'lipsum', (0, 0, 200, 15))], [('lipsum', 0, 15)])],
base_url=None,
warnings=[
'ERROR: No anchor #missing for internal URI reference'])
assert_links(
'''
<body style="width: 100px; transform: translateY(100px)">
<a href="#lipsum" id="lipsum" style="display: block; height: 20px;
transform: rotate(90deg) scale(2)">
''',
[[('internal', 'lipsum', (30, 10, 40, 200))]],
[{'lipsum': (70, 10)}],
[([('internal', 'lipsum', (30, 10, 40, 200))], [('lipsum', 70, 10)])],
round=True)
# Make relative URL references work with our custom URL scheme.
uses_relative.append('weasyprint-custom')
@assert_no_logs
def test_url_fetcher():
filename = resource_filename('pattern.png')
with open(filename, 'rb') as pattern_fd:
pattern_png = pattern_fd.read()
def fetcher(url):
if url == 'weasyprint-custom:foo/%C3%A9_%e9_pattern':
return dict(string=pattern_png, mime_type='image/png')
elif url == 'weasyprint-custom:foo/bar.css':
return dict(string='body { background: url(é_%e9_pattern)',
mime_type='text/css')
else:
return default_url_fetcher(url)
base_url = resource_filename('dummy.html')
css = CSS(string='''
@page { size: 8px; margin: 2px; background: #fff }
body { margin: 0; font-size: 0 }
''', base_url=base_url)
def test(html, blank=False):
html = FakeHTML(string=html, url_fetcher=fetcher, base_url=base_url)
check_png_pattern(html.write_png(stylesheets=[css]), blank=blank)
test('<body><img src="pattern.png">') # Test a "normal" URL
test('<body><img src="%s">' % Path(filename).as_uri())
test('<body><img src="%s?ignored">' % Path(filename).as_uri())
test('<body><img src="weasyprint-custom:foo/é_%e9_pattern">')
test('<body style="background: url(weasyprint-custom:foo/é_%e9_pattern)">')
test('<body><li style="list-style: inside '
'url(weasyprint-custom:foo/é_%e9_pattern)">')
test('<link rel=stylesheet href="weasyprint-custom:foo/bar.css"><body>')
test('<style>@import "weasyprint-custom:foo/bar.css";</style><body>')
with capture_logs() as logs:
test('<body><img src="custom:foo/bar">', blank=True)
assert len(logs) == 1
assert logs[0].startswith(
'ERROR: Failed to load image at "custom:foo/bar"')
def fetcher_2(url):
assert url == 'weasyprint-custom:%C3%A9_%e9.css'
return dict(string='', mime_type='text/css')
FakeHTML(string='<link rel=stylesheet href="weasyprint-custom:'
'é_%e9.css"><body>', url_fetcher=fetcher_2).render()
@assert_no_logs
def test_html_meta():
def assert_meta(html, **meta):
meta.setdefault('title', None)
meta.setdefault('authors', [])
meta.setdefault('keywords', [])
meta.setdefault('generator', None)
meta.setdefault('description', None)
meta.setdefault('created', None)
meta.setdefault('modified', None)
meta.setdefault('attachments', [])
assert vars(FakeHTML(string=html).render().metadata) == meta
assert_meta('<body>')
assert_meta(
'''
<meta name=author content="I Me &amp; Myself">
<meta name=author content="Smith, John">
<title>Test document</title>
<h1>Another title</h1>
<meta name=generator content="Human after all">
<meta name=dummy content=ignored>
<meta name=dummy>
<meta content=ignored>
<meta>
<meta name=keywords content="html ,\tcss,
pdf,css">
<meta name=dcterms.created content=2011-04>
<meta name=dcterms.created content=2011-05>
<meta name=dcterms.modified content=2013>
<meta name=keywords content="Python; cairo">
<meta name=description content="Blah… ">
''',
authors=['I Me & Myself', 'Smith, John'],
title='Test document',
generator='Human after all',
keywords=['html', 'css', 'pdf', 'Python; cairo'],
description="Blah… ",
created='2011-04',
modified='2013')
assert_meta(
'''
<title>One</title>
<meta name=Author>
<title>Two</title>
<title>Three</title>
<meta name=author content=Me>
''',
title='One',
authors=['', 'Me'])
@assert_no_logs
def test_http():
def gzip_compress(data):
file_obj = io.BytesIO()
gzip_file = gzip.GzipFile(fileobj=file_obj, mode='wb')
gzip_file.write(data)
gzip_file.close()
return file_obj.getvalue()
with http_server({
'/gzip': lambda env: (
(gzip_compress(b'<html test=ok>'), [('Content-Encoding', 'gzip')])
if 'gzip' in env.get('HTTP_ACCEPT_ENCODING', '') else
(b'<html test=accept-encoding-header-fail>', [])
),
'/deflate': lambda env: (
(zlib.compress(b'<html test=ok>'),
[('Content-Encoding', 'deflate')])
if 'deflate' in env.get('HTTP_ACCEPT_ENCODING', '') else
(b'<html test=accept-encoding-header-fail>', [])
),
'/raw-deflate': lambda env: (
# Remove zlib header and checksum
(zlib.compress(b'<html test=ok>')[2:-4],
[('Content-Encoding', 'deflate')])
if 'deflate' in env.get('HTTP_ACCEPT_ENCODING', '') else
(b'<html test=accept-encoding-header-fail>', [])
),
}) as root_url:
assert HTML(root_url + '/gzip').etree_element.get('test') == 'ok'
assert HTML(root_url + '/deflate').etree_element.get('test') == 'ok'
assert HTML(
root_url + '/raw-deflate').etree_element.get('test') == 'ok'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,490 @@
"""
weasyprint.tests.test_css
-------------------------
Test the CSS parsing, cascade, inherited and computed values.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from math import isclose
import pytest
import tinycss2
from .. import CSS, css, default_url_fetcher
from ..css import PageType, get_all_computed_styles, parse_page_selectors
from ..css.computed_values import strut_layout
from ..layout.pages import set_page_type_computed_styles
from ..urls import path2url
from .testing_utils import (
BASE_URL, FakeHTML, assert_no_logs, capture_logs, resource_filename)
@assert_no_logs
def test_style_dict():
style = {'margin_left': 12, 'display': 'block'}
assert style['display'] == 'block'
assert style['margin_left'] == 12
with pytest.raises(KeyError):
style['position']
@assert_no_logs
def test_find_stylesheets():
html = FakeHTML(resource_filename('doc1.html'))
sheets = list(css.find_stylesheets(
html.wrapper_element, 'print', default_url_fetcher, html.base_url,
font_config=None, page_rules=None))
assert len(sheets) == 2
# Also test that stylesheets are in tree order
assert [s.base_url.rsplit('/', 1)[-1].rsplit(',', 1)[-1] for s in sheets] \
== ['a%7Bcolor%3AcurrentColor%7D', 'doc1.html']
rules = []
for sheet in sheets:
for sheet_rules in sheet.matcher.lower_local_name_selectors.values():
for rule in sheet_rules:
rules.append(rule)
for rule in sheet.page_rules:
rules.append(rule)
assert len(rules) == 10
# TODO: test that the values are correct too
@assert_no_logs
def test_expand_shorthands():
sheet = CSS(resource_filename('sheet2.css'))
assert list(sheet.matcher.lower_local_name_selectors) == ['li']
rules = sheet.matcher.lower_local_name_selectors['li'][0][4]
assert rules[0][0] == 'margin_bottom'
assert rules[0][1] == (3, 'em')
assert rules[1][0] == 'margin_top'
assert rules[1][1] == (2, 'em')
assert rules[2][0] == 'margin_right'
assert rules[2][1] == (0, None)
assert rules[3][0] == 'margin_bottom'
assert rules[3][1] == (2, 'em')
assert rules[4][0] == 'margin_left'
assert rules[4][1] == (0, None)
assert rules[5][0] == 'margin_left'
assert rules[5][1] == (4, 'em')
# TODO: test that the values are correct too
@assert_no_logs
def test_annotate_document():
document = FakeHTML(resource_filename('doc1.html'))
document._ua_stylesheets = lambda: [CSS(resource_filename('mini_ua.css'))]
style_for = get_all_computed_styles(
document, user_stylesheets=[CSS(resource_filename('user.css'))])
# Element objects behave as lists of their children
_head, body = document.etree_element
h1, p, ul, div = body
li_0, _li_1 = ul
a, = li_0
span1, = div
span2, = span1
h1 = style_for(h1)
p = style_for(p)
ul = style_for(ul)
li_0 = style_for(li_0)
div = style_for(div)
after = style_for(a, 'after')
a = style_for(a)
span1 = style_for(span1)
span2 = style_for(span2)
assert h1['background_image'] == (
('url', path2url(resource_filename('logo_small.png'))),)
assert h1['font_weight'] == 700
assert h1['font_size'] == 40 # 2em
# x-large * initial = 3/2 * 16 = 24
assert p['margin_top'] == (24, 'px')
assert p['margin_right'] == (0, 'px')
assert p['margin_bottom'] == (24, 'px')
assert p['margin_left'] == (0, 'px')
assert p['background_color'] == 'currentColor'
# 2em * 1.25ex = 2 * 20 * 1.25 * 0.8 = 40
# 2.5ex * 1.25ex = 2.5 * 0.8 * 20 * 1.25 * 0.8 = 40
# TODO: ex unit doesn't work with @font-face fonts, see computed_values.py
# assert ul['margin_top'] == (40, 'px')
# assert ul['margin_right'] == (40, 'px')
# assert ul['margin_bottom'] == (40, 'px')
# assert ul['margin_left'] == (40, 'px')
assert ul['font_weight'] == 400
# thick = 5px, 0.25 inches = 96*.25 = 24px
assert ul['border_top_width'] == 0
assert ul['border_right_width'] == 5
assert ul['border_bottom_width'] == 0
assert ul['border_left_width'] == 24
assert li_0['font_weight'] == 700
assert li_0['font_size'] == 8 # 6pt
assert li_0['margin_top'] == (16, 'px') # 2em
assert li_0['margin_right'] == (0, 'px')
assert li_0['margin_bottom'] == (16, 'px')
assert li_0['margin_left'] == (32, 'px') # 4em
assert a['text_decoration_line'] == {'underline'}
assert a['font_weight'] == 900
assert a['font_size'] == 24 # 300% of 8px
assert a['padding_top'] == (1, 'px')
assert a['padding_right'] == (2, 'px')
assert a['padding_bottom'] == (3, 'px')
assert a['padding_left'] == (4, 'px')
assert a['border_top_width'] == 42
assert a['border_bottom_width'] == 42
assert a['color'] == (1, 0, 0, 1)
assert a['border_top_color'] == 'currentColor'
assert div['font_size'] == 40 # 2 * 20px
assert span1['width'] == (160, 'px') # 10 * 16px (root default is 16px)
assert span1['height'] == (400, 'px') # 10 * (2 * 20px)
assert span2['font_size'] == 32
# The href attr should be as in the source, not made absolute.
assert after['content'] == (
('string', ' ['), ('string', 'home.html'), ('string', ']'))
assert after['background_color'] == (1, 0, 0, 1)
assert after['border_top_width'] == 42
assert after['border_bottom_width'] == 3
# TODO: much more tests here: test that origin and selector precedence
# and inheritance are correct…
@assert_no_logs
def test_page():
document = FakeHTML(resource_filename('doc1.html'))
style_for = get_all_computed_styles(
document, user_stylesheets=[CSS(string='''
html { color: red }
@page { margin: 10px }
@page :right {
color: blue;
margin-bottom: 12pt;
font-size: 20px;
@top-left { width: 10em }
@top-right { font-size: 10px}
}
''')])
page_type = PageType(
side='left', first=True, blank=False, index=0, name='')
set_page_type_computed_styles(page_type, document, style_for)
style = style_for(page_type)
assert style['margin_top'] == (5, 'px')
assert style['margin_left'] == (10, 'px')
assert style['margin_bottom'] == (10, 'px')
assert style['color'] == (1, 0, 0, 1) # red, inherited from html
page_type = PageType(
side='right', first=True, blank=False, index=0, name='')
set_page_type_computed_styles(page_type, document, style_for)
style = style_for(page_type)
assert style['margin_top'] == (5, 'px')
assert style['margin_left'] == (10, 'px')
assert style['margin_bottom'] == (16, 'px')
assert style['color'] == (0, 0, 1, 1) # blue
page_type = PageType(
side='left', first=False, blank=False, index=1, name='')
set_page_type_computed_styles(page_type, document, style_for)
style = style_for(page_type)
assert style['margin_top'] == (10, 'px')
assert style['margin_left'] == (10, 'px')
assert style['margin_bottom'] == (10, 'px')
assert style['color'] == (1, 0, 0, 1) # red, inherited from html
page_type = PageType(
side='right', first=False, blank=False, index=1, name='')
set_page_type_computed_styles(page_type, document, style_for)
style = style_for(page_type)
assert style['margin_top'] == (10, 'px')
assert style['margin_left'] == (10, 'px')
assert style['margin_bottom'] == (16, 'px')
assert style['color'] == (0, 0, 1, 1) # blue
page_type = PageType(
side='left', first=True, blank=False, index=0, name='')
set_page_type_computed_styles(page_type, document, style_for)
style = style_for(page_type, '@top-left')
assert style is None
page_type = PageType(
side='right', first=True, blank=False, index=0, name='')
set_page_type_computed_styles(page_type, document, style_for)
style = style_for(page_type, '@top-left')
assert style['font_size'] == 20 # inherited from @page
assert style['width'] == (200, 'px')
page_type = PageType(
side='right', first=True, blank=False, index=0, name='')
set_page_type_computed_styles(page_type, document, style_for)
style = style_for(page_type, '@top-right')
assert style['font_size'] == 10
@assert_no_logs
@pytest.mark.parametrize('style, selectors', (
('@page {}', [{
'side': None, 'blank': None, 'first': None, 'name': None,
'index': None, 'specificity': [0, 0, 0]}]),
('@page :left {}', [{
'side': 'left', 'blank': None, 'first': None, 'name': None,
'index': None, 'specificity': [0, 0, 1]}]),
('@page:first:left {}', [{
'side': 'left', 'blank': None, 'first': True, 'name': None,
'index': None, 'specificity': [0, 1, 1]}]),
('@page pagename {}', [{
'side': None, 'blank': None, 'first': None, 'name': 'pagename',
'index': None, 'specificity': [1, 0, 0]}]),
('@page pagename:first:right:blank {}', [{
'side': 'right', 'blank': True, 'first': True, 'name': 'pagename',
'index': None, 'specificity': [1, 2, 1]}]),
('@page pagename, :first {}', [
{'side': None, 'blank': None, 'first': None, 'name': 'pagename',
'index': None, 'specificity': [1, 0, 0]},
{'side': None, 'blank': None, 'first': True, 'name': None,
'index': None, 'specificity': [0, 1, 0]}]),
('@page :first:first {}', [{
'side': None, 'blank': None, 'first': True, 'name': None,
'index': None, 'specificity': [0, 2, 0]}]),
('@page :left:left {}', [{
'side': 'left', 'blank': None, 'first': None, 'name': None,
'index': None, 'specificity': [0, 0, 2]}]),
('@page :nth(2) {}', [{
'side': None, 'blank': None, 'first': None, 'name': None,
'index': (0, 2, None), 'specificity': [0, 1, 0]}]),
('@page :nth(2n + 4) {}', [{
'side': None, 'blank': None, 'first': None, 'name': None,
'index': (2, 4, None), 'specificity': [0, 1, 0]}]),
('@page :nth(3n) {}', [{
'side': None, 'blank': None, 'first': None, 'name': None,
'index': (3, 0, None), 'specificity': [0, 1, 0]}]),
('@page :nth( n+2 ) {}', [{
'side': None, 'blank': None, 'first': None, 'name': None,
'index': (1, 2, None), 'specificity': [0, 1, 0]}]),
('@page :nth(even) {}', [{
'side': None, 'blank': None, 'first': None, 'name': None,
'index': (2, 0, None), 'specificity': [0, 1, 0]}]),
('@page pagename:nth(2) {}', [{
'side': None, 'blank': None, 'first': None, 'name': 'pagename',
'index': (0, 2, None), 'specificity': [1, 1, 0]}]),
('@page page page {}', None),
('@page :left page {}', None),
('@page :left, {}', None),
('@page , {}', None),
('@page :left, test, {}', None),
('@page :wrong {}', None),
('@page :left:wrong {}', None),
('@page :left:right {}', None),
))
def test_page_selectors(style, selectors):
at_rule, = tinycss2.parse_stylesheet(style)
assert parse_page_selectors(at_rule) == selectors
@assert_no_logs
@pytest.mark.parametrize('source, messages', (
(':lipsum { margin: 2cm', ['WARNING: Invalid or unsupported selector']),
('::lipsum { margin: 2cm', ['WARNING: Invalid or unsupported selector']),
('foo { margin-color: red', ['WARNING: Ignored', 'unknown property']),
('foo { margin-top: red', ['WARNING: Ignored', 'invalid value']),
('@import "relative-uri.css"',
['ERROR: Relative URI reference without a base URI']),
('@import "invalid-protocol://absolute-URL"',
['ERROR: Failed to load stylesheet at']),
))
def test_warnings(source, messages):
"""Check that appropriate warnings are logged."""
with capture_logs() as logs:
CSS(string=source)
assert len(logs) == 1, source
for message in messages:
assert message in logs[0]
@assert_no_logs
def test_warnings_stylesheet():
html = '<link rel=stylesheet href=invalid-protocol://absolute>'
with capture_logs() as logs:
FakeHTML(string=html).render()
assert len(logs) == 1
assert 'ERROR: Failed to load stylesheet at' in logs[0]
@assert_no_logs
@pytest.mark.parametrize('style', (
'<style> html { color red; color: blue; color',
'<html style="color; color: blue; color red">',
))
def test_error_recovery(style):
with capture_logs() as logs:
document = FakeHTML(string=style)
page, = document.render().pages
html, = page._page_box.children
assert html.style['color'] == (0, 0, 1, 1) # blue
assert len(logs) == 2
@assert_no_logs
def test_line_height_inheritance():
document = FakeHTML(string='''
<style>
html { font-size: 10px; line-height: 140% }
section { font-size: 10px; line-height: 1.4 }
div, p { font-size: 20px; vertical-align: 50% }
</style>
<body><div><section><p></p></section></div></body>
''')
page, = document.render().pages
html, = page._page_box.children
body, = html.children
div, = body.children
section, = div.children
paragraph, = section.children
assert html.style['font_size'] == 10
assert div.style['font_size'] == 20
# 140% of 10px = 14px is inherited from html
assert strut_layout(div.style)[0] == 14
assert div.style['vertical_align'] == 7 # 50 % of 14px
assert paragraph.style['font_size'] == 20
# 1.4 is inherited from p, 1.4 * 20px on em = 28px
assert strut_layout(paragraph.style)[0] == 28
assert paragraph.style['vertical_align'] == 14 # 50% of 28px
@assert_no_logs
def test_important():
document = FakeHTML(string='''
<style>
p:nth-child(1) { color: lime }
body p:nth-child(2) { color: red }
p:nth-child(3) { color: lime !important }
body p:nth-child(3) { color: red }
body p:nth-child(5) { color: lime }
p:nth-child(5) { color: red }
p:nth-child(6) { color: red }
p:nth-child(6) { color: lime }
</style>
<p></p>
<p></p>
<p></p>
<p></p>
<p></p>
<p></p>
''')
page, = document.render(stylesheets=[CSS(string='''
body p:nth-child(1) { color: red }
p:nth-child(2) { color: lime !important }
p:nth-child(4) { color: lime !important }
body p:nth-child(4) { color: red }
''')]).pages
html, = page._page_box.children
body, = html.children
for paragraph in body.children:
assert paragraph.style['color'] == (0, 1, 0, 1) # lime (light green)
@assert_no_logs
def test_named_pages():
document = FakeHTML(string='''
<style>
@page NARRow { size: landscape }
div { page: AUTO }
p { page: NARRow }
</style>
<div><p><span>a</span></p></div>
''')
page, = document.render().pages
html, = page._page_box.children
body, = html.children
div, = body.children
p, = div.children
span, = p.children
assert html.style['page'] == ''
assert body.style['page'] == ''
assert div.style['page'] == ''
assert p.style['page'] == 'NARRow'
assert span.style['page'] == 'NARRow'
@assert_no_logs
@pytest.mark.parametrize('value, width', (
('96px', 96),
('1in', 96),
('72pt', 96),
('6pc', 96),
('2.54cm', 96),
('25.4mm', 96),
('101.6q', 96),
('1.1em', 11),
('1.1rem', 17.6),
# TODO: ch and ex units don't work with font-face, see computed_values.py
# ('1.1ch', 11),
# ('1.5ex', 12),
))
def test_units(value, width):
document = FakeHTML(base_url=BASE_URL, string='''
<style>@font-face { src: url(AHEM____.TTF); font-family: ahem }</style>
<body style="font: 10px ahem"><p style="margin-left: %s"></p>''' % value)
page, = document.render().pages
html, = page._page_box.children
body, = html.children
p, = body.children
assert p.margin_left == width
@assert_no_logs
@pytest.mark.parametrize('parent_css, parent_size, child_css, child_size', (
('10px', 10, '10px', 10),
('x-small', 12, 'xx-large', 32),
('x-large', 24, '2em', 48),
('1em', 16, '1em', 16),
('1em', 16, 'larger', 6 / 5 * 16),
('medium', 16, 'larger', 6 / 5 * 16),
('x-large', 24, 'larger', 32),
('xx-large', 32, 'larger', 1.2 * 32),
('1px', 1, 'larger', 3 / 5 * 16),
('28px', 28, 'larger', 32),
('100px', 100, 'larger', 120),
('xx-small', 3 / 5 * 16, 'larger', 12),
('1em', 16, 'smaller', 8 / 9 * 16),
('medium', 16, 'smaller', 8 / 9 * 16),
('x-large', 24, 'smaller', 6 / 5 * 16),
('xx-large', 32, 'smaller', 24),
('xx-small', 3 / 5 * 16, 'smaller', 0.8 * 3 / 5 * 16),
('1px', 1, 'smaller', 0.8),
('28px', 28, 'smaller', 24),
('100px', 100, 'smaller', 32),
))
def test_font_size(parent_css, parent_size, child_css, child_size):
document = FakeHTML(string='<p>a<span>b')
style_for = get_all_computed_styles(document, user_stylesheets=[CSS(
string='p{font-size:%s}span{font-size:%s}' % (parent_css, child_css))])
_head, body = document.etree_element
p, = body
span, = p
assert isclose(style_for(p)['font_size'], parent_size)
assert isclose(style_for(span)['font_size'], child_size)

View File

@ -0,0 +1,196 @@
"""
weasyprint.tests.test_css_descriptors
-------------------------------------
Test CSS descriptors.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import tinycss2
from ..css import preprocess_stylesheet
from ..css.validation.descriptors import preprocess_descriptors
from .testing_utils import assert_no_logs, capture_logs
@assert_no_logs
def test_font_face_1():
stylesheet = tinycss2.parse_stylesheet(
'@font-face {'
' font-family: Gentium Hard;'
' src: url(http://example.com/fonts/Gentium.woff);'
'}')
at_rule, = stylesheet
assert at_rule.at_keyword == 'font-face'
font_family, src = list(preprocess_descriptors(
'http://weasyprint.org/foo/',
tinycss2.parse_declaration_list(at_rule.content)))
assert font_family == ('font_family', 'Gentium Hard')
assert src == (
'src', (('external', 'http://example.com/fonts/Gentium.woff'),))
@assert_no_logs
def test_font_face_2():
stylesheet = tinycss2.parse_stylesheet(
'@font-face {'
' font-family: "Fonty Smiley";'
' src: url(Fonty-Smiley.woff);'
' font-style: italic;'
' font-weight: 200;'
' font-stretch: condensed;'
'}')
at_rule, = stylesheet
assert at_rule.at_keyword == 'font-face'
font_family, src, font_style, font_weight, font_stretch = list(
preprocess_descriptors(
'http://weasyprint.org/foo/',
tinycss2.parse_declaration_list(at_rule.content)))
assert font_family == ('font_family', 'Fonty Smiley')
assert src == (
'src', (('external', 'http://weasyprint.org/foo/Fonty-Smiley.woff'),))
assert font_style == ('font_style', 'italic')
assert font_weight == ('font_weight', 200)
assert font_stretch == ('font_stretch', 'condensed')
@assert_no_logs
def test_font_face_3():
stylesheet = tinycss2.parse_stylesheet(
'@font-face {'
' font-family: Gentium Hard;'
' src: local();'
'}')
at_rule, = stylesheet
assert at_rule.at_keyword == 'font-face'
font_family, src = list(preprocess_descriptors(
'http://weasyprint.org/foo/',
tinycss2.parse_declaration_list(at_rule.content)))
assert font_family == ('font_family', 'Gentium Hard')
assert src == ('src', (('local', None),))
@assert_no_logs
def test_font_face_4():
# See bug #487
stylesheet = tinycss2.parse_stylesheet(
'@font-face {'
' font-family: Gentium Hard;'
' src: local(Gentium Hard);'
'}')
at_rule, = stylesheet
assert at_rule.at_keyword == 'font-face'
font_family, src = list(preprocess_descriptors(
'http://weasyprint.org/foo/',
tinycss2.parse_declaration_list(at_rule.content)))
assert font_family == ('font_family', 'Gentium Hard')
assert src == ('src', (('local', 'Gentium Hard'),))
def test_font_face_bad_1():
stylesheet = tinycss2.parse_stylesheet(
'@font-face {'
' font-family: "Bad Font";'
' src: url(BadFont.woff);'
' font-stretch: expanded;'
' font-style: wrong;'
' font-weight: bolder;'
' font-stretch: wrong;'
'}')
at_rule, = stylesheet
assert at_rule.at_keyword == 'font-face'
with capture_logs() as logs:
font_family, src, font_stretch = list(
preprocess_descriptors(
'http://weasyprint.org/foo/',
tinycss2.parse_declaration_list(at_rule.content)))
assert font_family == ('font_family', 'Bad Font')
assert src == (
'src', (('external', 'http://weasyprint.org/foo/BadFont.woff'),))
assert font_stretch == ('font_stretch', 'expanded')
assert logs == [
'WARNING: Ignored `font-style: wrong` at 1:91, invalid value.',
'WARNING: Ignored `font-weight: bolder` at 1:111, invalid value.',
'WARNING: Ignored `font-stretch: wrong` at 1:133, invalid value.']
def test_font_face_bad_2():
stylesheet = tinycss2.parse_stylesheet('@font-face{}')
with capture_logs() as logs:
descriptors = []
preprocess_stylesheet(
'print', 'http://wp.org/foo/', stylesheet, None, None, None,
descriptors, None)
assert not descriptors
assert logs == [
"WARNING: Missing src descriptor in '@font-face' rule at 1:1"]
def test_font_face_bad_3():
stylesheet = tinycss2.parse_stylesheet('@font-face{src: url(test.woff)}')
with capture_logs() as logs:
descriptors = []
preprocess_stylesheet(
'print', 'http://wp.org/foo/', stylesheet, None, None, None,
descriptors, None)
assert not descriptors
assert logs == [
"WARNING: Missing font-family descriptor in '@font-face' rule at 1:1"]
def test_font_face_bad_4():
stylesheet = tinycss2.parse_stylesheet('@font-face{font-family: test}')
with capture_logs() as logs:
descriptors = []
preprocess_stylesheet(
'print', 'http://wp.org/foo/', stylesheet, None, None, None,
descriptors, None)
assert not descriptors
assert logs == [
"WARNING: Missing src descriptor in '@font-face' rule at 1:1"]
def test_font_face_bad_5():
stylesheet = tinycss2.parse_stylesheet(
'@font-face { font-family: test; src: wrong }')
with capture_logs() as logs:
descriptors = []
preprocess_stylesheet(
'print', 'http://wp.org/foo/', stylesheet, None, None, None,
descriptors, None)
assert not descriptors
assert logs == [
'WARNING: Ignored `src: wrong ` at 1:33, invalid value.',
"WARNING: Missing src descriptor in '@font-face' rule at 1:1"]
def test_font_face_bad_6():
stylesheet = tinycss2.parse_stylesheet(
'@font-face { font-family: good, bad; src: url(test.woff) }')
with capture_logs() as logs:
descriptors = []
preprocess_stylesheet(
'print', 'http://wp.org/foo/', stylesheet, None, None, None,
descriptors, None)
assert not descriptors
assert logs == [
'WARNING: Ignored `font-family: good, bad` at 1:14, invalid value.',
"WARNING: Missing font-family descriptor in '@font-face' rule at 1:1"]
def test_font_face_bad_7():
stylesheet = tinycss2.parse_stylesheet(
'@font-face { font-family: good, bad; src: really bad }')
with capture_logs() as logs:
descriptors = []
preprocess_stylesheet(
'print', 'http://wp.org/foo/', stylesheet, None, None, None,
descriptors, None)
assert not descriptors
assert logs == [
'WARNING: Ignored `font-family: good, bad` at 1:14, invalid value.',
'WARNING: Ignored `src: really bad ` at 1:38, invalid value.',
"WARNING: Missing src descriptor in '@font-face' rule at 1:1"]

View File

@ -0,0 +1,920 @@
"""
weasyprint.tests.test_css_properties
------------------------------------
Test expanders for shorthand properties.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import math
import pytest
import tinycss2
from ..css import preprocess_declarations
from ..css.computed_values import ZERO_PIXELS
from ..css.properties import INITIAL_VALUES, Dimension
from ..images import LinearGradient, RadialGradient
from .testing_utils import assert_no_logs, capture_logs
def expand_to_dict(css, expected_error=None):
"""Helper to test shorthand properties expander functions."""
declarations = tinycss2.parse_declaration_list(css)
with capture_logs() as logs:
base_url = 'http://weasyprint.org/foo/'
declarations = list(preprocess_declarations(base_url, declarations))
if expected_error:
assert len(logs) == 1
assert expected_error in logs[0]
else:
assert not logs
return dict(
(name, value) for name, value, _priority in declarations
if value != 'initial')
def assert_invalid(css, message='invalid'):
assert expand_to_dict(css, message) == {}
@assert_no_logs
def test_not_print():
assert expand_to_dict(
'volume: 42', 'the property does not apply for the print media') == {}
@assert_no_logs
@pytest.mark.parametrize('rule, values', (
('1px, 3em, auto, auto', ((1, 'px'), (3, 'em'), 'auto', 'auto')),
('1px, 3em, auto auto', ((1, 'px'), (3, 'em'), 'auto', 'auto')),
('1px 3em auto 1px', ((1, 'px'), (3, 'em'), 'auto', (1, 'px'))),
))
def test_function(rule, values):
assert expand_to_dict('clip: rect(%s)' % rule) == {'clip': values}
@assert_no_logs
@pytest.mark.parametrize('rule', (
'clip: square(1px, 3em, auto, auto)',
'clip: rect(1px, 3em, auto)',
'clip: rect(1px, 3em / auto)',
))
def test_function_invalid(rule):
assert_invalid(rule)
@assert_no_logs
@pytest.mark.parametrize('rule, result', (
('counter-reset: foo bar 2 baz', {
'counter_reset': (('foo', 0), ('bar', 2), ('baz', 0))}),
('counter-increment: foo bar 2 baz', {
'counter_increment': (('foo', 1), ('bar', 2), ('baz', 1))}),
('counter-reset: foo', {'counter_reset': (('foo', 0),)}),
('counter-reset: FoO', {'counter_reset': (('FoO', 0),)}),
('counter-increment: foo bAr 2 Bar', {
'counter_increment': (('foo', 1), ('bAr', 2), ('Bar', 1))}),
('counter-reset: none', {'counter_reset': ()}),
))
def test_counters(rule, result):
assert expand_to_dict(rule) == result
@pytest.mark.parametrize('rule, warning, result', (
('counter-reset: foo initial', 'Invalid counter name: initial.', {}),
('counter-reset: foo none', 'Invalid counter name: none.', {}),
))
def test_counters_warning(rule, warning, result):
assert expand_to_dict(rule, warning) == result
@assert_no_logs
@pytest.mark.parametrize('rule', (
'counter-reset: foo 3px',
'counter-reset: 3',
))
def test_counters_invalid(rule):
assert_invalid(rule)
@assert_no_logs
@pytest.mark.parametrize('rule, result', (
('letter-spacing: normal', {'letter_spacing': 'normal'}),
('letter-spacing: 3px', {'letter_spacing': (3, 'px')}),
('word-spacing: normal', {'word_spacing': 'normal'}),
('word-spacing: 3px', {'word_spacing': (3, 'px')}),
))
def test_spacing(rule, result):
assert expand_to_dict(rule) == result
@assert_no_logs
def test_spacing_warning():
assert expand_to_dict(
'letter_spacing: normal', 'did you mean letter-spacing?') == {}
@assert_no_logs
@pytest.mark.parametrize('rule', (
'letter-spacing: 3',
'word-spacing: 3',
))
def test_spacing_invalid(rule):
assert_invalid(rule)
@assert_no_logs
@pytest.mark.parametrize('rule, result', (
('text-decoration-line: none', {'text_decoration_line': 'none'}),
('text-decoration-line: overline', {'text_decoration_line': {'overline'}}),
('text-decoration-line: overline blink line-through', {
'text_decoration_line': {'blink', 'line-through', 'overline'}}),
))
def test_decoration_line(rule, result):
assert expand_to_dict(rule) == result
@assert_no_logs
@pytest.mark.parametrize('rule, result', (
('text-decoration-style: solid', {'text_decoration_style': 'solid'}),
('text-decoration-style: double', {'text_decoration_style': 'double'}),
('text-decoration-style: dotted', {'text_decoration_style': 'dotted'}),
('text-decoration-style: dashed', {'text_decoration_style': 'dashed'}),
))
def test_decoration_style(rule, result):
assert expand_to_dict(rule) == result
TEXT_DECORATION_DEFAULT = {
'text_decoration_line': 'none',
'text_decoration_color': 'currentColor',
'text_decoration_style': 'solid',
}
@assert_no_logs
@pytest.mark.parametrize('rule, result', (
('text-decoration: none', {'text_decoration_line': 'none'}),
('text-decoration: overline', {'text_decoration_line': {'overline'}}),
('text-decoration: overline blink line-through', {
'text_decoration_line': {'blink', 'line-through', 'overline'}}),
('text-decoration: red', {'text_decoration_color': (1, 0, 0, 1)}),
))
def test_decoration(rule, result):
real_result = {**TEXT_DECORATION_DEFAULT, **result}
assert expand_to_dict(rule) == real_result
@assert_no_logs
@pytest.mark.parametrize('rule, result', (
('size: 200px', {'size': ((200, 'px'), (200, 'px'))}),
('size: 200px 300pt', {'size': ((200, 'px'), (300, 'pt'))}),
('size: auto', {'size': ((210, 'mm'), (297, 'mm'))}),
('size: portrait', {'size': ((210, 'mm'), (297, 'mm'))}),
('size: landscape', {'size': ((297, 'mm'), (210, 'mm'))}),
('size: A3 portrait', {'size': ((297, 'mm'), (420, 'mm'))}),
('size: A3 landscape', {'size': ((420, 'mm'), (297, 'mm'))}),
('size: portrait A3', {'size': ((297, 'mm'), (420, 'mm'))}),
('size: landscape A3', {'size': ((420, 'mm'), (297, 'mm'))}),
))
def test_size(rule, result):
assert expand_to_dict(rule) == result
@pytest.mark.parametrize('rule', (
'size: A3 landscape A3',
'size: A9',
'size: foo',
'size: foo bar',
'size: 20%',
))
def test_size_invalid(rule):
assert_invalid(rule)
@assert_no_logs
@pytest.mark.parametrize('rule, result', (
('transform: none', {'transform': ()}),
('transform: translate(6px) rotate(90deg)', {
'transform': (
('translate', ((6, 'px'), (0, 'px'))),
('rotate', math.pi / 2))}),
('transform: translate(-4px, 0)', {
'transform': (('translate', ((-4, 'px'), (0, None))),)}),
('transform: translate(6px, 20%)', {
'transform': (('translate', ((6, 'px'), (20, '%'))),)}),
('transform: scale(2)', {'transform': (('scale', (2, 2)),)}),
('transform: translate(6px 20%)', {
'transform': (('translate', ((6, 'px'), (20, '%'))),)}),
))
def test_transforms(rule, result):
assert expand_to_dict(rule) == result
@assert_no_logs
@pytest.mark.parametrize('rule', (
'transform: lipsumize(6px)',
'transform: foo',
'transform: scale(2) foo',
'transform: 6px',
))
def test_transforms_invalid(rule):
assert_invalid(rule)
@assert_no_logs
@pytest.mark.parametrize('rule, result', (
('margin: inherit', {
'margin_top': 'inherit',
'margin_right': 'inherit',
'margin_bottom': 'inherit',
'margin_left': 'inherit',
}),
('margin: 1em', {
'margin_top': (1, 'em'),
'margin_right': (1, 'em'),
'margin_bottom': (1, 'em'),
'margin_left': (1, 'em'),
}),
('margin: -1em auto 20%', {
'margin_top': (-1, 'em'),
'margin_right': 'auto',
'margin_bottom': (20, '%'),
'margin_left': 'auto',
}),
('padding: 1em 0', {
'padding_top': (1, 'em'),
'padding_right': (0, None),
'padding_bottom': (1, 'em'),
'padding_left': (0, None),
}),
('padding: 1em 0 2%', {
'padding_top': (1, 'em'),
'padding_right': (0, None),
'padding_bottom': (2, '%'),
'padding_left': (0, None),
}),
('padding: 1em 0 2em 5px', {
'padding_top': (1, 'em'),
'padding_right': (0, None),
'padding_bottom': (2, 'em'),
'padding_left': (5, 'px'),
}),
))
def test_expand_four_sides(rule, result):
assert expand_to_dict(rule) == result
@assert_no_logs
def test_expand_four_sides_warning():
assert expand_to_dict(
'padding: 1 2 3 4 5', 'Expected 1 to 4 token components got 5') == {}
@assert_no_logs
@pytest.mark.parametrize('rule', (
'margin: rgb(0, 0, 0)',
'padding: auto',
'padding: -12px',
'border-width: -3em',
'border-width: 12%',
))
def test_expand_four_sides_invalid(rule):
assert_invalid(rule)
@assert_no_logs
@pytest.mark.parametrize('rule, result', (
('border-top: 3px dotted red', {
'border_top_width': (3, 'px'),
'border_top_style': 'dotted',
'border_top_color': (1, 0, 0, 1), # red
}),
('border-top: 3px dotted', {
'border_top_width': (3, 'px'),
'border_top_style': 'dotted',
}),
('border-top: 3px red', {
'border_top_width': (3, 'px'),
'border_top_color': (1, 0, 0, 1), # red
}),
('border-top: solid', {'border_top_style': 'solid'}),
('border: 6px dashed lime', {
'border_top_width': (6, 'px'),
'border_top_style': 'dashed',
'border_top_color': (0, 1, 0, 1), # lime
'border_left_width': (6, 'px'),
'border_left_style': 'dashed',
'border_left_color': (0, 1, 0, 1), # lime
'border_bottom_width': (6, 'px'),
'border_bottom_style': 'dashed',
'border_bottom_color': (0, 1, 0, 1), # lime
'border_right_width': (6, 'px'),
'border_right_style': 'dashed',
'border_right_color': (0, 1, 0, 1), # lime
}),
))
def test_expand_borders(rule, result):
assert expand_to_dict(rule) == result
@assert_no_logs
def test_expand_borders_invalid():
assert_invalid('border: 6px dashed left')
@assert_no_logs
@pytest.mark.parametrize('rule, result', (
('list-style: inherit', {
'list_style_position': 'inherit',
'list_style_image': 'inherit',
'list_style_type': 'inherit',
}),
('list-style: url(../bar/lipsum.png)', {
'list_style_image': ('url', 'http://weasyprint.org/bar/lipsum.png'),
}),
('list-style: square', {
'list_style_type': 'square',
}),
('list-style: circle inside', {
'list_style_position': 'inside',
'list_style_type': 'circle',
}),
('list-style: none circle inside', {
'list_style_position': 'inside',
'list_style_image': ('none', None),
'list_style_type': 'circle',
}),
('list-style: none inside none', {
'list_style_position': 'inside',
'list_style_image': ('none', None),
'list_style_type': 'none',
}),
))
def test_expand_list_style(rule, result):
assert expand_to_dict(rule) == result
@assert_no_logs
def test_expand_list_style_warning():
assert_invalid(
'list-style: circle disc',
'got multiple type values in a list-style shorthand')
@assert_no_logs
@pytest.mark.parametrize('rule', (
'list-style: none inside none none',
'list-style: red',
))
def test_expand_list_style_invalid(rule):
assert_invalid(rule)
def assert_background(css, **expected):
"""Helper checking the background properties."""
expanded = expand_to_dict('background: ' + css)
assert expanded.pop('background_color') == expected.pop(
'background_color', INITIAL_VALUES['background_color'])
nb_layers = len(expanded['background_image'])
for name, value in expected.items():
assert expanded.pop(name) == value
for name, value in expanded.items():
assert tuple(value) == INITIAL_VALUES[name] * nb_layers
@assert_no_logs
def test_expand_background():
assert_background('red', background_color=(1, 0, 0, 1))
assert_background(
'url(lipsum.png)',
background_image=[('url', 'http://weasyprint.org/foo/lipsum.png')])
assert_background(
'no-repeat',
background_repeat=[('no-repeat', 'no-repeat')])
assert_background('fixed', background_attachment=['fixed'])
assert_background(
'repeat no-repeat fixed',
background_repeat=[('repeat', 'no-repeat')],
background_attachment=['fixed'])
assert_background(
'top',
background_position=[('left', (50, '%'), 'top', (0, '%'))])
assert_background(
'top right',
background_position=[('left', (100, '%'), 'top', (0, '%'))])
assert_background(
'top right 20px',
background_position=[('right', (20, 'px'), 'top', (0, '%'))])
assert_background(
'top 1% right 20px',
background_position=[('right', (20, 'px'), 'top', (1, '%'))])
assert_background(
'top no-repeat',
background_repeat=[('no-repeat', 'no-repeat')],
background_position=[('left', (50, '%'), 'top', (0, '%'))])
assert_background(
'top right no-repeat',
background_repeat=[('no-repeat', 'no-repeat')],
background_position=[('left', (100, '%'), 'top', (0, '%'))])
assert_background(
'top right 20px no-repeat',
background_repeat=[('no-repeat', 'no-repeat')],
background_position=[('right', (20, 'px'), 'top', (0, '%'))])
assert_background(
'top 1% right 20px no-repeat',
background_repeat=[('no-repeat', 'no-repeat')],
background_position=[('right', (20, 'px'), 'top', (1, '%'))])
assert_background(
'url(bar) #f00 repeat-y center left fixed',
background_color=(1, 0, 0, 1),
background_image=[('url', 'http://weasyprint.org/foo/bar')],
background_repeat=[('no-repeat', 'repeat')],
background_attachment=['fixed'],
background_position=[('left', (0, '%'), 'top', (50, '%'))])
assert_background(
'#00f 10% 200px',
background_color=(0, 0, 1, 1),
background_position=[('left', (10, '%'), 'top', (200, 'px'))])
assert_background(
'right 78px fixed',
background_attachment=['fixed'],
background_position=[('left', (100, '%'), 'top', (78, 'px'))])
assert_background(
'center / cover red',
background_size=['cover'],
background_position=[('left', (50, '%'), 'top', (50, '%'))],
background_color=(1, 0, 0, 1))
assert_background(
'center / auto red',
background_size=[('auto', 'auto')],
background_position=[('left', (50, '%'), 'top', (50, '%'))],
background_color=(1, 0, 0, 1))
assert_background(
'center / 42px',
background_size=[((42, 'px'), 'auto')],
background_position=[('left', (50, '%'), 'top', (50, '%'))])
assert_background(
'center / 7% 4em',
background_size=[((7, '%'), (4, 'em'))],
background_position=[('left', (50, '%'), 'top', (50, '%'))])
assert_background(
'red content-box',
background_color=(1, 0, 0, 1),
background_origin=['content-box'],
background_clip=['content-box'])
assert_background(
'red border-box content-box',
background_color=(1, 0, 0, 1),
background_origin=['border-box'],
background_clip=['content-box'])
assert_background(
'url(bar) center, no-repeat',
background_color=(0, 0, 0, 0),
background_image=[('url', 'http://weasyprint.org/foo/bar'),
('none', None)],
background_position=[('left', (50, '%'), 'top', (50, '%')),
('left', (0, '%'), 'top', (0, '%'))],
background_repeat=[('repeat', 'repeat'), ('no-repeat', 'no-repeat')])
assert_invalid('background: 10px lipsum')
assert_invalid('background-position: 10px lipsum')
assert_invalid('background: content-box red content-box')
assert_invalid('background-image: inexistent-gradient(blue, green)')
# Color must be in the last layer:
assert_invalid('background: red, url(foo)')
@assert_no_logs
def test_expand_background_position():
"""Test the ``background-position`` property."""
def position(css, *expected):
[(name, [value])] = expand_to_dict(
'background-position:' + css).items()
assert name == 'background_position'
assert value == expected
for css_x, val_x in [
('left', (0, '%')), ('center', (50, '%')), ('right', (100, '%')),
('4.5%', (4.5, '%')), ('12px', (12, 'px'))
]:
for css_y, val_y in [
('top', (0, '%')), ('center', (50, '%')), ('bottom', (100, '%')),
('7%', (7, '%')), ('1.5px', (1.5, 'px'))
]:
# Two tokens:
position('%s %s' % (css_x, css_y), 'left', val_x, 'top', val_y)
# One token:
position(css_x, 'left', val_x, 'top', (50, '%'))
# One token, vertical
position('top', 'left', (50, '%'), 'top', (0, '%'))
position('bottom', 'left', (50, '%'), 'top', (100, '%'))
# Three tokens:
position('center top 10%', 'left', (50, '%'), 'top', (10, '%'))
position('top 10% center', 'left', (50, '%'), 'top', (10, '%'))
position('center bottom 10%', 'left', (50, '%'), 'bottom', (10, '%'))
position('bottom 10% center', 'left', (50, '%'), 'bottom', (10, '%'))
position('right top 10%', 'right', (0, '%'), 'top', (10, '%'))
position('top 10% right', 'right', (0, '%'), 'top', (10, '%'))
position('right bottom 10%', 'right', (0, '%'), 'bottom', (10, '%'))
position('bottom 10% right', 'right', (0, '%'), 'bottom', (10, '%'))
position('center left 10%', 'left', (10, '%'), 'top', (50, '%'))
position('left 10% center', 'left', (10, '%'), 'top', (50, '%'))
position('center right 10%', 'right', (10, '%'), 'top', (50, '%'))
position('right 10% center', 'right', (10, '%'), 'top', (50, '%'))
position('bottom left 10%', 'left', (10, '%'), 'bottom', (0, '%'))
position('left 10% bottom', 'left', (10, '%'), 'bottom', (0, '%'))
position('bottom right 10%', 'right', (10, '%'), 'bottom', (0, '%'))
position('right 10% bottom', 'right', (10, '%'), 'bottom', (0, '%'))
# Four tokens:
position('left 10% bottom 3px', 'left', (10, '%'), 'bottom', (3, 'px'))
position('bottom 3px left 10%', 'left', (10, '%'), 'bottom', (3, 'px'))
position('right 10% top 3px', 'right', (10, '%'), 'top', (3, 'px'))
position('top 3px right 10%', 'right', (10, '%'), 'top', (3, 'px'))
assert_invalid('background-position: left center 3px')
assert_invalid('background-position: 3px left')
assert_invalid('background-position: bottom 4%')
assert_invalid('background-position: bottom top')
@assert_no_logs
def test_font():
"""Test the ``font`` property."""
assert expand_to_dict('font: 12px My Fancy Font, serif') == {
'font_size': (12, 'px'),
'font_family': ('My Fancy Font', 'serif'),
}
assert expand_to_dict('font: small/1.2 "Some Font", serif') == {
'font_size': 'small',
'line_height': (1.2, None),
'font_family': ('Some Font', 'serif'),
}
assert expand_to_dict('font: small-caps italic 700 large serif') == {
'font_style': 'italic',
'font_variant_caps': 'small-caps',
'font_weight': 700,
'font_size': 'large',
'font_family': ('serif',),
}
assert expand_to_dict(
'font: small-caps condensed normal 700 large serif'
) == {
'font_stretch': 'condensed',
'font_variant_caps': 'small-caps',
'font_weight': 700,
'font_size': 'large',
'font_family': ('serif',),
}
assert_invalid('font-family: "My" Font, serif')
assert_invalid('font-family: "My" "Font", serif')
assert_invalid('font-family: "My", 12pt, serif')
assert_invalid('font: menu', 'System fonts are not supported')
assert_invalid('font: 12deg My Fancy Font, serif')
assert_invalid('font: 12px')
assert_invalid('font: 12px/foo serif')
assert_invalid('font: 12px "Invalid" family')
assert_invalid('font: normal normal normal normal normal large serif')
assert_invalid('font: normal small-caps italic 700 condensed large serif')
assert_invalid('font: small-caps italic 700 normal condensed large serif')
assert_invalid('font: small-caps italic 700 condensed normal large serif')
@assert_no_logs
def test_font_variant():
"""Test the ``font-variant`` property."""
assert expand_to_dict('font-variant: normal') == {
'font_variant_alternates': 'normal',
'font_variant_caps': 'normal',
'font_variant_east_asian': 'normal',
'font_variant_ligatures': 'normal',
'font_variant_numeric': 'normal',
'font_variant_position': 'normal',
}
assert expand_to_dict('font-variant: none') == {
'font_variant_alternates': 'normal',
'font_variant_caps': 'normal',
'font_variant_east_asian': 'normal',
'font_variant_ligatures': 'none',
'font_variant_numeric': 'normal',
'font_variant_position': 'normal',
}
assert expand_to_dict('font-variant: historical-forms petite-caps') == {
'font_variant_alternates': 'historical-forms',
'font_variant_caps': 'petite-caps',
}
assert expand_to_dict(
'font-variant: lining-nums contextual small-caps common-ligatures'
) == {
'font_variant_ligatures': ('contextual', 'common-ligatures'),
'font_variant_numeric': ('lining-nums',),
'font_variant_caps': 'small-caps',
}
assert expand_to_dict('font-variant: jis78 ruby proportional-width') == {
'font_variant_east_asian': ('jis78', 'ruby', 'proportional-width'),
}
# CSS2-style font-variant
assert expand_to_dict('font-variant: small-caps') == {
'font_variant_caps': 'small-caps',
}
assert_invalid('font-variant: normal normal')
assert_invalid('font-variant: 2')
assert_invalid('font-variant: ""')
assert_invalid('font-variant: extra')
assert_invalid('font-variant: jis78 jis04')
assert_invalid('font-variant: full-width lining-nums ordinal normal')
assert_invalid('font-variant: diagonal-fractions stacked-fractions')
assert_invalid(
'font-variant: common-ligatures contextual no-common-ligatures')
assert_invalid('font-variant: sub super')
assert_invalid('font-variant: slashed-zero slashed-zero')
@assert_no_logs
def test_line_height():
"""Test the ``line-height`` property."""
assert expand_to_dict('line-height: 1px') == {'line_height': (1, 'px')}
assert expand_to_dict('line-height: 1.1%') == {'line_height': (1.1, '%')}
assert expand_to_dict('line-height: 1em') == {'line_height': (1, 'em')}
assert expand_to_dict('line-height: 1') == {'line_height': (1, None)}
assert expand_to_dict('line-height: 1.3') == {'line_height': (1.3, None)}
assert expand_to_dict('line-height: -0') == {'line_height': (0, None)}
assert expand_to_dict('line-height: 0px') == {'line_height': (0, 'px')}
assert_invalid('line-height: 1deg')
assert_invalid('line-height: -1px')
assert_invalid('line-height: -1')
assert_invalid('line-height: -0.5%')
assert_invalid('line-height: 1px 1px')
@assert_no_logs
def test_string_set():
"""Test the ``string-set`` property."""
assert expand_to_dict('string-set: test content(text)') == {
'string_set': (('test', (('content()', 'text'),)),)}
assert expand_to_dict('string-set: test content(before)') == {
'string_set': (('test', (('content()', 'before'),)),)}
assert expand_to_dict('string-set: test "string"') == {
'string_set': (('test', (('string', 'string'),)),)}
assert expand_to_dict(
'string-set: test1 "string", test2 "string"') == {
'string_set': (
('test1', (('string', 'string'),)),
('test2', (('string', 'string'),)))}
assert expand_to_dict('string-set: test attr(class)') == {
'string_set': (('test', (('attr()', ('class', 'string', '')),)),)}
assert expand_to_dict('string-set: test counter(count)') == {
'string_set': (('test', (('counter()', ('count', 'decimal')),)),)}
assert expand_to_dict(
'string-set: test counter(count, upper-roman)') == {
'string_set': (
('test', (('counter()', ('count', 'upper-roman')),)),)}
assert expand_to_dict('string-set: test counters(count, ".")') == {
'string_set': (
('test', (('counters()', ('count', '.', 'decimal')),)),)}
assert expand_to_dict(
'string-set: test counters(count, ".", upper-roman)') == {
'string_set': (
('test', (('counters()', ('count', '.', 'upper-roman')),)),)}
assert expand_to_dict(
'string-set: test content(text) "string" '
'attr(title) attr(title) counter(count)') == {
'string_set': (('test', (
('content()', 'text'), ('string', 'string'),
('attr()', ('title', 'string', '')),
('attr()', ('title', 'string', '')),
('counter()', ('count', 'decimal')))),)}
assert_invalid('string-set: test')
assert_invalid('string-set: test test1')
assert_invalid('string-set: test content(test)')
assert_invalid('string-set: test unknown()')
assert_invalid('string-set: test attr(id, class)')
@assert_no_logs
def test_linear_gradient():
red = (1, 0, 0, 1)
lime = (0, 1, 0, 1)
blue = (0, 0, 1, 1)
pi = math.pi
def gradient(css, direction, colors=[blue], stop_positions=[None]):
for repeating, prefix in ((False, ''), (True, 'repeating-')):
expanded = expand_to_dict(
'background-image: %slinear-gradient(%s)' % (prefix, css))
[(_, [(type_, image)])] = expanded.items()
assert type_ == 'linear-gradient'
assert isinstance(image, LinearGradient)
assert image.repeating == repeating
assert image.direction_type == direction[0]
if isinstance(image.direction, str):
image.direction == direction[1]
else:
assert image.direction == pytest.approx(direction[1])
assert image.colors == colors
assert image.stop_positions == stop_positions
def invalid(css):
assert_invalid('background-image: linear-gradient(%s)' % css)
assert_invalid('background-image: repeating-linear-gradient(%s)' % css)
invalid(' ')
invalid('1% blue')
invalid('blue 10deg')
invalid('blue 4')
invalid('soylent-green 4px')
invalid('red 4px 2px')
gradient('blue', ('angle', pi))
gradient('red', ('angle', pi), [red], [None])
gradient('blue 1%, lime,red 2em ', ('angle', pi),
[blue, lime, red], [(1, '%'), None, (2, 'em')])
invalid('18deg')
gradient('18deg, blue', ('angle', pi / 10))
gradient('4rad, blue', ('angle', 4))
gradient('.25turn, blue', ('angle', pi / 2))
gradient('100grad, blue', ('angle', pi / 2))
gradient('12rad, blue 1%, lime,red 2em ', ('angle', 12),
[blue, lime, red], [(1, '%'), None, (2, 'em')])
invalid('10arc-minutes, blue')
invalid('10px, blue')
invalid('to 90deg, blue')
gradient('to top, blue', ('angle', 0))
gradient('to right, blue', ('angle', pi / 2))
gradient('to bottom, blue', ('angle', pi))
gradient('to left, blue', ('angle', pi * 3 / 2))
gradient('to right, blue 1%, lime,red 2em ', ('angle', pi / 2),
[blue, lime, red], [(1, '%'), None, (2, 'em')])
invalid('to the top, blue')
invalid('to up, blue')
invalid('into top, blue')
invalid('top, blue')
gradient('to top left, blue', ('corner', 'top_left'))
gradient('to left top, blue', ('corner', 'top_left'))
gradient('to top right, blue', ('corner', 'top_right'))
gradient('to right top, blue', ('corner', 'top_right'))
gradient('to bottom left, blue', ('corner', 'bottom_left'))
gradient('to left bottom, blue', ('corner', 'bottom_left'))
gradient('to bottom right, blue', ('corner', 'bottom_right'))
gradient('to right bottom, blue', ('corner', 'bottom_right'))
invalid('to bottom up, blue')
invalid('bottom left, blue')
@assert_no_logs
def test_overflow_wrap():
assert expand_to_dict('overflow-wrap: normal') == {
'overflow_wrap': 'normal'}
assert expand_to_dict('overflow-wrap: break-word') == {
'overflow_wrap': 'break-word'}
assert_invalid('overflow-wrap: none')
assert_invalid('overflow-wrap: normal, break-word')
@assert_no_logs
def test_expand_word_wrap():
assert expand_to_dict('word-wrap: normal') == {
'overflow_wrap': 'normal'}
assert expand_to_dict('word-wrap: break-word') == {
'overflow_wrap': 'break-word'}
assert_invalid('word-wrap: none')
assert_invalid('word-wrap: normal, break-word')
@assert_no_logs
def test_radial_gradient():
red = (1, 0, 0, 1)
lime = (0, 1, 0, 1)
blue = (0, 0, 1, 1)
def gradient(css, shape='ellipse', size=('keyword', 'farthest-corner'),
center=('left', (50, '%'), 'top', (50, '%')),
colors=[blue], stop_positions=[None]):
for repeating, prefix in ((False, ''), (True, 'repeating-')):
expanded = expand_to_dict(
'background-image: %sradial-gradient(%s)' % (prefix, css))
[(_, [(type_, image)])] = expanded.items()
assert type_ == 'radial-gradient'
assert isinstance(image, RadialGradient)
assert image.repeating == repeating
assert image.shape == shape
assert image.size_type == size[0]
assert image.size == size[1]
assert image.center == center
assert image.colors == colors
assert image.stop_positions == stop_positions
def invalid(css):
assert_invalid('background-image: radial-gradient(%s)' % css)
assert_invalid('background-image: repeating-radial-gradient(%s)' % css)
invalid(' ')
invalid('1% blue')
invalid('blue 10deg')
invalid('blue 4')
invalid('soylent-green 4px')
invalid('red 4px 2px')
gradient('blue')
gradient('red', colors=[red])
gradient('blue 1%, lime,red 2em ', colors=[blue, lime, red],
stop_positions=[(1, '%'), None, (2, 'em')])
gradient('circle, blue', 'circle')
gradient('ellipse, blue', 'ellipse')
invalid('circle')
invalid('square, blue')
invalid('closest-triangle, blue')
invalid('center, blue')
gradient('ellipse closest-corner, blue',
'ellipse', ('keyword', 'closest-corner'))
gradient('circle closest-side, blue',
'circle', ('keyword', 'closest-side'))
gradient('farthest-corner circle, blue',
'circle', ('keyword', 'farthest-corner'))
gradient('farthest-side, blue',
'ellipse', ('keyword', 'farthest-side'))
gradient('5ch, blue',
'circle', ('explicit', ((5, 'ch'), (5, 'ch'))))
gradient('5ch circle, blue',
'circle', ('explicit', ((5, 'ch'), (5, 'ch'))))
gradient('circle 5ch, blue',
'circle', ('explicit', ((5, 'ch'), (5, 'ch'))))
invalid('ellipse 5ch')
invalid('5ch ellipse')
gradient('10px 50px, blue',
'ellipse', ('explicit', ((10, 'px'), (50, 'px'))))
gradient('10px 50px ellipse, blue',
'ellipse', ('explicit', ((10, 'px'), (50, 'px'))))
gradient('ellipse 10px 50px, blue',
'ellipse', ('explicit', ((10, 'px'), (50, 'px'))))
invalid('circle 10px 50px, blue')
invalid('10px 50px circle, blue')
invalid('10%, blue')
invalid('10% circle, blue')
invalid('circle 10%, blue')
gradient('10px 50px, blue',
'ellipse', ('explicit', ((10, 'px'), (50, 'px'))))
invalid('at appex, blue')
gradient('at top 10% right, blue',
center=('right', (0, '%'), 'top', (10, '%')))
gradient('circle at bottom, blue', shape='circle',
center=('left', (50, '%'), 'top', (100, '%')))
gradient('circle at 10px, blue', shape='circle',
center=('left', (10, 'px'), 'top', (50, '%')))
gradient('closest-side circle at right 5em, blue',
shape='circle', size=('keyword', 'closest-side'),
center=('left', (100, '%'), 'top', (5, 'em')))
@assert_no_logs
@pytest.mark.parametrize('rule, result', (
('flex: auto', {
'flex_grow': 1,
'flex_shrink': 1,
'flex_basis': 'auto',
}),
('flex: none', {
'flex_grow': 0,
'flex_shrink': 0,
'flex_basis': 'auto',
}),
('flex: 10', {
'flex_grow': 10,
'flex_shrink': 1,
'flex_basis': ZERO_PIXELS,
}),
('flex: 2 2', {
'flex_grow': 2,
'flex_shrink': 2,
'flex_basis': ZERO_PIXELS,
}),
('flex: 2 2 1px', {
'flex_grow': 2,
'flex_shrink': 2,
'flex_basis': Dimension(1, 'px'),
}),
('flex: 2 2 auto', {
'flex_grow': 2,
'flex_shrink': 2,
'flex_basis': 'auto',
}),
('flex: 2 auto', {
'flex_grow': 2,
'flex_shrink': 1,
'flex_basis': 'auto',
}),
))
def test_flex(rule, result):
"""Test the ``flex`` property."""
assert expand_to_dict(rule) == result

View File

@ -0,0 +1,165 @@
"""
weasyprint.tests.test_draw
--------------------------
Test the final, drawn results and compare PNG images pixel per pixel.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import os
import sys
import cairocffi as cairo
from ..testing_utils import FakeHTML, resource_filename
# RGBA to native-endian ARGB
as_pixel = (
lambda x: x[:-1][::-1] + x[-1:]
if sys.byteorder == 'little' else
lambda x: x[-1:] + x[:-1])
PIXELS_BY_CHAR = dict(
_=as_pixel(b'\xff\xff\xff\xff'), # white
R=as_pixel(b'\xff\x00\x00\xff'), # red
B=as_pixel(b'\x00\x00\xff\xff'), # blue
G=as_pixel(b'\x00\xff\x00\xff'), # lime green
V=as_pixel(b'\xBF\x00\x40\xff'), # average of 1*B and 3*R.
S=as_pixel(b'\xff\x3f\x3f\xff'), # R above R above #fff
r=as_pixel(b'\xff\x00\x00\xff'), # red
g=as_pixel(b'\x00\x80\x00\xff'), # half green
b=as_pixel(b'\x00\x00\x80\xff'), # half blue
v=as_pixel(b'\x80\x00\x80\xff'), # average of B and R.
h=as_pixel(b'\x40\x00\x40\xff'), # half average of B and R.
a=as_pixel(b'\x00\x00\xfe\xff'), # JPG is lossy...
p=as_pixel(b'\xc0\x00\x3f\xff'), # R above R above B above #fff.
)
# NOTE: "r" is not half red on purpose. In the pixel strings it has
# better contrast with "B" than does "R". eg. "rBBBrrBrB" vs "RBBBRRBRB".
def parse_pixels(pixels, pixels_overrides=None):
chars = dict(PIXELS_BY_CHAR, **(pixels_overrides or {}))
lines = [line.split('#')[0].strip() for line in pixels.splitlines()]
return [b''.join(chars[char] for char in line) for line in lines if line]
def assert_pixels(name, expected_width, expected_height, expected_pixels,
html):
"""Helper testing the size of the image and the pixels values."""
if isinstance(expected_pixels, str):
expected_pixels = parse_pixels(expected_pixels)
assert len(expected_pixels) == expected_height
assert len(expected_pixels[0]) == expected_width * 4
expected_raw = b''.join(expected_pixels)
_doc, pixels = html_to_pixels(name, expected_width, expected_height, html)
assert_pixels_equal(
name, expected_width, expected_height, pixels, expected_raw)
def assert_same_rendering(expected_width, expected_height, documents,
tolerance=0):
"""Render HTML documents to PNG and check that they render the same.
Each document is passed as a (name, html_source) tuple.
"""
pixels_list = []
for name, html in documents:
_doc, pixels = html_to_pixels(
name, expected_width, expected_height, html)
pixels_list.append((name, pixels))
_name, reference = pixels_list[0]
for name, pixels in pixels_list[1:]:
assert_pixels_equal(name, expected_width, expected_height,
reference, pixels, tolerance)
def assert_different_renderings(expected_width, expected_height, documents):
"""Render HTML documents to PNG and check that they don't render the same.
Each document is passed as a (name, html_source) tuple.
"""
pixels_list = []
for name, html in documents:
_doc, pixels = html_to_pixels(
name, expected_width, expected_height, html)
pixels_list.append((name, pixels))
for i, (name_1, pixels_1) in enumerate(pixels_list):
for name_2, pixels_2 in pixels_list[i + 1:]:
if pixels_1 == pixels_2: # pragma: no cover
write_png(name_1, pixels_1, expected_width, expected_height)
# Same as "assert pixels_1 != pixels_2" but the output of
# the assert hook would be gigantic and useless.
assert False, '%s and %s are the same' % (name_1, name_2)
def write_png(basename, pixels, width, height): # pragma: no cover
"""Take a pixel matrix and write a PNG file."""
directory = os.path.join(os.path.dirname(__file__), 'results')
if not os.path.isdir(directory):
os.mkdir(directory)
filename = os.path.join(directory, basename + '.png')
cairo.ImageSurface(
cairo.FORMAT_ARGB32, width, height,
data=bytearray(pixels), stride=width * 4
).write_to_png(filename)
def html_to_pixels(name, expected_width, expected_height, html):
"""Render an HTML document to PNG, checks its size and return pixel data.
Also return the document to aid debugging.
"""
document = FakeHTML(
string=html,
# Dummy filename, but in the right directory.
base_url=resource_filename('<test>'))
pixels = document_to_pixels(
document, name, expected_width, expected_height)
return document, pixels
def document_to_pixels(document, name, expected_width, expected_height):
"""Render an HTML document to PNG, check its size and return pixel data."""
surface = document.write_image_surface()
return image_to_pixels(surface, expected_width, expected_height)
def image_to_pixels(surface, width, height):
assert (surface.get_width(), surface.get_height()) == (width, height)
# RGB24 is actually the same as ARGB32, with A unused.
assert surface.get_format() in (cairo.FORMAT_ARGB32, cairo.FORMAT_RGB24)
pixels = surface.get_data()[:]
assert len(pixels) == width * height * 4
return pixels
def assert_pixels_equal(name, width, height, raw, expected_raw, tolerance=0):
"""Take 2 matrices of pixels and assert that they are the same."""
if raw != expected_raw: # pragma: no cover
for i, (value, expected) in enumerate(zip(raw, expected_raw)):
if abs(value - expected) > tolerance:
write_png(name, raw, width, height)
write_png(name + '.expected', expected_raw,
width, height)
pixel_n = i // 4
x = pixel_n // width
y = pixel_n % width
i % 4
pixel = tuple(list(raw[i:i + 4]))
expected_pixel = tuple(list(
expected_raw[i:i + 4]))
assert 0, (
'Pixel (%i, %i) in %s: expected rgba%s, got rgba%s'
% (x, y, name, expected_pixel, pixel))

View File

@ -0,0 +1,977 @@
"""
weasyprint.tests.test_draw.test_background
------------------------------------------
Test how backgrounds are drawn.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import pytest
from ..testing_utils import assert_no_logs
from . import assert_pixels
@assert_no_logs
@pytest.mark.parametrize(
'name, expected_width, expected_height, expected_pixels, html', (
('all_blue', 10, 10, (10 * (10 * 'B' + "\n")), '''
<style>
@page { size: 10px }
/* bodys background propagates to the whole canvas */
body { margin: 2px; background: #00f; height: 5px }
</style>
<body>'''),
('blocks', 10, 10, '''
rrrrrrrrrr
rrrrrrrrrr
rrBBBBBBrr
rrBBBBBBrr
rrBBBBBBrr
rrBBBBBBrr
rrBBBBBBrr
rrrrrrrrrr
rrrrrrrrrr
rrrrrrrrrr
''', '''
<style>
@page { size: 10px }
/* htmls background propagates to the whole canvas */
html { padding: 1px; background: #f00 }
/* html has a background, so bodys does not propagate */
body { margin: 1px; background: #00f; height: 5px }
</style>
<body>'''),
))
def test_canvas_background(name, expected_width, expected_height,
expected_pixels, html):
assert_pixels(name, expected_width, expected_height, expected_pixels, html)
@assert_no_logs
@pytest.mark.parametrize('name, css, pixels', (
('repeat', 'url(pattern.png)', '''
______________
______________
__rBBBrBBBrB__
__BBBBBBBBBB__
__BBBBBBBBBB__
__BBBBBBBBBB__
__rBBBrBBBrB__
__BBBBBBBBBB__
__BBBBBBBBBB__
__BBBBBBBBBB__
__rBBBrBBBrB__
__BBBBBBBBBB__
______________
______________
______________
______________
'''),
('repeat_x', 'url(pattern.png) repeat-x', '''
______________
______________
__rBBBrBBBrB__
__BBBBBBBBBB__
__BBBBBBBBBB__
__BBBBBBBBBB__
______________
______________
______________
______________
______________
______________
______________
______________
______________
______________
'''),
('repeat_y', 'url(pattern.png) repeat-y', '''
______________
______________
__rBBB________
__BBBB________
__BBBB________
__BBBB________
__rBBB________
__BBBB________
__BBBB________
__BBBB________
__rBBB________
__BBBB________
______________
______________
______________
______________
'''),
('left_top', 'url(pattern.png) no-repeat 0 0%', '''
______________
______________
__rBBB________
__BBBB________
__BBBB________
__BBBB________
______________
______________
______________
______________
______________
______________
______________
______________
______________
______________
'''),
('center_top', 'url(pattern.png) no-repeat 50% 0px', '''
______________
______________
_____rBBB_____
_____BBBB_____
_____BBBB_____
_____BBBB_____
______________
______________
______________
______________
______________
______________
______________
______________
______________
______________
'''),
('right_top', 'url(pattern.png) no-repeat 6px top', '''
______________
______________
________rBBB__
________BBBB__
________BBBB__
________BBBB__
______________
______________
______________
______________
______________
______________
______________
______________
______________
______________
'''),
('bottom_6_right_0', 'url(pattern.png) no-repeat bottom 6px right 0', '''
______________
______________
________rBBB__
________BBBB__
________BBBB__
________BBBB__
______________
______________
______________
______________
______________
______________
______________
______________
______________
______________
'''),
('left_center', 'url(pattern.png) no-repeat left center', '''
______________
______________
______________
______________
______________
__rBBB________
__BBBB________
__BBBB________
__BBBB________
______________
______________
______________
______________
______________
______________
______________
'''),
('center_left', 'url(pattern.png) no-repeat center left', '''
______________
______________
______________
______________
______________
__rBBB________
__BBBB________
__BBBB________
__BBBB________
______________
______________
______________
______________
______________
______________
______________
'''),
('center_center', 'url(pattern.png) no-repeat 3px 3px', '''
______________
______________
______________
______________
______________
_____rBBB_____
_____BBBB_____
_____BBBB_____
_____BBBB_____
______________
______________
______________
______________
______________
______________
______________
'''),
('right_center', 'url(pattern.png) no-repeat 100% 50%', '''
______________
______________
______________
______________
______________
________rBBB__
________BBBB__
________BBBB__
________BBBB__
______________
______________
______________
______________
______________
______________
______________
'''),
('left_bottom', 'url(pattern.png) no-repeat 0% bottom', '''
______________
______________
______________
______________
______________
______________
______________
______________
__rBBB________
__BBBB________
__BBBB________
__BBBB________
______________
______________
______________
______________
'''),
('center_bottom', 'url(pattern.png) no-repeat center 6px', '''
______________
______________
______________
______________
______________
______________
______________
______________
_____rBBB_____
_____BBBB_____
_____BBBB_____
_____BBBB_____
______________
______________
______________
______________
'''),
('bottom_center', 'url(pattern.png) no-repeat bottom center', '''
______________
______________
______________
______________
______________
______________
______________
______________
_____rBBB_____
_____BBBB_____
_____BBBB_____
_____BBBB_____
______________
______________
______________
______________
'''),
('right_bottom', 'url(pattern.png) no-repeat 6px 100%', '''
______________
______________
______________
______________
______________
______________
______________
______________
________rBBB__
________BBBB__
________BBBB__
________BBBB__
______________
______________
______________
______________
'''),
('repeat_x_1px_2px', 'url(pattern.png) repeat-x 1px 2px', '''
______________
______________
______________
______________
__BrBBBrBBBr__
__BBBBBBBBBB__
__BBBBBBBBBB__
__BBBBBBBBBB__
______________
______________
______________
______________
______________
______________
______________
______________
'''),
('repeat_y_local_2px_1px', 'url(pattern.png) repeat-y local 2px 1px', '''
______________
______________
____BBBB______
____rBBB______
____BBBB______
____BBBB______
____BBBB______
____rBBB______
____BBBB______
____BBBB______
____BBBB______
____rBBB______
______________
______________
______________
______________
'''),
('fixed', 'url(pattern.png) no-repeat fixed', '''
# The image is actually here:
#######
______________
______________
__BB__________
__BB__________
______________
______________
______________
______________
______________
______________
______________
______________
______________
______________
______________
______________
'''),
('fixed_right', 'url(pattern.png) no-repeat fixed right 3px', '''
# x x x x
______________
______________
______________
__________rB__ #
__________BB__ #
__________BB__ #
__________BB__ #
______________
______________
______________
______________
______________
______________
______________
______________
______________
'''),
('fixed_center_center', 'url(pattern.png)no-repeat fixed 50%center', '''
______________
______________
______________
______________
______________
______________
_____rBBB_____
_____BBBB_____
_____BBBB_____
_____BBBB_____
______________
______________
______________
______________
______________
______________
'''),
('multi_under', '''url(pattern.png) no-repeat,
url(pattern.png) no-repeat 2px 1px''', '''
______________
______________
__rBBB________
__BBBBBB______
__BBBBBB______
__BBBBBB______
____BBBB______
______________
______________
______________
______________
______________
______________
______________
______________
______________
'''),
('multi_over', '''url(pattern.png) no-repeat 2px 1px,
url(pattern.png) no-repeat''', '''
______________
______________
__rBBB________
__BBrBBB______
__BBBBBB______
__BBBBBB______
____BBBB______
______________
______________
______________
______________
______________
______________
______________
______________
______________
'''),
))
def test_background_image(name, css, pixels):
# pattern.png looks like this:
# rBBB
# BBBB
# BBBB
# BBBB
assert_pixels('background_' + name, 14, 16, pixels, '''
<style>
@page { size: 14px 16px }
html { background: #fff }
body { margin: 2px; height: 10px;
background: %s }
p { background: none }
</style>
<body>
<p>&nbsp;''' % css)
@assert_no_logs
def test_background_image_zero_size_background():
# Regression test for https://github.com/Kozea/WeasyPrint/issues/217
assert_pixels('zero_size_background', 10, 10, '''
__________
__________
__________
__________
__________
__________
__________
__________
__________
__________
''', '''
<style>
@page { size: 10px }
html { background: #fff }
body { background: url(pattern.png);
background-size: cover;
display: inline-block }
</style>
<body>''')
@assert_no_logs
def test_background_origin():
"""Test the background-origin property."""
def test_value(value, pixels, css=None):
assert_pixels('background_origin_' + value, 12, 12, pixels, '''
<style>
@page { size: 12px }
html { background: #fff }
body { margin: 1px; padding: 1px; height: 6px;
border: 1px solid transparent;
background: url(pattern.png) bottom right no-repeat;
background-origin: %s }
</style>
<body>''' % (css or value,))
test_value('border-box', '''
____________
____________
____________
____________
____________
____________
____________
_______rBBB_
_______BBBB_
_______BBBB_
_______BBBB_
____________
''')
test_value('padding-box', '''
____________
____________
____________
____________
____________
____________
______rBBB__
______BBBB__
______BBBB__
______BBBB__
____________
____________
''')
test_value('content-box', '''
____________
____________
____________
____________
____________
_____rBBB___
_____BBBB___
_____BBBB___
_____BBBB___
____________
____________
____________
''')
test_value('border-box_clip', '''
____________
____________
____________
____________
____________
____________
____________
_______rB___
_______BB___
____________
____________
____________
''', css='border-box; background-clip: content-box')
@assert_no_logs
def test_background_repeat_space_1():
assert_pixels('background_repeat_space', 12, 16, '''
____________
_rBBB__rBBB_
_BBBB__BBBB_
_BBBB__BBBB_
_BBBB__BBBB_
____________
_rBBB__rBBB_
_BBBB__BBBB_
_BBBB__BBBB_
_BBBB__BBBB_
____________
_rBBB__rBBB_
_BBBB__BBBB_
_BBBB__BBBB_
_BBBB__BBBB_
____________
''', '''
<style>
@page { size: 12px 16px }
html { background: #fff }
body { margin: 1px; height: 14px;
background: url(pattern.png) space; }
</style>
<body>''')
@assert_no_logs
def test_background_repeat_space_2():
assert_pixels('background_repeat_space', 12, 14, '''
____________
_rBBB__rBBB_
_BBBB__BBBB_
_BBBB__BBBB_
_BBBB__BBBB_
_rBBB__rBBB_
_BBBB__BBBB_
_BBBB__BBBB_
_BBBB__BBBB_
_rBBB__rBBB_
_BBBB__BBBB_
_BBBB__BBBB_
_BBBB__BBBB_
____________
''', '''
<style>
@page { size: 12px 14px }
html { background: #fff }
body { margin: 1px; height: 12px;
background: url(pattern.png) space; }
</style>
<body>''')
@assert_no_logs
def test_background_repeat_space_3():
assert_pixels('background_repeat_space', 12, 13, '''
____________
_rBBBrBBBrB_
_BBBBBBBBBB_
_BBBBBBBBBB_
_BBBBBBBBBB_
____________
____________
____________
_rBBBrBBBrB_
_BBBBBBBBBB_
_BBBBBBBBBB_
_BBBBBBBBBB_
____________
''', '''
<style>
@page { size: 12px 13px }
html { background: #fff }
body { margin: 1px; height: 11px;
background: url(pattern.png) repeat space; }
</style>
<body>''')
@assert_no_logs
def test_background_repeat_round_1():
assert_pixels('background_repeat_round', 10, 14, '''
__________
_rrBBBBBB_
_rrBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_rrBBBBBB_
_rrBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
__________
''', '''
<style>
@page { size: 10px 14px }
html { background: #fff }
body { margin: 1px; height: 12px;
image-rendering: pixelated;
background: url(pattern.png) top/6px round repeat; }
</style>
<body>''')
@assert_no_logs
def test_background_repeat_round_2():
assert_pixels('background_repeat_round', 10, 18, '''
__________
_rrBBBBBB_
_rrBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_rrBBBBBB_
_rrBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
__________
''', '''
<style>
@page { size: 10px 18px }
html { background: #fff }
body { margin: 1px; height: 16px;
image-rendering: pixelated;
background: url(pattern.png) center/auto 8px repeat round; }
</style>
<body>''')
@assert_no_logs
def test_background_repeat_round_3():
assert_pixels('background_repeat_round', 10, 14, '''
__________
_rrBBBBBB_
_rrBBBBBB_
_rrBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
__________
''', '''
<style>
@page { size: 10px 14px }
html { background: #fff }
body { margin: 1px; height: 12px;
image-rendering: pixelated;
background: url(pattern.png) center/6px 9px round; }
</style>
<body>''')
@assert_no_logs
def test_background_repeat_round_4():
assert_pixels('background_repeat_round', 10, 14, '''
__________
_rBBBrBBB_
_rBBBrBBB_
_rBBBrBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
__________
''', '''
<style>
@page { size: 10px 14px }
html { background: #fff }
body { margin: 1px; height: 12px;
image-rendering: pixelated;
background: url(pattern.png) center/5px 9px round; }
</style>
<body>''')
@assert_no_logs
@pytest.mark.parametrize('value, pixels', (
('#00f border-box', '''
________
_BBBBBB_
_BBBBBB_
_BBBBBB_
_BBBBBB_
_BBBBBB_
_BBBBBB_
________
'''),
('#00f padding-box', '''
________
________
__BBBB__
__BBBB__
__BBBB__
__BBBB__
________
________
'''),
('#00f content-box', '''
________
________
________
___BB___
___BB___
________
________
________
'''),
('url(pattern.png) padding-box, #0f0', '''
________
_GGGGGG_
_GrBBBG_
_GBBBBG_
_GBBBBG_
_GBBBBG_
_GGGGGG_
________
'''),
))
def test_background_clip(value, pixels):
assert_pixels('background_clip_' + value, 8, 8, pixels, '''
<style>
@page { size: 8px }
html { background: #fff }
body { margin: 1px; padding: 1px; height: 2px;
border: 1px solid transparent;
background: %s }
</style>
<body>''' % value)
@assert_no_logs
@pytest.mark.parametrize(
'name, expected_width, expected_height, expected_pixels, html', (
('background_size', 12, 12, '''
____________
____________
____________
___rrBBBBBB_
___rrBBBBBB_
___BBBBBBBB_
___BBBBBBBB_
___BBBBBBBB_
___BBBBBBBB_
___BBBBBBBB_
___BBBBBBBB_
____________
''', '''
<style>
@page { size: 12px }
html { background: #fff }
body { margin: 1px; height: 10px;
/* Use nearest neighbor algorithm for image resizing: */
image-rendering: pixelated;
background: url(pattern.png) no-repeat
bottom right / 80% 8px; }
</style>
<body>'''),
('background_size_auto', 12, 12, '''
____________
____________
____________
____________
____________
____________
____________
_______rBBB_
_______BBBB_
_______BBBB_
_______BBBB_
____________
''', '''
<style>
@page { size: 12px }
html { background: #fff }
body { margin: 1px; height: 10px;
/* Use nearest neighbor algorithm for image resizing: */
image-rendering: pixelated;
background: url(pattern.png) bottom right/auto no-repeat }
</style>
<body>'''),
('background_size_contain', 14, 10, '''
______________
_rrBBBBBB_____
_rrBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
______________
''', '''
<style>
@page { size: 14px 10px }
html { background: #fff }
body { margin: 1px; height: 8px;
/* Use nearest neighbor algorithm for image resizing: */
image-rendering: pixelated;
background: url(pattern.png) no-repeat;
background-size: contain }
</style>
<body>'''),
('background_size_mixed', 14, 10, '''
______________
_rrBBBBBB_____
_rrBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
______________
''', '''
<style>
@page { size: 14px 10px }
html { background: #fff }
body { margin: 1px; height: 8px;
/* Use nearest neighbor algorithm for image resizing: */
image-rendering: pixelated;
background: url(pattern.png) no-repeat left / auto 8px;
clip: auto; /* no-op to cover more validation */ }
</style>
<body>'''),
('background_size_double', 14, 10, '''
______________
_rrBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
_BBBBBBBB_____
______________
______________
______________
______________
______________
''', '''
<style>
@page { size: 14px 10px }
html { background: #fff }
body { margin: 1px; height: 8px;
/* Use nearest neighbor algorithm for image resizing: */
image-rendering: pixelated;
background: url(pattern.png) no-repeat 0 0 / 8px 4px;
clip: auto; /* no-op to cover more validation */ }
</style>
<body>'''),
('background_size_cover', 14, 10, '''
______________
_rrrBBBBBBBBB_
_rrrBBBBBBBBB_
_rrrBBBBBBBBB_
_BBBBBBBBBBBB_
_BBBBBBBBBBBB_
_BBBBBBBBBBBB_
_BBBBBBBBBBBB_
_BBBBBBBBBBBB_
______________
''', '''
<style>
@page { size: 14px 10px }
html { background: #fff }
body { margin: 1px; height: 8px;
/* Use nearest neighbor algorithm for image resizing: */
image-rendering: pixelated;
background: url(pattern.png) no-repeat right 0/cover }
</style>
<body>'''),
)
)
def test_background_size(name, expected_width, expected_height,
expected_pixels, html):
assert_pixels(
name, expected_width, expected_height, expected_pixels, html)

View File

@ -0,0 +1,80 @@
"""
weasyprint.tests.test_draw.test_before_after
--------------------------------------------
Test how before and after pseudo elements are drawn.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..testing_utils import assert_no_logs
from . import assert_same_rendering
@assert_no_logs
def test_before_after_1():
assert_same_rendering(300, 30, [
('pseudo_before', '''
<style>
@page { size: 300px 30px }
body { margin: 0; background: #fff }
a[href]:before { content: '[' attr(href) '] ' }
</style>
<p><a href="some url">some content</a></p>
'''),
('pseudo_before_reference', '''
<style>
@page { size: 300px 30px }
body { margin: 0; background: #fff }
</style>
<p><a href="another url"><span>[some url] </span>some content</p>
''')
], tolerance=10)
@assert_no_logs
def test_before_after_2():
assert_same_rendering(500, 30, [
('pseudo_quotes', '''
<style>
@page { size: 500px 30px }
body { margin: 0; background: #fff; quotes: '«' '»' '“' '”' }
q:before { content: open-quote ' '}
q:after { content: ' ' close-quote }
</style>
<p><q>Lorem ipsum <q>dolor</q> sit amet</q></p>
'''),
('pseudo_quotes_reference', '''
<style>
@page { size: 500px 30px }
body { margin: 0; background: #fff }
q:before, q:after { content: none }
</style>
<p><span><span>« </span>Lorem ipsum
<span><span> </span>dolor<span> </span></span>
sit amet<span> »</span></span></p>
''')
], tolerance=10)
@assert_no_logs
def test_before_after_3():
assert_same_rendering(100, 30, [
('pseudo_url', '''
<style>
@page { size: 100px 30px }
body { margin: 0; background: #fff; }
p:before { content: 'a' url(pattern.png) 'b'}
</style>
<p>c</p>
'''),
('pseudo_url_reference', '''
<style>
@page { size: 100px 30px }
body { margin: 0; background: #fff }
</style>
<p><span>a<img src="pattern.png" alt="Missing image">b</span>c</p>
''')
], tolerance=10)

View File

@ -0,0 +1,147 @@
"""
weasyprint.tests.test_draw.test_box
-----------------------------------
Test how boxes, borders, outlines are drawn.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import itertools
from ... import HTML
from ..testing_utils import assert_no_logs
from . import PIXELS_BY_CHAR, assert_different_renderings, assert_pixels
@assert_no_logs
def test_borders(margin='10px', prop='border'):
"""Test the rendering of borders"""
source = '''
<style>
@page { size: 140px 110px }
html { background: #fff }
body { width: 100px; height: 70px;
margin: %s; %s: 10px %s blue }
</style>
<body>'''
# Do not test the exact rendering of earch border style but at least
# check that they do not do the same.
assert_different_renderings(140, 110, [
('%s_%s' % (prop, border_style), source % (margin, prop, border_style))
for border_style in [
'none', 'solid', 'dashed', 'dotted', 'double',
'inset', 'outset', 'groove', 'ridge']])
css_margin = margin
width = 140
height = 110
margin = 10
border = 10
solid_pixels = [[PIXELS_BY_CHAR['_']] * width for y in range(height)]
for x in range(margin, width - margin):
for y in itertools.chain(
range(margin, margin + border),
range(height - margin - border, height - margin)):
solid_pixels[y][x] = PIXELS_BY_CHAR['B']
for y in range(margin, height - margin):
for x in itertools.chain(
range(margin, margin + border),
range(width - margin - border, width - margin)):
solid_pixels[y][x] = PIXELS_BY_CHAR['B']
solid_pixels = [b''.join(line) for line in solid_pixels]
assert_pixels(
prop + '_solid', 140, 110, solid_pixels,
source % (css_margin, prop, 'solid'))
@assert_no_logs
def test_outlines():
return test_borders(margin='20px', prop='outline')
@assert_no_logs
def test_small_borders_1():
# Regression test for ZeroDivisionError on dashed or dotted borders
# smaller than a dash/dot.
# https://github.com/Kozea/WeasyPrint/issues/49
html = '''
<style>
@page { size: 50px 50px }
html { background: #fff }
body { margin: 5px; height: 0; border: 10px %s blue }
</style>
<body>'''
for style in ['none', 'solid', 'dashed', 'dotted']:
HTML(string=html % style).write_image_surface()
@assert_no_logs
def test_small_borders_2():
# Regression test for ZeroDivisionError on dashed or dotted borders
# smaller than a dash/dot.
# https://github.com/Kozea/WeasyPrint/issues/146
html = '''
<style>
@page { size: 50px 50px }
html { background: #fff }
body { height: 0; width: 0; border-width: 1px 0; border-style: %s }
</style>
<body>'''
for style in ['none', 'solid', 'dashed', 'dotted']:
HTML(string=html % style).write_image_surface()
@assert_no_logs
def test_margin_boxes():
assert_pixels('margin_boxes', 15, 15, '''
_______________
_GGG______BBBB_
_GGG______BBBB_
_______________
_____RRRR______
_____RRRR______
_____RRRR______
_____RRRR______
_______________
_bbb______gggg_
_bbb______gggg_
_bbb______gggg_
_bbb______gggg_
_bbb______gggg_
_______________
''', '''
<style>
html { height: 100% }
body { background: #f00; height: 100% }
@page {
size: 15px;
margin: 4px 6px 7px 5px;
background: white;
@top-left-corner {
margin: 1px;
content: " ";
background: #0f0;
}
@top-right-corner {
margin: 1px;
content: " ";
background: #00f;
}
@bottom-right-corner {
margin: 1px;
content: " ";
background: #008000;
}
@bottom-left-corner {
margin: 1px;
content: " ";
background: #000080;
}
}
</style>
<body>''')

View File

@ -0,0 +1,63 @@
"""
weasyprint.tests.test_draw.test_column
--------------------------------------
Test how columns are drawn.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..testing_utils import assert_no_logs, requires
from . import assert_pixels
@assert_no_logs
@requires('cairo', (1, 14, 0))
def test_column_rule_1():
assert_pixels('solid', 5, 3, '''
a_r_a
a_r_a
_____
''', '''
<style>
img { display: inline-block; width: 1px; height: 1px }
div { columns: 2; column-rule-style: solid;
column-rule-width: 1px; column-gap: 3px;
column-rule-color: red }
body { margin: 0; font-size: 0; background: white}
@page { margin: 0; size: 5px 3px }
</style>
<div>
<img src=blue.jpg>
<img src=blue.jpg>
<img src=blue.jpg>
<img src=blue.jpg>
</div>''')
@assert_no_logs
@requires('cairo', (1, 14, 0))
def test_column_rule_2():
assert_pixels('dotted', 5, 3, '''
a_r_a
a___a
a_r_a
''', '''
<style>
img { display: inline-block; width: 1px; height: 1px }
div { columns: 2; column-rule-style: dotted;
column-rule-width: 1px; column-gap: 3px;
column-rule-color: red }
body { margin: 0; font-size: 0; background: white}
@page { margin: 0; size: 5px 3px }
</style>
<div>
<img src=blue.jpg>
<img src=blue.jpg>
<img src=blue.jpg>
<img src=blue.jpg>
<img src=blue.jpg>
<img src=blue.jpg>
</div>''')

View File

@ -0,0 +1,67 @@
"""
weasyprint.tests.test_draw.test_current_color
---------------------------------------------
Test the currentColor value.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..testing_utils import assert_no_logs
from . import assert_pixels
GREEN_2x2 = '''
GG
GG
'''
@assert_no_logs
def test_current_color_1():
assert_pixels('background_current_color', 2, 2, GREEN_2x2, '''
<style>
@page { size: 2px }
html, body { height: 100%; margin: 0 }
html { color: red; background: currentColor }
body { color: lime; background: inherit }
</style>
<body>''')
@assert_no_logs
def test_current_color_2():
assert_pixels('border_current_color', 2, 2, GREEN_2x2, '''
<style>
@page { size: 2px }
html { color: red; border-color: currentColor }
body { color: lime; border: 1px solid; border-color: inherit;
margin: 0 }
</style>
<body>''')
@assert_no_logs
def test_current_color_3():
assert_pixels('outline_current_color', 2, 2, GREEN_2x2, '''
<style>
@page { size: 2px }
html { color: red; outline-color: currentColor }
body { color: lime; outline: 1px solid; outline-color: inherit;
margin: 1px }
</style>
<body>''')
@assert_no_logs
def test_current_color_4():
assert_pixels('border_collapse_current_color', 2, 2, GREEN_2x2, '''
<style>
@page { size: 2px }
html { color: red; border-color: currentColor; }
body { margin: 0 }
table { border-collapse: collapse;
color: lime; border: 1px solid; border-color: inherit }
</style>
<table><td>''')

View File

@ -0,0 +1,189 @@
"""
weasyprint.tests.test_draw.test_gradient
----------------------------------------
Test how gradients are drawn.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..testing_utils import assert_no_logs, requires
from . import PIXELS_BY_CHAR, assert_pixels, html_to_pixels
@assert_no_logs
@requires('cairo', (1, 14, 0))
def test_linear_gradients_1():
assert_pixels('linear_gradient', 5, 9, '''
_____
_____
_____
BBBBB
BBBBB
RRRRR
RRRRR
RRRRR
RRRRR
''', '''<style>@page { size: 5px 9px; background: linear-gradient(
white, white 3px, blue 0, blue 5px, red 0, red
)''')
@assert_no_logs
@requires('cairo', (1, 14, 0))
def test_linear_gradients_2():
assert_pixels('linear_gradient', 5, 9, '''
_____
_____
_____
BBBBB
BBBBB
RRRRR
RRRRR
RRRRR
RRRRR
''', '''<style>@page { size: 5px 9px; background: linear-gradient(
white 3px, blue 0, blue 5px, red 0
)''')
@assert_no_logs
@requires('cairo', (1, 14, 0))
def test_linear_gradients_3():
assert_pixels('linear_gradient', 9, 5, '''
___BBrrrr
___BBrrrr
___BBrrrr
___BBrrrr
___BBrrrr
''', '''<style>@page { size: 9px 5px; background: linear-gradient(
to right, white 3px, blue 0, blue 5px, red 0
)''')
@assert_no_logs
@requires('cairo', (1, 14, 0))
def test_linear_gradients_4():
assert_pixels('linear_gradient', 10, 5, '''
BBBBBBrrrr
BBBBBBrrrr
BBBBBBrrrr
BBBBBBrrrr
BBBBBBrrrr
''', '''<style>@page { size: 10px 5px; background: linear-gradient(
to right, blue 5px, blue 6px, red 6px, red 9px
)''')
@assert_no_logs
@requires('cairo', (1, 14, 0))
def test_linear_gradients_5():
assert_pixels('linear_gradient', 10, 5, '''
rBrrrBrrrB
rBrrrBrrrB
rBrrrBrrrB
rBrrrBrrrB
rBrrrBrrrB
''', '''<style>@page { size: 10px 5px; background: repeating-linear-gradient(
to right, blue 50%, blue 60%, red 60%, red 90%
)''')
@assert_no_logs
@requires('cairo', (1, 14, 0))
def test_linear_gradients_6():
assert_pixels('linear_gradient', 9, 5, '''
BBBrrrrrr
BBBrrrrrr
BBBrrrrrr
BBBrrrrrr
BBBrrrrrr
''', '''<style>@page { size: 9px 5px; background: linear-gradient(
to right, blue 3px, blue 3px, red 3px, red 3px
)''')
@assert_no_logs
@requires('cairo', (1, 14, 0))
def test_linear_gradients_7():
assert_pixels('linear_gradient', 9, 5, '''
hhhhhhhhh
hhhhhhhhh
hhhhhhhhh
hhhhhhhhh
hhhhhhhhh
''', '''<style>@page { size: 9px 5px; background: repeating-linear-gradient(
to right, black 3px, black 3px, #800080 3px, #800080 3px
)''')
@assert_no_logs
@requires('cairo', (1, 14, 0))
def test_linear_gradients_8():
assert_pixels('linear_gradient', 9, 5, '''
VVVVVVVVV
VVVVVVVVV
VVVVVVVVV
VVVVVVVVV
VVVVVVVVV
''', '''
<style>
@page { size: 9px 5px; background: repeating-linear-gradient(
to right, blue 50%, blue 60%, red 60%, red 90%);
background-size: 1px 1px''')
@assert_no_logs
def test_radial_gradients_1():
assert_pixels('radial_gradient', 6, 6, '''
BBBBBB
BBBBBB
BBBBBB
BBBBBB
BBBBBB
BBBBBB
''', '''<style>@page { size: 6px; background:
radial-gradient(red -30%, blue -10%)''')
@assert_no_logs
def test_radial_gradients_2():
assert_pixels('radial_gradient', 6, 6, '''
RRRRRR
RRRRRR
RRRRRR
RRRRRR
RRRRRR
RRRRRR
''', '''<style>@page { size: 6px; background:
radial-gradient(red 110%, blue 130%)''')
@assert_no_logs
def test_radial_gradients_3():
for thin, gradient in ((False, 'red 20%, blue 80%'),
(True, 'red 50%, blue 50%')):
B, R = PIXELS_BY_CHAR['B'], PIXELS_BY_CHAR['R']
_, pixels = html_to_pixels(
'radial_gradient_' + gradient, 10, 16,
'<style>@page { size: 10px 16px; background: radial-gradient(%s)'
% gradient)
def pixel(x, y):
i = (x + 10 * y) * 4
return pixels[i:i + 4]
assert pixel(0, 0) == B
assert pixel(9, 0) == B
assert pixel(0, 15) == B
assert pixel(9, 15) == B
assert pixel(4, 7) == R
assert pixel(4, 8) == R
assert pixel(5, 7) == R
assert pixel(5, 8) == R
assert (pixel(3, 5) not in (B, R)) ^ thin
assert (pixel(3, 9) not in (B, R)) ^ thin
assert (pixel(7, 5) not in (B, R)) ^ thin
assert (pixel(7, 9) not in (B, R)) ^ thin

View File

@ -0,0 +1,350 @@
"""
weasyprint.tests.test_draw.test_image
-------------------------------------
Test how images are drawn.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import pytest
from ..testing_utils import assert_no_logs, capture_logs
from . import assert_pixels, assert_same_rendering
centered_image = '''
________
________
__rBBB__
__BBBB__
__BBBB__
__BBBB__
________
________
'''
blue_image = '''
________
________
__aaaa__
__aaaa__
__aaaa__
__aaaa__
________
________
'''
no_image = '''
________
________
________
________
________
________
________
________
'''
page_break = '''
________
________
__rBBB__
__BBBB__
__BBBB__
__BBBB__
________
________
________
________
________
________
________
________
________
________
________
________
__rBBB__
__BBBB__
__BBBB__
__BBBB__
________
________
'''
table = '''
________
________
__rBBB__
__BBBB__
__BBBB__
__BBBB__
________
________
__rBBB__
__BBBB__
__BBBB__
__BBBB__
________
________
________
________
'''
@assert_no_logs
@pytest.mark.parametrize('filename, image', (
('pattern.svg', centered_image),
('pattern.png', centered_image),
('pattern.palette.png', centered_image),
('pattern.gif', centered_image),
('blue.jpg', blue_image)
))
def test_images(filename, image):
assert_pixels('inline_image_' + filename, 8, 8, image, '''
<style>
@page { size: 8px }
body { margin: 2px 0 0 2px; background: #fff; font-size: 0 }
</style>
<div><img src="%s"></div>''' % filename)
@assert_no_logs
def test_images_block():
assert_pixels('block_image', 8, 8, centered_image, '''
<style>
@page { size: 8px }
body { margin: 0; background: #fff; font-size: 0 }
img { display: block; margin: 2px auto 0 }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_images_not_found():
with capture_logs() as logs:
assert_pixels('image_not_found', 8, 8, no_image, '''
<style>
@page { size: 8px }
body { margin: 0; background: #fff; font-size: 0 }
img { display: block; margin: 2px auto 0 }
</style>
<div><img src="inexistent1.png" alt=""></div>''')
assert len(logs) == 1
assert 'ERROR: Failed to load image' in logs[0]
assert 'inexistent1.png' in logs[0]
@assert_no_logs
def test_images_no_src():
assert_pixels('image_no_src', 8, 8, no_image, '''
<style>
@page { size: 8px }
body { margin: 0; background: #fff; font-size: 0 }
img { display: block; margin: 2px auto 0 }
</style>
<div><img alt=""></div>''')
@assert_no_logs
def test_images_alt():
with capture_logs() as logs:
assert_same_rendering(200, 30, [
(name, '''
<style>
@page { size: 200px 30px }
body { margin: 0; background: #fff; font-size: 0 }
</style>
<div>%s</div>''' % html)
for name, html in [
('image_alt_text_reference', 'Hello, world!'),
('image_alt_text_not_found',
'<img src="inexistent2.png" alt="Hello, world!">'),
('image_alt_text_no_src',
'<img alt="Hello, world!">'),
('image_svg_no_intrinsic_size',
'''<img src="data:image/svg+xml,<svg></svg>"
alt="Hello, world!">'''),
]
])
assert len(logs) == 1
assert 'ERROR: Failed to load image' in logs[0]
assert 'inexistent2.png' in logs[0]
@assert_no_logs
def test_images_no_width():
assert_pixels('image_0x1', 8, 8, no_image, '''
<style>
@page { size: 8px }
body { margin: 2px; background: #fff; font-size: 0 }
</style>
<div><img src="pattern.png" alt="not shown"
style="width: 0; height: 1px"></div>''')
@assert_no_logs
def test_images_no_height():
assert_pixels('image_1x0', 8, 8, no_image, '''
<style>
@page { size: 8px }
body { margin: 2px; background: #fff; font-size: 0 }
</style>
<div><img src="pattern.png" alt="not shown"
style="width: 1px; height: 0"></div>''')
@assert_no_logs
def test_images_no_width_height():
assert_pixels('image_0x0', 8, 8, no_image, '''
<style>
@page { size: 8px }
body { margin: 2px; background: #fff; font-size: 0 }
</style>
<div><img src="pattern.png" alt="not shown"
style="width: 0; height: 0"></div>''')
@assert_no_logs
def test_images_page_break():
assert_pixels('image_page_break', 8, 3 * 8, page_break, '''
<style>
@page { size: 8px; margin: 2px; background: #fff }
body { font-size: 0 }
</style>
<div><img src="pattern.png"></div>
<div style="page-break-before: right"><img src="pattern.png"></div>''')
@assert_no_logs
def test_image_repeat_inline():
# Test regression: https://github.com/Kozea/WeasyPrint/issues/808
assert_pixels('image_page_repeat_inline', 8, 2 * 8, table, '''
<style>
@page { size: 8px; margin: 0; background: #fff }
table { border-collapse: collapse; margin: 2px }
th, td { border: none; padding: 0 }
th { height: 4px; line-height: 4px }
td { height: 2px }
img { vertical-align: top }
</style>
<table>
<thead>
<tr><th><img src="pattern.png"></th></tr>
</thead>
<tbody>
<tr><td></td></tr>
<tr><td></td></tr>
</tbody>
</table>''')
@assert_no_logs
def test_image_repeat_block():
# Test regression: https://github.com/Kozea/WeasyPrint/issues/808
assert_pixels('image_page_repeat_block', 8, 2 * 8, table, '''
<style>
@page { size: 8px; margin: 0; background: #fff }
table { border-collapse: collapse; margin: 2px }
th, td { border: none; padding: 0 }
th { height: 4px }
td { height: 2px }
img { display: block }
</style>
<table>
<thead>
<tr><th><img src="pattern.png"></th></tr>
</thead>
<tbody>
<tr><td></td></tr>
<tr><td></td></tr>
</tbody>
</table>''')
@assert_no_logs
def test_images_padding():
# Regression test: padding used to be ignored on images
assert_pixels('image_with_padding', 8, 8, centered_image, '''
<style>
@page { size: 8px; background: #fff }
body { font-size: 0 }
</style>
<div style="line-height: 1px">
<img src=pattern.png style="padding: 2px 0 0 2px">
</div>''')
@assert_no_logs
def test_images_in_inline_block():
# Regression test: this used to cause an exception
assert_pixels('image_in_inline_block', 8, 8, centered_image, '''
<style>
@page { size: 8px }
body { margin: 2px 0 0 2px; background: #fff; font-size: 0 }
</style>
<div style="display: inline-block">
<p><img src=pattern.png></p>
</div>''')
@assert_no_logs
def test_images_shared_pattern():
# The same image is used in a repeating background,
# then in a non-repating <img>.
# If Pattern objects are shared carelessly, the image will be repeated.
assert_pixels('image_shared_pattern', 12, 12, '''
____________
____________
__aaaaaaaa__
__aaaaaaaa__
____________
__aaaa______
__aaaa______
__aaaa______
__aaaa______
____________
____________
____________
''', '''
<style>
@page { size: 12px }
body { margin: 2px; background: #fff; font-size: 0 }
</style>
<div style="background: url(blue.jpg);
height: 2px; margin-bottom: 1px"></div>
<img src=blue.jpg>
''')
def test_image_resolution():
assert_same_rendering(20, 20, [
('image_resolution_ref', '''
<style>@page { size: 20px; margin: 2px; background: #fff }</style>
<div style="font-size: 0">
<img src="pattern.png" style="width: 8px"></div>
'''),
('image_resolution_img', '''
<style>@page { size: 20px; margin: 2px; background: #fff }</style>
<div style="image-resolution: .5dppx; font-size: 0">
<img src="pattern.png"></div>
'''),
('image_resolution_content', '''
<style>@page { size: 20px; margin: 2px; background: #fff }
div::before { content: url(pattern.png) }
</style>
<div style="image-resolution: .5dppx; font-size: 0"></div>
'''),
('image_resolution_background', '''
<style>@page { size: 20px; margin: 2px; background: #fff }
</style>
<div style="height: 16px; image-resolution: .5dppx;
background: url(pattern.png) no-repeat"></div>
'''),
])

View File

@ -0,0 +1,84 @@
"""
weasyprint.tests.test_draw.test_list
------------------------------------
Test how lists are drawn.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import pytest
from ..testing_utils import SANS_FONTS, assert_no_logs
from . import assert_pixels
@assert_no_logs
@pytest.mark.parametrize('position, pixels', (
('outside',
# ++++++++++++++ ++++ <li> horizontal margins: 7px 2px
# ###### <li> width: 12 - 7 - 2 = 3px
# -- list marker margin: 0.5em = 2px
# ******** list marker image is 4px wide
'''
____________
____________
___rBBB_____
___BBBB_____
___BBBB_____
___BBBB_____
____________
____________
____________
____________
'''),
('inside',
# ++++++++++++++ ++++ <li> horizontal margins: 7px 2px
# ###### <li> width: 12 - 7 - 2 = 3px
# ******** list marker image is 4px wide: overflow
'''
____________
____________
_______rBBB_
_______BBBB_
_______BBBB_
_______BBBB_
____________
____________
____________
____________
''')
))
def test_list_style_image(position, pixels):
assert_pixels('list_style_image_' + position, 12, 10, pixels, '''
<style>
@page { size: 12px 10px }
body { margin: 0; background: white; font-family: %s }
ul { margin: 2px 2px 0 7px; list-style: url(pattern.png) %s;
font-size: 2px }
</style>
<ul><li></li></ul>''' % (SANS_FONTS, position))
@assert_no_logs
def test_list_style_image_none():
assert_pixels('list_style_none', 10, 10, '''
__________
__________
__________
__________
__________
__________
__________
__________
__________
__________
''', '''
<style>
@page { size: 10px }
body { margin: 0; background: white; font-family: %s }
ul { margin: 0 0 0 5px; list-style: none; font-size: 2px; }
</style>
<ul><li>''' % (SANS_FONTS,))

View File

@ -0,0 +1,63 @@
"""
weasyprint.tests.test_draw.test_opacity
---------------------------------------
Test opacity.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..testing_utils import assert_no_logs
from . import assert_same_rendering
opacity_source = '''
<style>
@page { size: 60px 60px }
body { margin: 0; background: #fff }
div { background: #000; width: 20px; height: 20px }
</style>
%s'''
@assert_no_logs
def test_opacity_1():
assert_same_rendering(60, 60, [
('opacity_0_reference', opacity_source % '''
<div></div>
'''),
('opacity_0', opacity_source % '''
<div></div>
<div style="opacity: 0"></div>
'''),
])
@assert_no_logs
def test_opacity_2():
assert_same_rendering(60, 60, [
('opacity_color_reference', opacity_source % '''
<div style="background: rgb(102, 102, 102)"></div>
'''),
('opacity_color', opacity_source % '''
<div style="opacity: 0.6"></div>
'''),
])
@assert_no_logs
def test_opacity_3():
assert_same_rendering(60, 60, [
('opacity_multiplied_reference', opacity_source % '''
<div style="background: rgb(102, 102, 102)"></div>
'''),
('opacity_multiplied', opacity_source % '''
<div style="opacity: 0.6"></div>
'''),
('opacity_multiplied_2', opacity_source % '''
<div style="background: none; opacity: 0.666666">
<div style="opacity: 0.9"></div>
</div>
'''), # 0.9 * 0.666666 == 0.6
])

View File

@ -0,0 +1,170 @@
"""
weasyprint.tests.test_draw.test_overflow
----------------------------------------
Test overflow and clipping.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import pytest
from ..testing_utils import assert_no_logs, requires
from . import assert_pixels
@assert_no_logs
def test_overflow_1():
# See test_images
assert_pixels('inline_image_overflow', 8, 8, '''
________
________
__rBBB__
__BBBB__
________
________
________
________
''', '''
<style>
@page { size: 8px }
body { margin: 2px 0 0 2px; background: #fff; font-size:0 }
div { height: 2px; overflow: hidden }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_overflow_2():
# <body> is only 1px high, but its overflow is propageted to the viewport
# ie. the padding edge of the page box.
assert_pixels('inline_image_viewport_overflow', 8, 8, '''
________
________
__rBBB__
__BBBB__
__BBBB__
________
________
________
''', '''
<style>
@page { size: 8px; background: #fff;
margin: 2px;
padding-bottom: 2px;
border-bottom: 1px transparent solid; }
body { height: 1px; overflow: hidden; font-size: 0 }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_overflow_3():
# Assert that the border is not clipped by overflow: hidden
assert_pixels('border_box_overflow', 8, 8, '''
________
________
__BBBB__
__B__B__
__B__B__
__BBBB__
________
________
''', '''
<style>
@page { size: 8px; background: #fff; margin: 2px; }
div { width: 2px; height: 2px; overflow: hidden;
border: 1px solid blue; }
</style>
<div></div>''')
@assert_no_logs
@requires('cairo', (1, 12, 0))
@pytest.mark.parametrize('number, css, pixels', (
(1, '5px, 5px, 9px, auto', '''
______________
______________
______________
______________
______________
______________
______rBBBrBg_
______BBBBBBg_
______BBBBBBg_
______BBBBBBg_
______________
______________
______________
______________
______________
______________
'''),
(2, '5px, 5px, auto, 10px', '''
______________
______________
______________
______________
______________
______________
______rBBBr___
______BBBBB___
______BBBBB___
______BBBBB___
______rBBBr___
______BBBBB___
______ggggg___
______________
______________
______________
'''),
(3, '5px, auto, 9px, 10px', '''
______________
______________
______________
______________
______________
______________
_grBBBrBBBr___
_gBBBBBBBBB___
_gBBBBBBBBB___
_gBBBBBBBBB___
______________
______________
______________
______________
______________
______________
'''),
(4, 'auto, 5px, 9px, 10px', '''
______________
______ggggg___
______rBBBr___
______BBBBB___
______BBBBB___
______BBBBB___
______rBBBr___
______BBBBB___
______BBBBB___
______BBBBB___
______________
______________
______________
______________
______________
______________
'''),
))
def test_clip(number, css, pixels):
assert_pixels('background_repeat_clipped_%s' % number, 14, 16, pixels, '''
<style>
@page { size: 14px 16px; background: #fff }
div { margin: 1px; border: 1px green solid;
background: url(pattern.png);
position: absolute; /* clip only applies on abspos */
top: 0; bottom: 2px; left: 0; right: 0;
clip: rect(%s); }
</style>
<div>''' % css)

View File

@ -0,0 +1,536 @@
"""
weasyprint.tests.test_draw.test_tables
--------------------------------------
Test how tables are drawn.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ...html import HTML_HANDLERS
from ..testing_utils import assert_no_logs, requires
from . import as_pixel, assert_pixels, parse_pixels
PIX_BY_CHAR_OVERRIDES = {
# rgba(255, 0, 0, 0.5) above #fff
'r': as_pixel(b'\xff\x7f\x7f\xff'),
# rgba(0, 255, 0, 0.5) above #fff
'g': as_pixel(b'\x7f\xff\x7f\xff'),
# r above B above #fff.
'b': as_pixel(b'\x80\x00\x7f\xff'),
}
def to_pix(pixels_str):
return parse_pixels(pixels_str, PIX_BY_CHAR_OVERRIDES)
# TODO: refactor colspan/rowspan into CSS:
# td, th { column-span: attr(colspan integer) }
HTML_HANDLERS['x-td'] = HTML_HANDLERS['td']
HTML_HANDLERS['x-th'] = HTML_HANDLERS['th']
tables_source = '''
<style>
@page { size: 28px; background: #fff }
x-table { margin: 1px; padding: 1px; border-spacing: 1px;
border: 1px solid transparent }
x-td { width: 2px; height: 2px; padding: 1px;
border: 1px solid transparent }
%(extra_css)s
</style>
<x-table>
<x-colgroup>
<x-col></x-col>
<x-col></x-col>
</x-colgroup>
<x-col></x-col>
<x-tbody>
<x-tr>
<x-td></x-td>
<x-td rowspan=2></x-td>
<x-td></x-td>
</x-tr>
<x-tr>
<x-td colspan=2></x-td>
<x-td></x-td>
</x-tr>
</x-tbody>
<x-tr>
<x-td></x-td>
<x-td></x-td>
</x-tr>
</x-table>
'''
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_tables_1():
assert_pixels('table_borders', 28, 28, to_pix('''
____________________________
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
_B________________________B_
_B________________________B_
_B__rrrrrr_rrrrrr_rrrrrr__B_
_B__r____r_r____r_r____r__B_
_B__r____r_r____r_r____r__B_
_B__r____r_r____r_r____r__B_
_B__r____r_r____r_r____r__B_
_B__rrrrrr_r____r_rrrrrr__B_
_B_________r____r_________B_
_B__rrrrrrrSrrrrS_rrrrrr__B_
_B__r______r____S_r____r__B_
_B__r______r____S_r____r__B_
_B__r______r____S_r____r__B_
_B__r______r____S_r____r__B_
_B__rrrrrrrSSSSSS_rrrrrr__B_
_B________________________B_
_B__rrrrrr_rrrrrr_________B_
_B__r____r_r____r_________B_
_B__r____r_r____r_________B_
_B__r____r_r____r_________B_
_B__r____r_r____r_________B_
_B__rrrrrr_rrrrrr_________B_
_B________________________B_
_B________________________B_
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
____________________________
'''), tables_source % {'extra_css': '''
x-table { border-color: #00f; table-layout: fixed }
x-td { border-color: rgba(255, 0, 0, 0.5) }
'''})
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_tables_2():
assert_pixels('table_collapsed_borders', 28, 28, to_pix('''
____________________________
_BBBBBBBBBBBBBBBBBB_________
_BBBBBBBBBBBBBBBBBB_________
_BB____r____r____BB_________
_BB____r____r____BB_________
_BB____r____r____BB_________
_BB____r____r____BB_________
_BBrrrrr____rrrrrBB_________
_BB_________r____BB_________
_BB_________r____BB_________
_BB_________r____BB_________
_BB_________r____BB_________
_BBrrrrrrrrrrrrrrBB_________
_BB____r____r____BB_________
_BB____r____r____BB_________
_BB____r____r____BB_________
_BB____r____r____BB_________
_BBBBBBBBBBBBBBBBBB_________
_BBBBBBBBBBBBBBBBBB_________
____________________________
____________________________
____________________________
____________________________
____________________________
____________________________
____________________________
____________________________
____________________________
'''), tables_source % {'extra_css': '''
x-table { border: 2px solid #00f; table-layout: fixed;
border-collapse: collapse }
x-td { border-color: #ff7f7f }
'''})
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_tables_3():
assert_pixels('table_collapsed_borders_paged', 28, 52, to_pix('''
____________________________
_gggggggggggggggggggggggggg_
_g________________________g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BB____r____r____BB_____g_
_g_BB____r____r____BB_____g_
_g_BB____r____r____BB_____g_
_g_BB____r____r____BB_____g_
_g_BBrrrrr____rrrrrBB_____g_
_g_BB_________r____BB_____g_
_g_BB_________r____BB_____g_
_g_BB_________r____BB_____g_
_g_BB_________r____BB_____g_
_g_BBrrrrrrrrrrrrrrBB_____g_
_g________________________g_
_g________________________g_
_g________________________g_
_gggggggggggggggggggggggggg_
____________________________
____________________________
_gggggggggggggggggggggggggg_
_g_BBrrrrrrrrrrrrrrBB_____g_
_g_BB____r____r____BB_____g_
_g_BB____r____r____BB_____g_
_g_BB____r____r____BB_____g_
_g_BB____r____r____BB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g_BBBBBBBBBBBBBBBBBB_____g_
_g________________________g_
_g________________________g_
_g________________________g_
_g________________________g_
_g________________________g_
_g________________________g_
_g________________________g_
_g________________________g_
_g________________________g_
_gggggggggggggggggggggggggg_
____________________________
'''), tables_source % {'extra_css': '''
x-table { border: solid #00f; border-width: 8px 2px;
table-layout: fixed; border-collapse: collapse }
x-td { border-color: #ff7f7f }
@page { size: 28px 26px; margin: 1px;
border: 1px solid rgba(0, 255, 0, 0.5); }
'''})
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_tables_4():
assert_pixels('table_td_backgrounds', 28, 28, to_pix('''
____________________________
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
_B________________________B_
_B________________________B_
_B__rrrrrr_rrrrrr_rrrrrr__B_
_B__rrrrrr_rrrrrr_rrrrrr__B_
_B__rrrrrr_rrrrrr_rrrrrr__B_
_B__rrrrrr_rrrrrr_rrrrrr__B_
_B__rrrrrr_rrrrrr_rrrrrr__B_
_B__rrrrrr_rrrrrr_rrrrrr__B_
_B_________rrrrrr_________B_
_B__rrrrrrrSSSSSS_rrrrrr__B_
_B__rrrrrrrSSSSSS_rrrrrr__B_
_B__rrrrrrrSSSSSS_rrrrrr__B_
_B__rrrrrrrSSSSSS_rrrrrr__B_
_B__rrrrrrrSSSSSS_rrrrrr__B_
_B__rrrrrrrSSSSSS_rrrrrr__B_
_B________________________B_
_B__rrrrrr_rrrrrr_________B_
_B__rrrrrr_rrrrrr_________B_
_B__rrrrrr_rrrrrr_________B_
_B__rrrrrr_rrrrrr_________B_
_B__rrrrrr_rrrrrr_________B_
_B__rrrrrr_rrrrrr_________B_
_B________________________B_
_B________________________B_
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
____________________________
'''), tables_source % {'extra_css': '''
x-table { border-color: #00f; table-layout: fixed }
x-td { background: rgba(255, 0, 0, 0.5) }
'''})
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_tables_5():
assert_pixels('table_row_backgrounds', 28, 28, to_pix('''
____________________________
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
_B________________________B_
_B________________________B_
_B__bbbbbb_bbbbbb_bbbbbb__B_
_B__bbbbbb_bbbbbb_bbbbbb__B_
_B__bbbbbb_bbbbbb_bbbbbb__B_
_B__bbbbbb_bbbbbb_bbbbbb__B_
_B__bbbbbb_bbbbbb_bbbbbb__B_
_B__bbbbbb_bbbbbb_bbbbbb__B_
_B_________bbbbbb_________B_
_B__bbbbbbbpppppp_bbbbbb__B_
_B__bbbbbbbpppppp_bbbbbb__B_
_B__bbbbbbbpppppp_bbbbbb__B_
_B__bbbbbbbpppppp_bbbbbb__B_
_B__bbbbbbbpppppp_bbbbbb__B_
_B__bbbbbbbpppppp_bbbbbb__B_
_B________________________B_
_B__rrrrrr_rrrrrr_________B_
_B__rrrrrr_rrrrrr_________B_
_B__rrrrrr_rrrrrr_________B_
_B__rrrrrr_rrrrrr_________B_
_B__rrrrrr_rrrrrr_________B_
_B__rrrrrr_rrrrrr_________B_
_B________________________B_
_B________________________B_
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
____________________________
'''), tables_source % {'extra_css': '''
x-table { border-color: #00f; table-layout: fixed }
x-tbody { background: rgba(0, 0, 255, 1) }
x-tr { background: rgba(255, 0, 0, 0.5) }
'''})
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_tables_6():
assert_pixels('table_column_backgrounds', 28, 28, to_pix('''
____________________________
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
_B________________________B_
_B________________________B_
_B__bbbbbb_bbbbbb_rrrrrr__B_
_B__bbbbbb_bbbbbb_rrrrrr__B_
_B__bbbbbb_bbbbbb_rrrrrr__B_
_B__bbbbbb_bbbbbb_rrrrrr__B_
_B__bbbbbb_bbbbbb_rrrrrr__B_
_B__bbbbbb_bbbbbb_rrrrrr__B_
_B_________bbbbbb_________B_
_B__bbbbbbbpppppp_rrrrrr__B_
_B__bbbbbbbpppppp_rrrrrr__B_
_B__bbbbbbbpppppp_rrrrrr__B_
_B__bbbbbbbpppppp_rrrrrr__B_
_B__bbbbbbbpppppp_rrrrrr__B_
_B__bbbbbbbpppppp_rrrrrr__B_
_B________________________B_
_B__bbbbbb_bbbbbb_________B_
_B__bbbbbb_bbbbbb_________B_
_B__bbbbbb_bbbbbb_________B_
_B__bbbbbb_bbbbbb_________B_
_B__bbbbbb_bbbbbb_________B_
_B__bbbbbb_bbbbbb_________B_
_B________________________B_
_B________________________B_
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
____________________________
'''), tables_source % {'extra_css': '''
x-table { border-color: #00f; table-layout: fixed }
x-colgroup { background: rgba(0, 0, 255, 1) }
x-col { background: rgba(255, 0, 0, 0.5) }
'''})
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_tables_7():
assert_pixels('table_borders_and_row_backgrounds', 28, 28, to_pix('''
____________________________
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
_B________________________B_
_B________________________B_
_B__bbbbbb_bbbbbb_bbbbbb__B_
_B__bBBBBb_bBBBBb_bBBBBb__B_
_B__bBBBBb_bBBBBb_bBBBBb__B_
_B__bBBBBb_bBBBBb_bBBBBb__B_
_B__bBBBBb_bBBBBb_bBBBBb__B_
_B__bbbbbb_bBBBBb_bbbbbb__B_
_B_________bBBBBb_________B_
_B__rrrrrrrpbbbbp_rrrrrr__B_
_B__r______bBBBBp_r____r__B_
_B__r______bBBBBp_r____r__B_
_B__r______bBBBBp_r____r__B_
_B__r______bBBBBp_r____r__B_
_B__rrrrrrrpppppp_rrrrrr__B_
_B________________________B_
_B__rrrrrr_rrrrrr_________B_
_B__r____r_r____r_________B_
_B__r____r_r____r_________B_
_B__r____r_r____r_________B_
_B__r____r_r____r_________B_
_B__rrrrrr_rrrrrr_________B_
_B________________________B_
_B________________________B_
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
____________________________
'''), tables_source % {'extra_css': '''
x-table { border-color: #00f; table-layout: fixed }
x-tr:first-child { background: blue }
x-td { border-color: rgba(255, 0, 0, 0.5) }
'''})
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_tables_8():
assert_pixels('table_borders_and_column_backgrounds', 28, 28, to_pix('''
____________________________
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
_B________________________B_
_B________________________B_
_B__bbbbbb_rrrrrr_rrrrrr__B_
_B__bBBBBb_r____r_r____r__B_
_B__bBBBBb_r____r_r____r__B_
_B__bBBBBb_r____r_r____r__B_
_B__bBBBBb_r____r_r____r__B_
_B__bbbbbb_r____r_rrrrrr__B_
_B_________r____r_________B_
_B__bbbbbbbpbbbbp_rrrrrr__B_
_B__bBBBBBBbBBBBp_r____r__B_
_B__bBBBBBBbBBBBp_r____r__B_
_B__bBBBBBBbBBBBp_r____r__B_
_B__bBBBBBBbBBBBp_r____r__B_
_B__bbbbbbbpppppp_rrrrrr__B_
_B________________________B_
_B__bbbbbb_rrrrrr_________B_
_B__bBBBBb_r____r_________B_
_B__bBBBBb_r____r_________B_
_B__bBBBBb_r____r_________B_
_B__bBBBBb_r____r_________B_
_B__bbbbbb_rrrrrr_________B_
_B________________________B_
_B________________________B_
_BBBBBBBBBBBBBBBBBBBBBBBBBB_
____________________________
'''), tables_source % {'extra_css': '''
x-table { border-color: #00f; table-layout: fixed }
x-col:first-child { background: blue }
x-td { border-color: rgba(255, 0, 0, 0.5) }
'''})
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_tables_9():
assert_pixels('collapsed_border_thead', 22, 36, '''
______________________
_BBBBBBBBBBBBBBBBBBBB_
_BBBBBBBBBBBBBBBBBBBB_
_BBB____R____R____BBB_
_BBB____R____R____BBB_
_BBB____R____R____BBB_
_BBBBBBBBBBBBBBBBBBBB_
_BBBBBBBBBBBBBBBBBBBB_
__R_____R____R_____R__
__R_____R____R_____R__
__R_____R____R_____R__
__RRRRRRRRRRRRRRRRRR__
__R_____R____R_____R__
__R_____R____R_____R__
__R_____R____R_____R__
__RRRRRRRRRRRRRRRRRR__
______________________
______________________
_BBBBBBBBBBBBBBBBBBBB_
_BBBBBBBBBBBBBBBBBBBB_
_BBB____R____R____BBB_
_BBB____R____R____BBB_
_BBB____R____R____BBB_
_BBBBBBBBBBBBBBBBBBBB_
_BBBBBBBBBBBBBBBBBBBB_
__R_____R____R_____R__
__R_____R____R_____R__
__R_____R____R_____R__
__RRRRRRRRRRRRRRRRRR__
______________________
______________________
______________________
______________________
______________________
______________________
______________________
''', '''
<style>
@page { size: 22px 18px; margin: 1px; background: #fff }
td { border: 1px red solid; width: 4px; height: 3px; }
</style>
<table style="table-layout: fixed; border-collapse: collapse">
<thead style="border: blue solid; border-width: 2px 3px;
"><td></td><td></td><td></td></thead>
<tr><td></td><td></td><td></td></tr>
<tr><td></td><td></td><td></td></tr>
<tr><td></td><td></td><td></td></tr>''')
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_tables_10():
assert_pixels('collapsed_border_tfoot', 22, 36, '''
______________________
__RRRRRRRRRRRRRRRRRR__
__R_____R____R_____R__
__R_____R____R_____R__
__R_____R____R_____R__
__RRRRRRRRRRRRRRRRRR__
__R_____R____R_____R__
__R_____R____R_____R__
_BBBBBBBBBBBBBBBBBBBB_
_BBBBBBBBBBBBBBBBBBBB_
_BBB____R____R____BBB_
_BBB____R____R____BBB_
_BBB____R____R____BBB_
_BBBBBBBBBBBBBBBBBBBB_
_BBBBBBBBBBBBBBBBBBBB_
______________________
______________________
______________________
______________________
__RRRRRRRRRRRRRRRRRR__
__R_____R____R_____R__
__R_____R____R_____R__
__R_____R____R_____R__
_BBBBBBBBBBBBBBBBBBBB_
_BBBBBBBBBBBBBBBBBBBB_
_BBB____R____R____BBB_
_BBB____R____R____BBB_
_BBB____R____R____BBB_
_BBBBBBBBBBBBBBBBBBBB_
_BBBBBBBBBBBBBBBBBBBB_
______________________
______________________
______________________
______________________
______________________
______________________
''', '''
<style>
@page { size: 22px 18px; margin: 1px; background: #fff }
td { border: 1px red solid; width: 4px; height: 3px; }
</style>
<table style="table-layout: fixed; margin-left: 1px;
border-collapse: collapse">
<tr><td></td><td></td><td></td></tr>
<tr><td></td><td></td><td></td></tr>
<tr><td></td><td></td><td></td></tr>
<tfoot style="border: blue solid; border-width: 2px 3px;
"><td></td><td></td><td></td></tfoot>''')
@assert_no_logs
@requires('cairo', (1, 12, 0))
def test_tables_11():
# Segression test for inline table with collapsed border and alignment
# rendering borders incorrectly
# https://github.com/Kozea/WeasyPrint/issues/82
assert_pixels('inline_text_align', 20, 10, '''
____________________
________RRRRRRRRRRR_
________R____R____R_
________R____R____R_
________R____R____R_
________RRRRRRRRRRR_
____________________
____________________
____________________
____________________
''', '''
<style>
@page { size: 20px 10px; margin: 1px; background: #fff }
body { text-align: right; font-size: 0 }
table { display: inline-table; width: 11px }
td { border: 1px red solid; width: 4px; height: 3px }
</style>
<table style="table-layout: fixed; border-collapse: collapse">
<tr><td></td><td></td></tr>''')

View File

@ -0,0 +1,93 @@
"""
weasyprint.tests.test_draw.test_text
------------------------------------
Test how text is drawn.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from . import assert_pixels
def test_text_overflow_clip():
assert_pixels('text_overflow', 9, 7, '''
_________
_RRRRRRR_
_RRRRRRR_
_________
_RR__RRR_
_RR__RRR_
_________
''', '''
<style>
@font-face {src: url(AHEM____.TTF); font-family: ahem}
@page {
size: 9px 7px;
background: white;
}
body {
color: red;
font-family: ahem;
font-size: 2px;
}
div {
line-height: 1;
margin: 1px;
overflow: hidden;
width: 3.5em;
}
</style>
<div>abcde</div>
<div style="white-space: nowrap">a bcde</div>''')
def test_text_overflow_ellipsis():
assert_pixels('text_overflow', 9, 16, '''
_________
_RRRRRR__
_RRRRRR__
_________
_RR__RR__
_RR__RR__
_________
_RRRRRR__
_RRRRRR__
_________
_RRRRRRR_
_RRRRRRR_
_________
_RRRRRRR_
_RRRRRRR_
_________
''', '''
<style>
@font-face {src: url(AHEM____.TTF); font-family: ahem}
@page {
background: white;
size: 9px 16px;
}
body {
color: red;
font-family: ahem;
font-size: 2px;
}
div {
line-height: 1;
margin: 1px;
overflow: hidden;
text-overflow: ellipsis;
width: 3.5em;
}
div div {
margin: 0;
}
</style>
<div>abcde</div>
<div style="white-space: nowrap">a bcde</div>
<div><span>a<span>b</span>cd</span>e</div>
<div><div style="text-overflow: clip">abcde</div></div>
<div><div style="overflow: visible">abcde</div></div>
''')

View File

@ -0,0 +1,297 @@
"""
weasyprint.tests.test_draw.test_transform
-----------------------------------------
Test transformations.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..testing_utils import assert_no_logs
from . import assert_pixels
@assert_no_logs
def test_2d_transform_1():
assert_pixels('image_rotate90', 8, 8, '''
________
________
__BBBr__
__BBBB__
__BBBB__
__BBBB__
________
________
''', '''
<style>
@page { size: 8px; margin: 2px; background: #fff; }
div { transform: rotate(90deg); font-size: 0 }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_2d_transform_2():
assert_pixels('image_translateX_rotate90', 12, 12, '''
____________
____________
_____BBBr___
_____BBBB___
_____BBBB___
_____BBBB___
____________
____________
____________
____________
____________
____________
''', '''
<style>
@page { size: 12px; margin: 2px; background: #fff; }
div { transform: translateX(3px) rotate(90deg);
font-size: 0; width: 4px }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_2d_transform_3():
# A translateX after the rotation is actually a translateY
assert_pixels('image_rotate90_translateX', 12, 12, '''
____________
____________
____________
____________
____________
__BBBr______
__BBBB______
__BBBB______
__BBBB______
____________
____________
____________
''', '''
<style>
@page { size: 12px; margin: 2px; background: #fff; }
div { transform: rotate(90deg) translateX(3px);
font-size: 0; width: 4px }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_2d_transform_4():
assert_pixels('nested_rotate90_translateX', 12, 12, '''
____________
____________
____________
____________
____________
__BBBr______
__BBBB______
__BBBB______
__BBBB______
____________
____________
____________
''', '''
<style>
@page { size: 12px; margin: 2px; background: #fff; }
div { transform: rotate(90deg); font-size: 0; width: 4px }
img { transform: translateX(3px) }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_2d_transform_5():
assert_pixels('image_reflection', 8, 8, '''
________
________
__BBBr__
__BBBB__
__BBBB__
__BBBB__
________
________
''', '''
<style>
@page { size: 8px; margin: 2px; background: #fff; }
div { transform: matrix(-1, 0, 0, 1, 0, 0); font-size: 0 }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_2d_transform_6():
assert_pixels('image_translate', 8, 8, '''
________
________
________
________
___rBBB_
___BBBB_
___BBBB_
___BBBB_
''', '''
<style>
@page { size: 8px; margin: 2px; background: #fff; }
div { transform: translate(1px, 2px); font-size: 0 }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_2d_transform_7():
assert_pixels('image_translate_percentage', 8, 8, '''
________
________
___rBBB_
___BBBB_
___BBBB_
___BBBB_
________
________
''', '''
<style>
@page { size: 8px; margin: 2px; background: #fff; }
div { transform: translate(25%, 0); font-size: 0 }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_2d_transform_8():
assert_pixels('image_translateX', 8, 8, '''
________
________
_____rBB
_____BBB
_____BBB
_____BBB
________
________
''', '''
<style>
@page { size: 8px; margin: 2px; background: #fff; }
div { transform: translateX(0.25em); font-size: 12px }
div div { font-size: 0 }
</style>
<div><div><img src="pattern.png"></div></div>''')
@assert_no_logs
def test_2d_transform_9():
assert_pixels('image_translateY', 8, 8, '''
________
__rBBB__
__BBBB__
__BBBB__
__BBBB__
________
________
________
''', '''
<style>
@page { size: 8px; margin: 2px; background: #fff; }
div { transform: translateY(-1px); font-size: 0 }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_2d_transform_10():
assert_pixels('image_scale', 10, 10, '''
__________
_rrBBBBBB_
_rrBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
__________
''', '''
<style>
@page { size: 10px; margin: 2px; background: #fff; }
div { transform: scale(2, 2);
transform-origin: 1px 1px 1px;
image-rendering: pixelated;
font-size: 0 }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_2d_transform_11():
assert_pixels('image_scale12', 10, 10, '''
__________
__rBBB____
__rBBB____
__BBBB____
__BBBB____
__BBBB____
__BBBB____
__BBBB____
__BBBB____
__________
''', '''
<style>
@page { size: 10px; margin: 2px; background: #fff; }
div { transform: scale(1, 2);
transform-origin: 1px 1px;
image-rendering: pixelated;
font-size: 0 }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_2d_transform_12():
assert_pixels('image_scaleY', 10, 10, '''
__________
__rBBB____
__rBBB____
__BBBB____
__BBBB____
__BBBB____
__BBBB____
__BBBB____
__BBBB____
__________
''', '''
<style>
@page { size: 10px; margin: 2px; background: #fff; }
div { transform: scaleY(2);
transform-origin: 1px 1px 0;
image-rendering: pixelated;
font-size: 0 }
</style>
<div><img src="pattern.png"></div>''')
@assert_no_logs
def test_2d_transform_13():
assert_pixels('image_scaleX', 10, 10, '''
__________
__________
_rrBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
_BBBBBBBB_
__________
__________
__________
__________
''', '''
<style>
@page { size: 10px; margin: 2px; background: #fff; }
div { transform: scaleX(2);
transform-origin: 1px 1px;
image-rendering: pixelated;
font-size: 0 }
</style>
<div><img src="pattern.png"></div>''')

View File

@ -0,0 +1,65 @@
"""
weasyprint.tests.test_draw.test_visibility
------------------------------------------
Test visibility.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from ..testing_utils import assert_no_logs
from . import assert_pixels
visibility_source = '''
<style>
@page { size: 12px 7px }
body { background: #fff; font: 1px/1 serif }
img { margin: 1px 0 0 1px; }
%(extra_css)s
</style>
<div>
<img src="pattern.png">
<span><img src="pattern.png"></span>
</div>'''
@assert_no_logs
def test_visibility_1():
assert_pixels('visibility_reference', 12, 7, '''
____________
_rBBB_rBBB__
_BBBB_BBBB__
_BBBB_BBBB__
_BBBB_BBBB__
____________
____________
''', visibility_source % {'extra_css': ''})
@assert_no_logs
def test_visibility_2():
assert_pixels('visibility_hidden', 12, 7, '''
____________
____________
____________
____________
____________
____________
____________
''', visibility_source % {'extra_css': 'div { visibility: hidden }'})
@assert_no_logs
def test_visibility_3():
assert_pixels('visibility_mixed', 12, 7, '''
____________
______rBBB__
______BBBB__
______BBBB__
______BBBB__
____________
____________
''', visibility_source % {'extra_css': '''div { visibility: hidden }
span { visibility: visible } '''})

View File

@ -0,0 +1,743 @@
"""
weasyprint.tests.layout
-----------------------
Tests for floating boxes layout.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import pytest
from ..formatting_structure import boxes
from .test_boxes import render_pages
from .testing_utils import assert_no_logs
def outer_area(box):
"""Return the (x, y, w, h) rectangle for the outer area of a box."""
return (box.position_x, box.position_y,
box.margin_width(), box.margin_height())
@assert_no_logs
def test_floats_1():
# adjacent-floats-001
page, = render_pages('''
<style>
div { float: left }
img { width: 100px; vertical-align: top }
</style>
<div><img src=pattern.png /></div>
<div><img src=pattern.png /></div>''')
html, = page.children
body, = html.children
div_1, div_2 = body.children
assert outer_area(div_1) == (0, 0, 100, 100)
assert outer_area(div_2) == (100, 0, 100, 100)
@assert_no_logs
def test_floats_2():
# c414-flt-fit-000
page, = render_pages('''
<style>
body { width: 290px }
div { float: left; width: 100px; }
img { width: 60px; vertical-align: top }
</style>
<div><img src=pattern.png /><!-- 1 --></div>
<div><img src=pattern.png /><!-- 2 --></div>
<div><img src=pattern.png /><!-- 4 --></div>
<img src=pattern.png /><!-- 3
--><img src=pattern.png /><!-- 5 -->''')
html, = page.children
body, = html.children
div_1, div_2, div_4, anon_block = body.children
line_3, line_5 = anon_block.children
img_3, = line_3.children
img_5, = line_5.children
assert outer_area(div_1) == (0, 0, 100, 60)
assert outer_area(div_2) == (100, 0, 100, 60)
assert outer_area(img_3) == (200, 0, 60, 60)
assert outer_area(div_4) == (0, 60, 100, 60)
assert outer_area(img_5) == (100, 60, 60, 60)
@assert_no_logs
def test_floats_3():
# c414-flt-fit-002
page, = render_pages('''
<style type="text/css">
body { width: 200px }
p { width: 70px; height: 20px }
.left { float: left }
.right { float: right }
</style>
<p class="left"> A 1 </p>
<p class="left"> B 2 </p>
<p class="left"> A 3 </p>
<p class="right"> B 4 </p>
<p class="left"> A 5 </p>
<p class="right"> B 6 </p>
<p class="right"> B 8 </p>
<p class="left"> A 7 </p>
<p class="left"> A 9 </p>
<p class="left"> B 10 </p>
''')
html, = page.children
body, = html.children
positions = [(paragraph.position_x, paragraph.position_y)
for paragraph in body.children]
assert positions == [
(0, 0), (70, 0), (0, 20), (130, 20), (0, 40), (130, 40),
(130, 60), (0, 60), (0, 80), (70, 80), ]
@assert_no_logs
def test_floats_4():
# c414-flt-wrap-000 ... more or less
page, = render_pages('''
<style>
body { width: 100px }
p { float: left; height: 100px }
img { width: 60px; vertical-align: top }
</style>
<p style="width: 20px"></p>
<p style="width: 100%"></p>
<img src=pattern.png /><img src=pattern.png />
''')
html, = page.children
body, = html.children
p_1, p_2, anon_block = body.children
line_1, line_2 = anon_block.children
assert anon_block.position_y == 0
assert (line_1.position_x, line_1.position_y) == (20, 0)
assert (line_2.position_x, line_2.position_y) == (0, 200)
@assert_no_logs
def test_floats_5():
# c414-flt-wrap-000 with text ... more or less
page, = render_pages('''
<style>
body { width: 100px; font: 60px ahem; }
p { float: left; height: 100px }
img { width: 60px; vertical-align: top }
</style>
<p style="width: 20px"></p>
<p style="width: 100%"></p>
A B
''')
html, = page.children
body, = html.children
p_1, p_2, anon_block = body.children
line_1, line_2 = anon_block.children
assert anon_block.position_y == 0
assert (line_1.position_x, line_1.position_y) == (20, 0)
assert (line_2.position_x, line_2.position_y) == (0, 200)
@assert_no_logs
def test_floats_6():
# floats-placement-vertical-001b
page, = render_pages('''
<style>
body { width: 90px; font-size: 0 }
img { vertical-align: top }
</style>
<body>
<span>
<img src=pattern.png style="width: 50px" />
<img src=pattern.png style="width: 50px" />
<img src=pattern.png style="float: left; width: 30px" />
</span>
''')
html, = page.children
body, = html.children
line_1, line_2 = body.children
span_1, = line_1.children
span_2, = line_2.children
img_1, = span_1.children
img_2, img_3 = span_2.children
assert outer_area(img_1) == (0, 0, 50, 50)
assert outer_area(img_2) == (30, 50, 50, 50)
assert outer_area(img_3) == (0, 50, 30, 30)
@assert_no_logs
def test_floats_7():
# Variant of the above: no <span>
page, = render_pages('''
<style>
body { width: 90px; font-size: 0 }
img { vertical-align: top }
</style>
<body>
<img src=pattern.png style="width: 50px" />
<img src=pattern.png style="width: 50px" />
<img src=pattern.png style="float: left; width: 30px" />
''')
html, = page.children
body, = html.children
line_1, line_2 = body.children
img_1, = line_1.children
img_2, img_3 = line_2.children
assert outer_area(img_1) == (0, 0, 50, 50)
assert outer_area(img_2) == (30, 50, 50, 50)
assert outer_area(img_3) == (0, 50, 30, 30)
@assert_no_logs
def test_floats_8():
# Floats do no affect other pages
page_1, page_2 = render_pages('''
<style>
body { width: 90px; font-size: 0 }
img { vertical-align: top }
</style>
<body>
<img src=pattern.png style="float: left; width: 30px" />
<img src=pattern.png style="width: 50px" />
<div style="page-break-before: always"></div>
<img src=pattern.png style="width: 50px" />
''')
html, = page_1.children
body, = html.children
float_img, anon_block, = body.children
line, = anon_block.children
img_1, = line.children
assert outer_area(float_img) == (0, 0, 30, 30)
assert outer_area(img_1) == (30, 0, 50, 50)
html, = page_2.children
body, = html.children
div, anon_block = body.children
line, = anon_block.children
img_2, = line.children
@assert_no_logs
def test_floats_9():
# Regression test
# https://github.com/Kozea/WeasyPrint/issues/263
page, = render_pages('''<div style="top:100%; float:left">''')
@assert_no_logs
def test_floats_page_breaks_1():
# Tests floated images shorter than the page
pages = render_pages('''
<style>
@page { size: 100px; margin: 10px }
img { height: 45px; width:70px; float: left;}
</style>
<body>
<img src=pattern.png>
<!-- page break should be here !!! -->
<img src=pattern.png>
''')
assert len(pages) == 2
page_images = []
for page in pages:
images = [d for d in page.descendants() if d.element_tag == 'img']
assert all([img.element_tag == 'img' for img in images])
assert all([img.position_x == 10 for img in images])
page_images.append(images)
del images
positions_y = [[img.position_y for img in images]
for images in page_images]
assert positions_y == [[10], [10]]
@assert_no_logs
def test_floats_page_breaks_2():
# Tests floated images taller than the page
pages = render_pages('''
<style>
@page { size: 100px; margin: 10px }
img { height: 81px; width:70px; float: left;}
</style>
<body>
<img src=pattern.png>
<!-- page break should be here !!! -->
<img src=pattern.png>
''')
assert len(pages) == 2
page_images = []
for page in pages:
images = [d for d in page.descendants() if d.element_tag == 'img']
assert all([img.element_tag == 'img' for img in images])
assert all([img.position_x == 10 for img in images])
page_images.append(images)
del images
positions_y = [[img.position_y for img in images]
for images in page_images]
assert positions_y == [[10], [10]]
@assert_no_logs
def test_floats_page_breaks_3():
# Tests floated images shorter than the page
pages = render_pages('''
<style>
@page { size: 100px; margin: 10px }
img { height: 30px; width:70px; float: left;}
</style>
<body>
<img src=pattern.png>
<img src=pattern.png>
<!-- page break should be here !!! -->
<img src=pattern.png>
<img src=pattern.png>
<!-- page break should be here !!! -->
<img src=pattern.png>
''')
assert len(pages) == 3
page_images = []
for page in pages:
images = [d for d in page.descendants() if d.element_tag == 'img']
assert all([img.element_tag == 'img' for img in images])
assert all([img.position_x == 10 for img in images])
page_images.append(images)
del images
positions_y = [[img.position_y for img in images]
for images in page_images]
assert positions_y == [[10, 40], [10, 40], [10]]
@assert_no_logs
def test_floats_page_breaks_4():
# last float does not fit, pushed to next page
pages = render_pages('''
<style>
@page{
size: 110px;
margin: 10px;
padding: 0;
}
.large {
width: 10px;
height: 60px;
}
.small {
width: 10px;
height: 20px;
}
</style>
<body>
<div class="large"></div>
<div class="small"></div>
<div class="large"></div>
''')
assert len(pages) == 2
page_divs = []
for page in pages:
divs = [div for div in page.descendants() if div.element_tag == 'div']
assert all([div.element_tag == 'div' for div in divs])
page_divs.append(divs)
del divs
positions_y = [[div.position_y for div in divs] for divs in page_divs]
assert positions_y == [[10, 70], [10]]
@assert_no_logs
def test_floats_page_breaks_5():
# last float does not fit, pushed to next page
# center div must not
pages = render_pages('''
<style>
@page{
size: 110px;
margin: 10px;
padding: 0;
}
.large {
width: 10px;
height: 60px;
}
.small {
width: 10px;
height: 20px;
page-break-after: avoid;
}
</style>
<body>
<div class="large"></div>
<div class="small"></div>
<div class="large"></div>
''')
assert len(pages) == 2
page_divs = []
for page in pages:
divs = [div for div in page.descendants() if div.element_tag == 'div']
assert all([div.element_tag == 'div' for div in divs])
page_divs.append(divs)
del divs
positions_y = [[div.position_y for div in divs] for divs in page_divs]
assert positions_y == [[10], [10, 30]]
@assert_no_logs
def test_floats_page_breaks_6():
# center div must be the last element,
# but float won't fit and will get pushed anyway
pages = render_pages('''
<style>
@page{
size: 110px;
margin: 10px;
padding: 0;
}
.large {
width: 10px;
height: 80px;
}
.small {
width: 10px;
height: 20px;
page-break-after: avoid;
}
</style>
<body>
<div class="large"></div>
<div class="small"></div>
<div class="large"></div>
''')
assert len(pages) == 3
page_divs = []
for page in pages:
divs = [div for div in page.descendants() if div.element_tag == 'div']
assert all([div.element_tag == 'div' for div in divs])
page_divs.append(divs)
del divs
positions_y = [[div.position_y for div in divs] for divs in page_divs]
assert positions_y == [[10], [10], [10]]
@assert_no_logs
def test_preferred_widths_1():
def get_float_width(body_width):
page, = render_pages('''
<style>
@font-face { src: url(AHEM____.TTF); font-family: ahem }
</style>
<body style="width: %spx; font-family: ahem">
<p style="white-space: pre-line; float: left">
Lorem ipsum dolor sit amet,
consectetur elit
</p>
<!-- ^ No-break space here -->
''' % body_width)
html, = page.children
body, = html.children
paragraph, = body.children
return paragraph.width
# Preferred minimum width:
assert get_float_width(10) == len('consectetur elit') * 16
# Preferred width:
assert get_float_width(1000000) == len('Lorem ipsum dolor sit amet,') * 16
@assert_no_logs
def test_preferred_widths_2():
# Non-regression test:
# Incorrect whitespace handling in preferred width used to cause
# unnecessary line break.
page, = render_pages('''
<p style="float: left">Lorem <em>ipsum</em> dolor.</p>
''')
html, = page.children
body, = html.children
paragraph, = body.children
assert len(paragraph.children) == 1
assert isinstance(paragraph.children[0], boxes.LineBox)
@assert_no_logs
def test_preferred_widths_3():
page, = render_pages('''
<style>img { width: 20px }</style>
<p style="float: left">
<img src=pattern.png><img src=pattern.png><br>
<img src=pattern.png></p>
''')
html, = page.children
body, = html.children
paragraph, = body.children
assert paragraph.width == 40
@assert_no_logs
def test_preferred_widths_4():
page, = render_pages(
'<style>'
' @font-face { src: url(AHEM____.TTF); font-family: ahem }'
' p { font: 20px ahem }'
'</style>'
'<p style="float: left">XX<br>XX<br>X</p>')
html, = page.children
body, = html.children
paragraph, = body.children
assert paragraph.width == 40
@assert_no_logs
def test_preferred_widths_5():
# The space is the start of the line is collapsed.
page, = render_pages(
'<style>'
' @font-face { src: url(AHEM____.TTF); font-family: ahem }'
' p { font: 20px ahem }'
'</style>'
'<p style="float: left">XX<br> XX<br>X</p>')
html, = page.children
body, = html.children
paragraph, = body.children
assert paragraph.width == 40
@assert_no_logs
def test_float_in_inline():
page, = render_pages('''
<style>
@font-face { src: url(AHEM____.TTF); font-family: ahem }
body {
font-family: ahem;
font-size: 20px;
}
p {
width: 14em;
text-align: justify;
}
span {
float: right;
}
</style>
<p>
aa bb <a><span>cc</span> ddd</a> ee ff
</p>
''')
html, = page.children
body, = html.children
paragraph, = body.children
line1, line2 = paragraph.children
p1, a, p2 = line1.children
assert p1.width == 6 * 20
assert p1.text == 'aa bb '
assert p1.position_x == 0 * 20
assert p2.width == 3 * 20
assert p2.text == ' ee'
assert p2.position_x == 9 * 20
span, a_text = a.children
assert a_text.width == 3 * 20 # leading space collapse
assert a_text.text == 'ddd'
assert a_text.position_x == 6 * 20
assert span.width == 2 * 20
assert span.children[0].children[0].text == 'cc'
assert span.position_x == 12 * 20
p3, = line2.children
assert p3.width == 2 * 20
@assert_no_logs
def test_float_next_line():
page, = render_pages('''
<style>
@font-face { src: url(AHEM____.TTF); font-family: ahem }
body {
font-family: ahem;
font-size: 20px;
}
p {
text-align: justify;
width: 13em;
}
span {
float: left;
}
</style>
<p>pp pp pp pp <a><span>ppppp</span> aa</a> pp pp pp pp pp</p>''')
html, = page.children
body, = html.children
paragraph, = body.children
line1, line2, line3 = paragraph.children
assert len(line1.children) == 1
assert len(line3.children) == 1
a, p = line2.children
span, a_text = a.children
assert span.position_x == 0
assert span.width == 5 * 20
assert a_text.position_x == a.position_x == 5 * 20
assert a_text.width == a.width == 2 * 20
assert p.position_x == 7 * 20
@assert_no_logs
def test_float_text_indent_1():
page, = render_pages('''
<style>
@font-face { src: url(AHEM____.TTF); font-family: ahem }
body {
font-family: ahem;
font-size: 20px;
}
p {
text-align: justify;
text-indent: 1em;
width: 14em;
}
span {
float: left;
}
</style>
<p><a>aa <span>float</span> aa</a></p>''')
html, = page.children
body, = html.children
paragraph, = body.children
line1, = paragraph.children
a, = line1.children
a1, span, a2 = a.children
span_text, = span.children
assert span.position_x == span_text.position_x == 0
assert span.width == span_text.width == (
(1 + 5) * 20) # text-indent + span text
assert a1.width == 3 * 20
assert a1.position_x == (1 + 5 + 1) * 20 # span + a1 text-indent
assert a2.width == 2 * 20 # leading space collapse
assert a2.position_x == (1 + 5 + 1 + 3) * 20 # span + a1 t-i + a1
@assert_no_logs
def test_float_text_indent_2():
page, = render_pages('''
<style>
@font-face { src: url(AHEM____.TTF); font-family: ahem }
body {
font-family: ahem;
font-size: 20px;
}
p {
text-align: justify;
text-indent: 1em;
width: 14em;
}
span {
float: left;
}
</style>
<p>
oooooooooooo
<a>aa <span>float</span> aa</a></p>''')
html, = page.children
body, = html.children
paragraph, = body.children
line1, line2 = paragraph.children
p1, = line1.children
assert p1.position_x == 1 * 20 # text-indent
assert p1.width == 12 * 20 # p text
a, = line2.children
a1, span, a2 = a.children
span_text, = span.children
assert span.position_x == span_text.position_x == 0
assert span.width == span_text.width == (
(1 + 5) * 20) # text-indent + span text
assert a1.width == 3 * 20
assert a1.position_x == (1 + 5) * 20 # span
assert a2.width == 2 * 20 # leading space collapse
assert a2.position_x == (1 + 5 + 3) * 20 # span + a1
@assert_no_logs
def test_float_text_indent_3():
page, = render_pages('''
<style>
@font-face { src: url(AHEM____.TTF); font-family: ahem }
body {
font-family: ahem;
font-size: 20px;
}
p {
text-align: justify;
text-indent: 1em;
width: 14em;
}
span {
float: right;
}
</style>
<p>
oooooooooooo
<a>aa <span>float</span> aa</a>
oooooooooooo
</p>''')
html, = page.children
body, = html.children
paragraph, = body.children
line1, line2, line3 = paragraph.children
p1, = line1.children
assert p1.position_x == 1 * 20 # text-indent
assert p1.width == 12 * 20 # p text
a, = line2.children
a1, span, a2 = a.children
span_text, = span.children
assert span.position_x == span_text.position_x == (14 - 5 - 1) * 20
assert span.width == span_text.width == (
(1 + 5) * 20) # text-indent + span text
assert a1.position_x == 0 # span
assert a2.width == 2 * 20 # leading space collapse
assert a2.position_x == (14 - 5 - 1 - 2) * 20
p2, = line3.children
assert p2.position_x == 0
assert p2.width == 12 * 20 # p text
@pytest.mark.xfail
@assert_no_logs
def test_float_fail():
page, = render_pages('''
<style>
@font-face { src: url(AHEM____.TTF); font-family: ahem }
body {
font-family: ahem;
font-size: 20px;
}
p {
text-align: justify;
width: 12em;
}
span {
float: left;
background: red;
}
a {
background: yellow;
}
</style>
<p>bb bb pp bb pp pb <a><span>pp pp</span> apa</a> bb bb</p>''')
html, = page.children
body, = html.children
paragraph, = body.children
line1, line2, line3 = paragraph.children

View File

@ -0,0 +1,133 @@
"""
weasyprint.tests.test_fonts
---------------------------
Test the fonts features.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from .test_boxes import render_pages
from .testing_utils import assert_no_logs, requires
@assert_no_logs
@requires('pango', (1, 38, 0))
def test_font_face():
page, = render_pages('''
<style>
@font-face { src: url(weasyprint.otf); font-family: weasyprint }
body { font-family: weasyprint }
</style>
<span>abc</span>''')
html, = page.children
body, = html.children
line, = body.children
assert line.width == 3 * 16
@assert_no_logs
@requires('pango', (1, 38, 0))
def test_kerning_default():
# Kerning and ligatures are on by default
page, = render_pages('''
<style>
@font-face { src: url(weasyprint.otf); font-family: weasyprint }
body { font-family: weasyprint }
</style>
<span>kk</span><span>liga</span>''')
html, = page.children
body, = html.children
line, = body.children
span1, span2 = line.children
assert span1.width == 1.5 * 16
assert span2.width == 1.5 * 16
@assert_no_logs
@requires('pango', (1, 38, 0))
def test_kerning_deactivate():
# Deactivate kerning
page, = render_pages('''
<style>
@font-face {
src: url(weasyprint.otf);
font-family: no-kern;
font-feature-settings: 'kern' off;
}
@font-face {
src: url(weasyprint.otf);
font-family: kern;
}
span:nth-child(1) { font-family: kern }
span:nth-child(2) { font-family: no-kern }
</style>
<span>kk</span><span>kk</span>''')
html, = page.children
body, = html.children
line, = body.children
span1, span2 = line.children
assert span1.width == 1.5 * 16
assert span2.width == 2 * 16
@assert_no_logs
@requires('pango', (1, 38, 0))
def test_kerning_ligature_deactivate():
# Deactivate kerning and ligatures
page, = render_pages('''
<style>
@font-face {
src: url(weasyprint.otf);
font-family: no-kern-liga;
font-feature-settings: 'kern' off;
font-variant: no-common-ligatures;
}
@font-face {
src: url(weasyprint.otf);
font-family: kern-liga;
}
span:nth-child(1) { font-family: kern-liga }
span:nth-child(2) { font-family: no-kern-liga }
</style>
<span>kk liga</span><span>kk liga</span>''')
html, = page.children
body, = html.children
line, = body.children
span1, span2 = line.children
assert span1.width == (1.5 + 1 + 1.5) * 16
assert span2.width == (2 + 1 + 4) * 16
@assert_no_logs
@requires('pango', (1, 38, 0))
def test_font_face_descriptors():
page, = render_pages(
'''
<style>
@font-face {
src: url(weasyprint.otf);
font-family: weasyprint;
font-variant: sub
discretionary-ligatures
oldstyle-nums
slashed-zero;
}
span { font-family: weasyprint }
</style>'''
'<span>kk</span>'
'<span>subs</span>'
'<span>dlig</span>'
'<span>onum</span>'
'<span>zero</span>')
html, = page.children
body, = html.children
line, = body.children
kern, subs, dlig, onum, zero = line.children
assert kern.width == 1.5 * 16
assert subs.width == 1.5 * 16
assert dlig.width == 1.5 * 16
assert onum.width == 1.5 * 16
assert zero.width == 1.5 * 16

View File

@ -0,0 +1,11 @@
"""
weasyprint.tests.layout
-----------------------
Tests for layout, ie. positioning and dimensioning of boxes,
line breaks, page breaks.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""

Some files were not shown because too many files have changed in this diff Show More