Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes15kdownloads
ImageText.py509 linesDownload Raw Back to PIL
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
509 
codekingpro/portable-devtools · Team Ai