xhtml2pdf 0.2.20__tar.gz → 0.2.22__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {xhtml2pdf-0.2.20/xhtml2pdf.egg-info → xhtml2pdf-0.2.22}/PKG-INFO +3 -3
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/pyproject.toml +7 -4
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_cli.py +16 -0
- xhtml2pdf-0.2.22/tests/test_css_parser.py +505 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_document.py +37 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_files.py +41 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_flex_flowable.py +21 -1
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_inline_block.py +10 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_inline_box.py +38 -1
- xhtml2pdf-0.2.22/tests/test_paragraph_height.py +85 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_parser.py +199 -0
- xhtml2pdf-0.2.22/tests/test_position.py +290 -0
- xhtml2pdf-0.2.22/tests/test_reportlab_api.py +681 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_right_to_left_font_support.py +9 -0
- xhtml2pdf-0.2.22/tests/test_security_limits.py +694 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_selectors.py +300 -12
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_special_css.py +150 -0
- xhtml2pdf-0.2.22/tests/test_table_border_radius.py +146 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_tables.py +128 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_tags.py +112 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_utils.py +237 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/__init__.py +1 -1
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/builders/flex.py +26 -8
- xhtml2pdf-0.2.22/xhtml2pdf/builders/position.py +591 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/builders/signs.py +46 -16
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/builders/watermarks.py +50 -9
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/config/resources.py +200 -1
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/context.py +142 -44
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/default.py +20 -4
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/document.py +32 -6
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/files.py +148 -7
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/parser.py +346 -39
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/pisa.py +9 -2
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/properties.py +42 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/reportlab_paragraph.py +134 -18
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/tables.py +110 -13
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/tags.py +42 -15
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/util.py +511 -4
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/w3c/css.py +325 -21
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/w3c/cssDOMElementInterface.py +151 -50
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/w3c/cssParser.py +291 -134
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/w3c/cssSpecial.py +91 -1
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/xhtml2pdf_reportlab.py +245 -5
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22/xhtml2pdf.egg-info}/PKG-INFO +3 -3
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf.egg-info/SOURCES.txt +6 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf.egg-info/requires.txt +2 -2
- xhtml2pdf-0.2.20/tests/test_reportlab_api.py +0 -236
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/AUTHORS.rst +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/LICENSE.txt +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/MANIFEST.in +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/README.rst +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/setup.cfg +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_asian_font_support.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_css_media_rule.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_flex.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_flex_layout.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_fonts.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_http.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_paragraph.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_pdf.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_reportlab.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_reportlab_paragraph.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_resource_policy.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_table_css.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_update_face_name.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_watermarks.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_wsgi.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/__main__.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/builders/__init__.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/builders/flex_layout.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/charts.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/config/__init__.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/config/httpconfig.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/paragraph.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/pdf.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/w3c/__init__.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/wsgi.py +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf.egg-info/dependency_links.txt +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf.egg-info/entry_points.txt +0 -0
- {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xhtml2pdf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.22
|
|
4
4
|
Summary: PDF generator using HTML and CSS
|
|
5
5
|
Author-email: Dirk Holtwick <dirk.holtwick@gmail.com>
|
|
6
6
|
Maintainer-email: Luis Zarate <luisza14@gmail.com>, Timo Brembeck <opensource@timo.brembeck.email>
|
|
@@ -250,10 +250,10 @@ Requires-Dist: pyHanko>=0.12.1
|
|
|
250
250
|
Requires-Dist: pyhanko-certvalidator>=0.19.5
|
|
251
251
|
Requires-Dist: pypdf>=3.1.0
|
|
252
252
|
Requires-Dist: python-bidi>=0.5.0
|
|
253
|
-
Requires-Dist: reportlab<6,>=4.
|
|
253
|
+
Requires-Dist: reportlab<6,>=4.4.9
|
|
254
254
|
Requires-Dist: svglib>=1.2.1
|
|
255
255
|
Provides-Extra: pycairo
|
|
256
|
-
Requires-Dist: reportlab[pycairo]<6,>=4.
|
|
256
|
+
Requires-Dist: reportlab[pycairo]<6,>=4.4.9; extra == "pycairo"
|
|
257
257
|
Provides-Extra: renderpm
|
|
258
258
|
Requires-Dist: xhtml2pdf[pycairo]; extra == "renderpm"
|
|
259
259
|
Provides-Extra: test
|
|
@@ -54,7 +54,7 @@ dependencies = [
|
|
|
54
54
|
"pyhanko-certvalidator>=0.19.5",
|
|
55
55
|
"pypdf>=3.1.0",
|
|
56
56
|
"python-bidi>=0.5.0",
|
|
57
|
-
"reportlab>=4.
|
|
57
|
+
"reportlab>=4.4.9,<6",
|
|
58
58
|
"svglib>=1.2.1"
|
|
59
59
|
]
|
|
60
60
|
dynamic = ["version"]
|
|
@@ -67,7 +67,7 @@ Changelog = "https://xhtml2pdf.readthedocs.io/en/latest/release-notes.html"
|
|
|
67
67
|
|
|
68
68
|
[project.optional-dependencies]
|
|
69
69
|
pycairo = [
|
|
70
|
-
"reportlab[pycairo]>=4.
|
|
70
|
+
"reportlab[pycairo]>=4.4.9,<6",
|
|
71
71
|
]
|
|
72
72
|
# Deprecated alias. reportlab 5 dropped the C renderPM backend along with its
|
|
73
73
|
# "renderpm" extra, so requesting it there resolves to nothing at all; the
|
|
@@ -113,13 +113,16 @@ include = ["xhtml2pdf*"]
|
|
|
113
113
|
[tool.tox]
|
|
114
114
|
legacy_tox_ini = """
|
|
115
115
|
[tox]
|
|
116
|
-
envlist = py{3.10,3.11,3.12,3.13,3.14}-rl{4,5}
|
|
116
|
+
envlist = py{3.10,3.11,3.12,3.13,3.14}-rl{4,5}, py3.10-rlmin
|
|
117
117
|
|
|
118
118
|
[testenv]
|
|
119
119
|
deps =
|
|
120
120
|
coverage[toml]
|
|
121
|
-
rl4: reportlab>=4.
|
|
121
|
+
rl4: reportlab>=4.4.9,<5
|
|
122
122
|
rl5: reportlab>=5,<6
|
|
123
|
+
# The declared floor, exactly: rl4 installs the newest 4.x, so the
|
|
124
|
+
# floor went untested and drifted below what the code needs.
|
|
125
|
+
rlmin: reportlab==4.4.9
|
|
123
126
|
commands =
|
|
124
127
|
python3 -c 'import sys;print(sys.version_info)'
|
|
125
128
|
python3 -c "from reportlab import Version; print('%s %s' % ('Reportlab Version', Version))"
|
|
@@ -179,6 +179,22 @@ class RunAsAModuleTest(TestCase):
|
|
|
179
179
|
self.assertTrue(dest.is_file())
|
|
180
180
|
self.assertEqual(1, len(PdfReader(dest).pages))
|
|
181
181
|
|
|
182
|
+
def test_stdin_to_stdout_writes_only_the_pdf(self) -> None:
|
|
183
|
+
"""`xhtml2pdf - -` (#464): the PDF alone on stdout, the message on stderr."""
|
|
184
|
+
result = subprocess.run(
|
|
185
|
+
[sys.executable, "-m", "xhtml2pdf", "-", "-"],
|
|
186
|
+
input=HTML.encode(),
|
|
187
|
+
capture_output=True,
|
|
188
|
+
check=False,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
self.assertEqual(0, result.returncode, result.stderr.decode())
|
|
192
|
+
self.assertTrue(result.stdout.startswith(b"%PDF-"))
|
|
193
|
+
self.assertIn(b"Converting - to -", result.stderr)
|
|
194
|
+
self.assertIn(
|
|
195
|
+
"hello", PdfReader(io.BytesIO(result.stdout)).pages[0].extract_text()
|
|
196
|
+
)
|
|
197
|
+
|
|
182
198
|
|
|
183
199
|
class StartViewerTest(TestCase):
|
|
184
200
|
"""
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The CSS parser on its own: the at-rules, selectors and declarations that no
|
|
3
|
+
document in the rest of the suite happens to contain.
|
|
4
|
+
|
|
5
|
+
What is invalid is dropped and the rest applies, as CSS Syntax 3 says: an
|
|
6
|
+
at-rule to its ";" or through its block, a declaration to its ";". Each of
|
|
7
|
+
these used to raise instead -- a CSSParseError, an IndexError, a TypeError --
|
|
8
|
+
and nothing catches it, so one line of a stylesheet cost the whole document.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import ClassVar
|
|
16
|
+
from unittest import TestCase
|
|
17
|
+
from xml.dom import minidom
|
|
18
|
+
|
|
19
|
+
from reportlab.lib.pagesizes import A5
|
|
20
|
+
from reportlab.lib.units import cm
|
|
21
|
+
|
|
22
|
+
from xhtml2pdf.context import pisaContext
|
|
23
|
+
from xhtml2pdf.document import pisaStory
|
|
24
|
+
from xhtml2pdf.w3c.css import CSSBuilder, CSSParser
|
|
25
|
+
from xhtml2pdf.w3c.cssDOMElementInterface import CSSDOMElementInterface
|
|
26
|
+
from xhtml2pdf.w3c.cssParser import CSSParseError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ParserTestCase(TestCase):
|
|
30
|
+
MEDIUM_SET: ClassVar[list[str]] = ["all", "print", "pdf"]
|
|
31
|
+
|
|
32
|
+
def setUp(self) -> None:
|
|
33
|
+
# The parser warns about every rule it drops, which is what most of
|
|
34
|
+
# these tests do on purpose.
|
|
35
|
+
logging.disable(logging.WARNING)
|
|
36
|
+
self.addCleanup(logging.disable, logging.NOTSET)
|
|
37
|
+
|
|
38
|
+
def _parser(self) -> CSSParser:
|
|
39
|
+
return CSSParser(CSSBuilder(mediumSet=self.MEDIUM_SET))
|
|
40
|
+
|
|
41
|
+
def _parse(self, css: str) -> dict:
|
|
42
|
+
# In a thread with a timeout: a skip that stops advancing loops
|
|
43
|
+
# forever, and that must fail one test rather than hang the suite.
|
|
44
|
+
result: dict = {}
|
|
45
|
+
|
|
46
|
+
def parse() -> None:
|
|
47
|
+
result["ruleset"] = self._parser().parse(css)[0]
|
|
48
|
+
|
|
49
|
+
worker = threading.Thread(target=parse, daemon=True)
|
|
50
|
+
worker.start()
|
|
51
|
+
worker.join(timeout=5)
|
|
52
|
+
self.assertFalse(worker.is_alive(), "parser did not terminate")
|
|
53
|
+
return {
|
|
54
|
+
str(selector): dict(decls) for selector, decls in result["ruleset"].items()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ParseErrorTest(ParserTestCase):
|
|
59
|
+
def test_message_points_at_the_error_in_its_context(self) -> None:
|
|
60
|
+
# What came before the error and where it starts, so a warning in
|
|
61
|
+
# the log says which CSS was dropped.
|
|
62
|
+
message = str(CSSParseError("Bad thing", "bc {x}", "p > bc {x}"))
|
|
63
|
+
self.assertTrue(message.startswith("Bad thing"))
|
|
64
|
+
self.assertIn("'p > '", message)
|
|
65
|
+
self.assertIn("'bc {x}'", message)
|
|
66
|
+
|
|
67
|
+
def test_error_at_the_start_is_located(self) -> None:
|
|
68
|
+
# Position 0 used to count as no position at all.
|
|
69
|
+
self.assertEqual("Bad:: ('', 'abc')", str(CSSParseError("Bad", "abc")))
|
|
70
|
+
|
|
71
|
+
def test_message_without_a_position_quotes_the_source(self) -> None:
|
|
72
|
+
self.assertEqual("Bad:: 'zzz'", str(CSSParseError("Bad", "zzz", "abc")))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PublicApiTest(ParserTestCase):
|
|
76
|
+
def test_parse_file(self) -> None:
|
|
77
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
78
|
+
path = Path(directory, "style.css")
|
|
79
|
+
path.write_text("p { color: red; }", encoding="utf-8")
|
|
80
|
+
ruleset = self._parser().parseFile(str(path))[0]
|
|
81
|
+
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
{"p": {"color": "red"}},
|
|
84
|
+
{str(selector): dict(decls) for selector, decls in ruleset.items()},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def test_parse_inline_drops_only_the_broken_declaration(self) -> None:
|
|
88
|
+
normal, _ = self._parser().parseInline("color: red; width: calc(1px")
|
|
89
|
+
self.assertEqual({"color": "red"}, dict(normal))
|
|
90
|
+
# An open "(" runs to the end of the source, taking the ";" with it
|
|
91
|
+
# (CSS Syntax 3, "consume a function"), as it does in a browser.
|
|
92
|
+
normal, _ = self._parser().parseInline("width: calc(1px ; color: red")
|
|
93
|
+
self.assertEqual({}, dict(normal))
|
|
94
|
+
|
|
95
|
+
def test_parse_attributes(self) -> None:
|
|
96
|
+
normal, important = self._parser().parseAttributes(
|
|
97
|
+
{"color": "red"}, font="10px Arial"
|
|
98
|
+
)
|
|
99
|
+
self.assertEqual({}, dict(important))
|
|
100
|
+
self.assertEqual("red", normal["color"])
|
|
101
|
+
self.assertEqual(("10", "px"), normal["font-size"])
|
|
102
|
+
self.assertEqual(["Arial"], normal["font-family"])
|
|
103
|
+
|
|
104
|
+
def test_parse_attributes_error(self) -> None:
|
|
105
|
+
# Unlike a style attribute, which drops the declaration: this API
|
|
106
|
+
# takes one value per property and has nothing else to keep.
|
|
107
|
+
with self.assertRaisesRegex(CSSParseError, "calc"):
|
|
108
|
+
self._parser().parseAttributes(width="calc(1px ;")
|
|
109
|
+
|
|
110
|
+
def test_parse_single_attribute(self) -> None:
|
|
111
|
+
parser = self._parser()
|
|
112
|
+
self.assertEqual(
|
|
113
|
+
[("110", "%"), "Times New Roman", "Arial"],
|
|
114
|
+
parser.parseSingleAttr('110%, "Times New Roman", Arial'),
|
|
115
|
+
)
|
|
116
|
+
# An !important value comes back from the other half of the result.
|
|
117
|
+
self.assertEqual("red", parser.parseSingleAttr("red !important"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AtRuleTest(ParserTestCase):
|
|
121
|
+
def test_html_comment_markers_are_ignored(self) -> None:
|
|
122
|
+
self.assertEqual({"p": {"color": "red"}}, self._parse("<!-- p{color:red} -->"))
|
|
123
|
+
|
|
124
|
+
def test_charset(self) -> None:
|
|
125
|
+
self.assertEqual(
|
|
126
|
+
{"p": {"color": "red"}}, self._parse('@charset "utf-8"; p{color:red}')
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def test_charset_without_semicolon(self) -> None:
|
|
130
|
+
# The at-rule runs on to the block of the next rule, and both go.
|
|
131
|
+
self.assertEqual({"div"}, set(self._parse('@charset "utf-8" p{c:d} div{c:d}')))
|
|
132
|
+
|
|
133
|
+
def _imports(self, css: str) -> tuple[list, set]:
|
|
134
|
+
"""The stylesheets css imports, and the rules it keeps."""
|
|
135
|
+
imported = []
|
|
136
|
+
|
|
137
|
+
class Parser(CSSParser):
|
|
138
|
+
@staticmethod
|
|
139
|
+
def parseExternal(cssResourceName):
|
|
140
|
+
imported.append(cssResourceName)
|
|
141
|
+
|
|
142
|
+
parser = Parser(CSSBuilder(mediumSet=self.MEDIUM_SET))
|
|
143
|
+
ruleset = parser.parse(css)[0]
|
|
144
|
+
return imported, {str(selector) for selector in ruleset}
|
|
145
|
+
|
|
146
|
+
def test_import_media_list(self) -> None:
|
|
147
|
+
self.assertEqual(
|
|
148
|
+
(["x.css"], {"p"}), self._imports("@import 'x.css' print, screen; p{c:d}")
|
|
149
|
+
)
|
|
150
|
+
# None of the media apply, so the file is not even fetched.
|
|
151
|
+
self.assertEqual(
|
|
152
|
+
([], {"p"}), self._imports("@import 'x.css' tv, aural; p{c:d}")
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def test_malformed_import_is_dropped(self) -> None:
|
|
156
|
+
self.assertEqual(([], {"p"}), self._imports("@import foo; p{c:d}"))
|
|
157
|
+
# Without its ";" it takes the next rule's block as its own.
|
|
158
|
+
self.assertEqual(
|
|
159
|
+
([], {"div"}), self._imports("@import 'x.css' print p{c:d} div{c:d}")
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def test_import_after_a_rule_is_ignored(self) -> None:
|
|
163
|
+
# CSS 2.1 6.3: @import must come before every other rule.
|
|
164
|
+
self.assertEqual(
|
|
165
|
+
([], {"p", "div"}), self._imports("p{c:d} @import url(x.css); div{c:d}")
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
NAMESPACED = (
|
|
169
|
+
"<root><circle xmlns='http://www.w3.org/2000/svg' id='svg'/>"
|
|
170
|
+
"<circle id='plain'/></root>"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _matched(self, css: str) -> set:
|
|
174
|
+
ruleset = self._parser().parse(css)[0]
|
|
175
|
+
document = minidom.parseString(self.NAMESPACED)
|
|
176
|
+
return {
|
|
177
|
+
element.getIdAttr()
|
|
178
|
+
for element in map(
|
|
179
|
+
CSSDOMElementInterface, document.getElementsByTagName("*")
|
|
180
|
+
)
|
|
181
|
+
if any(selector.matches(element) for selector in ruleset)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
def test_namespace(self) -> None:
|
|
185
|
+
declare = "@namespace svg url(http://www.w3.org/2000/svg);"
|
|
186
|
+
self.assertEqual({"svg"}, self._matched(declare + "svg|circle{c:d}"))
|
|
187
|
+
# A default namespace does not stop the prefixed one from resolving.
|
|
188
|
+
self.assertEqual(
|
|
189
|
+
{"svg"},
|
|
190
|
+
self._matched(
|
|
191
|
+
declare
|
|
192
|
+
+ "@namespace url(http://www.w3.org/1999/xhtml); svg|circle{c:d}"
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
self.assertEqual({"svg", "plain"}, self._matched("*|circle{c:d}"))
|
|
196
|
+
|
|
197
|
+
def test_empty_prefix_is_no_namespace(self) -> None:
|
|
198
|
+
# CSS Namespaces 3: "|circle" is a circle in no namespace. It used to
|
|
199
|
+
# match any, as "*|circle" does.
|
|
200
|
+
self.assertEqual({"plain"}, self._matched("|circle{c:d}"))
|
|
201
|
+
|
|
202
|
+
def test_malformed_namespace_is_dropped(self) -> None:
|
|
203
|
+
cases = {
|
|
204
|
+
"@namespace ; p{c:d}": {"p"},
|
|
205
|
+
"@namespace svg; p{c:d}": {"p"},
|
|
206
|
+
# Without its ";" it takes the next rule's block as its own.
|
|
207
|
+
"@namespace url(x) p{c:d} div{c:d}": {"div"},
|
|
208
|
+
}
|
|
209
|
+
for css, kept in cases.items():
|
|
210
|
+
with self.subTest(css=css):
|
|
211
|
+
self.assertEqual(kept, set(self._parse(css)))
|
|
212
|
+
|
|
213
|
+
def test_skipped_at_rule_ends_outside_strings(self) -> None:
|
|
214
|
+
self.assertEqual({"div"}, set(self._parse('@foo "a;b" { p{c:d} } div{c:d}')))
|
|
215
|
+
|
|
216
|
+
def test_at_rule_without_a_name_is_dropped(self) -> None:
|
|
217
|
+
self.assertEqual({"p"}, set(self._parse("@123 {} p{c:d}")))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class MediaTest(ParserTestCase):
|
|
221
|
+
def test_unsupported_at_rule_inside_media(self) -> None:
|
|
222
|
+
# Used to raise TypeError: the skipped rule's NotImplemented was
|
|
223
|
+
# iterated as if it were a list of rules.
|
|
224
|
+
rules = self._parse(
|
|
225
|
+
"@media print {"
|
|
226
|
+
" @keyframes fade { from { opacity: 0; } }"
|
|
227
|
+
" @layer base;"
|
|
228
|
+
" p { color: red; }"
|
|
229
|
+
"}"
|
|
230
|
+
)
|
|
231
|
+
self.assertEqual({"p": {"color": "red"}}, rules)
|
|
232
|
+
|
|
233
|
+
def test_skipped_at_rule_leaves_the_media_block_its_brace(self) -> None:
|
|
234
|
+
# "@foo bar" ends at the "}" of @media without taking it, so p is
|
|
235
|
+
# outside the (unmatched) screen block.
|
|
236
|
+
self.assertEqual({"p"}, set(self._parse("@media screen { @foo bar } p{c:d}")))
|
|
237
|
+
|
|
238
|
+
def test_at_rule_inside_media(self) -> None:
|
|
239
|
+
# The base CSSBuilder files a @font-face as a rule for "*"; that it is
|
|
240
|
+
# there at all is what this checks.
|
|
241
|
+
rules = self._parse(
|
|
242
|
+
"@media print { @font-face { font-family: x; src: url(a.ttf) }"
|
|
243
|
+
" p { color: red; } }"
|
|
244
|
+
)
|
|
245
|
+
self.assertEqual({"*", "p"}, set(rules))
|
|
246
|
+
|
|
247
|
+
def test_end_of_stylesheet_closes_the_block(self) -> None:
|
|
248
|
+
self.assertEqual(
|
|
249
|
+
{"p": {"color": "red"}}, self._parse("@media print { p{color:red}")
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def test_media_without_a_block_is_dropped(self) -> None:
|
|
253
|
+
# These used to raise IndexError and AttributeError.
|
|
254
|
+
for css in ("p{c:d} @media print", "p{c:d} @media (max-width: 1px) p"):
|
|
255
|
+
with self.subTest(css=css):
|
|
256
|
+
self.assertEqual({"p"}, set(self._parse(css)))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class PageTest(ParserTestCase):
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _context(css: str) -> pisaContext:
|
|
262
|
+
context = pisaContext(".")
|
|
263
|
+
context.addCSS(css)
|
|
264
|
+
context.parseCSS()
|
|
265
|
+
return context
|
|
266
|
+
|
|
267
|
+
def test_size_as_two_lengths(self) -> None:
|
|
268
|
+
width, height = self._context("@page { size: 10cm 20cm }").pageSize
|
|
269
|
+
self.assertAlmostEqual(10 * cm, width)
|
|
270
|
+
self.assertAlmostEqual(20 * cm, height)
|
|
271
|
+
|
|
272
|
+
def test_pdf_page_size(self) -> None:
|
|
273
|
+
context = self._context("@page { -pdf-page-size: a5 }")
|
|
274
|
+
self.assertEqual(A5, tuple(context.pageSize))
|
|
275
|
+
|
|
276
|
+
def test_unsupported_at_rule_inside_page(self) -> None:
|
|
277
|
+
context = self._context("@page { @supports (x) { p { c: d } } size: a5 }")
|
|
278
|
+
self.assertEqual(A5, tuple(context.pageSize))
|
|
279
|
+
|
|
280
|
+
def test_page_without_a_block_is_dropped(self) -> None:
|
|
281
|
+
context = self._context("@page x y; @page { size: a5 }")
|
|
282
|
+
self.assertEqual(A5, tuple(context.pageSize))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class AttributeSelectorTest(ParserTestCase):
|
|
286
|
+
HTML = (
|
|
287
|
+
"<form>"
|
|
288
|
+
"<input id='text' type='text' lang='en-GB' title='big red'/>"
|
|
289
|
+
"<input id='box' type='checkbox' lang='fr'/>"
|
|
290
|
+
"<a id='link' href='x' title='a b'/>"
|
|
291
|
+
"</form>"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _matched(self, css: str) -> set:
|
|
295
|
+
ruleset = self._parser().parse(css)[0]
|
|
296
|
+
document = minidom.parseString(self.HTML)
|
|
297
|
+
matched = set()
|
|
298
|
+
for node in document.getElementsByTagName("*"):
|
|
299
|
+
element = CSSDOMElementInterface(node)
|
|
300
|
+
if any(selector.matches(element) for selector in ruleset):
|
|
301
|
+
matched.add(element.getIdAttr())
|
|
302
|
+
return matched
|
|
303
|
+
|
|
304
|
+
def test_presence(self) -> None:
|
|
305
|
+
self.assertEqual({"link"}, self._matched("[href] {c:d}"))
|
|
306
|
+
|
|
307
|
+
def test_equals(self) -> None:
|
|
308
|
+
self.assertEqual({"text"}, self._matched("input[type=text] {c:d}"))
|
|
309
|
+
self.assertEqual({"link"}, self._matched('a[title="a b"] {c:d}'))
|
|
310
|
+
|
|
311
|
+
def test_word_and_language(self) -> None:
|
|
312
|
+
self.assertEqual({"text"}, self._matched("[title~=red] {c:d}"))
|
|
313
|
+
self.assertEqual(set(), self._matched("[title~=re] {c:d}"))
|
|
314
|
+
self.assertEqual({"text"}, self._matched("[lang|=en] {c:d}"))
|
|
315
|
+
self.assertEqual({"box"}, self._matched("[lang|=fr] {c:d}"))
|
|
316
|
+
# A prefix up to a "-", not any "-" separated part: this used to match.
|
|
317
|
+
self.assertEqual(set(), self._matched("[lang|=GB] {c:d}"))
|
|
318
|
+
|
|
319
|
+
def test_prefix_suffix_and_substring(self) -> None:
|
|
320
|
+
# Selectors 3. "^=" used to raise RuntimeError, out of the document.
|
|
321
|
+
self.assertEqual(
|
|
322
|
+
{"text", "box"}, self._matched("[type^=te], [type^=check] {c:d}")
|
|
323
|
+
)
|
|
324
|
+
self.assertEqual({"box"}, self._matched("[type$=box] {c:d}"))
|
|
325
|
+
self.assertEqual({"text", "link"}, self._matched("[title*=' '] {c:d}"))
|
|
326
|
+
self.assertEqual(set(), self._matched("[type^=ext] {c:d}"))
|
|
327
|
+
|
|
328
|
+
def test_empty_value_matches_nothing(self) -> None:
|
|
329
|
+
for op in ("^=", "$=", "*="):
|
|
330
|
+
with self.subTest(op=op):
|
|
331
|
+
self.assertEqual(set(), self._matched(f"[type{op}''] {{c:d}}"))
|
|
332
|
+
|
|
333
|
+
def test_undeclared_namespace_prefix_drops_the_rule(self) -> None:
|
|
334
|
+
self.assertEqual({"p"}, set(self._parse("[ns|attr] {c:d} p{c:d}")))
|
|
335
|
+
|
|
336
|
+
def test_malformed_attribute_drops_only_its_rule(self) -> None:
|
|
337
|
+
# "!=" is no CSS operator; it used to parse, then raise when matched.
|
|
338
|
+
for css in ("a[=x]", "a[x=]", "a[x=y", "a[x!=y]"):
|
|
339
|
+
with self.subTest(css=css):
|
|
340
|
+
self.assertEqual({"p"}, set(self._parse(css + " {c:d} p{c:d}")))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class SelectorTest(ParserTestCase):
|
|
344
|
+
def test_malformed_pseudo_drops_only_its_rule(self) -> None:
|
|
345
|
+
for css in ("a:", "li:nth-child(2"):
|
|
346
|
+
with self.subTest(css=css):
|
|
347
|
+
self.assertEqual({"p"}, set(self._parse(css + " {c:d} p{c:d}")))
|
|
348
|
+
|
|
349
|
+
def test_selector_starting_with_a_digit(self) -> None:
|
|
350
|
+
for css in (".1", "p .1"):
|
|
351
|
+
with self.subTest(css=css):
|
|
352
|
+
self.assertEqual({"p"}, set(self._parse(css + " {c:d} p{c:d}")))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class DeclarationTest(ParserTestCase):
|
|
356
|
+
def test_rule_without_a_block_takes_the_next_one_with_it(self) -> None:
|
|
357
|
+
# "p ; div" is one prelude, so its block is not div's (CSS Syntax 3).
|
|
358
|
+
self.assertEqual({"span"}, set(self._parse("p ; div{c:d} span{c:d}")))
|
|
359
|
+
|
|
360
|
+
def test_empty_declarations_are_skipped(self) -> None:
|
|
361
|
+
# Both used to throw the whole rule away.
|
|
362
|
+
self.assertEqual(
|
|
363
|
+
{"p": {"color": "red", "font-size": ("2", "px")}},
|
|
364
|
+
self._parse("p{color:red;;font-size:2px}"),
|
|
365
|
+
)
|
|
366
|
+
self.assertEqual({"p": {"color": "red"}}, self._parse("p{;color:red}"))
|
|
367
|
+
|
|
368
|
+
def test_end_of_stylesheet_closes_the_block(self) -> None:
|
|
369
|
+
self.assertEqual({"p": {"color": "red"}}, self._parse("p{color:red"))
|
|
370
|
+
|
|
371
|
+
def test_invalid_declaration_drops_only_itself(self) -> None:
|
|
372
|
+
# It used to take the whole rule with it.
|
|
373
|
+
self.assertEqual(
|
|
374
|
+
{"p": {"color": "red"}, "div": {"c": "d"}},
|
|
375
|
+
self._parse("p{color:red; !ie} div{c:d}"),
|
|
376
|
+
)
|
|
377
|
+
self.assertEqual(
|
|
378
|
+
{"p": {"color": "red"}}, self._parse("p{color: red blue !x; color: red}")
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def test_skipped_declaration_ends_outside_strings(self) -> None:
|
|
382
|
+
# The "}" in the string does not end the block.
|
|
383
|
+
self.assertEqual(
|
|
384
|
+
{"p": {"color": "red"}, "q": {"c": "d"}},
|
|
385
|
+
self._parse('p{ !x "a}b"; color: red } q{c:d}'),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def test_star_hack_is_dropped(self) -> None:
|
|
389
|
+
# "*font" is an old IE hack, invalid CSS that a browser ignores.
|
|
390
|
+
self.assertEqual(
|
|
391
|
+
{"color": "red"}, self._parse("p{*font: smaller; color:red}")["p"]
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def test_equals_as_separator(self) -> None:
|
|
395
|
+
self.assertEqual({"p": {"color": "red"}}, self._parse("p{color=red}"))
|
|
396
|
+
|
|
397
|
+
def test_unicode_range(self) -> None:
|
|
398
|
+
# Used to raise IndexError, from every @font-face Google Fonts serves.
|
|
399
|
+
self.assertEqual(
|
|
400
|
+
{"unicode-range": "U+0025-00FF"},
|
|
401
|
+
self._parse("p{unicode-range: U+0025-00FF}")["p"],
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def test_unclosed_function(self) -> None:
|
|
405
|
+
# At the end of the stylesheet this used to raise IndexError.
|
|
406
|
+
self.assertEqual(
|
|
407
|
+
{"p": {"color": "blue"}}, self._parse("p{color: blue; width: calc(1px")
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def test_non_ascii_strings_and_names(self) -> None:
|
|
411
|
+
# Only Latin-1 counted as a string character: "宋体" gave NotImplemented,
|
|
412
|
+
# '宋体' kept its quotes, and "a宋" lost the whole rule.
|
|
413
|
+
self.assertEqual(
|
|
414
|
+
{
|
|
415
|
+
"p": {"font-family": ["宋体", "serif"]},
|
|
416
|
+
"q": {"font-family": "宋体"},
|
|
417
|
+
"b": {"font-family": "宋体"},
|
|
418
|
+
"i": {"content": "a宋"},
|
|
419
|
+
},
|
|
420
|
+
self._parse(
|
|
421
|
+
"p{font-family: \"宋体\", serif} q{font-family: '宋体'}"
|
|
422
|
+
' b{font-family: 宋体} i{content: "a宋"}'
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def test_empty_string(self) -> None:
|
|
427
|
+
self.assertEqual({"content": ""}, self._parse("p{content: ''}")["p"])
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class DocumentTest(ParserTestCase):
|
|
431
|
+
"""What the fixes above are for: the document renders, with the rest of the CSS."""
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def _colors(html: bytes) -> list[str]:
|
|
435
|
+
return [
|
|
436
|
+
str(flowable.style.textColor)
|
|
437
|
+
for flowable in pisaStory(html).story
|
|
438
|
+
if hasattr(flowable, "style")
|
|
439
|
+
]
|
|
440
|
+
|
|
441
|
+
def test_malformed_at_rules_do_not_stop_the_document(self) -> None:
|
|
442
|
+
html = (
|
|
443
|
+
b"<style>@import foo; @namespace svg; @charset 'x'"
|
|
444
|
+
b" p { color: #ff0000 } @media print</style><p>x</p>"
|
|
445
|
+
)
|
|
446
|
+
# "@charset 'x' p {...}" is one invalid at-rule; the rule after it is not.
|
|
447
|
+
self.assertEqual(["Color(0,0,0,1)"], self._colors(html))
|
|
448
|
+
html = (
|
|
449
|
+
b"<style>@namespace svg; p { color: #ff0000 } @media print</style><p>x</p>"
|
|
450
|
+
)
|
|
451
|
+
self.assertEqual(["Color(1,0,0,1)"], self._colors(html))
|
|
452
|
+
|
|
453
|
+
def test_broken_inline_style_keeps_the_rest(self) -> None:
|
|
454
|
+
html = b'<p style="color: #ff0000; width: calc(1px">x</p>'
|
|
455
|
+
self.assertEqual(["Color(1,0,0,1)"], self._colors(html))
|
|
456
|
+
|
|
457
|
+
def test_sibling_combinators_tell_siblings_apart(self) -> None:
|
|
458
|
+
# Siblings shared one cache entry, so "h1 + p" coloured every
|
|
459
|
+
# paragraph after the heading and "h1 ~ p" and "p + p" none.
|
|
460
|
+
red, black = "Color(1,0,0,1)", "Color(0,0,0,1)"
|
|
461
|
+
cases = {
|
|
462
|
+
b"h1 + p": (b"<h1>t</h1><p>a</p><p>b</p>", [black, red, black]),
|
|
463
|
+
b"h1 ~ p": (b"<p>a</p><h1>t</h1><p>b</p>", [black, black, red]),
|
|
464
|
+
b"p + p": (b"<p>a</p><p>b</p><p>c</p>", [black, red, red]),
|
|
465
|
+
}
|
|
466
|
+
for selector, (body, colors) in cases.items():
|
|
467
|
+
with self.subTest(selector=selector):
|
|
468
|
+
css = b"<style>" + selector + b" { color: #ff0000 }</style>"
|
|
469
|
+
self.assertEqual(colors, self._colors(css + body))
|
|
470
|
+
|
|
471
|
+
def test_selector_list_pseudo_classes_tell_siblings_apart(self) -> None:
|
|
472
|
+
# Each of these tells two siblings with the same tag apart by
|
|
473
|
+
# something the style cache has to key on: a class inside :not(),
|
|
474
|
+
# the children for :has(), an attribute for :checked and :lang().
|
|
475
|
+
red, black = "Color(1,0,0,1)", "Color(0,0,0,1)"
|
|
476
|
+
cases = {
|
|
477
|
+
b"p:not(.x)": (b"<p class='x'>a</p><p>b</p>", [black, red]),
|
|
478
|
+
b"p:has(b)": (b"<p>a</p><p><b>b</b></p>", [black, red]),
|
|
479
|
+
b"p:not(:first-child)": (b"<p>a</p><p>b</p>", [black, red]),
|
|
480
|
+
b"p:lang(fr)": (b"<p lang='en'>a</p><p lang='fr'>b</p>", [black, red]),
|
|
481
|
+
b"p:is([title])": (b"<p>a</p><p title='t'>b</p>", [black, red]),
|
|
482
|
+
}
|
|
483
|
+
for selector, (body, colors) in cases.items():
|
|
484
|
+
with self.subTest(selector=selector):
|
|
485
|
+
css = b"<style>" + selector + b" { color: #ff0000 }</style>"
|
|
486
|
+
self.assertEqual(colors, self._colors(css + body))
|
|
487
|
+
|
|
488
|
+
def test_pseudo_class_specificity_in_the_cascade(self) -> None:
|
|
489
|
+
# div p:first-child is (0,1,2) and beats .x p (0,1,1); it used to be
|
|
490
|
+
# (0,0,3) and lose. :where() adds nothing, so p beats it.
|
|
491
|
+
html = (
|
|
492
|
+
b"<style>div p:first-child { color: #ff0000 } .x p { color: #000000 }"
|
|
493
|
+
b" p.y { color: #ff0000 } :where(p.y) { color: #000000 }</style>"
|
|
494
|
+
b"<div class='x'><p>a</p></div><p class='y'>b</p>"
|
|
495
|
+
)
|
|
496
|
+
self.assertEqual(["Color(1,0,0,1)", "Color(1,0,0,1)"], self._colors(html))
|
|
497
|
+
|
|
498
|
+
def test_attribute_selectors_tell_siblings_apart(self) -> None:
|
|
499
|
+
# The style cache keyed siblings by tag, class, id and style only, so
|
|
500
|
+
# the second paragraph was handed the first one's colour.
|
|
501
|
+
html = (
|
|
502
|
+
b"<style>p[title^=big] { color: #ff0000 }</style>"
|
|
503
|
+
b"<p title='big one'>x</p><p title='small'>y</p>"
|
|
504
|
+
)
|
|
505
|
+
self.assertEqual(["Color(1,0,0,1)", "Color(0,0,0,1)"], self._colors(html))
|
|
@@ -677,3 +677,40 @@ class SourceEncodingTest(TestCase):
|
|
|
677
677
|
declared = '<meta charset="utf-8">'
|
|
678
678
|
html = f"<html><head>{declared}</head><body><span>Ä-ö-ü</span></body></html>"
|
|
679
679
|
self.assertEqual("Ä-ö-ü", self.text(html.encode("utf-8")))
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
class DebugLogTest(TestCase):
|
|
683
|
+
"""The debug log does not repeat the whole source document (#92)."""
|
|
684
|
+
|
|
685
|
+
def test_the_source_is_shortened(self) -> None:
|
|
686
|
+
html = "<p>" + "long paragraph " * 2000 + "</p>"
|
|
687
|
+
for src in (html, html.encode()):
|
|
688
|
+
with (
|
|
689
|
+
self.subTest(type=type(src).__name__),
|
|
690
|
+
self.assertLogs("xhtml2pdf.document", level="DEBUG") as logs,
|
|
691
|
+
):
|
|
692
|
+
pisaDocument(src, BytesIO())
|
|
693
|
+
options = next(
|
|
694
|
+
line for line in logs.output if "pisaDocument options" in line
|
|
695
|
+
)
|
|
696
|
+
self.assertLess(len(options), 1000)
|
|
697
|
+
self.assertIn("long paragraph", options)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class DefaultCssTest(TestCase):
|
|
701
|
+
"""default_css replaces the predefined CSS; extending it keeps the text (#735)."""
|
|
702
|
+
|
|
703
|
+
@staticmethod
|
|
704
|
+
def text(**kwargs) -> str:
|
|
705
|
+
dest = BytesIO()
|
|
706
|
+
pisaDocument("<body><p>visible text</p></body>", dest, **kwargs)
|
|
707
|
+
dest.seek(0)
|
|
708
|
+
return PdfReader(dest).pages[0].extract_text()
|
|
709
|
+
|
|
710
|
+
def test_extending_the_default_css_keeps_the_text(self) -> None:
|
|
711
|
+
from xhtml2pdf.default import DEFAULT_CSS
|
|
712
|
+
|
|
713
|
+
self.assertIn(
|
|
714
|
+
"visible text",
|
|
715
|
+
self.text(default_css=DEFAULT_CSS + "body { font-size: 12pt; }"),
|
|
716
|
+
)
|