Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/PIL/ImageText.py: 36%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

235 statements  

1from __future__ import annotations 

2 

3import math 

4import re 

5from typing import AnyStr, Generic, NamedTuple 

6 

7from . import ImageFont 

8from ._typing import _Ink 

9 

10 

11class _Line(NamedTuple): 

12 x: float 

13 y: float 

14 anchor: str 

15 text: str | bytes 

16 

17 

18class _Wrap(Generic[AnyStr]): 

19 lines: list[AnyStr] = [] 

20 position = 0 

21 offset = 0 

22 

23 def __init__( 

24 self, 

25 text: Text[AnyStr], 

26 width: int, 

27 height: int | None = None, 

28 font: ImageFont.BaseImageFont | None = None, 

29 ) -> None: 

30 self.text: Text[AnyStr] = text 

31 self.width = width 

32 self.height = height 

33 self.font = font 

34 

35 input_text = self.text.text 

36 emptystring = "" if isinstance(input_text, str) else b"" 

37 line = emptystring 

38 

39 for word in re.findall( 

40 r"\s*\S+" if isinstance(input_text, str) else rb"\s*\S+", input_text 

41 ): 

42 newlines = re.findall( 

43 r"[^\S\n]*\n" if isinstance(input_text, str) else rb"[^\S\n]*\n", word 

44 ) 

45 if newlines: 

46 if not self.add_line(line): 

47 break 

48 for i, line in enumerate(newlines): 

49 if i != 0 and not self.add_line(emptystring): 

50 break 

51 self.position += len(line) 

52 word = word[len(line) :] 

53 line = emptystring 

54 

55 new_line = line + word 

56 if self.text._get_bbox(new_line, self.font)[2] <= width: 

57 # This word fits on the line 

58 line = new_line 

59 continue 

60 

61 # This word does not fit on the line 

62 if line and not self.add_line(line): 

63 break 

64 

65 original_length = len(word) 

66 word = word.lstrip() 

67 self.offset = original_length - len(word) 

68 

69 if self.text._get_bbox(word, self.font)[2] > width: 

70 if font is None: 

71 msg = "Word does not fit within line" 

72 raise ValueError(msg) 

73 break 

74 line = word 

75 else: 

76 if line: 

77 self.add_line(line) 

78 self.remaining_text: AnyStr = input_text[self.position :] 

79 

80 def add_line(self, line: AnyStr) -> bool: 

81 lines = self.lines + [line] 

82 if self.height is not None: 

83 last_line_y = self.text._split(lines=lines)[-1].y 

84 last_line_height = self.text._get_bbox(line, self.font)[3] 

85 if last_line_y + last_line_height > self.height: 

86 return False 

87 

88 self.lines = lines 

89 self.position += len(line) + self.offset 

90 self.offset = 0 

91 return True 

92 

93 

94class Text(Generic[AnyStr]): 

95 def __init__( 

96 self, 

97 text: AnyStr, 

98 font: ImageFont.BaseImageFont | None = None, 

99 mode: str = "RGB", 

100 spacing: float = 4, 

101 direction: str | None = None, 

102 features: list[str] | None = None, 

103 language: str | None = None, 

104 ) -> None: 

105 """ 

106 :param text: String to be drawn. 

107 :param font: Either an :py:class:`~PIL.ImageFont.ImageFont` instance, 

108 :py:class:`~PIL.ImageFont.FreeTypeFont` instance, 

109 :py:class:`~PIL.ImageFont.TransposedFont` instance or ``None``. If 

110 ``None``, the default font from :py:meth:`.ImageFont.load_default` 

111 will be used. 

112 :param mode: The image mode this will be used with. 

113 :param spacing: The number of pixels between lines. 

114 :param direction: Direction of the text. It can be ``"rtl"`` (right to left), 

115 ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). 

116 Requires libraqm. 

117 :param features: A list of OpenType font features to be used during text 

118 layout. This is usually used to turn on optional font features 

119 that are not enabled by default, for example ``"dlig"`` or 

120 ``"ss01"``, but can be also used to turn off default font 

121 features, for example ``"-liga"`` to disable ligatures or 

122 ``"-kern"`` to disable kerning. To get all supported 

123 features, see `OpenType docs`_. 

124 Requires libraqm. 

125 :param language: Language of the text. Different languages may use 

126 different glyph shapes or ligatures. This parameter tells 

127 the font which language the text is in, and to apply the 

128 correct substitutions as appropriate, if available. 

129 It should be a `BCP 47 language code`_. 

130 Requires libraqm. 

131 """ 

