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

236 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 

10Font = ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont 

11 

12 

13class _Line(NamedTuple): 

14 x: float 

15 y: float 

16 anchor: str 

17 text: str | bytes 

18 

19 

20class _Wrap(Generic[AnyStr]): 

21 lines: list[AnyStr] = [] 

22 position = 0 

23 offset = 0 

24 

25 def __init__( 

26 self, 

27 text: Text[AnyStr], 

28 width: int, 

29 height: int | None = None, 

30 font: Font | None = None, 

31 ) -> None: 

32 self.text: Text[AnyStr] = text 

33 self.width = width 

34 self.height = height 

35 self.font = font 

36 

37 input_text = self.text.text 

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

39 line = emptystring 

40 

41 for word in re.findall( 

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

43 ): 

44 newlines = re.findall( 

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

46 ) 

47 if newlines: 

48 if not self.add_line(line): 

49 break 

50 for i, line in enumerate(newlines): 

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

52 break 

53 self.position += len(line) 

54 word = word[len(line) :] 

55 line = emptystring 

56 

57 new_line = line + word 

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

59 # This word fits on the line 

60 line = new_line 

61 continue 

62 

63 # This word does not fit on the line 

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

65 break 

66 

67 original_length = len(word) 

68 word = word.lstrip() 

69 self.offset = original_length - len(word) 

70 

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

72 if font is None: 

73 msg = "Word does not fit within line" 

74 raise ValueError(msg) 

75 break 

76 line = word 

77 else: 

78 if line: 

79 self.add_line(line) 

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

81 

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

83 lines = self.lines + [line] 

84 if self.height is not None: 

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

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

87 if last_line_y + last_line_height > self.height: 

88 return False 

89 

90 self.lines = lines 

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

92 self.offset = 0 

93 return True 

94 

95 

96class Text(Generic[AnyStr]): 

97 def __init__( 

98 self, 

99 text: AnyStr, 

100 font: Font | None = None, 

101 mode: str = "RGB", 

102 spacing: float = 4, 

103 direction: str | None = None, 

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

105 language: str | None = None, 

106 ) -> None: 

107 """ 

108 :param text: String to be drawn. 

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

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

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

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

113 will be used. 

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

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

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

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

118 Requires libraqm. 

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

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

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

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

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

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

125 features, see `OpenType docs`_. 

126 Requires libraqm. 

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

128 different glyph shapes or ligatures. This parameter tells 

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

130 correct substitutions as appropriate, if available. 

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

132 Requires libraqm. 

133 """ 

134 self.text: AnyStr = text 

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

136 

137 self.mode = mode 

138 self.spacing = spacing 

139 self.direction = direction 

140 self.features = features 

141 self.language = language 

142 

143 self.embedded_color = False 

144 

145 self.stroke_width: float = 0 

146 self.stroke_fill: _Ink | None = None 

147 

148 def embed_color(self) -> None: 

149 """ 

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

151 """ 

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

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

154 raise ValueError(msg) 

155 self.embedded_color = True 

156 

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

158 """ 

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

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

161 default to the ``fill`` parameter from 

162 :py:meth:`.ImageDraw.ImageDraw.text`. 

163 """ 

164 self.stroke_width = width 

165 self.stroke_fill = fill 

166 

167 def _get_fontmode(self) -> str: 

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

169 return "1" 

170 elif self.embedded_color: 

171 return "RGBA" 

172 else: 

173 return "L" 

174 

175 def wrap( 

176 self, 

177 width: int, 

178 height: int | None = None, 

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

180 ) -> Text[AnyStr] | None: 

181 """ 

182 Wrap text to fit within a given width. 

183 

184 :param width: The width to fit within. 

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

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

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

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

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

190 integer limit to stop scaling at. 

191 

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

193 """ 

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

195 msg = "TransposedFont not supported" 

196 raise ValueError(msg) 

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

198 msg = "Only ltr direction supported" 

199 raise ValueError(msg) 

200 

201 if scaling is None: 

202 wrap = _Wrap(self, width, height) 

203 else: 

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

205 msg = "'scaling' only supports FreeTypeFont" 

206 raise ValueError(msg) 

207 if height is None: 

208 msg = "'scaling' requires 'height'" 

209 raise ValueError(msg) 

210 

211 if isinstance(scaling, str): 

212 limit = 1 

213 else: 

214 scaling, limit = scaling 

215 

216 font = self.font 

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

218 if scaling == "shrink": 

219 if not wrap.remaining_text: 

220 return None 

221 

222 size = math.ceil(font.size) 

223 while wrap.remaining_text: 

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

225 msg = "Text could not be scaled" 

226 raise ValueError(msg) 

227 size -= 1 

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

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

230 self.font = font 

231 else: 

232 if wrap.remaining_text: 

233 msg = "Text could not be scaled" 

234 raise ValueError(msg) 

235 

236 size = math.floor(font.size) 

237 while not wrap.remaining_text: 

238 if size == limit: 

239 msg = "Text could not be scaled" 

240 raise ValueError(msg) 

241 size += 1 

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

243 last_wrap = wrap 

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

245 size -= 1 

