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

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

238 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 

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

42 line = emptystring 

43 

44 for word in re.findall( 

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

46 ): 

47 newlines = re.findall( 

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

49 ) 

50 if newlines: 

51 if not self.add_line(line): 

52 break 

53 for i, line in enumerate(newlines): 

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

55 break 

56 self.position += len(line) 

57 word = word[len(line) :] 

58 line = emptystring 

59 

60 new_line = line + word 

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

62 # This word fits on the line 

63 line = new_line 

64 continue 

65 

66 # This word does not fit on the line 

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

68 break 

69 

70 original_length = len(word) 

71 word = word.lstrip() 

72 self.offset = original_length - len(word) 

73 

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

75 if font is None: 

76 msg = "Word does not fit within line" 

77 raise ValueError(msg) 

78 break 

79 line = word 

80 else: 

81 if line: 

82 self.add_line(line) 

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

84 

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

86 lines = self.lines + [line] 

87 if self.height is not None: 

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

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

90 if last_line_y + last_line_height > self.height: 

91 return False 

92 

93 self.lines = lines 

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

95 self.offset = 0 

96 return True 

97 

98 

99class Text(Generic[AnyStr]): 

100 def __init__( 

101 self, 

102 text: AnyStr, 

103 font: ImageFont.BaseImageFont | None = None, 

104 mode: str = "RGB", 

105 spacing: float = 4, 

106 direction: str | None = None, 

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

108 language: str | None = None, 

109 ) -> None: 

110 """ 

111 :param text: String to be drawn. 

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

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

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

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

116 will be used. 

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

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

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

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

121 Requires libraqm. 

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

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

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

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

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

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

128 features, see `OpenType docs`_. 

129 Requires libraqm. 

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

131 different glyph shapes or ligatures. This parameter tells 

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

133 correct substitutions as appropriate, if available. 

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

135 Requires libraqm. 

136 """ 

137 self.text: AnyStr = text 

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

139 

140 self.mode = mode 

141 self.spacing = spacing 

142 self.direction = direction 

143 self.features = features 

144 self.language = language 

145 

146 self.embedded_color = False 

147 

148 self.stroke_width: float = 0 

149 self.stroke_fill: _Ink | None = None 

150 

151 def embed_color(self) -> None: 

152 """ 

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

154 """ 

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

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

157 raise ValueError(msg) 

158 self.embedded_color = True 

159 

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

161 """ 

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

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

164 default to the ``fill`` parameter from 

165 :py:meth:`.ImageDraw.ImageDraw.text`. 

166 """ 

167 self.stroke_width = width 

168 self.stroke_fill = fill 

169 

170 def _get_fontmode(self) -> str: 

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

172 return "1" 

173 elif self.embedded_color: 

174 return "RGBA" 

175 else: 

176 return "L" 

177 

178 def wrap( 

179 self, 

180 width: int, 

181 height: int | None = None, 

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

183 ) -> Text[AnyStr] | None: 

184 """ 

185 Wrap text to fit within a given width. 

186 

187 :param width: The width to fit within. 

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

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

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

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

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

193 integer limit to stop scaling at. 

194 

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

196 """ 

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

198 msg = "TransposedFont not supported" 

199 raise ValueError(msg) 

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

201 msg = "Only ltr direction supported" 

202 raise ValueError(msg) 

203 

204 if scaling is None: 

205 wrap = _Wrap(self, width, height) 

206 else: 

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

208 msg = "'scaling' only supports FreeTypeFont" 

209 raise ValueError(msg) 

210 if height is None: 

211 msg = "'scaling' requires 'height'" 

212 raise ValueError(msg) 

213 

214 if isinstance(scaling, str): 

215 limit = 1 

216 else: 

217 scaling, limit = scaling 

218 

219 font = self.font 

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

221 if scaling == "shrink": 

222 if not wrap.remaining_text: 

223 return None 

224 

225 size = math.ceil(font.size) 

226 while wrap.remaining_text: 

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

228 msg = "Text could not be scaled" 

229 raise ValueError(msg) 

230 size -= 1 

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

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

233 self.font = font 

234 else: 

235 if wrap.remaining_text: 

236 msg = "Text could not be scaled" 

237 raise ValueError(msg) 

238 

239 size = math.floor(font.size) 

240 while not wrap.remaining_text: 

241 if size == limit: 

242 msg = "Text could not be scaled" 

243 raise ValueError(msg) 

244 size += 1 

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

246 last_wrap = wrap 

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

248 size -= 1 

249 if size != self.font.size: 

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

251 wrap = last_wrap 

252 

253 if wrap.remaining_text: 

254 text = Text( 

255 text=wrap.remaining_text, 

256 font=self.font, 

257 mode=self.mode, 

258 spacing=self.spacing, 

259 direction=self.direction, 

260 features=self.features, 

261 language=self.language, 

262 ) 

