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.
Files changed (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-arm64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. 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)