figkit 0.1.0__py3-none-any.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.
figkit/text.py ADDED
@@ -0,0 +1,625 @@
1
+ """Text layout: multi-line, inline ``$math$``, wrapping and optical centring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import warnings
7
+ from dataclasses import dataclass, field
8
+
9
+ from .core import Element
10
+ from .fonts import get_font, measure_text
11
+ from .geom import BBox
12
+ from .mathtext import MathError, math_available, render_math
13
+ from .style import normalize_dash
14
+ from .svgdoc import Node, RenderContext
15
+ from .svgpath import translate_path_data
16
+
17
+ __all__ = ["Text", "Label", "Span", "TextLayout", "layout_text",
18
+ "measure_block"]
19
+
20
+ # ``$...$`` with support for escaped ``\$``
21
+ _MATH_RE = re.compile(r"(?<!\\)\$(.+?)(?<!\\)\$", re.S)
22
+ _BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.S)
23
+ _ITALIC_RE = re.compile(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", re.S)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Span:
28
+ """A styled piece of a :class:`Text`.
29
+
30
+ >>> Text(["the model ", Span("fails", color="@bad", strike=True), " here"])
31
+
32
+ Anything left as ``None`` is inherited from the ``Text`` it belongs to.
33
+ """
34
+
35
+ text: str
36
+ color: str = None
37
+ bold: bool = None
38
+ italic: bool = None
39
+ weight: str = None
40
+ style: str = None
41
+ size: float = None
42
+ family: str = None
43
+ strike: bool = False
44
+ underline: bool = False
45
+
46
+ def overrides(self) -> dict:
47
+ """The properties this span changes, as a plain dict."""
48
+ out = {}
49
+ if self.color is not None:
50
+ out["color"] = self.color
51
+ if self.weight is not None:
52
+ out["weight"] = self.weight
53
+ elif self.bold is not None:
54
+ out["weight"] = "bold" if self.bold else "normal"
55
+ if self.style is not None:
56
+ out["style"] = self.style
57
+ elif self.italic is not None:
58
+ out["style"] = "italic" if self.italic else "normal"
59
+ if self.size is not None:
60
+ out["size"] = float(self.size)
61
+ if self.family is not None:
62
+ out["family"] = self.family
63
+ decoration = tuple(d for d, on in (("strike", self.strike),
64
+ ("underline", self.underline)) if on)
65
+ if decoration:
66
+ out["decoration"] = decoration
67
+ return out
68
+
69
+
70
+ @dataclass
71
+ class Run:
72
+ """One measured piece of a line: plain text or a math expression."""
73
+
74
+ kind: str # "text" | "math"
75
+ content: str
76
+ width: float = 0.0
77
+ ascent: float = 0.0
78
+ descent: float = 0.0
79
+ x: float = 0.0
80
+ font_size: float = 12.0
81
+ weight: str = "normal"
82
+ style: str = "normal"
83
+ math: object = None
84
+ color: str = None
85
+ family: object = None
86
+ decoration: tuple = ()
87
+
88
+ @property
89
+ def key(self) -> tuple:
90
+ """What must match for two runs to be merged into one SVG node."""
91
+ return (self.kind, self.weight, self.style, self.font_size,
92
+ self.color, self.family, self.decoration)
93
+
94
+
95
+ @dataclass
96
+ class Line:
97
+ runs: list = field(default_factory=list)
98
+ width: float = 0.0
99
+ ascent: float = 0.0
100
+ descent: float = 0.0
101
+ baseline: float = 0.0 # relative to the top of the block
102
+
103
+
104
+ @dataclass
105
+ class TextLayout:
106
+ """The measured result of laying out a string."""
107
+
108
+ lines: list = field(default_factory=list)
109
+ width: float = 0.0
110
+ height: float = 0.0
111
+ cap_top: float = 0.0 # top of the cap-height band (optical centring)
112
+ baseline_last: float = 0.0
113
+
114
+ @property
115
+ def optical_height(self) -> float:
116
+ return max(0.0, self.baseline_last - self.cap_top)
117
+
118
+
119
+ def _split_math(text: str) -> list:
120
+ """Split a string into alternating text / math pieces."""
121
+ out = []
122
+ pos = 0
123
+ for m in _MATH_RE.finditer(text):
124
+ if m.start() > pos:
125
+ out.append(("text", text[pos:m.start()]))
126
+ out.append(("math", m.group(1)))
127
+ pos = m.end()
128
+ if pos < len(text):
129
+ out.append(("text", text[pos:]))
130
+ if not out:
131
+ out = [("text", text)]
132
+ return [(k, v.replace("\\$", "$") if k == "text" else v) for k, v in out]
133
+
134
+
135
+ def _apply_markup(piece: str, weight: str, style: str) -> list:
136
+ """Expand ``**bold**`` / ``*italic*`` into styled sub-runs."""
137
+ out = []
138
+ pos = 0
139
+ tokens = []
140
+ for m in _BOLD_RE.finditer(piece):
141
+ tokens.append((m.start(), m.end(), m.group(1), "bold", None))
142
+ for m in _ITALIC_RE.finditer(piece):
143
+ if any(s <= m.start() < e for s, e, *_ in tokens):
144
+ continue
145
+ tokens.append((m.start(), m.end(), m.group(1), None, "italic"))
146
+ tokens.sort()
147
+ for start, end, body, w, s in tokens:
148
+ if start > pos:
149
+ out.append((piece[pos:start], weight, style))
150
+ out.append((body, w or weight, s or style))
151
+ pos = end
152
+ if pos < len(piece):
153
+ out.append((piece[pos:], weight, style))
154
+ return out or [(piece, weight, style)]
155
+
156
+
157
+ def _as_pieces(content, base: dict) -> list:
158
+ """Flatten ``str`` / :class:`Span` / lists into ``[(text, overrides)]``."""
159
+ if content is None:
160
+ return [("", dict(base))]
161
+ if isinstance(content, Span):
162
+ return [(str(content.text), {**base, **content.overrides()})]
163
+ if isinstance(content, str):
164
+ return [(content, dict(base))]
165
+ if isinstance(content, (list, tuple)):
166
+ out = []
167
+ for item in content:
168
+ out.extend(_as_pieces(item, base))
169
+ return out
170
+ return [(str(content), dict(base))]
171
+
172
+
173
+ def _split_lines(pieces: list) -> list:
174
+ """Break styled pieces on newlines, keeping each piece's styling."""
175
+ lines = [[]]
176
+ for text, overrides in pieces:
177
+ parts = str(text).split("\n")
178
+ for i, part in enumerate(parts):
179
+ if i:
180
+ lines.append([])
181
+ if part:
182
+ lines[-1].append((part, overrides))
183
+ return lines
184
+
185
+
186
+ def layout_text(text, font_family=None, font_size: float = 14.0,
187
+ weight="normal", style="normal", line_height: float = 1.3,
188
+ letter_spacing: float = 0.0, max_width: float = None,
189
+ math_backend: str = "auto", math_scale: float = 1.0,
190
+ markup: bool = False, color=None) -> TextLayout:
191
+ """Measure ``text``, returning line boxes, run positions and baselines.
192
+
193
+ ``text`` may be a string, a :class:`Span`, or a list mixing the two.
194
+ """
195
+ base = {"weight": weight, "style": style, "size": float(font_size),
196
+ "family": font_family, "color": color, "decoration": ()}
197
+ metrics = get_font(font_family, weight, style).metrics
198
+
199
+ lines: list = []
200
+ for styled_line in _split_lines(_as_pieces(text, base)):
201
+ runs: list = []
202
+ for body, ov in styled_line:
203
+ runs.extend(_build_runs(body, ov, markup, math_backend,
204
+ math_scale, letter_spacing))
205
+ if max_width and max_width > 0:
206
+ lines.extend(_wrap(runs, max_width, letter_spacing))
207
+ else:
208
+ lines.append(runs)
209
+
210
+ out_lines: list = []
211
+ default_asc = metrics.ascent * font_size
212
+ default_desc = metrics.descent * font_size
213
+ for runs in lines:
214
+ x = 0.0
215
+ for r in runs:
216
+ r.x = x
217
+ x += r.width
218
+ asc = max([r.ascent for r in runs] + [default_asc])
219
+ desc = max([r.descent for r in runs] + [default_desc])
220
+ out_lines.append(Line(runs=runs, width=x, ascent=asc, descent=desc))
221
+
222
+ leading = font_size * line_height
223
+ y = 0.0
224
+ max_w = 0.0
225
+ for ln in out_lines:
226
+ box = max(leading, ln.ascent + ln.descent)
227
+ extra = max(0.0, box - (ln.ascent + ln.descent))
228
+ ln.baseline = y + extra / 2.0 + ln.ascent
229
+ y += box
230
+ max_w = max(max_w, ln.width)
231
+
232
+ # The cap band (first cap-height down to the last baseline) is what we
233
+ # centre on: it is what the eye reads as the extent of a short label.
234
+ cap = (metrics.cap_height or 0.7) * font_size
235
+ first = out_lines[0] if out_lines else Line(baseline=cap)
236
+ last = out_lines[-1] if out_lines else first
237
+ top_reach = cap
238
+ if first.runs and any(r.kind == "math" for r in first.runs):
239
+ top_reach = max(cap, first.ascent)
240
+ return TextLayout(lines=out_lines, width=max_w, height=y,
241
+ cap_top=first.baseline - top_reach,
242
+ baseline_last=last.baseline)
243
+
244
+
245
+ def _build_runs(body: str, ov: dict, markup: bool, math_backend: str,
246
+ math_scale: float, letter_spacing: float) -> list:
247
+ """Turn one styled string into measured runs, expanding $math$ and markup."""
248
+ size = ov["size"]
249
+ runs: list = []
250
+ for kind, content in _split_math(body):
251
+ if kind == "math":
252
+ if not content.strip():
253
+ continue
254
+ try:
255
+ mr = render_math(content, size * math_scale, math_backend)
256
+ except MathError:
257
+ # A genuine TeX error is the caller's problem, but simply not
258
+ # having the optional dependency should not blow up a whole
259
+ # figure — set the source in italics and say what is missing.
260
+ if math_available():
261
+ raise
262
+ warnings.warn(
263
+ "figkit: $math$ needs matplotlib; the expression was set "
264
+ "as plain text instead. Install it with "
265
+ "pip install 'figkit[latex]'.", stacklevel=4)
266
+ runs.append(_text_run(content, ov, ov["weight"], "italic",
267
+ letter_spacing))
268
+ continue
269
+ runs.append(Run("math", content, mr.width, mr.ascent, mr.descent,
270
+ font_size=size, math=mr, color=ov.get("color"),
271
+ decoration=ov.get("decoration", ())))
272
+ continue
273
+ if content == "":
274
+ continue
275
+ sub = (_apply_markup(content, ov["weight"], ov["style"]) if markup
276
+ else [(content, ov["weight"], ov["style"])])
277
+ for part, w, st in sub:
278
+ if part == "":
279
+ continue
280
+ runs.append(_text_run(part, ov, w, st, letter_spacing))
281
+ return runs
282
+
283
+
284
+ def _text_run(part: str, ov: dict, weight, style, letter_spacing: float) -> Run:
285
+ size = ov["size"]
286
+ family = ov.get("family")
287
+ width = measure_text(part, _fam(family), size, weight, style, letter_spacing)
288
+ metrics = get_font(family, weight, style).metrics
289
+ return Run("text", part, width, metrics.ascent * size,
290
+ metrics.descent * size, font_size=size, weight=weight,
291
+ style=style, color=ov.get("color"), family=family,
292
+ decoration=ov.get("decoration", ()))
293
+
294
+
295
+ def _merge_runs(runs: list) -> list:
296
+ """Join neighbouring text runs that share styling.
297
+
298
+ Emitting one ``<text>`` per *word* would pin each word to an absolute x —
299
+ exact, but if the viewer lacks the declared font the substitute's wider
300
+ glyphs collide. One ``<text>`` per phrase lets the renderer space the words
301
+ itself, so a substituted font degrades gracefully instead.
302
+ """
303
+ out: list = []
304
+ for run in runs:
305
+ prev = out[-1] if out else None
306
+ if (prev is not None and prev.kind == "text" and run.kind == "text"
307
+ and prev.key == run.key
308
+ and abs((prev.x + prev.width) - run.x) < 0.01):
309
+ out[-1] = Run("text", prev.content + run.content,
310
+ prev.width + run.width, max(prev.ascent, run.ascent),
311
+ max(prev.descent, run.descent), prev.x,
312
+ prev.font_size, prev.weight, prev.style,
313
+ color=prev.color, family=prev.family,
314
+ decoration=prev.decoration)
315
+ else:
316
+ out.append(run)
317
+ return out
318
+
319
+
320
+ def _decoration_nodes(run, x: float, baseline: float, color, alpha) -> list:
321
+ """Draw strike-through / underline as geometry.
322
+
323
+ ``text-decoration`` is widely ignored by SVG rasterisers and disappears
324
+ entirely once text is outlined, so figkit draws the rules itself.
325
+ """
326
+ if not run.decoration:
327
+ return []
328
+ size = run.font_size or 12.0
329
+ thickness = max(0.8, size * 0.055)
330
+ out = []
331
+ for kind in run.decoration:
332
+ if kind == "strike":
333
+ y = baseline - size * 0.28
334
+ elif kind == "underline":
335
+ y = baseline + size * 0.12
336
+ else:
337
+ continue
338
+ out.append(Node("rect", x=x, y=y - thickness / 2.0, width=run.width,
339
+ height=thickness, fill=color, fill_opacity=alpha,
340
+ stroke="none"))
341
+ return out
342
+
343
+
344
+ def _norm_content(value):
345
+ """Accept a string, a Span, or a list of them."""
346
+ if value is None:
347
+ return ""
348
+ if isinstance(value, (str, Span, list, tuple)):
349
+ return value
350
+ return str(value)
351
+
352
+
353
+ def plain_text(content) -> str:
354
+ """The text of a span tree, with styling dropped."""
355
+ if content is None:
356
+ return ""
357
+ if isinstance(content, Span):
358
+ return str(content.text)
359
+ if isinstance(content, str):
360
+ return content
361
+ if isinstance(content, (list, tuple)):
362
+ return "".join(plain_text(item) for item in content)
363
+ return str(content)
364
+
365
+
366
+ def _fam(font_family):
367
+ if font_family is None:
368
+ return None
369
+ return font_family if isinstance(font_family, str) else ", ".join(font_family)
370
+
371
+
372
+ def _wrap(runs: list, max_width: float, letter_spacing: float) -> list:
373
+ """Greedy word wrap. Math runs are unbreakable atoms; every run keeps its
374
+ own styling, so a wrapped line can still mix spans."""
375
+ lines: list = []
376
+ cur: list = []
377
+ cur_w = 0.0
378
+
379
+ def flush():
380
+ nonlocal cur, cur_w
381
+ if cur:
382
+ while cur and cur[-1].kind == "text" and cur[-1].content.strip() == "":
383
+ cur.pop()
384
+ lines.append(cur)
385
+ cur = []
386
+ cur_w = 0.0
387
+
388
+ def clone(run: Run, content: str, width: float) -> Run:
389
+ return Run(run.kind, content, width, run.ascent, run.descent,
390
+ font_size=run.font_size, weight=run.weight, style=run.style,
391
+ math=run.math, color=run.color, family=run.family,
392
+ decoration=run.decoration)
393
+
394
+ for run in runs:
395
+ if run.kind == "math":
396
+ if cur_w + run.width > max_width and cur:
397
+ flush()
398
+ cur.append(clone(run, run.content, run.width))
399
+ cur_w += run.width
400
+ continue
401
+ for word in re.split(r"(\s+)", run.content):
402
+ if word == "":
403
+ continue
404
+ w = measure_text(word, _fam(run.family), run.font_size, run.weight,
405
+ run.style, letter_spacing)
406
+ if word.strip() == "":
407
+ if cur:
408
+ cur.append(clone(run, word, w))
409
+ cur_w += w
410
+ continue
411
+ if cur_w + w > max_width and cur:
412
+ flush()
413
+ cur.append(clone(run, word, w))
414
+ cur_w += w
415
+ flush()
416
+ return lines or [[]]
417
+
418
+
419
+ def measure_block(text: str, **kw) -> tuple:
420
+ """``(width, height)`` of a text block — handy for sizing things by hand."""
421
+ lay = layout_text(text, **kw)
422
+ return lay.width, lay.height
423
+
424
+
425
+ # ==========================================================================
426
+ # Text element
427
+ # ==========================================================================
428
+
429
+ class Text(Element):
430
+ """A block of text. Supports ``\\n``, inline ``$math$`` and wrapping.
431
+
432
+ >>> Text("Feature\\nExtractor", bold=True, font_size=16)
433
+ >>> Text("loss $L_{\\\\mathrm{fmap}}$", color="#333")
434
+ """
435
+
436
+ role = "text"
437
+ ANCHOR_MAP = {"left": "start", "start": "start", "center": "middle",
438
+ "middle": "middle", "right": "end", "end": "end"}
439
+
440
+ def __init__(self, text="", x: float = 0.0, y: float = 0.0, *,
441
+ width: float = None, wrap: float = None, markup: bool = False,
442
+ rotate: float = None, **kw):
443
+ self._text = _norm_content(text)
444
+ self._wrap = wrap if wrap is not None else width
445
+ self._markup = markup
446
+ self._layout: TextLayout | None = None
447
+ super().__init__(x, y, None, None, **kw)
448
+ if self._wrap:
449
+ self._explicit_w = False
450
+ if rotate:
451
+ self.rotate(rotate)
452
+
453
+ # -- content ---------------------------------------------------------
454
+ @property
455
+ def text(self) -> str:
456
+ """The plain-text content, with any span styling flattened away."""
457
+ return plain_text(self._text)
458
+
459
+ @property
460
+ def content(self):
461
+ """The content as given: a string, a :class:`Span`, or a list."""
462
+ return self._text
463
+
464
+ @text.setter
465
+ def text(self, value) -> None:
466
+ self._text = _norm_content(value)
467
+ self.invalidate()
468
+
469
+ def set_text(self, value: str) -> "Text":
470
+ self.text = value
471
+ return self
472
+
473
+ def wrap_at(self, width: float) -> "Text":
474
+ self._wrap = width
475
+ return self.invalidate()
476
+
477
+ # -- measurement -----------------------------------------------------
478
+ def _layout_kwargs(self) -> dict:
479
+ return dict(
480
+ font_family=self.prop("font_family"),
481
+ font_size=float(self.prop("font_size")),
482
+ weight=self.prop("font_weight"),
483
+ style=self.prop("font_style"),
484
+ line_height=float(self.prop("line_height")),
485
+ letter_spacing=float(self.prop("letter_spacing", 0) or 0),
486
+ max_width=self._wrap,
487
+ math_backend=self.prop("math_backend", "auto"),
488
+ math_scale=float(self.prop("math_scale", 1.0) or 1.0),
489
+ markup=self._markup,
490
+ color=self.prop("color"),
491
+ )
492
+
493
+ @property
494
+ def layout(self) -> TextLayout:
495
+ self._ensure()
496
+ return self._layout
497
+
498
+ def _measure(self) -> None:
499
+ self._layout = layout_text(self._text, **self._layout_kwargs())
500
+ self._w = self._wrap if self._wrap else self._layout.width
501
+ self._h = self._layout.height
502
+
503
+ @property
504
+ def optical_bbox(self) -> BBox:
505
+ """Cap-height band — what you want to centre inside a box."""
506
+ self._ensure()
507
+ bb = self.bbox
508
+ lay = self._layout
509
+ if not lay.lines:
510
+ return bb
511
+ top = bb.y + lay.cap_top
512
+ bottom = bb.y + lay.baseline_last
513
+ return BBox(bb.x, top, bb.w, max(0.0, bottom - top))
514
+
515
+ @property
516
+ def first_baseline(self) -> float:
517
+ self._ensure()
518
+ return self.bbox.y + (self._layout.lines[0].baseline
519
+ if self._layout.lines else 0.0)
520
+
521
+ # -- rendering -------------------------------------------------------
522
+ def _render_content(self, ctx: RenderContext):
523
+ self._ensure()
524
+ lay = self._layout
525
+ if not lay.lines:
526
+ return None
527
+ bb = self.local_bbox
528
+ from .paint import _color as _split_color
529
+ color, color_alpha = _split_color(self.prop("color"), ctx)
530
+ if color is None:
531
+ color, color_alpha = "#000000", None
532
+ align = str(self.prop("text_align", "center")).lower()
533
+ anchor = self.ANCHOR_MAP.get(align, "middle")
534
+ family = self.prop("font_family")
535
+ size = float(self.prop("font_size"))
536
+ weight = self.prop("font_weight")
537
+ fstyle = self.prop("font_style")
538
+ spacing = float(self.prop("letter_spacing", 0) or 0)
539
+ decoration = self.prop("text_decoration", None)
540
+ stroke = self.prop("stroke", None)
541
+ stroke_w = self.prop("stroke_width", 0)
542
+ math_color = self.prop("math_color", None) or color
543
+ as_paths = ctx.text_as_paths
544
+ ctx.note_font(family, weight, fstyle)
545
+
546
+ nodes: list = []
547
+ for line in lay.lines:
548
+ if not line.runs:
549
+ continue
550
+ runs = _merge_runs(line.runs)
551
+ if anchor == "start":
552
+ x0 = bb.x
553
+ elif anchor == "end":
554
+ x0 = bb.x + bb.w - line.width
555
+ else:
556
+ x0 = bb.x + (bb.w - line.width) / 2.0
557
+ by = bb.y + line.baseline
558
+ for run in runs:
559
+ rx = x0 + run.x
560
+ run_color, run_alpha = (_split_color(run.color, ctx)
561
+ if run.color else (color, color_alpha))
562
+ if run.kind == "math":
563
+ mr = run.math
564
+ if mr is None or mr.empty:
565
+ continue
566
+ d = translate_path_data(mr.d, rx, by)
567
+ nodes.append(Node("path", d=d,
568
+ fill=run_color or math_color,
569
+ fill_opacity=run_alpha,
570
+ fill_rule=mr.fill_rule, stroke="none"))
571
+ nodes.extend(_decoration_nodes(run, rx, by, run_color,
572
+ run_alpha))
573
+ continue
574
+ if not run.content.strip():
575
+ continue
576
+ run_family = run.family if run.family is not None else family
577
+ run_size = run.font_size or size
578
+ if as_paths:
579
+ font = get_font(run_family, run.weight, run.style)
580
+ if font.available:
581
+ d = font.text_to_path(run.content, run_size, spacing)
582
+ if d:
583
+ nodes.append(Node("path",
584
+ d=translate_path_data(d, rx, by),
585
+ fill=run_color,
586
+ fill_opacity=run_alpha,
587
+ stroke="none"))
588
+ nodes.extend(_decoration_nodes(run, rx, by,
589
+ run_color, run_alpha))
590
+ continue
591
+ ctx.warn(f"text_as_paths: no outline font for {run_family!r}")
592
+ attrs = dict(x=rx, y=by, fill=run_color, fill_opacity=run_alpha,
593
+ font_family=run_family, font_size=run_size)
594
+ if str(run.weight) not in ("normal", "400"):
595
+ attrs["font_weight"] = run.weight
596
+ if str(run.style) != "normal":
597
+ attrs["font_style"] = run.style
598
+ if spacing:
599
+ attrs["letter_spacing"] = spacing
600
+ if decoration:
601
+ attrs["text_decoration"] = decoration
602
+ if stroke and stroke != "none" and stroke_w:
603
+ attrs["stroke"] = stroke
604
+ attrs["stroke_width"] = stroke_w
605
+ attrs["paint_order"] = "stroke"
606
+ dash = normalize_dash(self.prop("stroke_dash", None))
607
+ if dash:
608
+ attrs["stroke_dasharray"] = dash
609
+ attrs["xml__space"] = ("preserve"
610
+ if run.content != run.content.strip()
611
+ else None)
612
+ nodes.append(Node("text", text=run.content, **attrs))
613
+ nodes.extend(_decoration_nodes(run, rx, by, run_color,
614
+ run_alpha))
615
+ return nodes or None
616
+
617
+ def __repr__(self) -> str:
618
+ preview = self._text if len(self._text) <= 24 else self._text[:21] + "..."
619
+ return f"<Text {preview!r} {self.bbox}>"
620
+
621
+
622
+ class Label(Text):
623
+ """Text with the ``label`` role — smaller by default, for annotations."""
624
+
625
+ role = "label"