246 if size != self.font.size: 

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

248 wrap = last_wrap 

249 

250 if wrap.remaining_text: 

251 text = Text( 

252 text=wrap.remaining_text, 

253 font=self.font, 

254 mode=self.mode, 

255 spacing=self.spacing, 

256 direction=self.direction, 

257 features=self.features, 

258 language=self.language, 

259 ) 

260 text.embedded_color = self.embedded_color 

261 text.stroke_width = self.stroke_width 

262 text.stroke_fill = self.stroke_fill 

263 else: 

264 text = None 

265 

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

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

268 return text 

269 

270 def get_length(self) -> float: 

271 """ 

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

273 

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

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

276 e.g. when using italics or accents. 

277 

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

279 

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

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

282 character and subtract its length. 

283 

284 For example, instead of:: 

285 

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

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

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

289 assert hello + world == helloworld 

290 

291 use:: 

292 

293 hello = ( 

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

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

296 ) # adjusted for kerning 

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

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

299 assert hello + world == helloworld 

300 

301 or disable kerning with (requires libraqm):: 

302 

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

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

305 helloworld = ImageText.Text( 

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

307 ).get_length() 

308 assert hello + world == helloworld 

309 

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

311 """ 

312 if isinstance(self.text, str): 

313 multiline = "\n" in self.text 

314 else: 

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

316 if multiline: 

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

318 raise ValueError(msg) 

319 return self.font.getlength( 

320 self.text, 

321 self._get_fontmode(), 

322 self.direction, 

323 self.features, 

324 self.language, 

325 ) 

326 

327 def _split( 

328 self, 

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

330 anchor: str | None = None, 

331 align: str = "left", 

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

333 ) -> list[_Line]: 

334 if anchor is None: 

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

336 elif len(anchor) != 2: 

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

338 raise ValueError(msg) 

339 

340 if lines is None: 

341 lines = ( 

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

343 if isinstance(self.text, str) 

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

345 ) 

346 if len(lines) == 1: 

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

348 

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

350 msg = "anchor not supported for multiline text" 

351 raise ValueError(msg) 

352 

353 fontmode = self._get_fontmode() 

354 line_spacing = ( 

355 self.font.getbbox( 

356 "A", 

357 fontmode, 

358 None, 

359 self.features, 

360 self.language, 

361 self.stroke_width, 

362 )[3] 

363 + self.stroke_width 

364 + self.spacing 

365 ) 

366 

367 top = xy[1] 

368 parts = [] 

369 if self.direction == "ttb": 

370 left = xy[0] 

371 for line in lines: 

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

373 left += line_spacing 

374 else: 

375 widths = [] 

376 max_width: float = 0 

377 for line in lines: 

378 line_width = self.font.getlength( 

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

380 ) 

381 widths.append(line_width) 

382 max_width = max(max_width, line_width) 

383 

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

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

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

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

388 

389 idx = -1 

390 for line in lines: 

391 left = xy[0] 

392 idx += 1 

393 width_difference = max_width - widths[idx] 

394 

395 # align by align parameter 

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

397 pass 

398 elif align == "center": 

399 left += width_difference / 2.0 

400 elif align == "right": 

401 left += width_difference 

402 else: 

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

404 raise ValueError(msg) 

405 

406 if ( 

407 align == "justify" 

408 and width_difference != 0 

409 and idx != len(lines) - 1 

410 ): 

411 words = ( 

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

413 ) 

414 if len(words) > 1: 

415 # align left by anchor 

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

417 left -= max_width / 2.0 

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

419 left -= max_width 

420 

421 word_widths = [ 

422 self.font.getlength( 

423 word, 

424 fontmode, 

425 self.direction, 

426 self.features, 

427 self.language, 

428 ) 

429 for word in words 

430 ] 

431 word_anchor = "l" + anchor[1] 

432 width_difference = max_width - sum(word_widths) 

433 i = 0 

434 for word in words: 

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

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

437 i += 1 

438 top += line_spacing 

439 continue 

440 

441 # align left by anchor 

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

443 left -= width_difference / 2.0 

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

445 left -= width_difference 

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

447 top += line_spacing 

448 

449 return parts 

450 

451 def _get_bbox( 

452 self, text: str | bytes, font: Font | None = None, anchor: str | None = None 

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

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

455 text, 

456 self._get_fontmode(), 

457 self.direction, 

458 self.features, 

459 self.language, 

460 self.stroke_width, 

461 anchor, 

462 ) 

463 

464 def get_bbox( 

465 self, 

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

467 anchor: str | None = None, 

468 align: str = "left", 

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

470 """ 

471 Returns bounding box (in pixels) of text. 

472 

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

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

475 or accents. 

476 

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

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

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

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

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

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

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

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

485 

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

487 """ 

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

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

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

491 bbox_line = ( 

492 bbox_line[0] + x, 

493 bbox_line[1] + y, 

494 bbox_line[2] + x, 

495 bbox_line[3] + y, 

496 ) 

497 if bbox is None: 

498 bbox = bbox_line 

499 else: 

500 bbox = ( 

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

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

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

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

505 ) 

506 

507 assert bbox is not None 

508 return bbox