Coverage for fpdf2_textindex / _fpdf / line_break.py: 83.43%
113 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 15:45 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 15:45 +0000
1"""Line Break."""
3# ruff: noqa: E501, UP045
5from typing import Optional
7from fpdf.enums import Align
8from fpdf.enums import WrapMode
9from fpdf.errors import FPDFException
10import fpdf.line_break
11from fpdf.line_break import BREAKING_SPACE_SYMBOLS_STR
12from fpdf.line_break import FORM_FEED
13from fpdf.line_break import Fragment
14from fpdf.line_break import HYPHEN
15from fpdf.line_break import HyphenHint
16from fpdf.line_break import NBSP
17from fpdf.line_break import NEWLINE
18from fpdf.line_break import SOFT_HYPHEN
19from fpdf.line_break import SPACE
20from fpdf.line_break import SpaceHint
21from fpdf.line_break import TextLine
22from fpdf.util import FloatTolerance
25class CurrentLine(fpdf.line_break.CurrentLine):
26 def add_character(
27 self,
28 character: str,
29 character_width: float,
30 original_fragment: Fragment | HyphenHint,
31 original_fragment_index: int,
32 original_character_index: int,
33 height: float,
34 url: Optional[str | int] = None,
35 ) -> None:
36 assert character != NEWLINE
37 self.height = height
38 if not self.fragments:
39 assert isinstance(original_fragment, Fragment)
40 self.fragments.append(
41 original_fragment.__class__(
42 characters="",
43 graphics_state=original_fragment.graphics_state,
44 k=original_fragment.k,
45 link=url,
46 )
47 )
49 # characters are expected to be grouped into fragments by font and
50 # character attributes. If the last existing fragment doesn't match
51 # the properties of the pending character -> add a new fragment.
52 elif isinstance(original_fragment, Fragment): 52 ↛ 66line 52 didn't jump to line 66 because the condition on line 52 was always true
53 # BUGFIX: https://github.com/py-pdf/fpdf2/issues/1814
54 if isinstance(self.fragments[-1], Fragment) and not (
55 original_fragment.has_same_style(self.fragments[-1])
56 and url == self.fragments[-1].link
57 ):
58 self.fragments.append(
59 original_fragment.__class__(
60 characters="",
61 graphics_state=original_fragment.graphics_state,
62 k=original_fragment.k,
63 link=url,
64 )
65 )
66 active_fragment = self.fragments[-1]
68 if character in BREAKING_SPACE_SYMBOLS_STR:
69 self.space_break_hint = SpaceHint(
70 original_fragment_index,
71 original_character_index,
72 len(self.fragments),
73 len(active_fragment.characters),
74 self.width,
75 self.number_of_spaces,
76 )
77 self.number_of_spaces += 1
78 elif character == NBSP: 78 ↛ 80line 78 didn't jump to line 80 because the condition on line 78 was never true
79 # PDF viewers ignore NBSP for word spacing with "Tw".
80 character = SPACE
81 self.number_of_spaces += 1
82 elif character == SOFT_HYPHEN and not self.print_sh: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true
83 self.hyphen_break_hint = HyphenHint(
84 original_fragment_index,
85 original_character_index,
86 len(self.fragments),
87 len(active_fragment.characters),
88 self.width,
89 self.number_of_spaces,
90 HYPHEN,
91 character_width,
92 original_fragment.graphics_state,
93 original_fragment.k,
94 )
96 if character != SOFT_HYPHEN or self.print_sh: 96 ↛ exitline 96 didn't return from function 'add_character' because the condition on line 96 was always true
97 active_fragment.characters.append(character)
100class MultiLineBreak(fpdf.line_break.MultiLineBreak):
101 __PATCHED__: bool = True
103 # pylint: disable=too-many-return-statements
104 def get_line(self) -> Optional[TextLine]:
105 first_char = True # "Tw" ignores the first character in a text object.
106 idx_last_forced_break = self.idx_last_forced_break
107 self.idx_last_forced_break = None
109 if self.fragment_index == len(self.fragments):
110 return None
112 current_font_height: float = 0
114 max_width = self.get_width(current_font_height)
115 # The full max width will be passed on via TextLine to FPDF._render_styled_text_line().
116 current_line = CurrentLine(
117 max_width=max_width,
118 print_sh=self.print_sh,
119 indent=self.first_line_indent if self._is_first_line else 0,
120 )
121 # For line wrapping we need to use the reduced width.
122 for margin in self.margins:
123 max_width -= float(margin)
124 if self._is_first_line:
125 max_width -= self.first_line_indent
127 if self.skip_leading_spaces: 127 ↛ 130line 127 didn't jump to line 130 because the condition on line 127 was never true
128 # write_html() with TextColumns uses this, since it can't know in
129 # advance where the lines will be broken.
130 while self.fragment_index < len(self.fragments):
131 if self.character_index >= len(
132 self.fragments[self.fragment_index].characters
133 ):
134 self.character_index = 0
135 self.fragment_index += 1
136 continue
137 character = self.fragments[self.fragment_index].characters[
138 self.character_index
139 ]
140 if character == SPACE:
141 self.character_index += 1
142 else:
143 break
145 while self.fragment_index < len(self.fragments):
146 current_fragment = self.fragments[self.fragment_index]
148 if FloatTolerance.greater_than(
149 current_fragment.font_size, current_font_height
150 ):
151 current_font_height = (
152 current_fragment.font_size
153 ) # document units
154 max_width = self.get_width(current_font_height)
155 current_line.max_width = max_width
156 for margin in self.margins:
157 max_width -= float(margin)
158 if self._is_first_line:
159 max_width -= self.first_line_indent
161 if self.character_index >= len(current_fragment.characters):
162 # BUGFIX: Support of empty md links
163 # Catch empty fragments with link
164 if (
165 len(current_fragment.characters) == 0
166 and current_fragment.link is not None
167 ):
168 current_line.add_character(
169 "",
170 0.0,
171 current_fragment,
172 self.fragment_index,
173 self.character_index,
174 current_font_height * self.line_height,
175 current_fragment.link,
176 )
177 self.character_index = 0
178 self.fragment_index += 1
179 continue
181 character = current_fragment.characters[self.character_index]
182 character_width = current_fragment.get_character_width(
183 character, self.print_sh, initial_cs=not first_char
184 )
185 first_char = False
187 if character in (NEWLINE, FORM_FEED):
188 self.character_index += 1
189 if not current_line.fragments:
190 current_line.height = current_font_height * self.line_height
191 self._is_first_line = False
192 return current_line.manual_break(
193 Align.L if self.align == Align.J else self.align,
194 trailing_nl=character == NEWLINE,
195 trailing_form_feed=character == FORM_FEED,
196 )
197 if FloatTolerance.greater_than(
198 current_line.width + character_width, max_width
199 ):
200 self._is_first_line = False
201 if (
202 character in BREAKING_SPACE_SYMBOLS_STR
203 ): # must come first, always drop a current space.
204 self.character_index += 1
205 return current_line.manual_break(self.align)
206 if self.wrapmode == WrapMode.CHAR: 206 ↛ 209line 206 didn't jump to line 209 because the condition on line 206 was never true
207 # If the line ends with one or more spaces, then we want to get
208 # rid of them so it can be justified correctly.
209 current_line.trim_trailing_spaces()
210 return current_line.manual_break(self.align)
211 if current_line.automatic_break_possible():
212 (
213 self.fragment_index,
214 self.character_index,
215 line,
216 ) = current_line.automatic_break(self.align)
217 self.character_index += 1
218 return line
219 if idx_last_forced_break == self.character_index: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true
220 raise FPDFException(
221 "Not enough horizontal space to render a single character"
222 )
223 self.idx_last_forced_break = self.character_index
224 return current_line.manual_break(
225 Align.L if self.align == Align.J else self.align,
226 )
228 current_line.add_character(
229 character,
230 character_width,
231 current_fragment,
232 self.fragment_index,
233 self.character_index,
234 current_font_height * self.line_height,
235 current_fragment.link,
236 )
238 self.character_index += 1
240 if current_line.width:
241 self._is_first_line = False
242 return current_line.manual_break(
243 Align.L if self.align == Align.J else self.align,
244 )
245 return None
248# Monkey-patch
249fpdf.fpdf.MultiLineBreak = MultiLineBreak # type: ignore[attr-defined]
250fpdf.line_break.CurrentLine = CurrentLine # type: ignore[misc]
251fpdf.line_break.MultiLineBreak = MultiLineBreak # type: ignore[misc]