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

248 statements  

1from __future__ import annotations 

2 

3__lazy_modules__ = {"math", "re"} 

4 

5import math 

6import re 

7from typing import AnyStr, Generic, NamedTuple 

8 

9from . import ImageFont 

10 

11TYPE_CHECKING = False 

12if TYPE_CHECKING: 

13 from ._typing import _Ink 

14 

15 

16class _Line(NamedTuple): 

17 x: float 

18 y: float 

19 anchor: str 

20 text: str | bytes 

21 

22 

23class _Wrap(Generic[AnyStr]): 

24 lines: list[AnyStr] = [] 

25 position = 0 

26 offset = 0 

27 

28 def __init__( 

29 self, 

30 text: Text[AnyStr], 

31 width: int, 

32 height: int | None = None, 

33 font: ImageFont.BaseImageFont | None = None, 

34 ) -> None: 

35 self.text: Text[AnyStr] = text 

36 self.width = width 

37 self.height = height 

38 self.font = font 

39 

40 input_text = self.text.text.rstrip() 

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

42 line = emptystring 

43 

44 newline_pattern = ( 

45 r"[^\S" 

46 + "".join(ImageFont._separators) 

47 + "]*(" 

48 + "|".join(ImageFont._separators) 

49 + ")" 

50 ) 

51 for word in re.findall( 

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

53 ): 

54 if newlines := re.findall( 

55 ( 

56 newline_pattern 

57 if isinstance(input_text, str) 

58 else newline_pattern.encode() 

59 ), 

60 word, 

61 ): 

62 if not self.add_line(line): 

63 break 

64 newlines_all_fit = True 

65 for i, line in enumerate(newlines): 

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

67 newlines_all_fit = False 

68 break 

69 self.position += len(line) 

70 word = word[len(line) :] 

71 if not newlines_all_fit: 

72 break 

73 line = emptystring 

74 

75 new_line = line + word 

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

77 # This word fits on the line 

78 line = new_line 

79 continue 

80 

81 # This word does not fit on the line 

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

83 break 

84 

85 original_length = len(word) 

86 word = word.lstrip() 

87 self.offset = original_length - len(word) 

88 

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

90 if font is None: 

91 msg = "Word does not fit within line" 

92 raise ValueError(msg) 

93 break 

94 line = word 

95 else: 

96 if line: 

97 self.add_line(line) 

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

99 

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

101 lines = self.lines + [line] 

102 if self.height is not None: 

103 last_line_y = self.text._split(lines=lines, font=self.font)[-1].y 

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

105 if last_line_y + last_line_height > self.height: 

106 return False 

107 

108 self.lines = lines 

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

110 self.offset = 0 

111 return True 

112 

113 

114class Text(Generic[AnyStr]): 

115 def __init__( 

116 self, 

117 text: AnyStr, 

118 font: ImageFont.BaseImageFont | None = None, 

119 mode: str = "RGB", 

120 spacing: float = 4, 

121 direction: str | None = None, 

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

123 language: str | None = None, 

124 ) -> None: 

125 """ 

126 :param text: String to be drawn. 

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

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

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

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

131 will be used. 

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

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

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

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

136 Requires libraqm. 

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

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

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

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

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

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

143 features, see `OpenType docs`_. 

144 Requires libraqm. 

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

146 different glyph shapes or ligatures. This parameter tells 

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

148 correct substitutions as appropriate, if available. 

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

150 Requires libraqm. 

151 """ 

152 self.text: AnyStr = text 

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

154 

155 self.mode = mode 

156 self.spacing = spacing 

157 self.direction = direction 

158 self.features = features 

159 self.language = language 

160 

161 self.embedded_color = False 

162 

163 self.stroke_width: float = 0 

164 self.stroke_fill: _Ink | None = None 

165 

166 def embed_color(self) -> None: 

167 """ 

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

169 """ 

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

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

172 raise ValueError(msg) 

173 self.embedded_color = True 