132 self.text: AnyStr = text 

133 self.font = font or ImageFont.load_default() 

134 

135 self.mode = mode 

136 self.spacing = spacing 

137 self.direction = direction 

138 self.features = features 

139 self.language = language 

140 

141 self.embedded_color = False 

142 

143 self.stroke_width: float = 0 

144 self.stroke_fill: _Ink | None = None 

145 

146 def embed_color(self) -> None: 

147 """ 

148 Use embedded color glyphs (COLR, CBDT, SBIX). 

149 """ 

150 if self.mode not in ("RGB", "RGBA"): 

151 msg = "Embedded color supported only in RGB and RGBA modes" 

152 raise ValueError(msg) 

153 self.embedded_color = True 

154 

155 def stroke(self, width: float = 0, fill: _Ink | None = None) -> None: 

156 """ 

157 :param width: The width of the text stroke. 

158 :param fill: Color to use for the text stroke when drawing. If not given, will 

159 default to the ``fill`` parameter from 

160 :py:meth:`.ImageDraw.ImageDraw.text`. 

161 """ 

162 self.stroke_width = width 

163 self.stroke_fill = fill 

164 

165 def _get_fontmode(self) -> str: 

166 if self.mode in ("1", "P", "I", "F"): 

167 return "1" 

168 elif self.embedded_color: 

169 return "RGBA" 

170 else: 

171 return "L" 

172 

173 def wrap( 

174 self, 

175 width: int, 

176 height: int | None = None, 

177 scaling: str | tuple[str, int] | None = None, 

178 ) -> Text[AnyStr] | None: 

179 """ 

180 Wrap text to fit within a given width. 

181 

182 :param width: The width to fit within. 

183 :param height: An optional height limit. Any text that does not fit within this 

184 will be returned as a new :py:class:`.Text` object. 

185 :param scaling: An optional directive to scale the text, either "grow" as much 

186 as possible within the given dimensions, or "shrink" until it 

187 fits. It can also be a tuple of (direction, limit), with an 

188 integer limit to stop scaling at. 

189 

190 :returns: An :py:class:`.Text` object, or None. 

191 """ 

192 if isinstance(self.font, ImageFont.TransposedFont): 

193 msg = "TransposedFont not supported" 

194 raise ValueError(msg) 

195 if self.direction not in (None, "ltr"): 

196 msg = "Only ltr direction supported" 

197 raise ValueError(msg) 

198 

199 if scaling is None: 

200 wrap = _Wrap(self, width, height) 

201 else: 

202 if not isinstance(self.font, ImageFont.FreeTypeFont): 

203 msg = "'scaling' only supports FreeTypeFont" 

204 raise ValueError(msg) 

205 if height is None: 

206 msg = "'scaling' requires 'height'" 

207 raise ValueError(msg) 

208 

209 if isinstance(scaling, str): 

210 limit = 1 

211 else: 

212 scaling, limit = scaling 

213 

214 font = self.font 

215 wrap = _Wrap(self, width, height, font) 

216 if scaling == "shrink": 

217 if not wrap.remaining_text: 

218 return None 

219 

220 size = math.ceil(font.size) 

221 while wrap.remaining_text: 

222 if size == max(limit, 1): 

223 msg = "Text could not be scaled" 

224 raise ValueError(msg) 

225 size -= 1 

226 font = self.font.font_variant(size=size) 

227 wrap = _Wrap(self, width, height, font) 

228 self.font = font 

229 else: 

230 if wrap.remaining_text: 

231 msg = "Text could not be scaled" 

232 raise ValueError(msg) 

233 

234 size = math.floor(font.size) 

235 while not wrap.remaining_text: 

236 if size == limit: 

237 msg = "Text could not be scaled" 

238 raise ValueError(msg) 

239 size += 1 

240 font = self.font.font_variant(size=size) 

241 last_wrap = wrap 

242 wrap = _Wrap(self, width, height, font) 

243 size -= 1 

244 if size != self.font.size: 

245 self.font = self.font.font_variant(size=size) 

246 wrap = last_wrap 

247 

248 if wrap.remaining_text: 

