plot-io 0.12.0__py3-none-win_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-arm64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/text.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Text: one line of plain or rich content shaped by :mod:`vec_io.text`, placed as a
|
|
2
|
+
:class:`Run` and drawn from the same shaped line.
|
|
3
|
+
|
|
4
|
+
Rich content is a list of strings and ``vec_io.Span`` objects. Exponents are spans with
|
|
5
|
+
``baseline_shift="super"`` and ``font_scale=0.7``. Markup in strings (``<b>``, ``<i>``, …:
|
|
6
|
+
:mod:`plot_io.markup`) becomes spans where text is shaped, when the font's ``markup`` is on
|
|
7
|
+
(:func:`rich`).
|
|
8
|
+
|
|
9
|
+
Text is shaped with a font database and needs no canvas (``fonts``: a ``FontDB``, or a
|
|
10
|
+
canvas whose document's database is used). A line is shaped once: plain strings are
|
|
11
|
+
memoized in the font database, rich content in the render in progress; :func:`run` keeps
|
|
12
|
+
the shaped line, so what the layout measured is what is drawn (kerned).
|
|
13
|
+
|
|
14
|
+
The font databases of outputs come from :func:`document_db` (shared between renders, keyed
|
|
15
|
+
by their font sources); :func:`has_family` tells whether a family has a face.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
import warnings
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from vec_io import Span
|
|
26
|
+
from vec_io import text as _text
|
|
27
|
+
from vec_io.fonts.download import mode as _download_mode
|
|
28
|
+
from vec_io.fonts.fontdb import GENERIC_FAMILIES, FontDB, env_font_dirs
|
|
29
|
+
from vec_io.text import GlyphRun
|
|
30
|
+
|
|
31
|
+
from .canvas import Canvas
|
|
32
|
+
from . import markup as _markup
|
|
33
|
+
|
|
34
|
+
__all__ = ["superscript", "Font", "font_of", "font_db", "document_db", "has_family", "shape",
|
|
35
|
+
"measure", "box", "Run", "run", "draw_run", "draw_text", "paragraphs", "wrap",
|
|
36
|
+
"runs_of", "plain", "rich", "lines_box", "lines_run"]
|
|
37
|
+
|
|
38
|
+
_SUPER_SHIFT = 0.33
|
|
39
|
+
_SUPER_SCALE = 0.7
|
|
40
|
+
# Cap height in em (0.71-0.73 in common fonts): "center" centres digits and capitals.
|
|
41
|
+
_CAP_HEIGHT = 0.72
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def superscript(text: str) -> Any:
|
|
45
|
+
return Span(text, baseline_shift="super", font_scale=_SUPER_SCALE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def runs_of(content: Any) -> Iterator[tuple[str, float, float]]:
|
|
49
|
+
"""``(text, size factor, baseline shift in em)`` of each run."""
|
|
50
|
+
if isinstance(content, str):
|
|
51
|
+
yield content, 1.0, 0.0
|
|
52
|
+
return
|
|
53
|
+
for item in content:
|
|
54
|
+
if isinstance(item, str):
|
|
55
|
+
yield item, 1.0, 0.0
|
|
56
|
+
else:
|
|
57
|
+
shift = getattr(item, "baseline_shift", None)
|
|
58
|
+
scale = getattr(item, "font_scale", None) or 1.0
|
|
59
|
+
em = {"super": _SUPER_SHIFT, "sub": -0.2}.get(shift, shift) if shift else 0.0
|
|
60
|
+
yield item.text, float(scale), float(em or 0.0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def plain(content: Any) -> str:
|
|
64
|
+
"""The text of content, without styles."""
|
|
65
|
+
return "".join(t for t, _, _ in runs_of(content))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Font:
|
|
69
|
+
"""Font settings of a piece of text (sizes in px) and its color; :attr:`face` is the
|
|
70
|
+
``vec_io.text.Font`` it is shaped with; ``markup``: whether markup in its strings is
|
|
71
|
+
read."""
|
|
72
|
+
|
|
73
|
+
__slots__ = ("size", "family", "weight", "style", "color", "stretch", "variations",
|
|
74
|
+
"optical_sizing", "line_height", "markup", "_face")
|
|
75
|
+
|
|
76
|
+
def __init__(self, size: float, family: str = "sans-serif", weight: Any = "normal",
|
|
77
|
+
style: str = "normal", color: Any = "black", stretch: str = "normal",
|
|
78
|
+
variations: Any = None, optical_sizing: str = "auto",
|
|
79
|
+
line_height: Any = "normal", markup: bool = True) -> None:
|
|
80
|
+
self.size = float(size)
|
|
81
|
+
self.family = family
|
|
82
|
+
self.weight = weight
|
|
83
|
+
self.style = style
|
|
84
|
+
self.color = color
|
|
85
|
+
self.stretch = stretch
|
|
86
|
+
self.variations = variations
|
|
87
|
+
self.optical_sizing = optical_sizing
|
|
88
|
+
self.line_height = line_height
|
|
89
|
+
self.markup = bool(markup)
|
|
90
|
+
self._face: _text.Font | None = None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def face(self) -> _text.Font:
|
|
94
|
+
"""The font description shaping uses (px; not the color)."""
|
|
95
|
+
if self._face is None:
|
|
96
|
+
self._face = _text.Font(self.family, self.size, weight=self.weight or "normal",
|
|
97
|
+
style=self.style or "normal",
|
|
98
|
+
stretch=self.stretch or "normal",
|
|
99
|
+
variations=self.variations or None,
|
|
100
|
+
optical_sizing=self.optical_sizing)
|
|
101
|
+
return self._face
|
|
102
|
+
|
|
103
|
+
def with_color(self, color: Any) -> "Font":
|
|
104
|
+
f = Font(self.size, self.family, self.weight, self.style, color, self.stretch,
|
|
105
|
+
self.variations, self.optical_sizing, self.line_height, self.markup)
|
|
106
|
+
f._face = self._face
|
|
107
|
+
return f
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def font_of(obj: Any, size: float, weight: Any = "normal", style: str = "normal",
|
|
111
|
+
color: Any = "black", markup: bool | None = None) -> Font:
|
|
112
|
+
"""The font of settings object ``obj`` (its family and inherited variation settings)
|
|
113
|
+
at ``size``; ``markup`` (default: ``obj``'s) whether markup in its text is read."""
|
|
114
|
+
return Font(size, obj.resolve("font_family"), weight or "normal", style or "normal", color,
|
|
115
|
+
obj.resolve("font_stretch"), obj.resolve("font_variations"),
|
|
116
|
+
obj.resolve("font_optical_sizing"), obj.resolve("line_height"),
|
|
117
|
+
obj.resolve("markup") if markup is None else markup)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def font_db(fonts: Any) -> Any:
|
|
121
|
+
"""The font database of ``fonts``: a ``FontDB``, a canvas (its document's) or ``None``
|
|
122
|
+
(vec_io's default database)."""
|
|
123
|
+
if fonts is None:
|
|
124
|
+
return _text.default_db()
|
|
125
|
+
doc = getattr(fonts, "document", None)
|
|
126
|
+
return doc.font_db if doc is not None else fonts
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
#: The ``system`` of a ``fonts`` dict that does not give it (plot_io's tests turn it off, so
|
|
130
|
+
#: that their text uses only the fonts of ``VEC_IO_FONT_DIRS``).
|
|
131
|
+
SYSTEM_FONTS = True
|
|
132
|
+
|
|
133
|
+
_DBS: dict[Any, FontDB] = {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def document_db(dirs: Any = None, system: bool | None = None, downloads: Any = None) -> FontDB:
|
|
137
|
+
"""A font database for documents with these font sources (vec_io ``Fonts`` fields),
|
|
138
|
+
shared between renders (so are the shaped lines it memoizes); a change of
|
|
139
|
+
``VEC_IO_FONT_DIRS`` or (with ``downloads=None``) ``VEC_IO_FONT_DOWNLOADS`` gives
|
|
140
|
+
another. ``system=None``: :data:`SYSTEM_FONTS`."""
|
|
141
|
+
if system is None:
|
|
142
|
+
system = SYSTEM_FONTS
|
|
143
|
+
dirs = tuple(os.fspath(d) for d in dirs or ())
|
|
144
|
+
mode = _download_mode(downloads)
|
|
145
|
+
key = (dirs, bool(system), mode, tuple(env_font_dirs()))
|
|
146
|
+
db = _DBS.get(key)
|
|
147
|
+
if db is None:
|
|
148
|
+
if len(_DBS) >= 16:
|
|
149
|
+
_DBS.clear()
|
|
150
|
+
db = _DBS[key] = FontDB(list(dirs), bool(system), font_downloads=mode)
|
|
151
|
+
return db
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def has_family(db: Any, family: str) -> bool:
|
|
155
|
+
"""Whether ``db`` has a face of one of the families of ``family`` (comma-separated;
|
|
156
|
+
generic families and metric aliases count), downloaded faces included."""
|
|
157
|
+
names = [n.strip().strip("\"'").lower() for n in family.split(",")]
|
|
158
|
+
if any(n in GENERIC_FAMILIES for n in names):
|
|
159
|
+
return True
|
|
160
|
+
if any(n and db.match(n, aliases=True) is not None for n in names):
|
|
161
|
+
return True
|
|
162
|
+
return any(n in f.families for f in db.all_faces() for n in names)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _hashable(v: Any) -> Any:
|
|
166
|
+
if isinstance(v, list):
|
|
167
|
+
return tuple(_hashable(x) for x in v)
|
|
168
|
+
if isinstance(v, dict):
|
|
169
|
+
return tuple(sorted((k, _hashable(x)) for k, x in v.items()))
|
|
170
|
+
return v
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_SPAN_KEYS = Span.__slots__
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _content_key(content: Any) -> Any:
|
|
177
|
+
if isinstance(content, Span):
|
|
178
|
+
content = [content]
|
|
179
|
+
return tuple(item if isinstance(item, str) else
|
|
180
|
+
tuple(_hashable(getattr(item, k, None)) for k in _SPAN_KEYS)
|
|
181
|
+
for item in content)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def rich(content: Any, font: Font) -> Any:
|
|
185
|
+
"""``content`` with its markup written as spans when ``font.markup`` is on (strings
|
|
186
|
+
without markup as given); raises ``ValueError`` for an unclosed or crossed tag."""
|
|
187
|
+
return _markup.parse(content) if font.markup else content
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def shape(fonts: Any, content: Any, font: Font) -> GlyphRun:
|
|
191
|
+
"""``content`` (its markup read, see :func:`rich`) shaped on one line with ``font``
|
|
192
|
+
(kerned). Plain strings are memoized in the font database, rich content in the render
|
|
193
|
+
in progress."""
|
|
194
|
+
return _shape(fonts, rich(content, font), font)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
#: ``(id(db), family)`` pairs already checked for an installed face.
|
|
198
|
+
_CHECKED: set[tuple[int, str]] = set()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _check_family(db: Any, family: str) -> None:
|
|
202
|
+
"""Warns once per font database and family when no family of ``family`` has a face."""
|
|
203
|
+
key = (id(db), family)
|
|
204
|
+
if key in _CHECKED:
|
|
205
|
+
return
|
|
206
|
+
_CHECKED.add(key)
|
|
207
|
+
if not has_family(db, family):
|
|
208
|
+
warnings.warn(f"no font of the family {family!r} is installed: its text is drawn "
|
|
209
|
+
f"with a fallback font", stacklevel=4)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _shape(fonts: Any, content: Any, font: Font) -> GlyphRun:
|
|
213
|
+
db = font_db(fonts)
|
|
214
|
+
if (id(db), font.family) not in _CHECKED:
|
|
215
|
+
_text.shape("", font.face, db) # downloads the family when allowed
|
|
216
|
+
_check_family(db, font.family)
|
|
217
|
+
if isinstance(content, str):
|
|
218
|
+
return _text.shape(content, font.face, db)
|
|
219
|
+
from .spec import current
|
|
220
|
+
r = current()
|
|
221
|
+
if r is None:
|
|
222
|
+
return _text.shape(content, font.face, db)
|
|
223
|
+
key = (id(db), font.face, _content_key(content))
|
|
224
|
+
got = r.runs.get(key)
|
|
225
|
+
if got is None:
|
|
226
|
+
got = r.runs[key] = _text.shape(content, font.face, db)
|
|
227
|
+
return got
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _empty(content: Any) -> bool:
|
|
231
|
+
return content is None or content == "" or content == []
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def half_leading(fonts: Any, font: Font) -> float:
|
|
235
|
+
"""What ``font.line_height`` adds above and below a line (negative: takes away): half
|
|
236
|
+
the difference between it and the font's ascent and descent."""
|
|
237
|
+
if font.line_height == "normal":
|
|
238
|
+
return 0.0
|
|
239
|
+
g = _text.shape("", font.face, font_db(fonts))
|
|
240
|
+
return (font.line_height * font.size - g.ascent - g.descent) / 2
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def line_pitch(fonts: Any, font: Font) -> float:
|
|
244
|
+
"""The distance between the baselines of lines of text in ``font``."""
|
|
245
|
+
g = _text.shape("", font.face, font_db(fonts))
|
|
246
|
+
return g.ascent + g.descent + 2 * half_leading(fonts, font)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def measure(fonts: Any, content: Any, font: Font) -> tuple[float, float, float]:
|
|
250
|
+
"""``(width, ascent, descent)`` of a line of content (see :func:`shape`), its box
|
|
251
|
+
``font.line_height`` tall (see :func:`half_leading`)."""
|
|
252
|
+
if _empty(content):
|
|
253
|
+
return 0.0, 0.0, 0.0
|
|
254
|
+
g = shape(fonts, content, font)
|
|
255
|
+
lead = half_leading(fonts, font)
|
|
256
|
+
return g.width, g.ascent + lead, g.descent + lead
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def box(fonts: Any, content: Any, font: Font, rotation: float = 0.0) -> tuple[float, float]:
|
|
260
|
+
"""Width and height of the bounding box of rotated text (degrees, counterclockwise)."""
|
|
261
|
+
w, a, d = measure(fonts, content, font)
|
|
262
|
+
h = a + d
|
|
263
|
+
if not rotation:
|
|
264
|
+
return w, h
|
|
265
|
+
r = math.radians(rotation)
|
|
266
|
+
c, s = abs(math.cos(r)), abs(math.sin(r))
|
|
267
|
+
return w * c + h * s, w * s + h * c
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
271
|
+
class Run:
|
|
272
|
+
"""A line of content placed on the page: anchored at ``(x, y)``, its baseline start
|
|
273
|
+
``(dx, dy)`` from the anchor in its own frame, rotated by ``rotation`` degrees
|
|
274
|
+
(counterclockwise) about the anchor; ``glyphs`` is the shaped line drawn."""
|
|
275
|
+
|
|
276
|
+
content: Any
|
|
277
|
+
x: float
|
|
278
|
+
y: float
|
|
279
|
+
font: Font
|
|
280
|
+
dx: float = 0.0
|
|
281
|
+
dy: float = 0.0
|
|
282
|
+
rotation: float = 0.0
|
|
283
|
+
glyphs: GlyphRun | None = dataclasses.field(default=None, compare=False, repr=False)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def run(fonts: Any, content: Any, x: float, y: float, font: Font, ha: str = "left",
|
|
287
|
+
va: str = "baseline", rotation: float = 0.0) -> Run | None:
|
|
288
|
+
"""``content`` anchored at ``(x, y)``: ``ha`` left/center/right, ``va``
|
|
289
|
+
baseline/top/center/bottom, in the text's own (rotated) frame (``center``: the middle
|
|
290
|
+
of the cap height); ``None`` for empty content. The run holds the content with its
|
|
291
|
+
markup read."""
|
|
292
|
+
if _empty(content):
|
|
293
|
+
return None
|
|
294
|
+
content = rich(content, font)
|
|
295
|
+
g = _shape(fonts, content, font)
|
|
296
|
+
lead = half_leading(fonts, font)
|
|
297
|
+
w, a, d = g.width, g.ascent + lead, g.descent + lead
|
|
298
|
+
dx = {"left": 0.0, "center": -w / 2, "right": -w}[ha]
|
|
299
|
+
dy = {"baseline": 0.0, "top": a, "center": _CAP_HEIGHT * font.size / 2, "bottom": -d}[va]
|
|
300
|
+
return Run(content, x, y, font, dx, dy, rotation, g)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _block(fonts: Any, content: Any, font: Font
|
|
304
|
+
) -> tuple[list[Any], list[tuple[float, float, float]], float, float] | None:
|
|
305
|
+
"""Content of several lines (split at ``\\n``, its markup read): the lines, each
|
|
306
|
+
one's ``measure``, the distance between baselines and the block's height; ``None``
|
|
307
|
+
for one line."""
|
|
308
|
+
from .rows import lines
|
|
309
|
+
if _empty(content):
|
|
310
|
+
return None
|
|
311
|
+
ls = lines(content, font)
|
|
312
|
+
if len(ls) < 2:
|
|
313
|
+
return None
|
|
314
|
+
ms = [measure(fonts, ln, font) for ln in ls]
|
|
315
|
+
pitch = line_pitch(fonts, font)
|
|
316
|
+
return ls, ms, pitch, ms[0][1] + (len(ls) - 1) * pitch + ms[-1][2]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def lines_box(fonts: Any, content: Any, font: Font, rotation: float = 0.0
|
|
320
|
+
) -> tuple[float, float]:
|
|
321
|
+
""":func:`box` of content whose lines are split at ``\\n`` (stacked ``line_pitch``
|
|
322
|
+
apart)."""
|
|
323
|
+
b = _block(fonts, content, font)
|
|
324
|
+
if b is None:
|
|
325
|
+
return box(fonts, content, font, rotation)
|
|
326
|
+
w, h = max(m[0] for m in b[1]), b[3]
|
|
327
|
+
if not rotation:
|
|
328
|
+
return w, h
|
|
329
|
+
r = math.radians(rotation)
|
|
330
|
+
c, s = abs(math.cos(r)), abs(math.sin(r))
|
|
331
|
+
return w * c + h * s, w * s + h * c
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def lines_run(fonts: Any, content: Any, x: float, y: float, font: Font, ha: str = "left",
|
|
335
|
+
va: str = "baseline", rotation: float = 0.0) -> "Run | tuple[Run, ...] | None":
|
|
336
|
+
""":func:`run` of content whose lines are split at ``\\n``: a run per line, each
|
|
337
|
+
aligned by ``ha``, the block placed by ``va`` (``center``: its middle; ``baseline``:
|
|
338
|
+
the first line's), all rotated about the anchor."""
|
|
339
|
+
b = _block(fonts, content, font)
|
|
340
|
+
if b is None:
|
|
341
|
+
return run(fonts, content, x, y, font, ha, va, rotation)
|
|
342
|
+
ls, ms, pitch, h = b
|
|
343
|
+
top = {"baseline": -ms[0][1], "top": 0.0, "center": -h / 2, "bottom": -h}[va]
|
|
344
|
+
out = []
|
|
345
|
+
for k, ln in enumerate(ls):
|
|
346
|
+
r = run(fonts, ln, x, y, font, ha, "baseline", rotation)
|
|
347
|
+
if r is not None:
|
|
348
|
+
out.append(dataclasses.replace(r, dy=top + ms[0][1] + k * pitch))
|
|
349
|
+
return tuple(out)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def draw_run(canvas: Canvas, r: "Run | tuple[Run | None, ...] | None") -> None:
|
|
353
|
+
"""Draw a placed run (a tuple: each of its runs, the lines of a wrapped label; nothing
|
|
354
|
+
for ``None``) in its font's color; the canvas' text alignment is ``start`` while
|
|
355
|
+
plot_io draws."""
|
|
356
|
+
if isinstance(r, tuple):
|
|
357
|
+
for one in r:
|
|
358
|
+
draw_run(canvas, one)
|
|
359
|
+
return
|
|
360
|
+
if r is None:
|
|
361
|
+
return
|
|
362
|
+
g = r.glyphs if r.glyphs is not None else shape(canvas, r.content, r.font)
|
|
363
|
+
if not r.rotation:
|
|
364
|
+
canvas.draw_text(g, r.x + r.dx, r.y + r.dy, fill=r.font.color, stroke=None)
|
|
365
|
+
return
|
|
366
|
+
with canvas.state():
|
|
367
|
+
canvas.translate(r.x, r.y)
|
|
368
|
+
canvas.rotate(-r.rotation)
|
|
369
|
+
canvas.draw_text(g, r.dx, r.dy, fill=r.font.color, stroke=None)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def draw_text(canvas: Canvas, content: Any, x: float, y: float, font: Font, ha: str = "left",
|
|
373
|
+
va: str = "baseline", rotation: float = 0.0) -> None:
|
|
374
|
+
"""Draw a line of content anchored at ``(x, y)`` (see :func:`run`)."""
|
|
375
|
+
draw_run(canvas, run(canvas, content, x, y, font, ha, va, rotation))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def wrap(fonts: Any, content: Any, font: Font, width: float) -> Any:
|
|
379
|
+
"""String ``content`` with its lines broken at spaces into lines at most ``width``
|
|
380
|
+
wide (a word longer than that keeps its own line); other content as given."""
|
|
381
|
+
if not isinstance(content, str) or measure(fonts, content, font)[0] <= width:
|
|
382
|
+
return content
|
|
383
|
+
out = []
|
|
384
|
+
for line in content.split("\n"):
|
|
385
|
+
cur = ""
|
|
386
|
+
for word in line.split(" "):
|
|
387
|
+
trial = word if not cur else cur + " " + word
|
|
388
|
+
if cur and measure(fonts, trial, font)[0] > width:
|
|
389
|
+
out.append(cur)
|
|
390
|
+
cur = word
|
|
391
|
+
else:
|
|
392
|
+
cur = trial
|
|
393
|
+
out.append(cur)
|
|
394
|
+
return "\n".join(out)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def paragraphs(fonts: Any, content: Any, font: Font, width: float, align: str = "start") -> Any:
|
|
398
|
+
"""``content`` (its markup read, see :func:`rich`) broken into lines ``width`` wide
|
|
399
|
+
(``vec_io.text.layout``: kerned, lines ``font.line_height`` apart, 1.2 for ``normal``):
|
|
400
|
+
``Paragraphs`` to draw with :func:`draw_paragraphs`."""
|
|
401
|
+
lh = 1.2 if font.line_height == "normal" else font.line_height
|
|
402
|
+
return _text.layout(rich(content, font), font.face, width, db=font_db(fonts),
|
|
403
|
+
text_align=align, line_height=lh)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def draw_paragraphs(canvas: Canvas, p: Any, x: float, y: float, fill: Any) -> None:
|
|
407
|
+
"""Draw paragraphs ``p`` (see :func:`paragraphs`) with the top-left corner of their box
|
|
408
|
+
at ``(x, y)``; glyphs without a colour in ``fill`` (``None``: the canvas' fill)."""
|
|
409
|
+
if fill is None:
|
|
410
|
+
canvas.draw_paragraphs(p, x, y)
|
|
411
|
+
else:
|
|
412
|
+
canvas.draw_paragraphs(p, x, y, fill=fill)
|