174 

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

176 """ 

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

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

179 default to the ``fill`` parameter from 

180 :py:meth:`.ImageDraw.ImageDraw.text`. 

181 """ 

182 self.stroke_width = width 

183 self.stroke_fill = fill 

184 

185 def _get_fontmode(self) -> str: 

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

187 return "1" 

188 elif self.embedded_color: 

189 return "RGBA" 

190 else: 

191 return "L" 

192 

193 def wrap( 

194 self, 

195 width: int, 

196 height: int | None = None, 

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

198 ) -> Text[AnyStr] | None: 

199 """ 

200 Wrap text to fit within a given width. 

201 

202 :param width: The width to fit within. 

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

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

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

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

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

208 integer limit to stop scaling at. 

209 

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

211 """ 

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

213 msg = "TransposedFont not supported" 

214 raise ValueError(msg) 

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

216 msg = "Only ltr direction supported" 

217 raise ValueError(msg) 

218 

219 if scaling is None: 

220 wrap = _Wrap(self, width, height) 

221 else: 

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

223 msg = "'scaling' only supports FreeTypeFont" 

224 raise ValueError(msg) 

225 if height is None: 

226 msg = "'scaling' requires 'height'" 

227 raise ValueError(msg) 

228 if not self.text.strip(): 

229 msg = ("Blank" if self.text else "Empty") + " text cannot be scaled" 

230 raise ValueError(msg) 

231 

232 if isinstance(scaling, str): 

233 limit = 1 

234 else: 

235 scaling, limit = scaling 

236 

237 font = self.font 

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

239 if scaling == "shrink": 

240 if wrap.remaining_text: 

241 size = math.ceil(font.size) 

242 while wrap.remaining_text: 

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

244 msg = "Text could not be scaled" 

245 raise ValueError(msg) 

246 size -= 1 

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

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

249 self.font = font 

250 else: 

251 if wrap.remaining_text: 

252 msg = "Text could not be scaled" 

253 raise ValueError(msg) 

254 

255 size = math.floor(font.size) 

256 while not wrap.remaining_text: 

257 if size == limit: 

258 msg = "Text could not be scaled" 

259 raise ValueError(msg) 

260 size += 1 

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

262 last_wrap = wrap 

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

264 size -= 1 

265 if size != self.font.size: 

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

267 wrap = last_wrap 

268 

269 if wrap.remaining_text: 

270 text = Text( 

271 text=wrap.remaining_text, 

272 font=self.font, 

273 mode=self.mode, 

274 spacing=self.spacing, 

275 direction=self.direction, 

276 features=self.features, 

277 language=self.language, 

278 ) 

279 text.embedded_color = self.embedded_color 

280 text.stroke_width = self.stroke_width 

281 text.stroke_fill = self.stroke_fill 

282 else: 

283 text = None 

284 

285 for separator in ("\r\n", "\r", "\n"): 

286 newline = separator if isinstance(self.text, str) else separator.encode() 

287 if newline in self.text: 

288 break 

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

290 return text 

291 

292 def get_length(self) -> float: 

293 """ 

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

295 

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

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

298 e.g. when using italics or accents. 

299 

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

301 

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

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

304 character and subtract its length. 

305 

306 For example, instead of:: 

307 

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

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

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

311 assert hello + world == helloworld 

312 

313 use:: 

314 

315 hello = ( 

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

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

318 ) # adjusted for kerning 

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

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

321 assert hello + world == helloworld 

322 

323 or disable kerning with (requires libraqm):: 

324 

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

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

327 helloworld = ImageText.Text( 

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

329 ).get_length() 

330 assert hello + world == helloworld 

331 

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

333 """ 

334 for separator in ImageFont._separators: 

335 if ( 

336 separator if isinstance(self.text, str) else separator.encode() 

337 ) in self.text: 

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

339 raise ValueError(msg) 

340 return self.font.getlength( 

341 self.text, 

342 self._get_fontmode(), 

343 self.direction, 

344 self.features, 

345 self.language, 

346 ) 

