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

1"""Line Break.""" 

2 

3# ruff: noqa: E501, UP045 

4 

5from typing import Optional 

6 

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 

23 

24 

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 ) 

48 

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] 

67 

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 ) 

95 

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) 

98 

99 

100class MultiLineBreak(fpdf.line_break.MultiLineBreak): 

101 __PATCHED__: bool = True 

102 

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 

108 

109 if self.fragment_index == len(self.fragments): 

110 return None 

111 

112 current_font_height: float = 0 

113 

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 

126 

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 

144 

145 while self.fragment_index < len(self.fragments): 

146 current_fragment = self.fragments[self.fragment_index] 

147 

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 

160 

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 

180 

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 

186 

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 ) 

227 

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 ) 

237 

238 self.character_index += 1 

239 

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 

246 

247 

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]