Import weasyprint_51.orig.tar.gz
[dgit import orig weasyprint_51.orig.tar.gz]
|
@ -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.
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
[console_scripts]
|
||||
weasyprint = weasyprint.__main__:main
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
weasyprint
|
|
@ -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
|
||||
|
|
@ -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()
|
|
@ -0,0 +1 @@
|
|||
51
|
|
@ -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
|
|
@ -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 input’s 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()
|
|
@ -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 property’s 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)
|
|
@ -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
|
|
@ -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;
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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
|
|
@ -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',
|
||||
}
|
|
@ -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'.
|
|
@ -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; }
|
|
@ -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)
|
|
@ -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
|
|
@ -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 stylesheet’s 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
|
|
@ -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:
|
||||
# We’re 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
|
|
@ -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
|
||||
#: `W3C’s 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
|
||||
#: `W3C’s 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> we’re
|
||||
# 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
|
|
@ -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
|
|
@ -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.
|
||||
|
||||
"""
|
|
@ -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 box’s position.
|
||||
|
||||
Also update the children’s 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(),
|
||||
# Python’s 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 CSS’s 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.
|
||||
|
||||
"""
|
|
@ -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.
|
||||
# We’ll 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 counter’s
|
||||
prefix and suffix.
|
||||
"""
|
||||
style = STYLES[counter_style]
|
||||
return style['prefix'] + format(value, counter_style) + style['suffix']
|
|
@ -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 element’s 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 children’s 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)
|
|
@ -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):
|
||||
# Don’t 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)
|
|
@ -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]
|
|
@ -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
|
|
@ -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: shouldn’t 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)
|
|
@ -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 don’t 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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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, we’re 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 box’s 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
|
|
@ -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))
|
|
@ -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
|
|
@ -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
|
|
@ -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 cell’s 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
|
|
@ -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')
|
|
@ -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):
|
||||
# cairo’s 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, we’ll 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')
|
||||
|
||||
# Don’t 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()
|
|
@ -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)
|
|
@ -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.
|
||||
|
||||
"""
|
|
@ -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 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 "Hello World!" on the test page in the same way that this paragraph is rendered below that text on this page."></a></p>
|
||||
</body>
|
||||
</html>
|
|
@ -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> </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> </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>
|
After Width: | Height: | Size: 289 B |
|
@ -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>
|
After Width: | Height: | Size: 814 B |
|
@ -0,0 +1,4 @@
|
|||
h1::before {
|
||||
content: "I løvë Unicode";
|
||||
background-image: url(pattern.png)
|
||||
}
|
After Width: | Height: | Size: 6.4 KiB |
|
@ -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 }
|
After Width: | Height: | Size: 37 B |
After Width: | Height: | Size: 140 B |
After Width: | Height: | Size: 76 B |
|
@ -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 |
After Width: | Height: | Size: 76 B |
|
@ -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 |
|
@ -0,0 +1,5 @@
|
|||
li {
|
||||
margin-bottom: 3em; /* Should be masked*/
|
||||
margin: 2em 0;
|
||||
margin-left: 4em; /* Should not be masked*/
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
html {
|
||||
/* Reversed contrast */
|
||||
color: white;
|
||||
background-color: black;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
h1::before {
|
||||
content: "I løvë Unicode";
|
||||
background-image: url(pattern.png)
|
||||
}
|
|
@ -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)
|
|
@ -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 & 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'
|
|
@ -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)
|
|
@ -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"]
|
|
@ -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
|
|
@ -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))
|
|
@ -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 }
|
||||
/* body’s 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 }
|
||||
/* html’s background propagates to the whole canvas */
|
||||
html { padding: 1px; background: #f00 }
|
||||
/* html has a background, so body’s 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> ''' % 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)
|
|
@ -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)
|
|
@ -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>''')
|
|
@ -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>''')
|
|
@ -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>''')
|
|
@ -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
|
|
@ -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>
|
||||
'''),
|
||||
])
|
|
@ -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,))
|
|
@ -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
|
||||
])
|
|
@ -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)
|
|
@ -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>''')
|
|
@ -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>
|
||||
''')
|
|
@ -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>''')
|
|
@ -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 } '''})
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
||||
|
||||
"""
|