249 text = Text( 

250 text=wrap.remaining_text, 

251 font=self.font, 

252 mode=self.mode, 

253 spacing=self.spacing, 

254 direction=self.direction, 

255 features=self.features, 

256 language=self.language, 

257 ) 

258 text.embedded_color = self.embedded_color 

259 text.stroke_width = self.stroke_width 

260 text.stroke_fill = self.stroke_fill 

261 else: 

262 text = None 

263 

264 newline = "\n" if isinstance(self.text, str) else b"\n" 

265 self.text = newline.join(wrap.lines) 

266 return text 

267 

268 def get_length(self) -> float: 

269 """ 

270 Returns length (in pixels with 1/64 precision) of text. 

271 

272 This is the amount by which following text should be offset. 

273 Text bounding box may extend past the length in some fonts, 

274 e.g. when using italics or accents. 

275 

276 The result is returned as a float; it is a whole number if using basic layout. 

277 

278 Note that the sum of two lengths may not equal the length of a concatenated 

279 string due to kerning. If you need to adjust for kerning, include the following 

280 character and subtract its length. 

281 

282 For example, instead of:: 

283 

284 hello = ImageText.Text("Hello", font).get_length() 

285 world = ImageText.Text("World", font).get_length() 

286 helloworld = ImageText.Text("HelloWorld", font).get_length() 

287 assert hello + world == helloworld 

288 

289 use:: 

290 

291 hello = ( 

292 ImageText.Text("HelloW", font).get_length() - 

293 ImageText.Text("W", font).get_length() 

294 ) # adjusted for kerning 

295 world = ImageText.Text("World", font).get_length() 

296 helloworld = ImageText.Text("HelloWorld", font).get_length() 

297 assert hello + world == helloworld 

298 

299 or disable kerning with (requires libraqm):: 

300 

301 hello = ImageText.Text("Hello", font, features=["-kern"]).get_length() 

302 world = ImageText.Text("World", font, features=["-kern"]).get_length() 

303 helloworld = ImageText.Text( 

304 "HelloWorld", font, features=["-kern"] 

305 ).get_length() 

306 assert hello + world == helloworld 

307 

308 :return: Either width for horizontal text, or height for vertical text. 

309 """ 

310 if isinstance(self.text, str): 

311 multiline = "\n" in self.text 

312 else: 

313 multiline = b"\n" in self.text 

314 if multiline: 

315 msg = "can't measure length of multiline text" 

316 raise ValueError(msg) 

317 return self.font.getlength( 

318 self.text, 

319 self._get_fontmode(), 

320 self.direction, 

321 self.features, 

322 self.language, 

323 ) 

324 

325 def _split( 

326 self, 

327 xy: tuple[float, float] = (0, 0), 

328 anchor: str | None = None, 

329 align: str = "left", 

330 lines: list[str] | list[bytes] | None = None, 

331 ) -> list[_Line]: 

332 if anchor is None: 

333 anchor = "lt" if self.direction == "ttb" else "la" 

334 elif len(anchor) != 2: 

335 msg = "anchor must be a 2 character string" 

336 raise ValueError(msg) 

337 

338 if lines is None: 

339 lines = ( 

340 self.text.split("\n") 

341 if isinstance(self.text, str) 

342 else self.text.split(b"\n") 

343 ) 

344 if len(lines) == 1: 

345 return [_Line(xy[0], xy[1], anchor, lines[0])] 

346 

347 if anchor[1] in "tb" and self.direction != "ttb": 

348 msg = "anchor not supported for multiline text" 

349 raise ValueError(msg) 

350 

351 fontmode = self._get_fontmode() 

352 line_spacing = ( 

353 self.font.getbbox( 

354 "A", 

355 fontmode, 

356 None, 

357 self.features, 

358 self.language, 

359 self.stroke_width, 

360 )[3] 

361 + self.stroke_width 

362 + self.spacing 

363 ) 

364 

365 top = xy[1] 

366 parts = [] 

367 if self.direction == "ttb": 

368 left = xy[0] 

369 for line in lines: 

370 parts.append(_Line(left, top, anchor, line)) 

371 left += line_spacing 

372 else: 

373 widths = [] 

374 max_width: float = 0 

375 for line in lines: 

376 line_width = self.font.getlength( 

377 line, fontmode, self.direction, self.features, self.language 

378 ) 

379 widths.append(line_width) 

380 max_width = max(max_width, line_width) 

