Skip to content

Commit

Permalink
Support character based line warpping (py-pdf#649)
Browse files Browse the repository at this point in the history
  • Loading branch information
gmischler committed Feb 28, 2023
1 parent 1093622 commit 8a0df52
Show file tree
Hide file tree
Showing 10 changed files with 101 additions and 12 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default',

## [2.6.2] - Not released yet
### Added
* [`FPDF.multi_cell()`](https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.multi_cell) and [`FPDF.write()`](https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write) now accept a `wrapmode` argument for word or character based line wrapping ("WORD"/"CHAR").
- [`FPDF.image()`](https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.image) has a new `keep_aspect_ratio` optional boolean parameter, to fit it inside a given rectangle: [documentation](https://pyfpdf.github.io/fpdf2/Images.html#fitting-an-image-inside-a-rectangle)
- new method `FPDF.preload_image()`: [documentation](https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.preload_image)
- new translation of the tutorial in [简体中文](https://pyfpdf.github.io/fpdf2/Tutorial-zh.html) - thanks to @Bubbu0129
Expand Down
17 changes: 10 additions & 7 deletions docs/Text.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ There are several ways in fpdf to add text to a PDF document, each of which come
| [`.cell()`](#cell) | one | yes | no | yes | Inserts a single-line text string within the boundaries of a given box, optionally with background and border. |
| [`.multi_cell()`](#multi_cell) | several | yes | no | yes | Inserts a multi-line text string within the boundaries of a given box, optionally with background and border. |
| [`.write()`](#write) | several | no | no | auto | Inserts a multi-line text string within the boundaries of the page margins, starting at the current x/y location (typically the end of the last inserted text). |
| [`.write_html()`](#write_html) | several | no | yes | auto | From [html.py](HTML.html). An extension to `.write()`, with additional parsing of basic HTML tags.
| [`.write_html()`](#write_html) | several | no | yes | auto | An extension to `.write()`, with additional parsing of basic HTML tags.

## Typographical Limitations

Expand Down Expand Up @@ -72,9 +72,9 @@ page break is performed before outputting.
[Signature and parameters for.cell()](https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.cell)

## .multi_cell()
Allows printing text with line breaks. Those can be automatic (breaking at the
most recent space or soft-hyphen character) as soon as the text reaches the
right border of the cell, or explicit (via the `\\n` character).
Allows printing text with word or character based line breaks. Those can be automatic
(breaking at the most recent space or soft-hyphen character) as soon as the text
reaches the right border of the cell, or explicit (via the `\\n` character).
As many cells as necessary are stacked, one below the other.
Text can be aligned, centered or justified. The cell block can be framed and
the background painted.
Expand All @@ -90,9 +90,12 @@ When `split_only == True`, returns `txt` split into lines in an array (with any
## .write()
Prints multi-line text between the page margins, starting from the current position.
When the right margin is reached, a line break occurs at the most recent
space or soft-hyphen character, and text continues from the left margin.
A manual break happens any time the \\n character is met,
Upon method exit, the current position is left near the end of the text, ready for the next call to continue without a gap, potentially with a different font or size set. Returns a boolean indicating if page break was triggered.
space or soft-hyphen character (in word wrap mode) or at the current position (in
character break mode), and text continues from the left margin.
A manual break happens any time the \\n character is met.
Upon method exit, the current position is left near the end of the text, ready for
the next call to continue without a gap, potentially with a different font or size set.
Returns a boolean indicating if page break was triggered.

The primary purpose of this method is to print continuously wrapping text, where different parts may be rendered in different fonts or font sizes. This contrasts eg. with `.multi_cell()`, where a change in font family or size can only become effective on a new line.

Expand Down
9 changes: 9 additions & 0 deletions fpdf/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ def coerce(cls, value):
raise TypeError(f"{value} cannot convert to a {cls.__name__}")


class WrapMode(CoerciveEnum):
"Defines how to break and wrap lines in multi-line text."
WORD = intern("WORD")
"Wrap by word"

CHAR = intern("CHAR")
"Wrap by character"


class CharVPos(CoerciveEnum):
"Defines the vertical position of text relative to the line."
SUP = intern("SUP")
Expand Down
17 changes: 16 additions & 1 deletion fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class Image:
FontDescriptorFlags,
AccessPermission,
CharVPos,
WrapMode,
)
from .errors import FPDFException, FPDFPageFormatException, FPDFUnicodeEncodingException
from .fonts import fpdf_charwidths
Expand Down Expand Up @@ -3251,6 +3252,7 @@ def multi_cell(
print_sh=False,
new_x=XPos.RIGHT,
new_y=YPos.NEXT,
wrapmode: WrapMode = WrapMode.WORD,
):
"""
This method allows printing text with line breaks. They can be automatic
Expand Down Expand Up @@ -3287,13 +3289,16 @@ def multi_cell(
of text as bold / italics / underlined. Default to False.
print_sh (bool): Treat a soft-hyphen (\\u00ad) as a normal printable
character, instead of a line breaking opportunity. Default value: False
wrapmode (fpdf.enums.WrapMode): "WORD" for word based line wrapping (default),
"CHAR" for character based line wrapping.
Using `new_x=XPos.RIGHT, new_y=XPos.TOP, maximum height=pdf.font_size` is
useful to build tables with multiline text in cells.
Returns: a boolean indicating if page break was triggered,
or if `split_only == True`: `txt` splitted into lines in an array
"""
wrapmode = WrapMode.coerce(wrapmode)
if isinstance(w, str) or isinstance(h, str):
raise ValueError(
"Parameter 'w' and 'h' must be numbers, not strings."
Expand Down Expand Up @@ -3361,6 +3366,7 @@ def multi_cell(
styled_text_fragments,
justify=(align == Align.J),
print_sh=print_sh,
wrapmode=wrapmode,
)
text_line = multi_line_break.get_line_of_given_width(maximum_allowed_width)
while (text_line) is not None:
Expand Down Expand Up @@ -3476,7 +3482,12 @@ def multi_cell(

@check_page
def write(
self, h: float = None, txt: str = "", link: str = "", print_sh: bool = False
self,
h: float = None,
txt: str = "",
link: str = "",
print_sh: bool = False,
wrapmode: WrapMode = WrapMode.WORD,
):
"""
Prints text from the current position.
Expand All @@ -3492,7 +3503,10 @@ def write(
(identifier returned by `FPDF.add_link`) or external URL.
print_sh (bool): Treat a soft-hyphen (\\u00ad) as a normal printable
character, instead of a line breaking opportunity. Default value: False
wrapmode (fpdf.enums.WrapMode): "WORD" for word based line wrapping (default),
"CHAR" for character based line wrapping.
"""
wrapmode = WrapMode.coerce(wrapmode)
if not self.font_family:
raise FPDFException("No font set, you need to call set_font() beforehand")
if isinstance(h, str):
Expand All @@ -3511,6 +3525,7 @@ def write(
multi_line_break = MultiLineBreak(
styled_text_fragments,
print_sh=print_sh,
wrapmode=wrapmode,
)
# first line from current x position to right margin
first_width = self.w - self.x - self.r_margin
Expand Down
25 changes: 23 additions & 2 deletions fpdf/line_break.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from typing import NamedTuple, Any, Union, Sequence

from .enums import CharVPos
from .enums import CharVPos, WrapMode
from .errors import FPDFException

SOFT_HYPHEN = "\u00ad"
Expand Down Expand Up @@ -306,6 +306,20 @@ def add_character(
self.width += character_width
active_fragment.characters.append(character)

def trim_trailing_spaces(self):
if not self.fragments:
return
last_frag = self.fragments[-1]
last_char = last_frag.characters[-1]
while last_char == " ":
char_width = last_frag.get_character_width(" ")
self.width -= char_width
last_frag.trim(-1)
if not last_frag.characters:
del self.fragments[-1]
last_frag = self.fragments[-1]
last_char = last_frag.characters[-1]

def _apply_automatic_hint(self, break_hint: Union[SpaceHint, HyphenHint]):
"""
This function mutates the current_line, applying one of the states
Expand Down Expand Up @@ -364,10 +378,12 @@ def __init__(
styled_text_fragments: Sequence,
justify: bool = False,
print_sh: bool = False,
wrapmode: WrapMode = WrapMode.WORD,
):
self.styled_text_fragments = styled_text_fragments
self.justify = justify
self.print_sh = print_sh
self.wrapmode = wrapmode
self.fragment_index = 0
self.character_index = 0
self.idx_last_forced_break = None
Expand Down Expand Up @@ -405,9 +421,14 @@ def get_line_of_given_width(self, maximum_width: float, wordsplit: bool = True):
return current_line.manual_break(trailing_nl=True)

if current_line.width + character_width > maximum_width:
if character == SPACE:
if character == SPACE: # must come first, always drop a current space.
self.character_index += 1
return current_line.manual_break(self.justify)
if self.wrapmode == WrapMode.CHAR:
# If the line ends with one or more spaces, then we want to get rid of them
# so it can be justified correctly.
current_line.trim_trailing_spaces()
return current_line.manual_break(self.justify)
if current_line.automatic_break_possible():
(
self.fragment_index,
Expand Down
Binary file added test/text/multi_cell_char_wrap.pdf
Binary file not shown.
19 changes: 19 additions & 0 deletions test/text/test_multi_cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,3 +399,22 @@ def test_multi_cell_char_spacing(tmp_path): # issue #489
pdf.set_char_spacing(10)
pdf.multi_cell(w=150, txt=LOREM_IPSUM[:200], new_x="LEFT", fill=True)
assert_pdf_equal(pdf, HERE / "multi_cell_char_spacing.pdf", tmp_path)


def test_multi_cell_char_wrap(tmp_path): # issue #649
pdf = FPDF()
pdf.add_page()
pdf.set_font("Helvetica", "", 10)
pdf.set_fill_color(255, 255, 0)
pdf.multi_cell(w=50, txt=LOREM_IPSUM[:200], new_x="LEFT", fill=True)
pdf.ln()
pdf.multi_cell(
w=50, txt=LOREM_IPSUM[:200], new_x="LEFT", fill=True, wrapmode="CHAR"
)
pdf.ln()
pdf.set_font("Courier", "", 10)
txt = " " + "abcdefghijklmnopqrstuvwxyz" * 3
pdf.multi_cell(w=50, txt=txt, new_x="LEFT", fill=True, align="L")
pdf.ln()
pdf.multi_cell(w=50, txt=txt, new_x="LEFT", fill=True, align="L", wrapmode="CHAR")
assert_pdf_equal(pdf, HERE / "multi_cell_char_wrap.pdf", tmp_path)
25 changes: 23 additions & 2 deletions test/text/test_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ def test_write_font_stretching(tmp_path): # issue #478
pdf.add_page()
# built-in font
pdf.set_font("Helvetica", "", 8)
pdf.set_fill_color(255, 255, 0)
pdf.set_right_margin(pdf.w - right_boundary)
pdf.write(txt=LOREM_IPSUM[:100])
pdf.ln()
Expand All @@ -70,7 +69,6 @@ def test_write_font_stretching(tmp_path): # issue #478
pdf.set_stretching(100)
pdf.add_font(fname=FONTS_DIR / "DroidSansFallback.ttf")
pdf.set_font("DroidSansFallback", "", 8)
pdf.set_fill_color(255, 255, 0)
pdf.write(txt=LOREM_IPSUM[:100])
pdf.ln()
pdf.ln()
Expand Down Expand Up @@ -129,3 +127,26 @@ def write_this():
pdf.denom_lift = 1.0
write_this()
assert_pdf_equal(pdf, HERE / "write_superscript.pdf", tmp_path)


def test_write_char_wrap(tmp_path): # issue #649
right_boundary = 50
pdf = fpdf.FPDF()
pdf.add_page()
pdf.set_right_margin(pdf.w - right_boundary)
pdf.set_font("Helvetica", "", 10)
pdf.write(txt=LOREM_IPSUM[:200])
pdf.ln()
pdf.ln()
pdf.write(txt=LOREM_IPSUM[:200], wrapmode="CHAR")
pdf.ln()
pdf.ln()
pdf.set_font("Courier", "", 10)
txt = " " + "abcdefghijklmnopqrstuvwxyz" * 3
pdf.write(txt=txt)
pdf.ln()
pdf.ln()
pdf.write(txt=txt, wrapmode="CHAR")
pdf.line(pdf.l_margin, 10, pdf.l_margin, 130)
pdf.line(right_boundary, 10, right_boundary, 130)
assert_pdf_equal(pdf, HERE / "write_char_wrap.pdf", tmp_path)
Binary file added test/text/write_char_wrap.pdf
Binary file not shown.
Binary file modified test/text/write_font_stretching.pdf
Binary file not shown.

0 comments on commit 8a0df52

Please sign in to comment.