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.
Files changed (80) hide show
  1. {xhtml2pdf-0.2.20/xhtml2pdf.egg-info → xhtml2pdf-0.2.22}/PKG-INFO +3 -3
  2. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/pyproject.toml +7 -4
  3. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_cli.py +16 -0
  4. xhtml2pdf-0.2.22/tests/test_css_parser.py +505 -0
  5. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_document.py +37 -0
  6. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_files.py +41 -0
  7. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_flex_flowable.py +21 -1
  8. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_inline_block.py +10 -0
  9. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_inline_box.py +38 -1
  10. xhtml2pdf-0.2.22/tests/test_paragraph_height.py +85 -0
  11. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_parser.py +199 -0
  12. xhtml2pdf-0.2.22/tests/test_position.py +290 -0
  13. xhtml2pdf-0.2.22/tests/test_reportlab_api.py +681 -0
  14. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_right_to_left_font_support.py +9 -0
  15. xhtml2pdf-0.2.22/tests/test_security_limits.py +694 -0
  16. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_selectors.py +300 -12
  17. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_special_css.py +150 -0
  18. xhtml2pdf-0.2.22/tests/test_table_border_radius.py +146 -0
  19. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_tables.py +128 -0
  20. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_tags.py +112 -0
  21. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_utils.py +237 -0
  22. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/__init__.py +1 -1
  23. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/builders/flex.py +26 -8
  24. xhtml2pdf-0.2.22/xhtml2pdf/builders/position.py +591 -0
  25. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/builders/signs.py +46 -16
  26. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/builders/watermarks.py +50 -9
  27. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/config/resources.py +200 -1
  28. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/context.py +142 -44
  29. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/default.py +20 -4
  30. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/document.py +32 -6
  31. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/files.py +148 -7
  32. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/parser.py +346 -39
  33. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/pisa.py +9 -2
  34. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/properties.py +42 -0
  35. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/reportlab_paragraph.py +134 -18
  36. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/tables.py +110 -13
  37. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/tags.py +42 -15
  38. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/util.py +511 -4
  39. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/w3c/css.py +325 -21
  40. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/w3c/cssDOMElementInterface.py +151 -50
  41. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/w3c/cssParser.py +291 -134
  42. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/w3c/cssSpecial.py +91 -1
  43. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/xhtml2pdf_reportlab.py +245 -5
  44. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22/xhtml2pdf.egg-info}/PKG-INFO +3 -3
  45. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf.egg-info/SOURCES.txt +6 -0
  46. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf.egg-info/requires.txt +2 -2
  47. xhtml2pdf-0.2.20/tests/test_reportlab_api.py +0 -236
  48. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/AUTHORS.rst +0 -0
  49. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/LICENSE.txt +0 -0
  50. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/MANIFEST.in +0 -0
  51. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/README.rst +0 -0
  52. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/setup.cfg +0 -0
  53. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_asian_font_support.py +0 -0
  54. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_css_media_rule.py +0 -0
  55. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_flex.py +0 -0
  56. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_flex_layout.py +0 -0
  57. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_fonts.py +0 -0
  58. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_http.py +0 -0
  59. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_paragraph.py +0 -0
  60. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_pdf.py +0 -0
  61. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_reportlab.py +0 -0
  62. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_reportlab_paragraph.py +0 -0
  63. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_resource_policy.py +0 -0
  64. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_table_css.py +0 -0
  65. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_update_face_name.py +0 -0
  66. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_watermarks.py +0 -0
  67. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/tests/test_wsgi.py +0 -0
  68. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/__main__.py +0 -0
  69. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/builders/__init__.py +0 -0
  70. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/builders/flex_layout.py +0 -0
  71. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/charts.py +0 -0
  72. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/config/__init__.py +0 -0
  73. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/config/httpconfig.py +0 -0
  74. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/paragraph.py +0 -0
  75. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/pdf.py +0 -0
  76. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/w3c/__init__.py +0 -0
  77. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf/wsgi.py +0 -0
  78. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf.egg-info/dependency_links.txt +0 -0
  79. {xhtml2pdf-0.2.20 → xhtml2pdf-0.2.22}/xhtml2pdf.egg-info/entry_points.txt +0 -0
  80. {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.20
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.0.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.0.4; extra == "pycairo"
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.0.4,<6",
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.0.4,<6",
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.0.4,<5
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
+ )