CoolFace
Apppublic

Aluode/PerceptionLabPortable

sourceHugging Faceupdated 9mo agoView on Hugging Face
0likes
ImageText.py321 linesDownload Raw Back to PIL
1from __future__ import annotations
2
3from . import ImageFont
4from ._typing import _Ink
5
6
7class Text:
8    def __init__(
9        self,
10        text: str | bytes,
11        font: (
12            ImageFont.ImageFont
13            | ImageFont.FreeTypeFont
14            | ImageFont.TransposedFont
15            | None
16        ) = None,
17        mode: str = "RGB",
18        spacing: float = 4,
19        direction: str | None = None,
20        features: list[str] | None = None,
21        language: str | None = None,
22    ) -> None:
23        """
24        :param text: String to be drawn.
25        :param font: Either an :py:class:`~PIL.ImageFont.ImageFont` instance,
26                     :py:class:`~PIL.ImageFont.FreeTypeFont` instance,
27                     :py:class:`~PIL.ImageFont.TransposedFont` instance or ``None``. If
28                     ``None``, the default font from :py:meth:`.ImageFont.load_default`
29                     will be used.
30        :param mode: The image mode this will be used with.
31        :param spacing: The number of pixels between lines.
32        :param direction: Direction of the text. It can be ``"rtl"`` (right to left),
33                          ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
34                          Requires libraqm.
35        :param features: A list of OpenType font features to be used during text
36                         layout. This is usually used to turn on optional font features
37                         that are not enabled by default, for example ``"dlig"`` or
38                         ``"ss01"``, but can be also used to turn off default font
39                         features, for example ``"-liga"`` to disable ligatures or
40                         ``"-kern"`` to disable kerning.  To get all supported
41                         features, see `OpenType docs`_.
42                         Requires libraqm.
43        :param language: Language of the text. Different languages may use
44                         different glyph shapes or ligatures. This parameter tells
45                         the font which language the text is in, and to apply the
46                         correct substitutions as appropriate, if available.
47                         It should be a `BCP 47 language code`_.
48                         Requires libraqm.
49        """
50        self.text = text
51        self.font = font or ImageFont.load_default()
52
53        self.mode = mode
54        self.spacing = spacing
55        self.direction = direction
56        self.features = features
57        self.language = language
58
59        self.embedded_color = False
60
61        self.stroke_width: float = 0
62        self.stroke_fill: _Ink | None = None
63
64    def embed_color(self) -> None:
65        """
66        Use embedded color glyphs (COLR, CBDT, SBIX).
67        """
68        if self.mode not in ("RGB", "RGBA"):
69            msg = "Embedded color supported only in RGB and RGBA modes"
70            raise ValueError(msg)
71        self.embedded_color = True
72
73    def stroke(self, width: float = 0, fill: _Ink | None = None) -> None:
74        """
75        :param width: The width of the text stroke.
76        :param fill: Color to use for the text stroke when drawing. If not given, will
77                     default to the ``fill`` parameter from
78                     :py:meth:`.ImageDraw.ImageDraw.text`.
79        """
80        self.stroke_width = width
81        self.stroke_fill = fill
82
83    def _get_fontmode(self) -> str:
84        if self.mode in ("1", "P", "I", "F"):
85            return "1"
86        elif self.embedded_color:
87            return "RGBA"
88        else:
89            return "L"
90
91    def get_length(self) -> float:
92        """
93        Returns length (in pixels with 1/64 precision) of text.
94
95        This is the amount by which following text should be offset.
96        Text bounding box may extend past the length in some fonts,
97        e.g. when using italics or accents.
98
99        The result is returned as a float; it is a whole number if using basic layout.
100
101        Note that the sum of two lengths may not equal the length of a concatenated
102        string due to kerning. If you need to adjust for kerning, include the following
103        character and subtract its length.
104
105        For example, instead of::
106
107            hello = ImageText.Text("Hello", font).get_length()
108            world = ImageText.Text("World", font).get_length()
109            helloworld = ImageText.Text("HelloWorld", font).get_length()
110            assert hello + world == helloworld
111
112        use::
113
114            hello = (
115                ImageText.Text("HelloW", font).get_length() -
116                ImageText.Text("W", font).get_length()
117            )  # adjusted for kerning
118            world = ImageText.Text("World", font).get_length()
119            helloworld = ImageText.Text("HelloWorld", font).get_length()
120            assert hello + world == helloworld
121
122        or disable kerning with (requires libraqm)::
123
124            hello = ImageText.Text("Hello", font, features=["-kern"]).get_length()
125            world = ImageText.Text("World", font, features=["-kern"]).get_length()
126            helloworld = ImageText.Text(
127                "HelloWorld", font, features=["-kern"]
128            ).get_length()
129            assert hello + world == helloworld
130
131        :return: Either width for horizontal text, or height for vertical text.
132        """
133        if isinstance(self.text, str):
134            multiline = "\n" in self.text
135        else:
136            multiline = b"\n" in self.text
137        if multiline:
138            msg = "can't measure length of multiline text"
139            raise ValueError(msg)
140        return self.font.getlength(
141            self.text,
142            self._get_fontmode(),
143            self.direction,
144            self.features,
145            self.language,
146        )
147
148    def _split(
149        self, xy: tuple[float, float], anchor: str | None, align: str
150    ) -> list[tuple[tuple[float, float], str, str | bytes]]:
151        if anchor is None:
152            anchor = "lt" if self.direction == "ttb" else "la"
153        elif len(anchor) != 2:
154            msg = "anchor must be a 2 character string"
155            raise ValueError(msg)
156
157        lines = (
158            self.text.split("\n")
159            if isinstance(self.text, str)
160            else self.text.split(b"\n")
161        )
162        if len(lines) == 1:
163            return [(xy, anchor, self.text)]
164
165        if anchor[1] in "tb" and self.direction != "ttb":
166            msg = "anchor not supported for multiline text"
167            raise ValueError(msg)
168
169        fontmode = self._get_fontmode()
170        line_spacing = (
171            self.font.getbbox(
172                "A",
173                fontmode,
174                None,
175                self.features,
176                self.language,
177                self.stroke_width,
178            )[3]
179            + self.stroke_width
180            + self.spacing
181        )
182
183        top = xy[1]
184        parts = []
185        if self.direction == "ttb":
186            left = xy[0]
187            for line in lines:
188                parts.append(((left, top), anchor, line))
189                left += line_spacing
190        else:
191            widths = []
192            max_width: float = 0
193            for line in lines:
194                line_width = self.font.getlength(
195                    line, fontmode, self.direction, self.features, self.language
196                )
197                widths.append(line_width)
198                max_width = max(max_width, line_width)
199
200            if anchor[1] == "m":
201                top -= (len(lines) - 1) * line_spacing / 2.0
202            elif anchor[1] == "d":
203                top -= (len(lines) - 1) * line_spacing
204
205            idx = -1
206            for line in lines:
207                left = xy[0]
208                idx += 1
209                width_difference = max_width - widths[idx]
210
211                # align by align parameter
212                if align in ("left", "justify"):
213                    pass
214                elif align == "center":
215                    left += width_difference / 2.0
216                elif align == "right":
217                    left += width_difference
218                else:
219                    msg = 'align must be "left", "center", "right" or "justify"'
220                    raise ValueError(msg)
221
222                if (
223                    align == "justify"
224                    and width_difference != 0
225                    and idx != len(lines) - 1
226                ):
227                    words = (
228                        line.split(" ") if isinstance(line, str) else line.split(b" ")
229                    )
230                    if len(words) > 1:
231                        # align left by anchor
232                        if anchor[0] == "m":
233                            left -= max_width / 2.0
234                        elif anchor[0] == "r":
235                            left -= max_width
236
237                        word_widths = [
238                            self.font.getlength(
239                                word,
240                                fontmode,
241                                self.direction,
242                                self.features,
243                                self.language,
244                            )
245                            for word in words
246                        ]
247                        word_anchor = "l" + anchor[1]
248                        width_difference = max_width - sum(word_widths)
249                        i = 0
250                        for word in words:
251                            parts.append(((left, top), word_anchor, word))
252                            left += word_widths[i] + width_difference / (len(words) - 1)
253                            i += 1
254                        top += line_spacing
255                        continue
256
257                # align left by anchor
258                if anchor[0] == "m":
259                    left -= width_difference / 2.0
260                elif anchor[0] == "r":
261                    left -= width_difference
262                parts.append(((left, top), anchor, line))
263                top += line_spacing
264
265        return parts
266
267    def get_bbox(
268        self,
269        xy: tuple[float, float] = (0, 0),
270        anchor: str | None = None,
271        align: str = "left",
272    ) -> tuple[float, float, float, float]:
273        """
274        Returns bounding box (in pixels) of text.
275
276        Use :py:meth:`get_length` to get the offset of following text with 1/64 pixel
277        precision. The bounding box includes extra margins for some fonts, e.g. italics
278        or accents.
279
280        :param xy: The anchor coordinates of the text.
281        :param anchor: The text anchor alignment. Determines the relative location of
282                       the anchor to the text. The default alignment is top left,
283                       specifically ``la`` for horizontal text and ``lt`` for
284                       vertical text. See :ref:`text-anchors` for details.
285        :param align: For multiline text, ``"left"``, ``"center"``, ``"right"`` or
286                      ``"justify"`` determines the relative alignment of lines. Use the
287                      ``anchor`` parameter to specify the alignment to ``xy``.
288
289        :return: ``(left, top, right, bottom)`` bounding box
290        """
291        bbox: tuple[float, float, float, float] | None = None
292        fontmode = self._get_fontmode()
293        for xy, anchor, line in self._split(xy, anchor, align):
294            bbox_line = self.font.getbbox(
295                line,
296                fontmode,
297                self.direction,
298                self.features,
299                self.language,
300                self.stroke_width,
301                anchor,
302            )
303            bbox_line = (
304                bbox_line[0] + xy[0],
305                bbox_line[1] + xy[1],
306                bbox_line[2] + xy[0],
307                bbox_line[3] + xy[1],
308            )
309            if bbox is None:
310                bbox = bbox_line
311            else:
312                bbox = (
313                    min(bbox[0], bbox_line[0]),
314                    min(bbox[1], bbox_line[1]),
315                    max(bbox[2], bbox_line[2]),
316                    max(bbox[3], bbox_line[3]),
317                )
318
319        assert bbox is not None
320        return bbox
321 
Aluode/PerceptionLabPortable · CoolFace