381 

382 if anchor[1] == "m": 

383 top -= (len(lines) - 1) * line_spacing / 2.0 

384 elif anchor[1] == "d": 

385 top -= (len(lines) - 1) * line_spacing 

386 

387 idx = -1 

388 for line in lines: 

389 left = xy[0] 

390 idx += 1 

391 width_difference = max_width - widths[idx] 

392 

393 # align by align parameter 

394 if align in ("left", "justify"): 

395 pass 

396 elif align == "center": 

397 left += width_difference / 2.0 

398 elif align == "right": 

399 left += width_difference 

400 else: 

401 msg = 'align must be "left", "center", "right" or "justify"' 

402 raise ValueError(msg) 

403 

404 if ( 

405 align == "justify" 

406 and width_difference != 0 

407 and idx != len(lines) - 1 

408 ): 

409 words = ( 

410 line.split(" ") if isinstance(line, str) else line.split(b" ") 

411 ) 

412 if len(words) > 1: 

413 # align left by anchor 

414 if anchor[0] == "m": 

415 left -= max_width / 2.0 

416 elif anchor[0] == "r": 

417 left -= max_width 

418 

419 word_widths = [ 

420 self.font.getlength( 

421 word, 

422 fontmode, 

423 self.direction, 

424 self.features, 

425 self.language, 

426 ) 

427 for word in words 

428 ] 

429 word_anchor = "l" + anchor[1] 

430 width_difference = max_width - sum(word_widths) 

431 i = 0 

432 for word in words: 

433 parts.append(_Line(left, top, word_anchor, word)) 

434 left += word_widths[i] + width_difference / (len(words) - 1) 

435 i += 1 

436 top += line_spacing 

437 continue 

438 

439 # align left by anchor 

440 if anchor[0] == "m": 

441 left -= width_difference / 2.0 

442 elif anchor[0] == "r": 

443 left -= width_difference 

444 parts.append(_Line(left, top, anchor, line)) 

445 top += line_spacing 

446 

447 return parts 

448 

449 def _get_bbox( 

450 self, 

451 text: str | bytes, 

452 font: ImageFont.BaseImageFont | None = None, 

453 anchor: str | None = None, 

454 ) -> tuple[float, float, float, float]: 

455 return (font or self.font).getbbox( 

456 text, 

457 self._get_fontmode(), 

458 self.direction, 

459 self.features, 

460 self.language, 

461 self.stroke_width, 

462 anchor, 

463 ) 

464 

465 def get_bbox( 

466 self, 

467 xy: tuple[float, float] = (0, 0), 

468 anchor: str | None = None, 

469 align: str = "left", 

470 ) -> tuple[float, float, float, float]: 

471 """ 

472 Returns bounding box (in pixels) of text. 

473 

474 Use :py:meth:`get_length` to get the offset of following text with 1/64 pixel 

475 precision. The bounding box includes extra margins for some fonts, e.g. italics 

476 or accents. 

477 

478 :param xy: The anchor coordinates of the text. 

479 :param anchor: The text anchor alignment. Determines the relative location of 

480 the anchor to the text. The default alignment is top left, 

481 specifically ``la`` for horizontal text and ``lt`` for 

482 vertical text. See :ref:`text-anchors` for details. 

483 :param align: For multiline text, ``"left"``, ``"center"``, ``"right"`` or 

484 ``"justify"`` determines the relative alignment of lines. Use the 

485 ``anchor`` parameter to specify the alignment to ``xy``. 

486 

487 :return: ``(left, top, right, bottom)`` bounding box 

488 """ 

489 bbox: tuple[float, float, float, float] | None = None 

490 for x, y, anchor, text in self._split(xy, anchor, align): 

491 bbox_line = self._get_bbox(text, anchor=anchor) 

492 bbox_line = ( 

493 bbox_line[0] + x, 

494 bbox_line[1] + y, 

495 bbox_line[2] + x, 

496 bbox_line[3] + y, 

497 ) 

498 if bbox is None: 

499 bbox = bbox_line 

500 else: 

501 bbox = ( 

502 min(bbox[0], bbox_line[0]), 

503 min(bbox[1], bbox_line[1]), 

504 max(bbox[2], bbox_line[2]), 

505 max(bbox[3], bbox_line[3]), 

506 ) 

507 

508 assert bbox is not None 

509 return bbox