diff --git a/docs/TextRegion.md b/docs/TextRegion.md index 088bad974..8c4e05f08 100644 --- a/docs/TextRegion.md +++ b/docs/TextRegion.md @@ -1,34 +1,36 @@ +_New in [:octicons-tag-24: 2.7.7](https://github.com/py-pdf/fpdf2/blob/master/CHANGELOG.md)_ # Text Flow Regions # -_New in [:octicons-tag-24: 2.7.6](https://github.com/py-pdf/fpdf2/blob/master/CHANGELOG.md)_ -**Notice:** As of fpdf2 release 2.7.6, this is an experimental feature. Both the API and the functionality may change before it is finalized, without prior notice. +**Notice:** As of fpdf2 release 2.7.7, this is an experimental feature. Both the API and the functionality may change before it is finalized, without prior notice. Text regions are a hierarchy of classes that enable to flow text within a given outline. In the simplest case, it is just the running text column of a page. But it can also be a sequence of outlines, such as several parallel columns or the cells of a table. Other outlines may be combined by addition or subtraction to create more complex shapes. There are two general categories of regions. One defines boundaries for running text that will just continue in the same manner one the next page. Those include columns and tables. The second category are distinct shapes. Examples would be a circle, a rectangle, a polygon of individual shape or even an image. They may be used individually, in combination, or to modify the outline of a multipage column. Shape regions will typically not cause a page break when they are full. In the future, a possibility to chain them may be implemented, so that a new shape will continue with the text that didn't fit into the previous one. +**The current implementation only supports columns.** Shaped regions and combinations are still in the design phase. + ## General Operation ## Using the different region types and combination always follows the same pattern. The main difference to the normal `FPDF.write()` method is that all added text will first be buffered, and you need to explicitly trigger its rendering on the page. This is necessary so that text can be aligned within the given boundaries even if its font, style, or size are arbitrarily varied along the way. * Create the region instance with an `FPDF` method. -* If desired, add or subtract other shapes from it (with geometric regions). +* future: (_If desired, add or subtract other shapes from it (with geometric regions)_). * Use the `.write()` method to feed text into its buffer. * Best practise is to use the region instance as a context manager for filling. * Text will be rendered automatically after closing the context. * When used as a context manager, you can change all text styling parameters within that context, and they will be used by the added text, but won't leak to the surroundings -* For adding text with the already existing settings, just use the region instance as is. In that case, you'll have to explicitly use the `render()` method. +* Alternatively, eg. for filling a single column of text with the already existing settings, just use the region instance as is. In that case, you'll have to explicitly use the `render()` method after adding the text. * Within a region, paragraphs can be inserted. The primary purpose of a paragraph is to apply a different horizontal alignment than the surrounding text. ### Text Start Position ### -When rendering, the vertical start position of the text will be at the lowest one out of the current y position, the top of the region (if it has a defined top), or the top margin of the page. The horizontal start position will either at the current x position or at the left edge of the region, whichever is further to the right. In both horizontal and vertical positioning, regions with multiple columns may follow additional rules and restrictions. +When rendering, the vertical start position of the text will be at the lowest one out of the current y position, the top of the region (if it has a defined top), or the top margin of the page. The horizontal start position will be either at the current x position if that lies within the boundaries of the region/column or at the left edge of the region. In both horizontal and vertical positioning, regions with multiple columns may follow additional rules and restrictions. ### Interaction between Regions ### -Several region instances can exist at the same time. But only one of them can act as context manager at any given time. It is not currently possible to operate them recursively. -But it is possible to use them intermittingly. This will probably most often make sense between a columnar region and a table. You may have some running text ending at a given height, then insert a table with data, and finally continue the running text at the new height below the table within the existing column. +Several region instances can exist at the same time. But only one of them can act as context manager at any given time. It is not currently possible to use them recursively. +But it is possible to use them intermittingly. This will probably most often make sense between a columnar region and a table. You may have some running text ending at a given height, then insert a table with data, and finally continue the running text at the new height below the table within the existing column(s). ## Columns ## @@ -53,8 +55,7 @@ In this example an inserted paragraph is used in order to format its content wit Here we have a layout with three columns. Note that font type and text size can be varied within a text region, while still maintaining the justified (in this case) horizontal alignment. ```python - cols = pdf.text_columns(align="J", ncols=3, gutter=5) - with cols: + with pdf.text_columns(align="J", ncols=3, gutter=5) as cols cols.write(txt=LOREM_IPSUM) pdf.set_font("Times", "", 8) cols.write(txt=LOREM_IPSUM) @@ -69,11 +70,22 @@ Normally the columns will be filled left to right, and if the text ends before t If you prefer that all columns on a page end on the same height, you can use the `balance=True` argument. In that case a simple algorithm will be applied that attempts to approximately balance their bottoms. ```python - with pdf.text_columns(align="J", ncols=3, gutter=5, balanced=True) as cols: + cols = pdf.text_columns(align="J", ncols=3, gutter=5, balanced=True) + # fill columns with balanced text + with cols: pdf.set_font("Times", "", 14) cols.write(txt=LOREM_IPSUM[:300]) + pdf.ln() + # add an image below + img_info = pdf.image("image_spanning_the_page_width.png") + # move vertical position to below the image + pdf.ln(img_info.rendered_hight + pdf.font_size) + # continue multi-column text + with cols: + cols.write(txt=LOREM_IPSUM[300:600]) ``` -Note that this only works reliably when the font size (specifically the line height) doesn't change. If parts of the text use a larger or smaller font than the rest, then the balancing will usually be out of whack. Contributions for a more refined balancing algorithm are welcome. + +Note that column balancing only works reliably when the font size (specifically the line height) doesn't change. If parts of the text use a larger or smaller font than the rest, then the balancing will usually be out of whack. Contributions for a more refined balancing algorithm are welcome. ### Possible future extensions @@ -84,7 +96,13 @@ Those features are currently not supported, but Pull Requests are welcome to imp ## Paragraphs ## -The primary purpose of paragraphs is to enable variations in horizontal text alignment, while the horizontal extents of the text are managed by the text region. +The primary purpose of paragraphs is to enable variations in horizontal text alignment, while the horizontal extents of the text are managed by the text region. To set the alignment, you can use the `align` argument when creating the paragraph, with the same `Align` values as elsewhere in the library. Note that the `write()` methods of paragraphs and text regions in general don't accept this argument, they only accept text. + +For more typographical control, you can also use the following arguments: +* line_height (default: 1.0) - This is a factor by which the line spacing will be different from the font height. It works similar to the attribute of the same name in HTML/CSS. +* top_margin (default: 0.0) +* bottom_margin (default: 0.0) - Those two values determine how much spacing is added above and below the paragraph. No spacing will be added at the top if the paragraph if the current y position is at (or above) the top margin of the page. Similarly, none will be added at the bottom if it would result in overstepping the bottom margin of the page. +* skip_leading_spaces (default: False) - This flag is primarily used by `write_html()`, but may also have other uses. It removes all space characters at the beginning of each line. Other than text regions, paragraphs should alway be used as context managers and never be reused. Violating those rules may result in the entered text turning up on the page out of sequence. @@ -92,5 +110,5 @@ Other than text regions, paragraphs should alway be used as context managers and Those features are currently not supported, but Pull Requests are welcome to implement them: -* Setting the spacing between paragraphs -* first-line indent +* per-paragraph indentation +* first-line indentation diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index 389e2bb61..b441cc9f6 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -3409,6 +3409,7 @@ def multi_cell( """ padding = Padding.new(padding) + wrapmode = WrapMode.coerce(wrapmode) if split_only: warnings.warn( @@ -3443,7 +3444,6 @@ def multi_cell( ) if not self.font_family: raise FPDFException("No font set, you need to call set_font() beforehand") - wrapmode = WrapMode.coerce(wrapmode) if isinstance(w, str) or isinstance(h, str): raise ValueError( # pylint: disable=implicit-str-concat @@ -3746,6 +3746,7 @@ def text_column( l_margin: float = None, r_margin: float = None, print_sh: bool = False, + wrapmode: WrapMode = WrapMode.WORD, skip_leading_spaces: bool = False, ): """Establish a layout with a single column to fill with text. @@ -3770,6 +3771,7 @@ def text_column( l_margin=l_margin, r_margin=r_margin, print_sh=print_sh, + wrapmode=wrapmode, skip_leading_spaces=skip_leading_spaces, ) @@ -3785,6 +3787,7 @@ def text_columns( l_margin: float = None, r_margin: float = None, print_sh: bool = False, + wrapmode: WrapMode = WrapMode.WORD, skip_leading_spaces: bool = False, ): """Establish a layout with multiple columns to fill with text. @@ -3813,6 +3816,7 @@ def text_columns( l_margin=l_margin, r_margin=r_margin, print_sh=print_sh, + wrapmode=wrapmode, skip_leading_spaces=skip_leading_spaces, ) diff --git a/fpdf/html.py b/fpdf/html.py index 252be1044..b4215b433 100644 --- a/fpdf/html.py +++ b/fpdf/html.py @@ -392,6 +392,8 @@ def handle_data(self, data): # ignore anything else than td inside a table pass elif self._pre_formatted: # pre blocks + # If we want to mimick the exact HTML semantics about newlines at the + # beginning and end of the block, then this needs some more thought. s_nl = data.startswith("\n") and self._pre_started self._pre_started = False e_nl = data.endswith("\n") @@ -522,11 +524,9 @@ def handle_starttag(self, tag, attrs): self.font_color = color if "face" in attrs: face = attrs.get("face").lower() - try: - self.set_font(face) - self.font_face = face - except RuntimeError: - pass # font not found, ignore + # This may result in a FPDFException "font not found". + self.set_font(face) + self.font_face = face if "size" in attrs: self.font_size = int(attrs.get("size")) self.set_font() diff --git a/fpdf/text_region.py b/fpdf/text_region.py index df37df00c..9b1b31171 100644 --- a/fpdf/text_region.py +++ b/fpdf/text_region.py @@ -2,13 +2,18 @@ from typing import NamedTuple, Sequence from .errors import FPDFException -from .enums import Align, XPos, YPos +from .enums import Align, XPos, YPos, WrapMode from .line_break import MultiLineBreak # Since Python doesn't have "friend classes"... # pylint: disable=protected-access +class Extents(NamedTuple): + left: float + right: float + + class TextRegionMixin: """Mix-in to be added FPDF() in order to support text regions.""" @@ -35,8 +40,9 @@ def __init__( top_margin: float = 0, bottom_margin: float = 0, skip_leading_spaces: bool = False, + wrapmode: WrapMode = None, ): - self.region = region + self._region = region self.pdf = region.pdf if align: align = Align.coerce(align) @@ -48,13 +54,17 @@ def __init__( self.top_margin = top_margin self.bottom_margin = bottom_margin self.skip_leading_spaces = skip_leading_spaces + if wrapmode is None: + self.wrapmode = self._region.wrapmode + else: + self.wrapmode = WrapMode.coerce(wrapmode) self._text_fragments = [] def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): - self.region.end_paragraph() + self._region.end_paragraph() def write(self, text: str, link=None): if not self.pdf.font_family: @@ -80,14 +90,14 @@ def build_lines(self, print_sh): text_lines = [] multi_line_break = MultiLineBreak( self._text_fragments, - max_width=self.region.get_width, + max_width=self._region.get_width, margins=(self.pdf.c_margin, self.pdf.c_margin), - align=self.align or self.region.align or Align.L, + align=self.align or self._region.align or Align.L, print_sh=print_sh, - # wrapmode=self.wrapmode, + wrapmode=self.wrapmode, line_height=self.line_height, skip_leading_spaces=self.skip_leading_spaces - or self.region.skip_leading_spaces, + or self._region.skip_leading_spaces, ) self._text_fragments = [] text_line = multi_line_break.get_line() @@ -113,12 +123,14 @@ def __init__( line_height: float = 1.0, print_sh: bool = False, skip_leading_spaces: bool = False, + wrapmode: WrapMode = None, **kwargs, ): self.pdf = pdf self.align = Align.coerce(align) # default for auto paragraphs self.line_height = line_height self.print_sh = print_sh + self.wrapmode = WrapMode.coerce(wrapmode) self.skip_leading_spaces = skip_leading_spaces self._paragraphs = [] self._active_paragraph = None @@ -172,6 +184,7 @@ def paragraph( skip_leading_spaces: bool = False, top_margin=0, bottom_margin=0, + wrapmode: WrapMode = None, ): if self._active_paragraph == "EXPLICIT": raise FPDFException("Unable to nest paragraphs.") @@ -180,6 +193,7 @@ def paragraph( align=align or self.align, line_height=line_height, skip_leading_spaces=skip_leading_spaces or self.skip_leading_spaces, + wrapmode=wrapmode, top_margin=top_margin, bottom_margin=bottom_margin, ) @@ -197,18 +211,17 @@ def end_paragraph(self): class TextRegion(ParagraphCollectorMixin): """Abstract base class for all text region subclasses.""" - def _do_ln(self, h=None): - self.pdf.ln(h) - - def current_x_extents( - self, y, height - ): # xpylint: disable=no-self-use,unused-argument - """Return the horizontal extents of the current line.""" + def current_x_extents(self, y, height): + """ + Return the horizontal extents of the current line. + Columnar regions simply return the boundaries of the column. + Regions with non-vertical boundaries need to check how the largest + font-height in the current line actually fits in there. + For that reason we include the current y and the line height. + """ raise NotImplementedError() - def _render_column_lines( - self, text_lines, top, bottom - ): # xpylint: disable=undefined-loop-variable + def _render_column_lines(self, text_lines, top, bottom): if not text_lines: return 0 # no rendered height self.pdf.y = top @@ -236,7 +249,6 @@ def _render_column_lines( prev_line_height = last_line_height last_line_height = text_line.height col_left, col_right = self.current_x_extents(self.pdf.y, 0) - # self.pdf.x = extents[0] if self.pdf.x < col_left or self.pdf.x >= col_right: self.pdf.x = col_left # Don't check the return, we never render past the bottom here. @@ -247,7 +259,6 @@ def _render_column_lines( new_x=XPos.LEFT, new_y=YPos.NEXT, fill=False, - # link=link, # Must be part of Fragment ) if tl_wrapper.last_line: margin = tl_wrapper.paragraph.bottom_margin @@ -295,22 +306,14 @@ def __init__(self, pdf, *args, l_margin=None, r_margin=None, **kwargs): self._set_left_right(left, right) def _set_left_right(self, left, right): - self.left = self.pdf.l_margin if left is None else left - self.right = (self.pdf.w - self.pdf.r_margin) if right is None else right - if self.right <= self.left: + left = self.pdf.l_margin if left is None else left + right = (self.pdf.w - self.pdf.r_margin) if right is None else right + if right <= left: raise FPDFException( f"{self.__class__.__name__}(): " - f"Right limit ({self.right}) lower than left limit ({self.left})." + f"Right limit ({right}) lower than left limit ({left})." ) - - def current_x_extents(self, y, height): # pylint: disable=unused-argument - """Return the horizontal extents of the current line. - Columnar regions simply return the boundaries of the column. - Regions with non-vertical boundaries need to check how the largest - font-height in the current line actually fits in there. - For that reason we include the current y and the line height. - """ - return self.left, self.right + self.extents = Extents(left, right) class TextColumns(TextRegion, TextColumnarMixin): @@ -324,26 +327,25 @@ def __init__( **kwargs, ): super().__init__(pdf, *args, **kwargs) - self.cur_column = 0 - self.ncols = ncols - self.gutter = gutter + self._cur_column = 0 + self._ncols = ncols self.balance = balance - total_w = self.right - self.left - self.col_width = (total_w - (self.ncols - 1) * self.gutter) / self.ncols + total_w = self.extents.right - self.extents.left + col_width = (total_w - (ncols - 1) * gutter) / ncols # We calculate the column extents once in advance, and store them for lookup. - c_left = self.left - self.cols = [(c_left, c_left + self.col_width)] + c_left = self.extents.left + self._cols = [Extents(c_left, c_left + col_width)] for i in range(1, ncols): # pylint: disable=unused-variable - c_left += self.col_width + self.gutter - self.cols.append((c_left, c_left + self.col_width)) + c_left += col_width + gutter + self._cols.append(Extents(c_left, c_left + col_width)) self._first_page_top = max(self.pdf.t_margin, self.pdf.y) def __enter__(self): super().__enter__() self._first_page_top = max(self.pdf.t_margin, self.pdf.y) if self.balance: - self.cur_column = 0 - self.pdf.x = self.cols[self.cur_column][0] + self._cur_column = 0 + self.pdf.x = self._cols[self._cur_column].left return self def _render_page_lines(self, text_lines, top, bottom): @@ -361,14 +363,14 @@ def _render_page_lines(self, text_lines, top, bottom): if not text_lines: return tot_height = sum(l.line.height for l in text_lines) - col_height = tot_height / self.ncols + col_height = tot_height / self._ncols avail_height = bottom - top if col_height < avail_height: balancing = True # We actually have room to balance on this page. # total height divided by n bottom = top + col_height # A bit more generous: Try to keep the rightmost column the shortest. - lines_per_column = math.ceil(len(text_lines) / self.ncols) + 0.5 + lines_per_column = math.ceil(len(text_lines) / self._ncols) + 0.5 mult_height = text_lines[0].line.height * lines_per_column if mult_height > col_height: bottom = top + mult_height @@ -376,15 +378,15 @@ def _render_page_lines(self, text_lines, top, bottom): # Turns out we don't actually have enough room. bottom = page_bottom balancing = False - for c in range(self.cur_column, self.ncols): + for c in range(self._cur_column, self._ncols): if not text_lines: return - if c != self.cur_column: - self.cur_column = c + if c != self._cur_column: + self._cur_column = c col_left, col_right = self.current_x_extents(0, 0) if self.pdf.x < col_left or self.pdf.x >= col_right: self.pdf.x = col_left - if balancing and c == (self.ncols - 1): + if balancing and c == (self._ncols - 1): # Give the last column more space in case the balancing is out of whack. bottom = self.pdf.h - self.pdf.b_margin last_line_height = self._render_column_lines(text_lines, top, bottom) @@ -406,16 +408,11 @@ def render(self): self._render_page_lines(text_lines, _first_page_top, page_bottom) while text_lines: self.pdf.add_page(same=True) - self.cur_column = 0 + self._cur_column = 0 self._render_page_lines(text_lines, self.pdf.y, page_bottom) - def _do_ln(self, h=None): - self.pdf.ln(h=h) - self.pdf.x = self.cols[self.cur_column][0] - def current_x_extents(self, y, height): - left = self.cols[self.cur_column][0] - right = self.cols[self.cur_column][1] + left, right = self._cols[self._cur_column] return left, right diff --git a/test/html/html_align_paragraph.pdf b/test/html/html_align_paragraph.pdf new file mode 100644 index 000000000..09ed3b811 Binary files /dev/null and b/test/html/html_align_paragraph.pdf differ diff --git a/test/html/html_custom_line_height.pdf b/test/html/html_custom_line_height.pdf index dc4fb235a..916327fe7 100644 Binary files a/test/html/html_custom_line_height.pdf and b/test/html/html_custom_line_height.pdf differ diff --git a/test/html/html_justify_paragraph.pdf b/test/html/html_justify_paragraph.pdf deleted file mode 100644 index 9ae0906c3..000000000 Binary files a/test/html/html_justify_paragraph.pdf and /dev/null differ diff --git a/test/html/test_html.py b/test/html/test_html.py index 2254a5927..8bc2428c9 100644 --- a/test/html/test_html.py +++ b/test/html/test_html.py @@ -5,7 +5,7 @@ from fpdf import FPDF, HTMLMixin from fpdf.errors import FPDFException -from test.conftest import assert_pdf_equal +from test.conftest import assert_pdf_equal, LOREM_IPSUM HERE = Path(__file__).resolve().parent @@ -216,18 +216,28 @@ class CustomPDF(FPDF): assert_pdf_equal(pdf, HERE / "html_customize_ul.pdf", tmp_path) -def test_html_justify_paragraph(tmp_path): +def test_html_align_paragraph(tmp_path): pdf = FPDF() pdf.add_page() + pdf.set_margins(50, 20) pdf.write_html( - '

' - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." - " Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." - " Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." - " Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." - "

" + f""" + No align given, default left: +

{LOREM_IPSUM[:200]}"

+ align=justify: +

{LOREM_IPSUM[:200]}"

+ align=right: +

{LOREM_IPSUM[200:400]}"

+ align=left: +

{LOREM_IPSUM[400:600]}"

+ align=center: +

{LOREM_IPSUM[600:800]}"

+ + align=invalid, ignore and default left: +

{LOREM_IPSUM[800:1000]}"

+ """ ) - assert_pdf_equal(pdf, HERE / "html_justify_paragraph.pdf", tmp_path) + assert_pdf_equal(pdf, HERE / "html_align_paragraph.pdf", tmp_path) def test_issue_156(tmp_path): @@ -400,7 +410,11 @@ def test_html_custom_line_height(tmp_path): text-text-text-text-text-text-text-text-text-text- text-text-text-text-text-text-text-text-text-text- text-text-text-text-text-text-text-text-text-text

-

+

+text-text-text-text-text-text-text-text-text-text- +text-text-text-text-text-text-text-text-text-text- +text-text-text-text-text-text-text-text-text-text-

+

text-text-text-text-text-text-text-text-text-text- text-text-text-text-text-text-text-text-text-text- text-text-text-text-text-text-text-text-text-text-

@@ -490,3 +504,15 @@ def test_html_format_within_p(tmp_path): # discussion 880 """ ) assert_pdf_equal(pdf, HERE / "html_format_within_p.pdf", tmp_path) + + +def test_html_bad_font(): + pdf = FPDF() + pdf.add_page() + pdf.set_font("times", size=18) + with pytest.raises(FPDFException): + pdf.write_html( + """ + pdf.write_html('

hello helvetica

') + """ + ) diff --git a/test/text/test_write.py b/test/text/test_write.py index 1ea85233f..1ee6cc58d 100644 --- a/test/text/test_write.py +++ b/test/text/test_write.py @@ -165,3 +165,17 @@ def test_write_overflow_no_initial_newline(tmp_path): # issue-847 pdf.set_font(family="Helvetica", size=20) pdf.write(7, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX") assert_pdf_equal(pdf, HERE / "write_overflow_no_initial_newline.pdf", tmp_path) + + +def test_write_empty(): + # Feeding an empty string to write() should not have any effect + # on the internal state of the library. + pdf = FPDF() + pdf.add_page() + pdf.set_font(family="Helvetica", size=20) + x = pdf.x + y = pdf.y + pdf.write(None, "") + assert ( + pdf.x == x and pdf.y == y + ), f"write('') has changed pdf.x ({pdf.x} from {x}) or pdf.x ({pdf.y} from {y})" diff --git a/test/text_region/tcols_charwrap.pdf b/test/text_region/tcols_charwrap.pdf new file mode 100644 index 000000000..2fa1e6f0a Binary files /dev/null and b/test/text_region/tcols_charwrap.pdf differ diff --git a/test/text_region/test_text_columns.py b/test/text_region/test_text_columns.py index a9de74dda..042a412e1 100644 --- a/test/text_region/test_text_columns.py +++ b/test/text_region/test_text_columns.py @@ -1,6 +1,7 @@ from pathlib import Path -import fpdf +import pytest +from fpdf import FPDF, FPDFException from test.conftest import assert_pdf_equal, LOREM_IPSUM HERE = Path(__file__).resolve().parent @@ -8,55 +9,45 @@ def test_tcols_align(tmp_path): - pdf = fpdf.FPDF() + pdf = FPDF() pdf.add_page() pdf.set_font("Helvetica", "", 12) - cols = pdf.text_column() - with cols: - cols.write(text=LOREM_IPSUM[:100]) + col = pdf.text_column() + with col: + col.write(text=LOREM_IPSUM[:100]) pdf.set_font("Times", "", 12) - cols.write(text=LOREM_IPSUM[100:200]) + col.write(text=LOREM_IPSUM[100:200]) pdf.set_font("Courier", "", 12) - cols.write(text=LOREM_IPSUM[200:300]) - - pdf.ln() - pdf.ln() + col.write(text=LOREM_IPSUM[200:300]) pdf.set_font("Helvetica", "I", 12) - with cols: - with cols.paragraph(align="J") as par: + with col: + with col.paragraph(align="J", top_margin=pdf.font_size * 2) as par: par.write(text=LOREM_IPSUM[:100]) pdf.set_font("Times", "I", 12) par.write(text=LOREM_IPSUM[100:200]) pdf.set_font("Courier", "I", 12) par.write(text=LOREM_IPSUM[200:300]) - - pdf.ln() - pdf.ln() pdf.set_font("Helvetica", "B", 12) - with cols: - with cols.paragraph(align="R") as par: + with col: + with col.paragraph(align="R", top_margin=pdf.font_size * 2) as par: par.write(text=LOREM_IPSUM[:100]) pdf.set_font("Times", "B", 12) par.write(text=LOREM_IPSUM[100:200]) pdf.set_font("Courier", "B", 12) par.write(text=LOREM_IPSUM[200:300]) - - pdf.ln() - pdf.ln() pdf.set_font("Helvetica", "BI", 12) - with cols: - with cols.paragraph(align="C") as par: + with col: + with col.paragraph(align="C", top_margin=pdf.font_size * 2) as par: par.write(text=LOREM_IPSUM[:100]) pdf.set_font("Times", "BI", 12) par.write(text=LOREM_IPSUM[100:200]) pdf.set_font("Courier", "BI", 12) par.write(text=LOREM_IPSUM[200:300]) - assert_pdf_equal(pdf, HERE / "tcols_align.pdf", tmp_path) def test_tcols_3cols(tmp_path): - pdf = fpdf.FPDF() + pdf = FPDF() pdf.add_page() pdf.t_margin = 50 pdf.set_auto_page_break(True, 100) @@ -77,7 +68,7 @@ def test_tcols_3cols(tmp_path): def test_tcols_balance(tmp_path): - pdf = fpdf.FPDF() + pdf = FPDF() pdf.add_page() pdf.set_auto_page_break(True, 100) pdf.set_font("Helvetica", "", 6) @@ -98,8 +89,84 @@ def test_tcols_balance(tmp_path): assert_pdf_equal(pdf, HERE / "tcols_balance.pdf", tmp_path) +def test_tcols_charwrap(tmp_path): + pdf = FPDF() + pdf.add_page() + pdf.set_font("courier", "", 16) + col = pdf.text_column(l_margin=50, r_margin=50) + # wrapmode on paragraph + with col.paragraph(wrapmode="CHAR", bottom_margin=pdf.font_size) as par: + par.write(text=LOREM_IPSUM[:500]) + col.render() + # wrapmode on column + with pdf.text_column( + # align="J", + l_margin=50, + r_margin=50, + wrapmode="CHAR", + ) as col: + with col.paragraph() as par: + par.write(text=LOREM_IPSUM[500:1000]) + assert_pdf_equal(pdf, HERE / "tcols_charwrap.pdf", tmp_path) + + +def test_tcols_no_font(): + pdf = FPDF() + pdf.add_page() + with pytest.raises(FPDFException) as error: + col = pdf.text_column() + col.write("something") + expected_msg = "No font set, you need to call set_font() beforehand" + assert str(error.value) == expected_msg + with pytest.raises(FPDFException) as error: + col.ln() + expected_msg = "No font set, you need to call set_font() beforehand" + assert str(error.value) == expected_msg + + +def test_tcols_bad_uses(): + pdf = FPDF() + pdf.add_page() + pdf.set_font("courier", "", 16) + col = pdf.text_column() + # recursive text region context + with col: + col.write("something") + with pytest.raises(FPDFException) as error: + with col: + pass + expected_msg = "Unable to enter the same TextColumns context recursively." + assert str(error.value) == expected_msg + # recursive use of paragraph context + with col.paragraph() as par: + par.write("something") + with pytest.raises(FPDFException) as error: + col.paragraph() + expected_msg = "Unable to nest paragraphs." + assert str(error.value) == expected_msg + # writing to column while we have an explicit paragraph active + with col.paragraph() as par: + par.write("something") + with pytest.raises(FPDFException) as error: + col.write("else") + expected_msg = "Conflicts with active paragraph. Either close the current paragraph or write your text inside it." + assert str(error.value) == expected_msg + # ending a non-existent paragraph + with pytest.raises(FPDFException) as error: + col.end_paragraph() + expected_msg = "No active paragraph to end." + assert str(error.value) == expected_msg + # column with negative width + with pytest.raises(FPDFException) as error: + col = pdf.text_column(l_margin=150, r_margin=150) + expected_msg = ( + "TextColumns(): Right limit (60.00155555555551) lower than left limit (150)." + ) + assert str(error.value) == expected_msg + + def xest_tcols_text_shaping(tmp_path): - pdf = fpdf.FPDF() + pdf = FPDF() pdf.add_page() pdf.t_margin = 50 pdf.set_text_shaping(True)