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.
Files changed (84) hide show
  1. {xhtml2pdf-0.2.22/xhtml2pdf.egg-info → xhtml2pdf-0.2.23}/PKG-INFO +1 -1
  2. xhtml2pdf-0.2.23/tests/test_block_box.py +486 -0
  3. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_css_parser.py +81 -0
  4. xhtml2pdf-0.2.23/tests/test_css_variables.py +159 -0
  5. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_document.py +84 -0
  6. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_fonts.py +21 -0
  7. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_parser.py +95 -6
  8. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_reportlab_paragraph.py +43 -0
  9. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_security_limits.py +28 -8
  10. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_special_css.py +76 -0
  11. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_tables.py +360 -1
  12. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_tags.py +490 -6
  13. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_utils.py +53 -0
  14. xhtml2pdf-0.2.23/tests/test_word_breaking.py +127 -0
  15. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/__init__.py +1 -1
  16. xhtml2pdf-0.2.23/xhtml2pdf/builders/block.py +510 -0
  17. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/flex.py +57 -9
  18. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/watermarks.py +11 -0
  19. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/charts.py +145 -8
  20. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/context.py +100 -13
  21. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/default.py +6 -0
  22. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/document.py +60 -3
  23. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/parser.py +346 -37
  24. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/properties.py +51 -2
  25. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/reportlab_paragraph.py +218 -26
  26. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/tables.py +58 -4
  27. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/tags.py +84 -22
  28. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/util.py +81 -0
  29. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/w3c/css.py +14 -0
  30. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/w3c/cssParser.py +130 -12
  31. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/w3c/cssSpecial.py +16 -0
  32. xhtml2pdf-0.2.23/xhtml2pdf/w3c/css_variables.py +184 -0
  33. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/xhtml2pdf_reportlab.py +352 -24
  34. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23/xhtml2pdf.egg-info}/PKG-INFO +1 -1
  35. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf.egg-info/SOURCES.txt +6 -1
  36. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/AUTHORS.rst +0 -0
  37. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/LICENSE.txt +0 -0
  38. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/MANIFEST.in +0 -0
  39. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/README.rst +0 -0
  40. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/pyproject.toml +0 -0
  41. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/setup.cfg +0 -0
  42. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_asian_font_support.py +0 -0
  43. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_cli.py +0 -0
  44. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_css_media_rule.py +0 -0
  45. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_files.py +0 -0
  46. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_flex.py +0 -0
  47. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_flex_flowable.py +0 -0
  48. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_flex_layout.py +0 -0
  49. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_http.py +0 -0
  50. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_inline_block.py +0 -0
  51. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_inline_box.py +0 -0
  52. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_paragraph.py +0 -0
  53. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_paragraph_height.py +0 -0
  54. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_pdf.py +0 -0
  55. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_position.py +0 -0
  56. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_reportlab.py +0 -0
  57. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_reportlab_api.py +0 -0
  58. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_resource_policy.py +0 -0
  59. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_right_to_left_font_support.py +0 -0
  60. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_selectors.py +0 -0
  61. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_table_border_radius.py +0 -0
  62. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_table_css.py +0 -0
  63. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_update_face_name.py +0 -0
  64. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_watermarks.py +0 -0
  65. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/tests/test_wsgi.py +0 -0
  66. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/__main__.py +0 -0
  67. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/__init__.py +0 -0
  68. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/flex_layout.py +0 -0
  69. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/position.py +0 -0
  70. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/builders/signs.py +0 -0
  71. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/config/__init__.py +0 -0
  72. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/config/httpconfig.py +0 -0
  73. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/config/resources.py +0 -0
  74. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/files.py +0 -0
  75. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/paragraph.py +0 -0
  76. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/pdf.py +0 -0
  77. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/pisa.py +0 -0
  78. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/w3c/__init__.py +0 -0
  79. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/w3c/cssDOMElementInterface.py +0 -0
  80. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf/wsgi.py +0 -0
  81. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf.egg-info/dependency_links.txt +0 -0
  82. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf.egg-info/entry_points.txt +0 -0
  83. {xhtml2pdf-0.2.22 → xhtml2pdf-0.2.23}/xhtml2pdf.egg-info/requires.txt +0 -0
  84. {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.22
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))