Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions js/src/51_annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ Object.assign(ChartView.prototype, {
const laidOut = [];
this._resolvedAnnotationAnchors = new Map();
for (const [annotationIndex, ann] of annotations.entries()) {
const text = typeof ann.text === "string" ? ann.text : "";
const text: string = typeof ann.text === "string" ? ann.text : "";
if (!text) continue;
const style = ann && typeof ann.style === "object" ? ann.style : {};
let px = null;
Expand Down Expand Up @@ -448,7 +448,24 @@ Object.assign(ChartView.prototype, {
continue;
}
const d = document.createElement("div");
d.textContent = text;
const mathRanges = String(style.math_italic_ranges || "")
.split(",")
.map((range) => range.split(":").map(Number))
.filter(([start, end]) => Number.isInteger(start) && Number.isInteger(end) && start >= 0 && start < end);
if (mathRanges.length) {
for (const [index, char] of Array.from(text).entries()) {
if (mathRanges.some(([start, end]) => start <= index && index < end)) {
const italic = document.createElement("span");
italic.style.fontStyle = "italic";
italic.textContent = char;
d.appendChild(italic);
} else {
d.appendChild(document.createTextNode(char));
}
}
} else {
d.textContent = text;
}
const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0;
const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0;
// Rule/band specs carry no anchor; default to the placement that keeps
Expand Down Expand Up @@ -520,6 +537,7 @@ Object.assign(ChartView.prototype, {
const opacityIsShape = ann.kind !== "text" && ann.kind !== "callout";
const labelStyle = {};
for (const [key, value] of Object.entries(style)) {
if (key === "math_italic_ranges") continue;
if (key === "opacity" && ann.kind === "text") {
labelStyle[key] = value;
continue;
Expand Down
132 changes: 127 additions & 5 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,15 @@
_AFFINE_POINTS,
_AFFINE_CHANNEL_POINTS,
_STROKED_TRIANGLES,
) = range(17)
_STYLED_TEXT,
) = range(18)
# Anchor-byte rotation flags — must match TEXT_ROTATED/TEXT_ROTATED_CW in
# src/raster.rs. CCW reads bottom-to-top (y-axis titles), CW top-to-bottom
# (right-margin titles, matplotlib rotation=270).
_TEXT_ROT_CCW = 0x80
_TEXT_ROT_CW = 0x40
_TEXT_ITALIC = 0x01
_TEXT_BOLD = 0x02
_SYMBOLS = {
"circle": 0,
"square": 1,
Expand Down Expand Up @@ -601,9 +604,36 @@ def heatmap_image(
self.buf += stops.tobytes()

def text(
self, x: float, y: float, anchor: int, size: float, color: tuple[int, ...], s: str
self,
x: float,
y: float,
anchor: int,
size: float,
color: tuple[int, ...],
s: str,
*,
angle: float = 0.0,
italic: bool = False,
bold: bool = False,
italic_ranges: Sequence[tuple[int, int]] = (),
) -> None:
data = str(s).encode("utf-8")
if angle or italic or bold or italic_ranges:
self.buf.append(_STYLED_TEXT)
self._f(x)
self._f(y)
self.buf.append(anchor & 0x03)
self._f(size)
self._raw_f(angle)
self.buf.append((_TEXT_ITALIC if italic else 0) | (_TEXT_BOLD if bold else 0))
self._u32(len(italic_ranges))
for start, end in italic_ranges:
self._u32(start)
self._u32(end)
self._rgba(color)
self._u32(len(data))
self.buf += data
return
self.buf.append(_TEXT_OP)
self._f(x)
self._f(y)
Expand Down Expand Up @@ -1077,6 +1107,29 @@ def _annotation_point(
return float(sx(x)), float(sy(y))


def _native_font_emphasis(style: dict[str, Any]) -> tuple[bool, bool]:
"""Return baked-font italic/bold approximations for an annotation."""
italic = str(style.get("font_style", "")).lower() in {"italic", "oblique"}
weight = str(style.get("font_weight", "")).lower()
try:
bold = float(weight) >= 600
except ValueError:
bold = weight in {"bold", "semibold", "demibold", "heavy", "black"}
return italic, bold


def _math_italic_ranges(style: dict[str, Any]) -> list[tuple[int, int]]:
ranges: list[tuple[int, int]] = []
for item in str(style.get("math_italic_ranges", "")).split(","):
try:
start, end = (int(value) for value in item.split(":", 1))
except ValueError:
continue
if 0 <= start < end:
ranges.append((start, end))
return ranges


def _emit_annotations(
cmd: _Cmd,
annotations: list[dict[str, Any]],
Expand Down Expand Up @@ -1171,6 +1224,9 @@ def _emit_annotations(
)
color = _rgba(label_color, _TEXT, float(label_opacity))
rotation = float(style.get("rotation", 0.0)) % 360.0
italic, bold = _native_font_emphasis(style)
math_ranges = _math_italic_ranges(style)
line_offset = 0
if rotation in (90.0, 270.0):
# Vertical text via the rasterizer's rotated glyph paths.
# matplotlib aligns the post-rotation box: vertical_align picks
Expand All @@ -1188,30 +1244,96 @@ def _emit_annotations(
base = {0: ascent, 1: (ascent - descent) / 2, 2: -descent}[anchor]
stack = -line_height if cw else line_height # later lines: glyph-down
for index, line in enumerate(lines):
line_ranges = [
(max(0, start - line_offset), min(len(line), end - line_offset))
for start, end in math_ranges
if start < line_offset + len(line) and end > line_offset
]
cmd.text(
x + base + index * stack,
y,
along | (_TEXT_ROT_CW if cw else _TEXT_ROT_CCW),
along,
font_size,
color,
line,
angle=90.0 if cw else -90.0,
italic=italic,
bold=bold,
italic_ranges=line_ranges,
)
line_offset += len(line) + 1
continue
first_y = y - (len(lines) - 1) * line_height / 2
vertical_align = style.get("vertical_align")
if vertical_align in ("center", "middle"):
first_y += font_size * 0.35
elif vertical_align == "top":
first_y += font_size * 0.8
text_x = x + float(ann.get("dx", 0.0))
text_y = first_y + float(ann.get("dy", 0.0))
_emit_text_box(cmd, style, lines, text_x, text_y, line_height, font_size, anchor)
for index, line in enumerate(lines):
line_ranges = [
(max(0, start - line_offset), min(len(line), end - line_offset))
for start, end in math_ranges
if start < line_offset + len(line) and end > line_offset
]
cmd.text(
x + float(ann.get("dx", 0.0)),
first_y + index * line_height + float(ann.get("dy", 0.0)),
text_x,
text_y + index * line_height,
anchor,
font_size,
color,
line,
angle=-rotation,
italic=italic,
bold=bold,
italic_ranges=line_ranges,
)
line_offset += len(line) + 1


def _emit_text_box(
cmd: _Cmd,
style: dict[str, Any],
lines: list[str],
x: float,
first_y: float,
line_height: float,
font_size: float,
anchor: int,
) -> None:
"""Draw the bounded CSS approximation used by pyplot ``text(bbox=)``."""
background = style.get("background")
border = str(style.get("border", ""))
if background is None and not border:
return
pad_parts = str(style.get("padding", "0")).split()

def px(value: str) -> float:
try:
return max(0.0, float(value.removesuffix("px")))
except ValueError:
return 0.0

pad_y = px(pad_parts[0]) if pad_parts else 0.0
pad_x = px(pad_parts[1]) if len(pad_parts) > 1 else pad_y
text_width = max((len(line) for line in lines), default=0) * font_size * 0.48
left = x - (text_width / 2 if anchor == 1 else text_width if anchor == 2 else 0.0) - pad_x
top = first_y - font_size * 0.8 - pad_y
right = left + text_width + pad_x * 2
bottom = top + font_size + (len(lines) - 1) * line_height + pad_y * 2
points = _rect_pts(left, top, right, bottom)
if background is not None:
cmd.fill(points, _parse_color(str(background)))
if border:
parts = border.split()
try:
width = max(0.0, float(parts[0].removesuffix("px")))
except (IndexError, ValueError):
width = 1.0
if width:
cmd.stroke(points + [points[0]], width, _parse_color(parts[-1]))


def _emit_area(
Expand Down
116 changes: 109 additions & 7 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1960,15 +1960,18 @@ def _annotation_svg(
style.get("opacity", 1.0) if kind == "text" else 1.0,
)
)
line_offset = 0
for index, line in enumerate(lines):
bx = tx + float(ann.get("dx", 0)) + base + index * stack
styled_line = _svg_mathtext_spans(line, style, line_offset)
labels.append(
f'<text text-anchor="{along}" font-size="{_num(font_size)}" '
f'transform="rotate({90 if cw else -90} {_num(bx)} {_num(by)})" '
f'x="{_num(bx)}" y="{_num(by)}" '
+ (f'fill-opacity="{_num(text_opacity)}" ' if text_opacity < 1 else "")
+ f'fill="{color}">{escape(line)}</text>'
+ f'fill="{color}">{styled_line}</text>'
)
line_offset += len(line) + 1
continue
x_text = tx + float(ann.get("dx", 0))
y_text = ty + float(ann.get("dy", 0)) - (len(lines) - 1) * line_height / 2
Expand All @@ -1977,11 +1980,16 @@ def _annotation_svg(
y_text += font_size * 0.35
elif vertical_align == "top":
y_text += font_size * 0.8
tspans = "".join(
f'<tspan x="{_num(x_text)}" y="{_num(y_text + index * line_height)}">'
f"{escape(line)}</tspan>"
for index, line in enumerate(lines)
)
line_offset = 0
tspan_parts = []
for index, line in enumerate(lines):
styled_line = _svg_mathtext_spans(line, style, line_offset)
tspan_parts.append(
f'<tspan x="{_num(x_text)}" y="{_num(y_text + index * line_height)}">'
f"{styled_line}</tspan>"
)
line_offset += len(line) + 1
tspans = "".join(tspan_parts)
text_opacity = float(
style.get(
"label_opacity",
Expand All @@ -1990,14 +1998,108 @@ def _annotation_svg(
)
# A callout's `color` paints its arrow; the label prefers its own.
label_color = escape(_css(style.get("label_color"), "")) or color
labels.extend(
_svg_text_box(style, lines, x_text, y_text, line_height, font_size, anchor)
)
font_attrs = _svg_font_attrs(style)
rotation_attr = (
f' transform="rotate({_num(-rotation)} {_num(x_text)} {_num(y_text)})"'
if rotation
else ""
)
labels.append(
f'<text text-anchor="{anchor}" font-size="{_num(font_size)}" '
f'<text text-anchor="{anchor}" font-size="{_num(font_size)}"{font_attrs}'
f"{rotation_attr} "
+ (f'fill-opacity="{_num(text_opacity)}" ' if text_opacity < 1 else "")
+ f'fill="{label_color}">{tspans}</text>'
)
return marks, labels


def _svg_font_attrs(style: dict[str, Any]) -> str:
attrs = []
for key, attribute in (
("font_family", "font-family"),
("font_weight", "font-weight"),
("font_style", "font-style"),
):
if style.get(key) is not None:
attrs.append(f' {attribute}="{escape(str(style[key]))}"')
return "".join(attrs)


def _svg_mathtext_spans(line: str, style: dict[str, Any], offset: int) -> str:
ranges: list[tuple[int, int]] = []
for item in str(style.get("math_italic_ranges", "")).split(","):
try:
start, end = (int(value) for value in item.split(":", 1))
except ValueError:
continue
start, end = max(0, start - offset), min(len(line), end - offset)
if start < end:
ranges.append((start, end))
if not ranges:
return escape(line)
out: list[str] = []
cursor = 0
for start, end in ranges:
if cursor < start:
out.append(escape(line[cursor:start]))
out.append(f'<tspan font-style="italic">{escape(line[start:end])}</tspan>')
cursor = end
out.append(escape(line[cursor:]))
return "".join(out)


def _svg_text_box(
style: dict[str, Any],
lines: list[str],
x: float,
first_y: float,
line_height: float,
font_size: float,
anchor: str,
) -> list[str]:
"""SVG counterpart of the pyplot text-bbox CSS approximation."""
background = style.get("background")
border = str(style.get("border", ""))
if background is None and not border:
return []
pad_parts = str(style.get("padding", "0")).split()

def px(value: str) -> float:
try:
return max(0.0, float(value.removesuffix("px")))
except ValueError:
return 0.0

pad_y = px(pad_parts[0]) if pad_parts else 0.0
pad_x = px(pad_parts[1]) if len(pad_parts) > 1 else pad_y
text_width = max((len(line) for line in lines), default=0) * font_size * 0.48
left = (
x
- (text_width / 2 if anchor == "middle" else text_width if anchor == "end" else 0.0)
- pad_x
)
top = first_y - font_size * 0.8 - pad_y
height = font_size + (len(lines) - 1) * line_height + pad_y * 2
fill = "none" if background is None else escape(str(background))
stroke = "none"
stroke_width = 0.0
if border:
parts = border.split()
stroke = escape(parts[-1])
try:
stroke_width = max(0.0, float(parts[0].removesuffix("px")))
except (IndexError, ValueError):
stroke_width = 1.0
return [
f'<rect x="{_num(left)}" y="{_num(top)}" '
f'width="{_num(text_width + pad_x * 2)}" height="{_num(height)}" '
f'fill="{fill}" stroke="{stroke}" stroke-width="{_num(stroke_width)}"/>'
]


def _segment_marks(
t: dict[str, Any], blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, color: str
) -> str:
Expand Down
Loading
Loading