263 text.embedded_color = self.embedded_color 

264 text.stroke_width = self.stroke_width 

265 text.stroke_fill = self.stroke_fill 

266 else: 

267 text = None 

268 

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

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

271 return text 

272 

273 def get_length(self) -> float: 

274 """ 

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

276 

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

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

279 e.g. when using italics or accents. 

280 

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

282 

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

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

285 character and subtract its length. 

286 

287 For example, instead of:: 

288 

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

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

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

292 assert hello + world == helloworld 

293 

294 use:: 

295 

296 hello = ( 

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

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

299 ) # adjusted for kerning 

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

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

302 assert hello + world == helloworld 

303 

304 or disable kerning with (requires libraqm):: 

305 

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

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

308 helloworld = ImageText.Text( 

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

310 ).get_length() 

311 assert hello + world == helloworld 

312 

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

314 """ 

315 if isinstance(self.text, str): 

316 multiline = "\n" in self.text 

317 else: 

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

319 if multiline: 

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

321 raise ValueError(msg) 

322 return self.font.getlength( 

323 self.text, 

324 self._get_fontmode(), 

325 self.direction, 

326 self.features, 

327 self.language, 

328 ) 

329 

330 def _split( 

331 self, 

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

333 anchor: str | None = None, 

334 align: str = "left", 

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

336 ) -> list[_Line]: 

337 if anchor is None: 

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

339 elif len(anchor) != 2: 

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

341 raise ValueError(msg) 

342 

343 if lines is None: 

344 lines = ( 

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

346 if isinstance(self.text, str) 

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

348 ) 

349 if len(lines) == 1: 

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

351 

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

353 msg = "anchor not supported for multiline text" 

354 raise ValueError(msg) 

355 

356 fontmode = self._get_fontmode() 

357 line_spacing = ( 

358 self.font.getbbox( 

359 "A", 

360 fontmode, 

361 None, 

362 self.features, 

363 self.language, 

364 self.stroke_width, 

365 )[3] 

366 + self.stroke_width 

367 + self.spacing 

368 ) 

369 

370 top = xy[1] 

371 parts = [] 

372 if self.direction == "ttb": 

373 left = xy[0] 

374 for line in lines: 

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

376 left += line_spacing 

377 else: 

378 widths = [] 

379 max_width: float = 0 

380 for line in lines: 

381 line_width = self.font.getlength( 

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

383 ) 

384 widths.append(line_width) 

385 max_width = max(max_width, line_width) 

386 

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

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

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

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

391 

392 idx = -1 

393 for line in lines: 

394 left = xy[0] 

395 idx += 1 

396 width_difference = max_width - widths[idx] 

397 

398 # align by align parameter 

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

400 pass 

401 elif align == "center": 

402 left += width_difference / 2.0 

403 elif align == "right": 

404 left += width_difference 

405 else: 

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

407 raise ValueError(msg) 

408 

409 if ( 

410 align == "justify" 

411 and width_difference != 0 

412 and idx != len(lines) - 1 

413 ): 

414 words = ( 

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

416 ) 

417 if len(words) > 1: 

418 # align left by anchor 

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

420 left -= max_width / 2.0 

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

422 left -= max_width 

423 

424 word_widths = [ 

425 self.font.getlength( 

426 word, 

427 fontmode, 

428 self.direction, 

429 self.features, 

430 self.language, 

431 ) 

432 for word in words 

433 ] 

434 word_anchor = "l" + anchor[1] 

435 width_difference = max_width - sum(word_widths) 

436 i = 0 

437 for word in words: 

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

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

440 i += 1 

441 top += line_spacing 

442 continue 

443 

444 # align left by anchor 

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

446 left -= width_difference / 2.0 

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

448 left -= width_difference 

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

450 top += line_spacing 

451 

452 return parts 

453 

454 def _get_bbox( 

455 self, 

456 text: str | bytes, 

457 font: ImageFont.BaseImageFont | None = None, 

458 anchor: str | None = None, 

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

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

461 text, 

462 self._get_fontmode(), 

463 self.direction, 

464 self.features, 

465 self.language, 

466 self.stroke_width, 

467 anchor, 

468 ) 

469 

470 def get_bbox( 

471 self, 

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

473 anchor: str | None = None, 

474 align: str = "left", 

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

476 """ 

477 Returns bounding box (in pixels) of text. 

478 

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

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

481 or accents. 

482 

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

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

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

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

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

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

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

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

491 

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

493 """ 

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

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

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

497 bbox_line = ( 

498 bbox_line[0] + x, 

499 bbox_line[1] + y, 

500 bbox_line[2] + x, 

501 bbox_line[3] + y, 

502 ) 

503 if bbox is None: 

504 bbox = bbox_line 

505 else: 

506 bbox = ( 

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

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

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

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

511 ) 

512 

513 assert bbox is not None 

514 return bbox