xhtml2pdf 0.2.22__tar.gz → 0.2.23__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.22/xhtml2pdf.egg-info → xhtml2pdf-0.2.23}/PKG-INFO +1 -1
- xhtml2pdf-0.2.23/tests/test_block_box.py +486 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_css_parser.py +81 -0
- xhtml2pdf-0.2.23/tests/test_css_variables.py +159 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_document.py +84 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_fonts.py +21 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_parser.py +95 -6
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_reportlab_paragraph.py +43 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_security_limits.py +28 -8
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_special_css.py +76 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_tables.py +360 -1
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_tags.py +490 -6
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_utils.py +53 -0
- xhtml2pdf-0.2.23/tests/test_word_breaking.py +127 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/__init__.py +1 -1
- xhtml2pdf-0.2.23/xhtml2pdf/builders/block.py +510 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/flex.py +57 -9
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/watermarks.py +11 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/charts.py +145 -8
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/context.py +100 -13
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/default.py +6 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/document.py +60 -3
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/parser.py +346 -37
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/properties.py +51 -2
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/reportlab_paragraph.py +218 -26
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/tables.py +58 -4
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/tags.py +84 -22
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/util.py +81 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/w3c/css.py +14 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/w3c/cssParser.py +130 -12
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/w3c/cssSpecial.py +16 -0
- xhtml2pdf-0.2.23/xhtml2pdf/w3c/css_variables.py +184 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/xhtml2pdf_reportlab.py +352 -24
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23/xhtml2pdf.egg-info}/PKG-INFO +1 -1
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf.egg-info/SOURCES.txt +6 -1
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/AUTHORS.rst +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/LICENSE.txt +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/MANIFEST.in +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/README.rst +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/pyproject.toml +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/setup.cfg +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_asian_font_support.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_cli.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_css_media_rule.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_files.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_flex.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_flex_flowable.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_flex_layout.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_http.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_inline_block.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_inline_box.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_paragraph.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_paragraph_height.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_pdf.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_position.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_reportlab.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_reportlab_api.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_resource_policy.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_right_to_left_font_support.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_selectors.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_table_border_radius.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_table_css.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_update_face_name.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_watermarks.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_wsgi.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/__main__.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/__init__.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/flex_layout.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/position.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/signs.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/config/__init__.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/config/httpconfig.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/config/resources.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/files.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/paragraph.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/pdf.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/pisa.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/w3c/__init__.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/w3c/cssDOMElementInterface.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/wsgi.py +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf.egg-info/dependency_links.txt +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf.egg-info/entry_points.txt +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf.egg-info/requires.txt +0 -0
- {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/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.23
|
|
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>
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""A block's padding, border and background drawn once around all it holds (#627)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
7
|
+
from unittest import TestCase, mock
|
|
8
|
+
|
|
9
|
+
from pypdf import PdfReader
|
|
10
|
+
from reportlab.lib.colors import toColor
|
|
11
|
+
from reportlab.platypus.tables import Table
|
|
12
|
+
|
|
13
|
+
from xhtml2pdf import pisa
|
|
14
|
+
from xhtml2pdf.builders import block
|
|
15
|
+
from xhtml2pdf.builders.block import BlockBox
|
|
16
|
+
from xhtml2pdf.document import pisaStory
|
|
17
|
+
from xhtml2pdf.xhtml2pdf_reportlab import PmlParagraph, PmlTable, PmlTableOfContents
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from xhtml2pdf.builders.flex import BoxStyle
|
|
21
|
+
|
|
22
|
+
RED = toColor("red")
|
|
23
|
+
BLUE = toColor("blue")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Box(NamedTuple):
|
|
27
|
+
"""A box's border, as drawn: page, left, bottom, width, height, style."""
|
|
28
|
+
|
|
29
|
+
page: int
|
|
30
|
+
x: float
|
|
31
|
+
y: float
|
|
32
|
+
w: float
|
|
33
|
+
h: float
|
|
34
|
+
style: BoxStyle
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Drawn(NamedTuple):
|
|
38
|
+
"""A table as drawn: page, left, bottom, width, height."""
|
|
39
|
+
|
|
40
|
+
page: int
|
|
41
|
+
x: float
|
|
42
|
+
y: float
|
|
43
|
+
w: float
|
|
44
|
+
h: float
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render(
|
|
48
|
+
html: str, *, last_pass: bool = False
|
|
49
|
+
) -> tuple[list[Box], list[Drawn], bytes]:
|
|
50
|
+
"""
|
|
51
|
+
The boxes BlockBox drew, the tables, and the PDF.
|
|
52
|
+
|
|
53
|
+
A document with an index is built more than once; last_pass keeps only
|
|
54
|
+
what the last build drew.
|
|
55
|
+
"""
|
|
56
|
+
boxes: list[Box] = []
|
|
57
|
+
tables: list[Drawn] = []
|
|
58
|
+
borders = block.drawBoxBorders
|
|
59
|
+
draw = Table.drawOn
|
|
60
|
+
before = PmlTableOfContents.beforeBuild
|
|
61
|
+
|
|
62
|
+
def spy_pass(self) -> None:
|
|
63
|
+
if last_pass:
|
|
64
|
+
boxes.clear()
|
|
65
|
+
tables.clear()
|
|
66
|
+
before(self)
|
|
67
|
+
|
|
68
|
+
def spy_borders(canvas, x, y, w, h, style, *args, **kw):
|
|
69
|
+
ax, ay = canvas.absolutePosition(x, y)
|
|
70
|
+
boxes.append(Box(canvas.getPageNumber(), ax, ay, w, h, style))
|
|
71
|
+
return borders(canvas, x, y, w, h, style, *args, **kw)
|
|
72
|
+
|
|
73
|
+
def spy_table(self, canvas, x, y, _sW=0):
|
|
74
|
+
ax, ay = canvas.absolutePosition(self._hAlignAdjust(x, _sW), y)
|
|
75
|
+
tables.append(Drawn(canvas.getPageNumber(), ax, ay, self._width, self._height))
|
|
76
|
+
return draw(self, canvas, x, y, _sW)
|
|
77
|
+
|
|
78
|
+
dest = io.BytesIO()
|
|
79
|
+
with (
|
|
80
|
+
mock.patch.object(block, "drawBoxBorders", spy_borders),
|
|
81
|
+
mock.patch.object(Table, "drawOn", spy_table),
|
|
82
|
+
mock.patch.object(PmlTableOfContents, "beforeBuild", spy_pass),
|
|
83
|
+
):
|
|
84
|
+
result = pisa.CreatePDF(html, dest=dest)
|
|
85
|
+
assert not result.err
|
|
86
|
+
return boxes, tables, dest.getvalue()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def table_commands(html: str) -> list[tuple]:
|
|
90
|
+
"""The style commands of every PmlTable built."""
|
|
91
|
+
commands: list[tuple] = []
|
|
92
|
+
init = PmlTable.__init__
|
|
93
|
+
|
|
94
|
+
def spy(self, data, *args, **kw):
|
|
95
|
+
init(self, data, *args, **kw)
|
|
96
|
+
commands.extend(kw["style"].getCommands())
|
|
97
|
+
|
|
98
|
+
with mock.patch.object(PmlTable, "__init__", spy):
|
|
99
|
+
pisa.CreatePDF(html, dest=io.BytesIO())
|
|
100
|
+
return commands
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def frame() -> tuple[float, float]:
|
|
104
|
+
"""The left edge and the width of the default frame."""
|
|
105
|
+
_, (table,), _ = render("<table><tr><td>x</td></tr></table>")
|
|
106
|
+
return table.x, table.w
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
ROW = "<tr><td>one</td><td>two</td></tr>"
|
|
110
|
+
DIV = '<div style="margin-left: 50pt; padding: 10pt; border: 2pt solid red">{}</div>'
|
|
111
|
+
TABLE = f'<table style="border: 1pt solid blue">{ROW}</table>'
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class OneBoxTestCase(TestCase):
|
|
115
|
+
def test_text_table_and_text_are_in_one_box(self) -> None:
|
|
116
|
+
boxes, (table,), _ = render(DIV.format(f"<p>before</p>{TABLE}<p>after</p>"))
|
|
117
|
+
|
|
118
|
+
(box,) = boxes
|
|
119
|
+
self.assertGreater(box.y + box.h, table.y + table.h)
|
|
120
|
+
self.assertLess(box.y, table.y)
|
|
121
|
+
# Room above the table for "before", below it for "after".
|
|
122
|
+
self.assertGreater(box.y + box.h - (table.y + table.h), 20)
|
|
123
|
+
self.assertGreater(table.y - box.y, 20)
|
|
124
|
+
|
|
125
|
+
def test_the_table_sits_inside_the_margin_and_padding(self) -> None:
|
|
126
|
+
left, width = frame()
|
|
127
|
+
boxes, (table,), _ = render(DIV.format(TABLE))
|
|
128
|
+
|
|
129
|
+
(box,) = boxes
|
|
130
|
+
self.assertAlmostEqual(left + 50, box.x)
|
|
131
|
+
self.assertAlmostEqual(width - 50, box.w)
|
|
132
|
+
self.assertAlmostEqual(left + 50 + 12, table.x)
|
|
133
|
+
self.assertAlmostEqual(width - 50 - 24, table.w)
|
|
134
|
+
self.assertAlmostEqual(box.y + 12, table.y)
|
|
135
|
+
|
|
136
|
+
def test_each_border_keeps_its_own_colour(self) -> None:
|
|
137
|
+
html = DIV.format(TABLE)
|
|
138
|
+
lines = [c for c in table_commands(html) if c[0].startswith("LINE")]
|
|
139
|
+
boxes, _, _ = render(html)
|
|
140
|
+
|
|
141
|
+
self.assertTrue(lines)
|
|
142
|
+
self.assertTrue(all(c[4] == BLUE for c in lines), lines)
|
|
143
|
+
(box,) = boxes
|
|
144
|
+
for side in ("Left", "Right", "Top", "Bottom"):
|
|
145
|
+
self.assertEqual(RED, getattr(box.style, f"border{side}Color"))
|
|
146
|
+
|
|
147
|
+
def test_the_table_does_not_take_the_padding(self) -> None:
|
|
148
|
+
commands = table_commands(DIV.format(TABLE))
|
|
149
|
+
|
|
150
|
+
paddings = {c[3] for c in commands if c[0].endswith("PADDING")}
|
|
151
|
+
self.assertNotIn(10.0, paddings, commands)
|
|
152
|
+
|
|
153
|
+
def test_what_is_inside_does_not_repeat_the_box(self) -> None:
|
|
154
|
+
story = pisaStory(DIV.format(f"<p>before</p>{TABLE}<p>after</p>")).story
|
|
155
|
+
(box,) = [f for f in story if isinstance(f, BlockBox)]
|
|
156
|
+
|
|
157
|
+
paragraphs = [f for f in box.content if isinstance(f, PmlParagraph)]
|
|
158
|
+
self.assertEqual(2, len(paragraphs))
|
|
159
|
+
for paragraph in paragraphs:
|
|
160
|
+
style = paragraph.style
|
|
161
|
+
self.assertEqual(0, style.paddingLeft)
|
|
162
|
+
self.assertEqual(0, style.borderLeftWidth or 0)
|
|
163
|
+
self.assertIsNone(style.backColor)
|
|
164
|
+
self.assertEqual(0, style.leftIndent)
|
|
165
|
+
|
|
166
|
+
def test_a_block_of_text_alone_keeps_its_paragraph_box(self) -> None:
|
|
167
|
+
story = pisaStory(DIV.format("only text")).story
|
|
168
|
+
|
|
169
|
+
self.assertFalse([f for f in story if isinstance(f, BlockBox)])
|
|
170
|
+
(paragraph,) = [f for f in story if isinstance(f, PmlParagraph)]
|
|
171
|
+
self.assertEqual(10, paragraph.style.paddingLeft)
|
|
172
|
+
|
|
173
|
+
def test_an_inherited_padding_is_no_box(self) -> None:
|
|
174
|
+
"""A margin alone makes no box: the indent is the paragraphs' still."""
|
|
175
|
+
story = pisaStory('<div style="margin-left: 50pt"><p>a</p><p>b</p></div>').story
|
|
176
|
+
|
|
177
|
+
self.assertFalse([f for f in story if isinstance(f, BlockBox)])
|
|
178
|
+
|
|
179
|
+
def test_a_background_covers_the_gap_between_paragraphs(self) -> None:
|
|
180
|
+
story = pisaStory(
|
|
181
|
+
'<div style="background-color: #eee"><p>a</p><p>b</p></div>'
|
|
182
|
+
).story
|
|
183
|
+
|
|
184
|
+
(box,) = [f for f in story if isinstance(f, BlockBox)]
|
|
185
|
+
self.assertIsNotNone(box.style.backColor)
|
|
186
|
+
for paragraph in box.content:
|
|
187
|
+
self.assertIsNone(paragraph.style.backColor)
|
|
188
|
+
|
|
189
|
+
def test_nested_boxes(self) -> None:
|
|
190
|
+
inner = (
|
|
191
|
+
'<div style="border: 1pt solid blue; padding: 5pt"><p>a</p><p>b</p></div>'
|
|
192
|
+
)
|
|
193
|
+
boxes, _, _ = render(DIV.format(f"<p>x</p>{inner}"))
|
|
194
|
+
|
|
195
|
+
outer, nested = sorted(boxes, key=lambda b: b.w, reverse=True)
|
|
196
|
+
self.assertAlmostEqual(outer.x + 12, nested.x)
|
|
197
|
+
self.assertAlmostEqual(outer.w - 24, nested.w)
|
|
198
|
+
|
|
199
|
+
def test_a_shrunk_table_is_centred_in_the_content_area(self) -> None:
|
|
200
|
+
boxes, (table,), _ = render(
|
|
201
|
+
DIV.format(f'<table style="margin: 0 auto">{ROW}</table>')
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
(box,) = boxes
|
|
205
|
+
middle = box.x + box.w / 2
|
|
206
|
+
self.assertAlmostEqual(middle, table.x + table.w / 2, places=3)
|
|
207
|
+
self.assertLess(table.w, box.w / 2)
|
|
208
|
+
|
|
209
|
+
def test_a_rounded_border_is_drawn_once(self) -> None:
|
|
210
|
+
boxes, _, _ = render(
|
|
211
|
+
'<div style="border: 1pt solid red; border-radius: 6pt">'
|
|
212
|
+
f"<p>a</p>{TABLE}</div>"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
self.assertEqual(1, len(boxes))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class PageTestCase(TestCase):
|
|
219
|
+
PARAS = "".join(f"<p>paragraph {i}</p>" for i in range(120))
|
|
220
|
+
|
|
221
|
+
def test_a_box_across_pages_is_sliced(self) -> None:
|
|
222
|
+
boxes, _, _ = render(DIV.format(self.PARAS))
|
|
223
|
+
|
|
224
|
+
self.assertGreater(len(boxes), 1)
|
|
225
|
+
self.assertEqual(list(range(1, len(boxes) + 1)), [b.page for b in boxes])
|
|
226
|
+
first, last = boxes[0].style, boxes[-1].style
|
|
227
|
+
self.assertIsNone(first.borderBottomStyle)
|
|
228
|
+
self.assertEqual("solid", first.borderTopStyle)
|
|
229
|
+
self.assertIsNone(last.borderTopStyle)
|
|
230
|
+
self.assertEqual("solid", last.borderBottomStyle)
|
|
231
|
+
|
|
232
|
+
def test_a_page_break_inside_cuts_the_box(self) -> None:
|
|
233
|
+
for brk in ("<pdf:nextpage/>", '<p style="page-break-before: always">b</p>'):
|
|
234
|
+
with self.subTest(brk=brk):
|
|
235
|
+
boxes, _, _ = render(DIV.format(f"<p>a</p>{brk}<p>c</p>"))
|
|
236
|
+
|
|
237
|
+
self.assertEqual([1, 2], [b.page for b in boxes])
|
|
238
|
+
self.assertIsNone(boxes[0].style.borderBottomStyle)
|
|
239
|
+
self.assertIsNone(boxes[1].style.borderTopStyle)
|
|
240
|
+
|
|
241
|
+
def test_page_break_inside_avoid_moves_the_box_whole(self) -> None:
|
|
242
|
+
filler = '<div style="height: 1pt"></div>' + "<p>filler</p>" * 50
|
|
243
|
+
boxes, _, _ = render(
|
|
244
|
+
filler
|
|
245
|
+
+ '<div style="border: 1pt solid red; page-break-inside: avoid">'
|
|
246
|
+
+ "".join(f"<p>{i}</p>" for i in range(20))
|
|
247
|
+
+ "</div>"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# The 1pt filler is a box of its own now that a height makes one.
|
|
251
|
+
self.assertEqual([2], [b.page for b in boxes if b.style.border("Top")])
|
|
252
|
+
|
|
253
|
+
def test_a_heading_inside_reaches_the_index(self) -> None:
|
|
254
|
+
_, _, pdf = render(
|
|
255
|
+
'<div style="page-break-after: always"><pdf:toc/></div>'
|
|
256
|
+
+ DIV.format("<h1>Chapter</h1><p>text</p>")
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
reader = PdfReader(io.BytesIO(pdf))
|
|
260
|
+
self.assertIn("Chapter", reader.pages[0].extract_text())
|
|
261
|
+
self.assertEqual(["Chapter"], [o.title for o in reader.outline])
|
|
262
|
+
|
|
263
|
+
def test_an_index_inside_a_box_still_fills(self) -> None:
|
|
264
|
+
_, _, pdf = render(
|
|
265
|
+
DIV.format("<pdf:toc/><p>x</p>")
|
|
266
|
+
+ '<h1 style="page-break-before: always">Chapter</h1>'
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
reader = PdfReader(io.BytesIO(pdf))
|
|
270
|
+
self.assertIn("Chapter", reader.pages[0].extract_text())
|
|
271
|
+
|
|
272
|
+
def assertInside(self, box: Box, table: Drawn) -> None:
|
|
273
|
+
self.assertEqual(box.page, table.page)
|
|
274
|
+
self.assertGreaterEqual(table.x, box.x)
|
|
275
|
+
self.assertLessEqual(table.x + table.w, box.x + box.w + 0.01)
|
|
276
|
+
self.assertGreaterEqual(table.y, box.y - 0.01)
|
|
277
|
+
self.assertLessEqual(table.y + table.h, box.y + box.h + 0.01)
|
|
278
|
+
|
|
279
|
+
def test_an_index_is_drawn_inside_its_box(self) -> None:
|
|
280
|
+
for html in (DIV.format("<pdf:toc/>"), DIV.format(DIV.format("<pdf:toc/>"))):
|
|
281
|
+
with self.subTest(html=html):
|
|
282
|
+
boxes, tables, _ = render(
|
|
283
|
+
html + '<h1 style="page-break-before: always">Chapter</h1>',
|
|
284
|
+
last_pass=True,
|
|
285
|
+
)
|
|
286
|
+
# The index is the table of its entries; the box is not one
|
|
287
|
+
# around nothing, with the index below it.
|
|
288
|
+
(index,) = tables
|
|
289
|
+
self.assertTrue(boxes)
|
|
290
|
+
for box in boxes:
|
|
291
|
+
self.assertInside(box, index)
|
|
292
|
+
|
|
293
|
+
def test_an_index_in_a_box_goes_on_to_the_next_page(self) -> None:
|
|
294
|
+
headings = "".join(f"<h1>Chapter {i}</h1>" for i in range(80))
|
|
295
|
+
boxes, tables, pdf = render(
|
|
296
|
+
DIV.format("<pdf:toc/>")
|
|
297
|
+
+ f'<div style="page-break-before: always">{headings}</div>',
|
|
298
|
+
last_pass=True,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
parts = [t for t in tables if t.page <= 2]
|
|
302
|
+
self.assertEqual(2, len(parts))
|
|
303
|
+
self.assertEqual([1, 2], [b.page for b in boxes])
|
|
304
|
+
for box, part in zip(boxes, parts, strict=True):
|
|
305
|
+
self.assertInside(box, part)
|
|
306
|
+
reader = PdfReader(io.BytesIO(pdf))
|
|
307
|
+
text = reader.pages[0].extract_text() + reader.pages[1].extract_text()
|
|
308
|
+
for i in range(80):
|
|
309
|
+
self.assertIn(f"Chapter {i}", text)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class ListTestCase(TestCase):
|
|
313
|
+
@staticmethod
|
|
314
|
+
def marker_and_text(html: str) -> tuple[float, float]:
|
|
315
|
+
"""The x of the list marker and of the item's text."""
|
|
316
|
+
dest = io.BytesIO()
|
|
317
|
+
pisa.CreatePDF(html, dest=dest)
|
|
318
|
+
runs: list[tuple[float, str]] = []
|
|
319
|
+
|
|
320
|
+
def visit(text, cm, tm, font_dict, font_size) -> None: # noqa: ARG001
|
|
321
|
+
if text.strip():
|
|
322
|
+
runs.append((tm[4] * cm[0] + cm[4], text.strip()))
|
|
323
|
+
|
|
324
|
+
PdfReader(io.BytesIO(dest.getvalue())).pages[0].extract_text(visitor_text=visit)
|
|
325
|
+
marker = min(x for x, t in runs if "item" not in t)
|
|
326
|
+
text = min(x for x, t in runs if "item" in t)
|
|
327
|
+
return marker, text
|
|
328
|
+
|
|
329
|
+
def test_a_marker_hangs_outside_a_bordered_item(self) -> None:
|
|
330
|
+
plain = self.marker_and_text("<ul><li><p>item</p><p>more</p></li></ul>")
|
|
331
|
+
boxed = self.marker_and_text(
|
|
332
|
+
'<ul><li style="border: 1pt solid red"><p>item</p><p>more</p></li></ul>'
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
self.assertLess(boxed[0], boxed[1])
|
|
336
|
+
self.assertAlmostEqual(plain[0], boxed[0], places=1)
|
|
337
|
+
|
|
338
|
+
def test_a_box_inside_an_item_keeps_the_marker(self) -> None:
|
|
339
|
+
marker, text = self.marker_and_text(
|
|
340
|
+
'<ul><li><div style="border: 1pt solid red"><p>item</p><p>x</p></div>'
|
|
341
|
+
"</li></ul>"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
self.assertLess(marker, text)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class PlacesTestCase(TestCase):
|
|
348
|
+
def test_in_a_table_cell(self) -> None:
|
|
349
|
+
boxes, _, _ = render(
|
|
350
|
+
f"<table><tr><td>{DIV.format('<p>a</p><p>b</p>')}</td></tr></table>"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
self.assertEqual(1, len(boxes))
|
|
354
|
+
|
|
355
|
+
def test_in_a_flex_item(self) -> None:
|
|
356
|
+
boxes, _, _ = render(
|
|
357
|
+
'<div style="display: flex"><div>'
|
|
358
|
+
+ DIV.format("<p>a</p><p>b</p>")
|
|
359
|
+
+ "</div><div>c</div></div>"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
self.assertEqual(1, len(boxes))
|
|
363
|
+
|
|
364
|
+
def test_right_to_left(self) -> None:
|
|
365
|
+
left, width = frame()
|
|
366
|
+
boxes, (table,), _ = render(
|
|
367
|
+
'<div dir="rtl" style="margin-right: 50pt; padding: 10pt;'
|
|
368
|
+
f' border: 2pt solid red">{TABLE}</div>'
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
(box,) = boxes
|
|
372
|
+
self.assertAlmostEqual(left, box.x)
|
|
373
|
+
self.assertAlmostEqual(width - 50, box.w)
|
|
374
|
+
self.assertAlmostEqual(box.x + box.w - 12, table.x + table.w)
|
|
375
|
+
|
|
376
|
+
def test_a_relative_box(self) -> None:
|
|
377
|
+
boxes, _, _ = render(
|
|
378
|
+
'<div style="position: relative; border: 1pt solid red; padding: 5pt">'
|
|
379
|
+
'<p>a</p><p>b</p><div style="position: absolute; top: 0; right: 0">'
|
|
380
|
+
"corner</div></div>"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
self.assertEqual(1, len(boxes))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class EmptyBoxTestCase(TestCase):
|
|
387
|
+
"""
|
|
388
|
+
A block with a border or a background and nothing in it is drawn.
|
|
389
|
+
|
|
390
|
+
It made no flowable at all: no block content for a box, and no text for
|
|
391
|
+
a paragraph to draw one round. CSS draws it, padding and borders tall.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
def test_an_empty_div(self) -> None:
|
|
395
|
+
boxes, _, _ = render(
|
|
396
|
+
'<div style="border: 2pt solid red; padding: 3pt"></div><p>after</p>'
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
(box,) = boxes
|
|
400
|
+
self.assertAlmostEqual(2 * (2 + 3), box.h)
|
|
401
|
+
|
|
402
|
+
def test_an_empty_paragraph(self) -> None:
|
|
403
|
+
boxes, _, _ = render('<p style="border-bottom: 1pt solid red"></p>')
|
|
404
|
+
|
|
405
|
+
self.assertEqual(1, len(boxes))
|
|
406
|
+
|
|
407
|
+
def test_a_block_of_text_keeps_its_paragraph_box(self) -> None:
|
|
408
|
+
boxes, _, _ = render('<div style="border: 1pt solid red">text</div>')
|
|
409
|
+
|
|
410
|
+
self.assertEqual([], boxes)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class HeightTestCase(TestCase):
|
|
414
|
+
"""
|
|
415
|
+
A block's CSS height is the least its content area takes.
|
|
416
|
+
|
|
417
|
+
It was read only for images, cells, flex items, barcodes and positioned
|
|
418
|
+
boxes; a block in the flow was as tall as its content whatever it said.
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
TWENTY_MM = 20 * 72 / 25.4
|
|
422
|
+
|
|
423
|
+
def test_an_empty_block(self) -> None:
|
|
424
|
+
boxes, _, _ = render('<div style="border: 1pt solid red; height: 20mm"></div>')
|
|
425
|
+
|
|
426
|
+
(box,) = boxes
|
|
427
|
+
self.assertAlmostEqual(self.TWENTY_MM + 2, box.h, places=3)
|
|
428
|
+
|
|
429
|
+
def test_a_block_of_blocks(self) -> None:
|
|
430
|
+
boxes, _, _ = render(
|
|
431
|
+
'<div style="border: 1pt solid red; height: 20mm"><p>a</p><p>b</p></div>'
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
(box,) = boxes
|
|
435
|
+
self.assertAlmostEqual(self.TWENTY_MM + 2, box.h, places=3)
|
|
436
|
+
|
|
437
|
+
def test_a_block_of_text(self) -> None:
|
|
438
|
+
boxes, _, _ = render('<div style="border: 1pt solid red; height: 20mm">a</div>')
|
|
439
|
+
|
|
440
|
+
(box,) = boxes
|
|
441
|
+
self.assertAlmostEqual(self.TWENTY_MM + 2, box.h, places=3)
|
|
442
|
+
|
|
443
|
+
def test_content_taller_than_the_height_wins(self) -> None:
|
|
444
|
+
boxes, _, _ = render(
|
|
445
|
+
'<div style="border: 1pt solid red; height: 5pt">'
|
|
446
|
+
+ "<p>line</p>" * 5
|
|
447
|
+
+ "</div>"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
(box,) = boxes
|
|
451
|
+
self.assertGreater(box.h, 50)
|
|
452
|
+
|
|
453
|
+
def test_a_percentage_is_ignored(self) -> None:
|
|
454
|
+
boxes, _, _ = render(
|
|
455
|
+
'<div style="border: 1pt solid red; height: 50%"><p>a</p><p>b</p></div>'
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
(box,) = boxes
|
|
459
|
+
self.assertLess(box.h, 100)
|
|
460
|
+
|
|
461
|
+
def test_a_block_without_a_box_still_takes_the_room(self) -> None:
|
|
462
|
+
def after(html: str) -> float:
|
|
463
|
+
from pypdf import PdfReader
|
|
464
|
+
|
|
465
|
+
ys: list[float] = []
|
|
466
|
+
page = PdfReader(io.BytesIO(render(html)[2])).pages[0]
|
|
467
|
+
page.extract_text(
|
|
468
|
+
visitor_text=lambda text, cm, tm, *_: (
|
|
469
|
+
ys.append(tm[5] * cm[3] + cm[5]) if "after" in text else None
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
return ys[0]
|
|
473
|
+
|
|
474
|
+
spaced = after('<p>before</p><div style="height: 20mm"></div><p>after</p>')
|
|
475
|
+
plain = after("<p>before</p><p>after</p>")
|
|
476
|
+
self.assertGreater(plain - spaced, self.TWENTY_MM)
|
|
477
|
+
|
|
478
|
+
def test_a_flex_item_is_left_to_its_container(self) -> None:
|
|
479
|
+
"""The container sizes and draws an item; a box inside it was drawn too."""
|
|
480
|
+
boxes, _, _ = render(
|
|
481
|
+
'<div style="display: flex">'
|
|
482
|
+
'<div style="border: 1pt solid red; height: 20mm">a</div>'
|
|
483
|
+
'<div style="border: 1pt solid red"></div></div>'
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
self.assertEqual([], boxes)
|
|
@@ -503,3 +503,84 @@ class DocumentTest(ParserTestCase):
|
|
|
503
503
|
b"<p title='big one'>x</p><p title='small'>y</p>"
|
|
504
504
|
)
|
|
505
505
|
self.assertEqual(["Color(1,0,0,1)", "Color(0,0,0,1)"], self._colors(html))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class SourceTest(ParserTestCase):
|
|
509
|
+
"""Each stylesheet is parsed on its own, and a warning names it (#780)."""
|
|
510
|
+
|
|
511
|
+
def _render(self, html: str, files: dict[str, str]) -> tuple[list[str], list]:
|
|
512
|
+
logging.disable(logging.NOTSET)
|
|
513
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
514
|
+
for name, text in files.items():
|
|
515
|
+
Path(directory, name).write_text(text, encoding="utf-8")
|
|
516
|
+
with self.assertLogs("xhtml2pdf", logging.WARNING) as logs:
|
|
517
|
+
logging.getLogger("xhtml2pdf").warning("start")
|
|
518
|
+
context = pisaStory(html, path=str(Path(directory, "doc.html")))
|
|
519
|
+
styles = [f.style for f in context.story if hasattr(f, "style")]
|
|
520
|
+
return logs.output, styles
|
|
521
|
+
|
|
522
|
+
def test_a_warning_names_the_stylesheet(self) -> None:
|
|
523
|
+
output, _ = self._render(
|
|
524
|
+
'<link rel="stylesheet" href="fa.css"><style>p { width: calc(1px }</style>'
|
|
525
|
+
'<p class="fa" style="color: calc(2px">x</p>',
|
|
526
|
+
{"fa.css": ".fa { margin-left: calc(var(--w, 2em) * -1) }"},
|
|
527
|
+
)
|
|
528
|
+
messages = "\n".join(output)
|
|
529
|
+
# The issue's own declaration: var() is substituted when an element
|
|
530
|
+
# uses it, and what comes out is still traced to its stylesheet.
|
|
531
|
+
self.assertRegex(
|
|
532
|
+
messages, r"fa\.css, var\(\) in margin-left: Ignoring CSS declaration"
|
|
533
|
+
)
|
|
534
|
+
self.assertIn("<style> block 2: Ignoring CSS declaration", messages)
|
|
535
|
+
self.assertIn("the style attribute of <p>: Ignoring", messages)
|
|
536
|
+
|
|
537
|
+
def test_unimplemented_properties_are_named_by_source(self) -> None:
|
|
538
|
+
output, _ = self._render(
|
|
539
|
+
'<style>p { box-shadow: 1px 1px red }</style><link rel="stylesheet"'
|
|
540
|
+
' href="ext.css"><p style="opacity: 0.5; width: min(1pt, 2pt)">y</p>',
|
|
541
|
+
{"ext.css": "p { float: left }"},
|
|
542
|
+
)
|
|
543
|
+
unimplemented = [
|
|
544
|
+
# "WARNING:logger:message"
|
|
545
|
+
line.split(":", 2)[2]
|
|
546
|
+
for line in output
|
|
547
|
+
if "does not implement" in line
|
|
548
|
+
]
|
|
549
|
+
self.assertEqual(3, len(unimplemented), output)
|
|
550
|
+
style, ext, attribute = unimplemented
|
|
551
|
+
self.assertTrue(style.startswith("<style> block 1: "), style)
|
|
552
|
+
self.assertTrue(style.endswith(": box-shadow"), style)
|
|
553
|
+
self.assertIn("ext.css: ", ext)
|
|
554
|
+
self.assertTrue(ext.endswith(": float"), ext)
|
|
555
|
+
# A style attribute used to be read without a word about it.
|
|
556
|
+
self.assertTrue(attribute.startswith("the style attribute of <p>: "))
|
|
557
|
+
self.assertTrue(attribute.endswith(": opacity"), attribute)
|
|
558
|
+
self.assertIn(
|
|
559
|
+
"the style attribute of <p>: Ignoring CSS declarations whose value"
|
|
560
|
+
" xhtml2pdf cannot evaluate: width: min()",
|
|
561
|
+
"\n".join(output),
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
def test_the_default_css_is_not_reported(self) -> None:
|
|
565
|
+
output, _ = self._render("<p>x</p>", {})
|
|
566
|
+
self.assertFalse([line for line in output if "does not implement" in line])
|
|
567
|
+
|
|
568
|
+
def test_a_link_after_a_style_applies(self) -> None:
|
|
569
|
+
# Both used to be one source, the <link> an "@import" in the middle
|
|
570
|
+
# of it, and an @import after a rule is ignored.
|
|
571
|
+
output, styles = self._render(
|
|
572
|
+
'<style>p { color: #ff0000 }</style><link rel="stylesheet" href="b.css">'
|
|
573
|
+
"<p>x</p>",
|
|
574
|
+
{"b.css": "p { font-size: 30pt }"},
|
|
575
|
+
)
|
|
576
|
+
self.assertNotIn("@import", "\n".join(output))
|
|
577
|
+
self.assertEqual(30, styles[0].fontSize)
|
|
578
|
+
self.assertEqual("Color(1,0,0,1)", str(styles[0].textColor))
|
|
579
|
+
|
|
580
|
+
def test_a_later_stylesheet_wins_a_tie(self) -> None:
|
|
581
|
+
_, styles = self._render(
|
|
582
|
+
'<link rel="stylesheet" href="a.css"><style>p { color: #0000ff }</style>'
|
|
583
|
+
'<link rel="stylesheet" href="a2.css"><p>x</p>',
|
|
584
|
+
{"a.css": "p { color: #ff0000 }", "a2.css": "p { color: #00ff00 }"},
|
|
585
|
+
)
|
|
586
|
+
self.assertEqual("Color(0,1,0,1)", str(styles[0].textColor))
|