347 

348 def _split( 

349 self, 

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

351 anchor: str | None = None, 

352 align: str = "left", 

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

354 font: ImageFont.BaseImageFont | None = None, 

355 ) -> list[_Line]: 

356 if anchor is None: 

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

358 elif len(anchor) != 2: 

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

360 raise ValueError(msg) 

361 

362 if lines is None: 

363 pattern = "|".join(ImageFont._separators) 

364 lines = re.split( 

365 pattern if isinstance(self.text, str) else pattern.encode(), self.text 

366 ) 

367 if len(lines) == 1: 

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

369 

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

371 msg = "anchor not supported for multiline text" 

372 raise ValueError(msg) 

373 

374 if font is None: 

375 font = self.font 

376 fontmode = self._get_fontmode() 

377 line_spacing = ( 

378 font.getbbox( 

379 "A", 

380 fontmode, 

381 None, 

382 self.features, 

383 self.language, 

384 self.stroke_width, 

385 )[3] 

386 + self.stroke_width 

387 + self.spacing 

388 ) 

389 

390 top = xy[1] 

391 parts = [] 

392 if self.direction == "ttb": 

393 left = xy[0] 

394 for line in lines: 

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

396 left += line_spacing 

397 else: 

398 widths = [] 

399 max_width: float = 0 

400 for line in lines: 

401 line_width = font.getlength( 

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

403 ) 

404 widths.append(line_width) 

405 max_width = max(max_width, line_width) 

406 

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

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

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

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

411 

412 idx = -1 

413 for line in lines: 

414 left = xy[0] 

415 idx += 1 

416 width_difference = max_width - widths[idx] 

417 

418 # align by align parameter 

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

420 pass 

421 elif align == "center": 

422 left += width_difference / 2.0 

423 elif align == "right": 

424 left += width_difference 

425 else: 

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

427 raise ValueError(msg) 

428 

429 if ( 

430 align == "justify" 

431 and width_difference != 0 

432 and idx != len(lines) - 1 

433 ): 

434 words = ( 

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

436 ) 

437 if len(words) > 1: 

438 # align left by anchor 

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

440 left -= max_width / 2.0 

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

442 left -= max_width 

443 

444 word_widths = [ 

445 font.getlength( 

446 word, 

447 fontmode, 

448 self.direction, 

449 self.features, 

450 self.language, 

451 ) 

452 for word in words 

453 ] 

454 word_anchor = "l" + anchor[1] 

455 width_difference = max_width - sum(word_widths) 

456 i = 0 

457 for word in words: 

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

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

460 i += 1 

461 top += line_spacing 

462 continue 

463 

464 # align left by anchor 

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

466 left -= width_difference / 2.0 

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

468 left -= width_difference 

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

470 top += line_spacing 

471 

472 return parts 

473 

474 def _get_bbox( 

475 self, 

476 text: str | bytes, 

477 font: ImageFont.BaseImageFont | None = None, 

478 anchor: str | None = None, 

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

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

481 text, 

482 self._get_fontmode(), 

483 self.direction, 

484 self.features, 

485 self.language, 

486 self.stroke_width, 

487 anchor, 

488 ) 

489 

490 def get_bbox( 

491 self, 

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

493 anchor: str | None = None, 

494 align: str = "left", 

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

496 """ 

497 Returns bounding box (in pixels) of text. 

498 

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

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

501 or accents. 

502 

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

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

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

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

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

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

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

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

511 

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

513 """ 

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

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

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

517 bbox_line = ( 

518 bbox_line[0] + x, 

519 bbox_line[1] + y, 

520 bbox_line[2] + x, 

521 bbox_line[3] + y, 

522 ) 

523 if bbox is None: 

524 bbox = bbox_line 

525 else: 

526 bbox = ( 

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

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

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

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

531 ) 

532 

533 assert bbox is not None 

534 return bbox