From fd36ffb126c0f08c70cf58894265e15c9ca8da1d Mon Sep 17 00:00:00 2001 From: philippe Date: Mon, 20 Jul 2026 11:59:04 -0400 Subject: [PATCH] Add dependency-free text renderers for go.Figure (text-utf/ascii/ansi/html) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four pure-Python, dependency-free plotly.io renderers that draw an existing go.Figure as plain text: - text-utf — braille/block-char (default) - text-ascii — 7-bit ASCII fallback - text-ansi — 24-bit truecolor ANSI escapes - text-html — self-contained class-based HTML fragment Usable via fig.show(renderer="text-utf") (etc.). Supports scatter/scattergl (lines & markers), bar (honouring orientation, with grouped fan-out), histogram, and heatmap/histogram2d (colorscale-sampled density shading). Per-series colour is taken from each trace's marker/line colour or the default qualitative palette. Unsupported traces and undersized canvases degrade to a one-line note instead of raising. Adds unit tests and a CHANGELOG entry. --- CHANGELOG.md | 4 + plotly/io/_renderers.py | 7 + plotly/io/_text/__init__.py | 64 ++ plotly/io/_text/adapters/__init__.py | 808 ++++++++++++++++++ plotly/io/_text/adapters/bar.py | 79 ++ plotly/io/_text/adapters/heatmap.py | 282 ++++++ plotly/io/_text/adapters/histogram.py | 187 ++++ plotly/io/_text/adapters/scatter.py | 80 ++ plotly/io/_text/canvas.py | 771 +++++++++++++++++ plotly/io/_text/rasterizer.py | 206 +++++ plotly/io/_text/renderers.py | 130 +++ plotly/io/_text/serializers.py | 396 +++++++++ plotly/io/_text/tests/__init__.py | 0 .../io/_text/tests/test_adapters_renderers.py | 327 +++++++ .../io/_text/tests/test_canvas_serializers.py | 201 +++++ plotly/io/_text/tests/test_v2_adapters.py | 549 ++++++++++++ plotly/io/_text/tests/test_v2_serializers.py | 318 +++++++ 17 files changed, 4409 insertions(+) create mode 100644 plotly/io/_text/__init__.py create mode 100644 plotly/io/_text/adapters/__init__.py create mode 100644 plotly/io/_text/adapters/bar.py create mode 100644 plotly/io/_text/adapters/heatmap.py create mode 100644 plotly/io/_text/adapters/histogram.py create mode 100644 plotly/io/_text/adapters/scatter.py create mode 100644 plotly/io/_text/canvas.py create mode 100644 plotly/io/_text/rasterizer.py create mode 100644 plotly/io/_text/renderers.py create mode 100644 plotly/io/_text/serializers.py create mode 100644 plotly/io/_text/tests/__init__.py create mode 100644 plotly/io/_text/tests/test_adapters_renderers.py create mode 100644 plotly/io/_text/tests/test_canvas_serializers.py create mode 100644 plotly/io/_text/tests/test_v2_adapters.py create mode 100644 plotly/io/_text/tests/test_v2_serializers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e52c392dc79..8e4b2969003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Added +- Add pure-Python, dependency-free text renderers `text-utf` (braille/block-char, default) and `text-ascii` (7-bit fallback) that draw an existing `go.Figure` as plain text — `fig.show(renderer="text-utf")` / `"text-ascii"`. Supports scatter/scattergl (lines & markers), bar (honouring `orientation`), and histogram (bins reproduced client-side, numpy-accelerated when available); multi-series bars fan out into sub-columns, unsupported traces and undersized canvases degrade to a one-line note instead of crashing, and output is written as forced UTF-8. +- Add colour text renderers `text-ansi` (24-bit truecolor ANSI escapes) and `text-html` (self-contained class-based HTML fragment) — `fig.show(renderer="text-ansi")` / `"text-html"` — and `heatmap` / `histogram2d` trace support (density shaded via a colorscale-sampled ramp, `histogram2d` binned client-side). Each series is coloured from its own `marker`/`line` colour (normalized from `#hex`, `rgb()/rgba()`, or a CSS name) or the default qualitative palette. + ## [6.9.0] - 2026-07-09 diff --git a/plotly/io/_renderers.py b/plotly/io/_renderers.py index 8eea1fe8bf8..82e7726c1cd 100644 --- a/plotly/io/_renderers.py +++ b/plotly/io/_renderers.py @@ -467,6 +467,13 @@ def show(fig, renderer=None, validate=True, **kwargs): renderers["sphinx_gallery"] = SphinxGalleryHtmlRenderer() renderers["sphinx_gallery_png"] = SphinxGalleryOrcaRenderer() +# Text renderers: text-utf (default), text-ascii, and the colour modes +# text-ansi / text-html. +# Registered via a hook so the _text subpackage owns its own renderer classes. +from plotly.io._text import register_text_renderers # noqa: E402 + +register_text_renderers(renderers) + # Set default renderer # -------------------- # Version 4 renderer configuration diff --git a/plotly/io/_text/__init__.py b/plotly/io/_text/__init__.py new file mode 100644 index 00000000000..1da04163d55 --- /dev/null +++ b/plotly/io/_text/__init__.py @@ -0,0 +1,64 @@ +"""Text (Unicode/braille) renderers for ``go.Figure``. + +Draws an existing figure as a braille/block-char chart in plain text, as +registered ``plotly.io`` renderer strings (``text-utf`` default, ``text-ascii``; +plus the colour modes ``text-ansi`` and ``text-html``). Pure-Python, **no +external dependency** — the braille + block-char rasterizer is built in here (see +:mod:`~plotly.io._text.rasterizer`). + +Architecture ("one grid, many serializers"): + +* :mod:`~plotly.io._text.canvas` — Plotly-agnostic drawing surface -> abstract + cell grid. +* :mod:`~plotly.io._text.serializers` — per-mode grid -> string. +* :mod:`~plotly.io._text.adapters` — ``go.Figure`` -> Canvas calls. +* :mod:`~plotly.io._text.renderers` — ``plotly.io`` registration. + +This ``__init__`` is the stable public surface of the subpackage; +:mod:`plotly.io._renderers` imports :func:`register_text_renderers` from here. +""" + +from __future__ import annotations + +from plotly.io._text.canvas import ( + Canvas, + Cell, + CellGrid, + CellRole, + Tick, +) +from plotly.io._text.serializers import ( + SERIALIZERS, + Serializer, + get_serializer, + register_serializer, +) +from plotly.io._text.adapters import ( + ADAPTERS, + AdapterContext, + TraceAdapter, + figure_to_canvas, + get_adapter, + register_adapter, +) +from plotly.io._text.renderers import TextRenderer, register_text_renderers + +__all__ = [ + "Canvas", + "Cell", + "CellGrid", + "CellRole", + "Tick", + "Serializer", + "SERIALIZERS", + "register_serializer", + "get_serializer", + "ADAPTERS", + "AdapterContext", + "TraceAdapter", + "register_adapter", + "get_adapter", + "figure_to_canvas", + "TextRenderer", + "register_text_renderers", +] diff --git a/plotly/io/_text/adapters/__init__.py b/plotly/io/_text/adapters/__init__.py new file mode 100644 index 00000000000..d9d29f00221 --- /dev/null +++ b/plotly/io/_text/adapters/__init__.py @@ -0,0 +1,808 @@ +"""Trace adapters — ``go.Figure`` -> Canvas calls. + +An **adapter** translates one Plotly trace (a plain dict from +``fig_dict["data"]``) into a sequence of Plotly-agnostic +:class:`~plotly.io._text.canvas.Canvas` calls. Adapters know **nothing about +braille or glyph encodings** — they speak only the Canvas contract. The +serializers later turn the grid into text. + +This package owns: + +* the :data:`ADAPTERS` registry keyed by trace ``type`` string, +* :func:`register_adapter` / :func:`get_adapter`, +* :func:`figure_to_canvas`, the top-level ``fig_dict -> drawn Canvas`` driver. + +The built-in handlers (``scatter``/``scattergl``, ``bar``, ``histogram``, +``heatmap``/``histogram2d``) live in sibling modules and self-register on import. + +The :data:`TraceAdapter` signature, the :class:`AdapterContext` shape, the +registry, and :func:`figure_to_canvas` are the stable interface a new handler +plugs into. +""" + +from __future__ import annotations + +import base64 +import math +import re +import struct +from dataclasses import dataclass, field +from typing import Callable, Dict, List, NamedTuple, Optional, Sequence, Tuple + +from _plotly_utils.basevalidators import is_typed_array_spec +from _plotly_utils.utils import plotlyjsShortTypes + +from plotly.io._text.canvas import ( + Canvas, + DEFAULT_HEIGHT, + DEFAULT_WIDTH, + Range, + Tick, +) +from plotly.io._text.rasterizer import MARKER_COLORS, MARKER_GLYPHS + + +@dataclass +class AdapterContext: + """Per-trace shared state handed to every adapter by the driver. + + Carries what a handler needs beyond the trace itself: the figure layout, the + shared data ranges the frame was drawn with, the series' assigned glyph / + colour, and a sink for degradation notes. + """ + + #: the figure's ``layout`` dict (titles, axis config, ...). + layout: dict + #: shared x data range the frame was drawn with. + x_range: Range + #: shared y data range the frame was drawn with. + y_range: Range + #: 0-based index of this trace among rendered traces. + series_index: int + #: the marker glyph assigned to this series. + glyph: str + #: colour assigned to this series (v2 modes); None in v1. + color: Optional[str] = None + #: degradation notes to print. + warnings: List[str] = field(default_factory=list) + #: total number of rendered series — lets bar/histogram offset each series + #: into its own sub-column so grouped series don't overwrite each other + #: (scatter ignores it). + series_count: int = 1 + #: the target output mode, so an adapter can format a mode-correct note + #: (``text-ascii`` degrades the ``⚠`` prefix to ``!``). + mode: str = "text-utf" + + +#: An adapter draws one trace onto the canvas. It must not return anything and +#: must not touch glyph encodings — only Canvas primitives + ``ctx``. +TraceAdapter = Callable[[dict, Canvas, AdapterContext], None] + + +#: Registry of adapters keyed by trace ``type`` (e.g. ``"scatter"``, ``"bar"``). +#: ``"scattergl"`` maps to the same handler as ``"scatter"``. +ADAPTERS: Dict[str, TraceAdapter] = {} + + +def register_adapter(trace_type: str, handler: TraceAdapter) -> None: + """Register ``handler`` for trace ``type`` ``trace_type``.""" + ADAPTERS[trace_type] = handler + + +def get_adapter(trace_type: str) -> Optional[TraceAdapter]: + """Return the adapter for ``trace_type`` or ``None`` if unsupported.""" + return ADAPTERS.get(trace_type) + + +# --------------------------------------------------------------------------- +# Series styling — distinct semantic marker glyph per series. +# --------------------------------------------------------------------------- + +#: Semantic marker glyphs cycled across series. Sourced from the canonical +#: :data:`plotly.io._text.rasterizer.MARKER_GLYPHS` (which pairs each unicode +#: glyph with a distinct 7-bit ascii fallback) so ``text-utf`` and ``text-ascii`` +#: markers stay index-aligned. These are *intent* glyphs handed to +#: :meth:`Canvas.markers` via :attr:`AdapterContext.glyph`. +GLYPH_PALETTE: Tuple[str, ...] = tuple(MARKER_GLYPHS) + +#: Per-series colour palette cycled across series (colour modes). Sourced from +#: the canonical :data:`plotly.io._text.rasterizer.MARKER_COLORS`, which is +#: index-aligned with :data:`GLYPH_PALETTE` so a series' glyph and colour share a +#: slot. Handed to the colour-aware Canvas calls via :attr:`AdapterContext.color`. +COLOR_PALETTE: Tuple[str, ...] = tuple(MARKER_COLORS) + +#: Human-facing template for an unsupported trace type. +UNSUPPORTED_MSG = "{ttype} traces aren't supported by the text renderer" + + +def _warn_prefix(mode: str) -> str: + """``⚠`` for unicode modes, ascii ``!`` for ``text-ascii`` (encodable).""" + return "! " if mode == "text-ascii" else "⚠ " + + +def _assign_glyph(series_index: int) -> str: + """Return the marker glyph for the ``series_index``-th rendered series.""" + return GLYPH_PALETTE[series_index % len(GLYPH_PALETTE)] + + +# --------------------------------------------------------------------------- +# Colour normalization — every colour that reaches a Cell must be a +# ``#rrggbb`` hex, because the v2 colour serializers parse ``#hex`` only. Plotly +# colours arrive in many idiomatic forms (``#hex`` / ``#rgb``, ``rgb()`` / +# ``rgba()``, and CSS names like ``"red"`` / ``"steelblue"``); this bridge maps +# all of them to hex and returns ``None`` for anything unresolvable so the caller +# can fall back to the palette rather than crash the serializer. Same discipline +# the heatmap colorscale path already uses (which now imports :func:`_color_to_hex` +# from here so there is a single implementation for the whole adapter package). +# --------------------------------------------------------------------------- + +#: Standard CSS3 / SVG named-colour hex table (CSS Color Module Level 4). Keys +#: are exactly the names plotly validates in +#: :attr:`_plotly_utils.basevalidators.ColorValidator.named_colors`; we assert +#: full coverage on import (below) so a plotly-accepted name always resolves. +_CSS_NAMED_COLORS: Dict[str, str] = { + "aliceblue": "#f0f8ff", + "antiquewhite": "#faebd7", + "aqua": "#00ffff", + "aquamarine": "#7fffd4", + "azure": "#f0ffff", + "beige": "#f5f5dc", + "bisque": "#ffe4c4", + "black": "#000000", + "blanchedalmond": "#ffebcd", + "blue": "#0000ff", + "blueviolet": "#8a2be2", + "brown": "#a52a2a", + "burlywood": "#deb887", + "cadetblue": "#5f9ea0", + "chartreuse": "#7fff00", + "chocolate": "#d2691e", + "coral": "#ff7f50", + "cornflowerblue": "#6495ed", + "cornsilk": "#fff8dc", + "crimson": "#dc143c", + "cyan": "#00ffff", + "darkblue": "#00008b", + "darkcyan": "#008b8b", + "darkgoldenrod": "#b8860b", + "darkgray": "#a9a9a9", + "darkgrey": "#a9a9a9", + "darkgreen": "#006400", + "darkkhaki": "#bdb76b", + "darkmagenta": "#8b008b", + "darkolivegreen": "#556b2f", + "darkorange": "#ff8c00", + "darkorchid": "#9932cc", + "darkred": "#8b0000", + "darksalmon": "#e9967a", + "darkseagreen": "#8fbc8f", + "darkslateblue": "#483d8b", + "darkslategray": "#2f4f4f", + "darkslategrey": "#2f4f4f", + "darkturquoise": "#00ced1", + "darkviolet": "#9400d3", + "deeppink": "#ff1493", + "deepskyblue": "#00bfff", + "dimgray": "#696969", + "dimgrey": "#696969", + "dodgerblue": "#1e90ff", + "firebrick": "#b22222", + "floralwhite": "#fffaf0", + "forestgreen": "#228b22", + "fuchsia": "#ff00ff", + "gainsboro": "#dcdcdc", + "ghostwhite": "#f8f8ff", + "gold": "#ffd700", + "goldenrod": "#daa520", + "gray": "#808080", + "grey": "#808080", + "green": "#008000", + "greenyellow": "#adff2f", + "honeydew": "#f0fff0", + "hotpink": "#ff69b4", + "indianred": "#cd5c5c", + "indigo": "#4b0082", + "ivory": "#fffff0", + "khaki": "#f0e68c", + "lavender": "#e6e6fa", + "lavenderblush": "#fff0f5", + "lawngreen": "#7cfc00", + "lemonchiffon": "#fffacd", + "lightblue": "#add8e6", + "lightcoral": "#f08080", + "lightcyan": "#e0ffff", + "lightgoldenrodyellow": "#fafad2", + "lightgray": "#d3d3d3", + "lightgrey": "#d3d3d3", + "lightgreen": "#90ee90", + "lightpink": "#ffb6c1", + "lightsalmon": "#ffa07a", + "lightseagreen": "#20b2aa", + "lightskyblue": "#87cefa", + "lightslategray": "#778899", + "lightslategrey": "#778899", + "lightsteelblue": "#b0c4de", + "lightyellow": "#ffffe0", + "lime": "#00ff00", + "limegreen": "#32cd32", + "linen": "#faf0e6", + "magenta": "#ff00ff", + "maroon": "#800000", + "mediumaquamarine": "#66cdaa", + "mediumblue": "#0000cd", + "mediumorchid": "#ba55d3", + "mediumpurple": "#9370db", + "mediumseagreen": "#3cb371", + "mediumslateblue": "#7b68ee", + "mediumspringgreen": "#00fa9a", + "mediumturquoise": "#48d1cc", + "mediumvioletred": "#c71585", + "midnightblue": "#191970", + "mintcream": "#f5fffa", + "mistyrose": "#ffe4e1", + "moccasin": "#ffe4b5", + "navajowhite": "#ffdead", + "navy": "#000080", + "oldlace": "#fdf5e6", + "olive": "#808000", + "olivedrab": "#6b8e23", + "orange": "#ffa500", + "orangered": "#ff4500", + "orchid": "#da70d6", + "palegoldenrod": "#eee8aa", + "palegreen": "#98fb98", + "paleturquoise": "#afeeee", + "palevioletred": "#db7093", + "papayawhip": "#ffefd5", + "peachpuff": "#ffdab9", + "peru": "#cd853f", + "pink": "#ffc0cb", + "plum": "#dda0dd", + "powderblue": "#b0e0e6", + "purple": "#800080", + "red": "#ff0000", + "rosybrown": "#bc8f8f", + "royalblue": "#4169e1", + "rebeccapurple": "#663399", + "saddlebrown": "#8b4513", + "salmon": "#fa8072", + "sandybrown": "#f4a460", + "seagreen": "#2e8b57", + "seashell": "#fff5ee", + "sienna": "#a0522d", + "silver": "#c0c0c0", + "skyblue": "#87ceeb", + "slateblue": "#6a5acd", + "slategray": "#708090", + "slategrey": "#708090", + "snow": "#fffafa", + "springgreen": "#00ff7f", + "steelblue": "#4682b4", + "tan": "#d2b48c", + "teal": "#008080", + "thistle": "#d8bfd8", + "tomato": "#ff6347", + "turquoise": "#40e0d0", + "violet": "#ee82ee", + "wheat": "#f5deb3", + "white": "#ffffff", + "whitesmoke": "#f5f5f5", + "yellow": "#ffff00", + "yellowgreen": "#9acd32", +} + + +def _hex_or_none(s: str) -> Optional[str]: + """Normalize ``#rgb``/``#rrggbb`` to lowercase ``#rrggbb``, else ``None``.""" + h = s[1:] if s.startswith("#") else s + if len(h) == 3: + h = "".join(ch * 2 for ch in h) + if len(h) == 6 and all(ch in "0123456789abcdef" for ch in h): + return "#" + h + return None + + +def _color_to_hex(c) -> Optional[str]: + """Convert one plotly colour spec to ``#rrggbb``, or ``None`` if unresolvable. + + Handles every idiomatic plotly colour string: ``#hex`` / ``#rgb`` (normalized), + ``rgb(...)`` / ``rgba(...)`` (parsed; alpha dropped — the text grid has no + compositing), and CSS named colours via :data:`_CSS_NAMED_COLORS`. Returns + ``None`` for a non-string, an empty string, or anything unrecognized so the + caller can fall back to the palette instead of feeding a raw string to the + ``#hex``-only colour serializers. This is the single colour bridge for the + whole adapter package (the heatmap colorscale path imports it too). + """ + if not isinstance(c, str): + return None + s = c.strip().lower() + if not s: + return None + if s.startswith("#"): + return _hex_or_none(s) + if s.startswith("rgb"): + nums = re.findall(r"[-+]?\d*\.?\d+", s) + if len(nums) >= 3: + r, g, b = (max(0, min(255, int(round(float(nums[i]))))) for i in range(3)) + return "#%02x%02x%02x" % (r, g, b) + return None + return _CSS_NAMED_COLORS.get(s) + + +def _single_trace_color(trace: dict) -> Optional[str]: + """Return the trace's own single colour as ``#rrggbb``, or ``None``. + + Reads ``marker.color`` then ``line.color`` and accepts only a *single* colour + string (e.g. ``"#636efa"`` / ``"red"`` / ``"rgb(1,2,3)"``), which is + **normalized to hex** via :func:`_color_to_hex` before it can reach a + :class:`~plotly.io._text.canvas.Cell` — the v2 colour serializers parse hex + only, so a raw CSS name / ``rgb()`` string would otherwise crash them. A + per-point colour **array** (numeric or list) is not a series colour, and an + unresolvable colour string also yields ``None``, so the caller falls back to + the categorical palette. This is the per-series colour source for the colour + modes. + """ + for key in ("marker", "line"): + spec = trace.get(key) + if isinstance(spec, dict): + c = spec.get("color") + if isinstance(c, str): + return _color_to_hex(c) + return None + + +def _assign_color(trace: dict, series_index: int) -> str: + """Resolve the ``series_index``-th series' colour hex (v2 colour modes). + + The trace's own single :func:`_single_trace_color` wins; otherwise a colour + is assigned from :data:`COLOR_PALETTE` (Plotly's default qualitative colorway) + by ``series_index``, index-aligned with the assigned glyph. Always returns a + hex/colour string so the colour-aware Canvas calls have a value; the + monochrome v1 modes simply ignore :attr:`Cell.fg`. + """ + return ( + _single_trace_color(trace) or COLOR_PALETTE[series_index % len(COLOR_PALETTE)] + ) + + +def _unsupported_warning(ttype: str, mode: str) -> str: + """Format the one-line degradation note for an unsupported ``ttype``. + + The prefix degrades to ASCII for ``text-ascii`` so the note itself stays + encodable on locale-hostile sinks (the whole point of that mode). + """ + return _warn_prefix(mode) + UNSUPPORTED_MSG.format(ttype=ttype) + + +def _is_too_small_error(exc: Exception) -> bool: + """True only for the genuine undersized-canvas / frame ``ValueError``. + + :meth:`Canvas.__init__` and :meth:`Canvas.frame` are the only + places that raise a *size* ``ValueError`` — their messages say ``"too small"`` + (frame) or ``"must be at least"`` (constructor). Matching on that lets the + renderer's defensive serialize-time ``except`` degrade a real undersize signal + to a note while re-raising any *other* ``ValueError`` accurately, instead of + mislabelling it "canvas too small" on a full-size canvas. + """ + msg = str(exc) + return "too small" in msg or "must be at least" in msg + + +def _too_small_warning(mode: str, exc: Exception) -> str: + """One-line graceful note for an undersized canvas (the Canvas raises + ``ValueError`` from :meth:`Canvas.__init__` / :meth:`Canvas.frame`). + + Surfaces the minimum size from the Canvas message when present, using an + ascii ``x`` separator so the note is safe in every mode. + """ + m = re.search(r"at least (\d+)\s*[x×]\s*(\d+)", str(exc)) + if m: + core = f"canvas too small to render (need at least {m.group(1)}x{m.group(2)} cells)" + else: + core = "canvas too small to render (increase width/height)" + return _warn_prefix(mode) + core + + +def _grouped_density_warning(mode: str) -> str: + """Honesty note when grouped bars can't be separated into sub-columns.""" + return _warn_prefix(mode) + ( + "grouped bars exceed the canvas width — some series merge (widen the canvas)" + ) + + +# --------------------------------------------------------------------------- +# Typed-array decoding — plotly.py base64-encodes numpy arrays on +# ``to_dict`` into ``{"dtype": "f8", "bdata": "..."}`` typed-array specs; the +# adapters must decode them back to numbers or the coercion path iterates the +# dict *keys* and renders garbage. numpy is used when present (fast path) with a +# stdlib ``struct`` fallback so the renderer stays numpy-optional. +# --------------------------------------------------------------------------- + +#: plotly.js short dtype -> numpy dtype name (inverse of plotly's own table). +_SHORT_TO_NP: Dict[str, str] = { + short: name for name, short in plotlyjsShortTypes.items() +} + +#: plotly.js short dtype -> stdlib ``struct`` format char (fixed little-endian +#: sizes when prefixed with ``<``), for the numpy-absent fallback. +_SHORT_TO_STRUCT: Dict[str, str] = { + "i1": "b", + "u1": "B", + "i2": "h", + "u2": "H", + "i4": "i", + "u4": "I", + "f4": "f", + "f8": "d", +} + +#: Test hook: force the numpy-optional pure-Python paths even when numpy is +#: importable (so both branches are covered without uninstalling numpy). +_FORCE_NO_NUMPY = False + + +def _numpy(): + """Return the ``numpy`` module, or ``None`` (honouring :data:`_FORCE_NO_NUMPY`).""" + if _FORCE_NO_NUMPY: + return None + try: + import numpy as np + + return np + except ImportError: + return None + + +def _decode_typed_array(spec: dict) -> List[float]: + """Decode a plotly.js typed-array spec ``{"dtype", "bdata"}`` to a list. + + Uses ``numpy.frombuffer`` when numpy is available, else a stdlib + ``base64`` + ``struct`` unpack (little-endian, matching plotly.js). + """ + dtype = spec.get("dtype") + bdata = spec.get("bdata", "") + raw = base64.b64decode(bdata) if isinstance(bdata, str) else bytes(bdata) + + np = _numpy() + if np is not None and dtype in _SHORT_TO_NP: + return np.frombuffer(raw, dtype=_SHORT_TO_NP[dtype]).tolist() + + if not isinstance(dtype, str): + return [] + code = _SHORT_TO_STRUCT.get(dtype) + if code is None: + return [] + size = struct.calcsize("<" + code) + n = len(raw) // size + if n == 0: + return [] + return list(struct.unpack("<%d%s" % (n, code), raw[: n * size])) + + +def _as_sequence(v): + """Normalize any array-like to a plain sequence (or ``None``). + + Handles typed-array specs (decoded), lists/tuples (passed through), other + iterables like numpy arrays / pandas Series (materialized), and rejects a + non-array dict (returns ``None``) so it is never iterated by key. + """ + if v is None: + return None + if is_typed_array_spec(v): + return _decode_typed_array(v) + if isinstance(v, (list, tuple)): + return v + if isinstance(v, dict): + return None + try: + return list(v) + except TypeError: + return [v] + + +# --------------------------------------------------------------------------- +# Numeric coercion helpers — shared with the sibling handler modules. +# --------------------------------------------------------------------------- + + +def _is_finite_number(v) -> bool: + """True for a real, finite int/float (bools and NaN/inf excluded).""" + return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) + + +def _as_float(v) -> Optional[float]: + """Best-effort convert ``v`` to a finite float, else ``None``. + + Booleans are rejected (they are not data), as are NaN/inf and unparseable + strings — categorical/None values return ``None`` so callers can decide how + to place them (typically by positional index). + """ + if isinstance(v, bool) or v is None: + return None + if isinstance(v, (int, float)): + return float(v) if math.isfinite(v) else None + try: + f = float(v) + except (TypeError, ValueError): + return None + return f if math.isfinite(f) else None + + +def _coerce_numeric(seq) -> List[float]: + """Map a data array to floats, substituting the positional index for any + non-numeric (categorical / missing) entry. + + The input is first normalized via :func:`_as_sequence`, so a base64 + typed-array spec (from ``fig.to_dict()`` on numpy data) is decoded rather + than iterated by key. This is a v1 simplification: categorical axes render + against integer positions rather than true category ticks. + """ + seq = _as_sequence(seq) + if not seq: + return [] + out: List[float] = [] + for i, v in enumerate(seq): + f = _as_float(v) + out.append(f if f is not None else float(i)) + return out + + +# --------------------------------------------------------------------------- +# Range / frame helpers. +# --------------------------------------------------------------------------- + + +def _range_of(vals: Sequence, fallback: Range) -> Range: + """Return a ``(min, max)`` range over the finite numbers in ``vals``. + + Falls back to ``fallback`` when there is no numeric data, and pads a + degenerate (min == max) range so the frame has non-zero extent. + """ + nums = [float(v) for v in vals if _is_finite_number(v)] + if not nums: + return fallback + lo, hi = min(nums), max(nums) + if lo == hi: + pad = abs(lo) * 0.05 or 0.5 + return (lo - pad, hi + pad) + return (lo, hi) + + +def _bar_extent( + positions: Sequence[float], + values: Sequence[float], + orientation: str, + base: float, +) -> Tuple[List[float], List[float]]: + """(xs, ys) data extent contributed by a bar/histogram-style trace. + + The value axis includes ``base`` so bars are always framed from their + baseline; orientation decides which axis carries positions vs values. + """ + vals = [float(v) for v in values] + [float(base)] + pos = [float(p) for p in positions] + if orientation == "h": + return (vals, pos) + return (pos, vals) + + +def _trace_extent(trace: dict, ttype: str): + """Return ``(xs, ys)`` this trace contributes to the shared ranges. + + Reuses each handler's own data extraction (imported lazily to avoid an + import cycle) so the range and the drawing never disagree. ``None`` means + the trace contributes nothing. + """ + if ttype in ("scatter", "scattergl"): + from plotly.io._text.adapters.scatter import scatter_xy + + xs, ys, _mode = scatter_xy(trace) + return (xs, ys) + if ttype == "bar": + from plotly.io._text.adapters.bar import bar_positions_values + + positions, values, orientation, base = bar_positions_values(trace) + return _bar_extent(positions, values, orientation, base) + if ttype == "histogram": + from plotly.io._text.adapters.histogram import histogram_bins + + _centers, counts, edges, orientation = histogram_bins(trace) + # Use the bin *edges* for the position axis so the frame spans the data. + return _bar_extent(edges, counts, orientation, 0.0) + if ttype == "heatmap": + from plotly.io._text.adapters.heatmap import heatmap_extent + + return heatmap_extent(trace) + if ttype == "histogram2d": + from plotly.io._text.adapters.heatmap import histogram2d_extent + + return histogram2d_extent(trace) + return None + + +def _fmt_tick(v: float) -> str: + """Compact label for a tick value (integers without a trailing ``.0``).""" + try: + if v == int(v): + return str(int(v)) + except (OverflowError, ValueError): + pass + return f"{v:.3g}" + + +def _derive_ticks(rng: Range, n: int = 3) -> List[Tick]: + """Evenly spaced ticks across ``rng`` (Plotly-agnostic numeric ticks).""" + lo, hi = rng + if n < 2 or hi <= lo: + return [Tick(lo, _fmt_tick(lo))] + step = (hi - lo) / (n - 1) + return [Tick(lo + i * step, _fmt_tick(lo + i * step)) for i in range(n)] + + +def _layout_title(layout: dict) -> Optional[str]: + """Extract the figure title text from ``layout`` (dict or bare string).""" + t = layout.get("title") + if isinstance(t, dict): + return t.get("text") + if isinstance(t, str): + return t + return None + + +def _axis_title(layout: dict, axis: str) -> Optional[str]: + """Extract an axis title text (``xaxis`` / ``yaxis``) from ``layout``.""" + ax = layout.get(axis) + if not isinstance(ax, dict): + return None + t = ax.get("title") + if isinstance(t, dict): + return t.get("text") + if isinstance(t, str): + return t + return None + + +def _group_offset( + positions: Sequence[float], + ctx: "AdapterContext", + orientation: str, + canvas: Canvas, +) -> Tuple[List[float], bool]: + """Shift a bar/histogram series into its own sub-column within each category. + + Uses :attr:`AdapterContext.series_index` / :attr:`~AdapterContext.series_count` + to split the ~80%-wide category slot into one lane per series (matching + Plotly's grouped-bar layout) so concurrent series never draw into the same + cells. Returns ``(shifted_positions, collided)`` where ``collided`` is True + when a lane is narrower than one character cell (so bars would still merge). + """ + positions = [float(p) for p in positions] + count = max(1, getattr(ctx, "series_count", 1)) + if count <= 1 or not positions: + return positions, False + + if orientation == "h": + lo, hi = ctx.y_range + span_cells = getattr(canvas, "height", DEFAULT_HEIGHT) + else: + lo, hi = ctx.x_range + span_cells = getattr(canvas, "width", DEFAULT_WIDTH) + + uniq = sorted(set(positions)) + if len(uniq) > 1: + spacing = min(b - a for a, b in zip(uniq, uniq[1:])) + else: + spacing = (hi - lo) if hi > lo else 1.0 + + lane = (spacing * 0.8) / count + offset = (ctx.series_index - (count - 1) / 2.0) * lane + shifted = [p + offset for p in positions] + + data_per_cell = (hi - lo) / max(1, span_cells) if hi > lo else float("inf") + collided = lane < data_per_cell + return shifted, collided + + +class DrawResult(NamedTuple): + """The result of :func:`figure_to_canvas`: the drawn canvas (``None`` when + the figure could not be drawn, e.g. an undersized canvas) plus the ordered + degradation notes to print after the plot. + + Replaces an earlier ``canvas._adapter_warnings`` dynamic-attribute stash: + passing the notes back explicitly keeps this a clean internal contract + between :func:`figure_to_canvas` and + :class:`plotly.io._text.renderers.TextRenderer`, not a Canvas API change. + """ + + canvas: Optional[Canvas] + warnings: List[str] + + +def figure_to_canvas( + fig_dict: dict, + *, + width: int = DEFAULT_WIDTH, + height: int = DEFAULT_HEIGHT, + mode: str = "text-utf", +) -> DrawResult: + """Draw a figure dict onto a fresh :class:`Canvas` and return a + :class:`DrawResult` (the canvas + degradation notes). + + Responsibilities: + + 1. compute the shared x/y data ranges across all *supported* traces, + 2. create the Canvas and draw the frame (title, axes, ticks); if the canvas + is too small the Canvas raises ``ValueError`` — caught here and degraded to + a one-line note with ``canvas=None`` (no crash out of ``fig.show``), + 3. assign a distinct glyph / colour per series and the shared series count so + grouped bars/histograms fan out into sub-columns, + 4. dispatch each trace to its :data:`ADAPTERS` handler; for an unsupported + trace, append a one-line note and skip it (graceful degradation), + 5. return the :class:`DrawResult` (the caller serializes ``result.canvas``). + + ``mode`` is passed through (via :attr:`AdapterContext.mode`) so handlers can + format mode-correct notes; the grid->string step happens in + :meth:`Canvas.render`. + """ + data = list(fig_dict.get("data") or []) + layout = dict(fig_dict.get("layout") or {}) + warnings: List[str] = [] + + # 1. Partition traces into supported / unsupported, preserving order. + supported: List[Tuple[dict, str]] = [] + for trace in data: + ttype = trace.get("type") or "scatter" + if get_adapter(ttype) is not None: + supported.append((trace, ttype)) + else: + warnings.append(_unsupported_warning(ttype, mode)) + + # 2. Shared ranges across the supported traces. + xs_all: List[float] = [] + ys_all: List[float] = [] + for trace, ttype in supported: + extent = _trace_extent(trace, ttype) + if extent is not None: + txs, tys = extent + xs_all.extend(txs) + ys_all.extend(tys) + + x_range = _range_of(xs_all, (0.0, 1.0)) + y_range = _range_of(ys_all, (0.0, 1.0)) + + # 3. Frame (fixes the data->cell transform). Degrade an undersized canvas. + try: + canvas = Canvas(width=width, height=height) + canvas.frame( + x_range, + y_range, + x_ticks=_derive_ticks(x_range), + y_ticks=_derive_ticks(y_range), + title=_layout_title(layout), + x_title=_axis_title(layout, "xaxis"), + y_title=_axis_title(layout, "yaxis"), + ) + except ValueError as exc: + warnings.append(_too_small_warning(mode, exc)) + return DrawResult(canvas=None, warnings=warnings) + + # 4. Dispatch each supported trace with a shared warning sink + series count. + series_count = len(supported) + for series_index, (trace, ttype) in enumerate(supported): + ctx = AdapterContext( + layout=layout, + x_range=x_range, + y_range=y_range, + series_index=series_index, + glyph=_assign_glyph(series_index), + color=_assign_color(trace, series_index), + warnings=warnings, + series_count=series_count, + mode=mode, + ) + handler = get_adapter(ttype) + if handler is None: + continue # unreachable: only supported (registered) ttypes reach here + handler(trace, canvas, ctx) + + return DrawResult(canvas=canvas, warnings=warnings) diff --git a/plotly/io/_text/adapters/bar.py b/plotly/io/_text/adapters/bar.py new file mode 100644 index 00000000000..a4f0889ba7a --- /dev/null +++ b/plotly/io/_text/adapters/bar.py @@ -0,0 +1,79 @@ +"""``bar`` adapter. + +Bars -> fractionally filled cells via :meth:`Canvas.bar`. **Honours the figure's +``orientation``** (``"v"``/``"h"``) — never silently reorients the user's chart. +Knows nothing about block-char glyphs. +""" + +from __future__ import annotations + +from typing import List, Tuple + +from plotly.io._text.adapters import ( + AdapterContext, + _as_float, + _coerce_numeric, + _group_offset, + _grouped_density_warning, + register_adapter, +) +from plotly.io._text.canvas import Canvas + + +def bar_positions_values(trace: dict) -> Tuple[List[float], List[float], str, float]: + """Return ``(positions, values, orientation, base)`` for a bar trace. + + Honours the figure's own ``orientation`` (never silently reorients): for the + default ``"v"`` the categories live on ``x`` and the lengths on ``y``; for + ``"h"`` the categories live on ``y`` and the lengths on ``x``. Categorical + positions map to integer indices; only a scalar ``base`` is supported in v1 + (a per-bar ``base`` array falls back to ``0``). + """ + orientation = trace.get("orientation") or "v" + base = _as_float(trace.get("base")) + base = base if base is not None else 0.0 + + x = trace.get("x") + y = trace.get("y") + + if orientation == "h": + values = _coerce_numeric(x) if x is not None else [] + positions = ( + _coerce_numeric(y) + if y is not None + else list(map(float, range(len(values)))) + ) + else: + values = _coerce_numeric(y) if y is not None else [] + positions = ( + _coerce_numeric(x) + if x is not None + else list(map(float, range(len(values)))) + ) + + n = min(len(positions), len(values)) + return positions[:n], values[:n], orientation, base + + +def bar_adapter(trace: dict, canvas: Canvas, ctx: AdapterContext) -> None: + """Draw one ``bar`` trace onto ``canvas``. + + Derives category positions and values from ``trace["x"]``/``trace["y"]`` + per ``trace.get("orientation", "v")`` and calls :meth:`Canvas.bar` with the + same orientation. When several series share a category axis, each is offset + into its own sub-column (via :func:`_group_offset`) so a later series never + silently overwrites an earlier one; if the canvas is too narrow to separate + them a one-line density note is appended (honesty floor). + """ + positions, values, orientation, base = bar_positions_values(trace) + if not positions: + return + positions, collided = _group_offset(positions, ctx, orientation, canvas) + if collided: + note = _grouped_density_warning(ctx.mode) + if note not in ctx.warnings: + ctx.warnings.append(note) + canvas.bar(positions, values, orientation=orientation, base=base, color=ctx.color) + + +register_adapter("bar", bar_adapter) diff --git a/plotly/io/_text/adapters/heatmap.py b/plotly/io/_text/adapters/heatmap.py new file mode 100644 index 00000000000..d57383e97e9 --- /dev/null +++ b/plotly/io/_text/adapters/heatmap.py @@ -0,0 +1,282 @@ +"""``heatmap`` / ``histogram2d`` adapter. + +Density grids -> shaded cells via :meth:`Canvas.heatmap`, with a per-cell ``bg`` +colour sampled from the trace's ``colorscale`` (default Viridis). ``histogram2d`` +reduces to the same path after binning its raw ``x``/``y`` into a 2D count grid. +The colour modes (``text-ansi`` / ``text-html``) are the natural showcase; in the +monochrome modes only the shade ramp (``░▒▓█`` / ``.:+#``) shows. + +numpy is used when present (fast path) with a pure-Python fallback so the +renderer stays numpy-optional — the same discipline the ``histogram`` adapter +uses. ``z`` (and the ``histogram2d`` samples) are normalized through +:func:`~plotly.io._text.adapters._as_sequence` so a base64 typed-array from +``fig.to_dict()`` on numpy data is decoded rather than iterated by its dict keys +(the v1 numpy blocker, guarded against here too). +""" + +from __future__ import annotations + +from typing import List, Optional, Sequence, Tuple + +from plotly.io._text.adapters import ( + AdapterContext, + _as_float, + _as_sequence, + _color_to_hex, + _decode_typed_array, + _numpy, + is_typed_array_spec, + register_adapter, +) +from plotly.io._text.canvas import Canvas + +#: Default per-axis bin count for a ``histogram2d`` when the trace sets no +#: ``nbinsx`` / ``nbinsy``. +DEFAULT_HIST2D_BINS = 20 + + +def _decode_2d_typed_array(z: dict) -> Optional[List[List[float]]]: + """Reshape a **2D** typed-array spec ``{"dtype", "bdata", "shape": "r, c"}``. + + ``fig.to_dict()`` base64-encodes a 2D numpy ``z`` as a *flat* buffer plus a + ``"r, c"`` shape string. :func:`_decode_typed_array` alone would yield a flat + list — iterated as rows that would be single-element garbage (the v1 numpy + blocker, in 2D). Here we decode flat then reshape per ``shape``. A 1D typed + array (no 2-tuple shape) becomes a single row; a non-spec returns ``None`` so + the caller falls back to the generic path. + """ + if not is_typed_array_spec(z): + return None + flat = _decode_typed_array(z) + shape = z.get("shape") + if isinstance(shape, str): + dims = [int(s) for s in shape.split(",") if s.strip()] + if len(dims) == 2: + r, c = dims + if c > 0: + return [flat[i * c : (i + 1) * c] for i in range(r)] + return [list(flat)] if flat else None + + +def heatmap_z(trace: dict) -> Optional[List[List[float]]]: + """Extract the 2D ``z`` grid from a ``heatmap`` trace, or ``None``. + + Handles three shapes of ``z``: + + * a **2D typed-array spec** (numpy ``z`` through ``fig.to_dict()``) — decoded + *and reshaped* per its ``shape`` (:func:`_decode_2d_typed_array`) so a + numpy heatmap isn't flattened into garbage rows (the v1 numpy blocker); + * a nested list / list-of-arrays — each row normalized via + :func:`_as_sequence` (decoding any per-row typed array); + * a raw 2D numpy array (materialized to rows). + + Non-rectangular ``z`` is preserved row-by-row; the Canvas resamples each row + independently. Returns ``None`` when there is no usable 2D grid. + """ + z = trace.get("z") + if z is None: + return None + + reshaped = _decode_2d_typed_array(z) + if reshaped is not None: + return reshaped or None + + rows = _as_sequence(z) + if rows is None: + return None + out: List[List[float]] = [] + for row in rows: + r = _as_sequence(row) + if r is not None: + out.append(list(r)) + return out or None + + +def _finite_xy_pairs(trace: dict) -> Tuple[List[float], List[float]]: + """Return the finite ``(xs, ys)`` sample pairs from a ``histogram2d`` trace. + + ``x``/``y`` are normalized via :func:`_as_sequence` (typed-array decode) then + paired and filtered to finite floats — a pair is dropped if either coordinate + is missing/non-numeric so a NaN never lands in a bin. + """ + xr = _as_sequence(trace.get("x")) or [] + yr = _as_sequence(trace.get("y")) or [] + n = min(len(xr), len(yr)) + xs: List[float] = [] + ys: List[float] = [] + for i in range(n): + fx = _as_float(xr[i]) + fy = _as_float(yr[i]) + if fx is not None and fy is not None: + xs.append(fx) + ys.append(fy) + return xs, ys + + +def _hist2d_nbins(trace: dict) -> int: + """Per-axis bin count for a ``histogram2d`` (honours ``nbinsx``/``nbinsy``).""" + for key in ("nbinsx", "nbinsy"): + v = _as_float(trace.get(key)) + if v is not None and v >= 1: + return int(v) + return DEFAULT_HIST2D_BINS + + +def _bin_index(v: float, lo: float, hi: float, n: int) -> int: + """Clamp ``v`` into one of ``n`` equal bins over ``[lo, hi]`` (last closed).""" + if hi <= lo: + return 0 + idx = int((v - lo) / (hi - lo) * n) + if idx < 0: + return 0 + if idx >= n: + return n - 1 + return idx + + +def _histogram2d_to_z( + x: Sequence[float], y: Sequence[float], nbins: int = DEFAULT_HIST2D_BINS +) -> List[List[float]]: + """Bin raw ``x``/``y`` samples into an ``nbins`` x ``nbins`` count grid. + + The returned grid is ``z[row][col]`` with ``col`` the x bin (ascending left + -> right) and ``row`` the y bin ordered **top -> bottom for descending y** + (row 0 is the highest-y bin) so it lines up with :meth:`Canvas.heatmap` + drawing ``z[0]`` at the top of the plot region against a y axis that + increases upward. numpy's :func:`numpy.histogram2d` is used when importable + (fast path); otherwise a pure-Python equal-width binning — matching the + ``histogram`` adapter's numpy-optional approach. Returns ``[]`` when there is + no data. + """ + xs = [float(v) for v in x] + ys = [float(v) for v in y] + if not xs or not ys: + return [] + n = max(1, int(nbins)) + + np = _numpy() + if np is not None: + h, _xe, _ye = np.histogram2d( + np.asarray(xs, dtype=float), np.asarray(ys, dtype=float), bins=n + ) + # h[xi, yi] -> want z[yrow][xcol]; transpose then flip y so row 0 = top. + z = h.T[::-1] + return [[float(c) for c in row] for row in z] + + xlo, xhi = min(xs), max(xs) + ylo, yhi = min(ys), max(ys) + grid = [[0 for _ in range(n)] for _ in range(n)] # grid[yi][xi], yi ascending + for xv, yv in zip(xs, ys): + xi = _bin_index(xv, xlo, xhi, n) + yi = _bin_index(yv, ylo, yhi, n) + grid[yi][xi] += 1 + # Flip y so row 0 is the highest-y bin (top of the plot). + return [[float(c) for c in row] for row in grid[::-1]] + + +def _normalize_colorscale(cs): + """Bridge a trace's ``colorscale`` to what :meth:`Canvas.heatmap` accepts. + + ``fig.to_dict()`` expands a named colorscale (``"Viridis"``, ``"Greys"``, + ...) into a list of ``[stop, "rgb(...)"]`` pairs — which the Canvas colour + sampler can't parse (it expects ``#hex``). Here we convert each entry's + colour to hex. ``None`` and a bare named string pass through unchanged (the + Canvas resolves those itself); an entry we can't convert makes the whole + thing fall back to ``None`` so the Canvas uses its default scale rather than + crashing. + """ + if cs is None or isinstance(cs, str): + return cs + try: + pairs = [] + for stop, color in cs: + hx = _color_to_hex(color) + if hx is None: + return None + pairs.append([float(stop), hx]) + except (TypeError, ValueError): + return None + return pairs or None + + +def _finite_floats(v) -> List[float]: + """Finite floats from an array-like (typed-array decoded), non-numeric dropped.""" + out: List[float] = [] + for item in _as_sequence(v) or []: + f = _as_float(item) + if f is not None: + out.append(f) + return out + + +def heatmap_extent(trace: dict): + """``(xs, ys)`` data extent a ``heatmap`` trace contributes to the frame. + + Uses the trace's ``x``/``y`` coordinate arrays when present (finite values + only); otherwise the grid index extents (``0..ncols-1`` / ``0..nrows-1``) so + the axes span the ``z`` grid. Returns ``None`` when there is no usable grid. + """ + z = heatmap_z(trace) + if not z: + return None + nrows = len(z) + ncols = max((len(r) for r in z), default=0) + if ncols == 0: + return None + + xs = _finite_floats(trace.get("x")) + ys = _finite_floats(trace.get("y")) + if not xs: + xs = [0.0, float(ncols - 1)] + if not ys: + ys = [0.0, float(nrows - 1)] + return xs, ys + + +def histogram2d_extent(trace: dict): + """``(xs, ys)`` data extent a ``histogram2d`` trace contributes to the frame. + + The finite raw ``x``/``y`` samples — the axes span the binned data range. + Returns ``None`` when there are no finite samples. + """ + xs, ys = _finite_xy_pairs(trace) + if not xs or not ys: + return None + return xs, ys + + +def heatmap_adapter(trace: dict, canvas: Canvas, ctx: AdapterContext) -> None: + """Draw one ``heatmap`` / ``histogram2d`` trace onto ``canvas``. + + 1. for ``histogram2d``, bin the finite ``trace["x"]``/``trace["y"]`` samples + via :func:`_histogram2d_to_z`; for ``heatmap``, read :func:`heatmap_z` + (typed-array-decoded); + 2. call ``canvas.heatmap(z, colorscale=trace.get("colorscale"))`` — the + Canvas normalizes ``z`` and samples per-cell ``bg`` colours from the + colorscale (default Viridis), resampling ``z`` to the plot region. + + Never raises on empty / malformed data (returns quietly) so ``fig.show`` + degrades cleanly. + """ + ttype = trace.get("type") + if ttype == "histogram2d": + xs, ys = _finite_xy_pairs(trace) + if not xs or not ys: + return + # Already oriented row 0 = highest-y bin (top of the plot region). + z = _histogram2d_to_z(xs, ys, nbins=_hist2d_nbins(trace)) + else: + z = heatmap_z(trace) + if z: + # Plotly draws ``z[0]`` at the **bottom** (y increases upward), but + # :meth:`Canvas.heatmap` draws row 0 at the top — reverse so the + # orientation matches the on-screen chart. + z = z[::-1] + + if not z: + return + canvas.heatmap(z, colorscale=_normalize_colorscale(trace.get("colorscale"))) + + +register_adapter("heatmap", heatmap_adapter) +register_adapter("histogram2d", heatmap_adapter) diff --git a/plotly/io/_text/adapters/histogram.py b/plotly/io/_text/adapters/histogram.py new file mode 100644 index 00000000000..23a152603d8 --- /dev/null +++ b/plotly/io/_text/adapters/histogram.py @@ -0,0 +1,187 @@ +"""``histogram`` adapter. + +Histograms bin at *render* time in plotly.js, so the figure JSON usually carries +only raw ``x`` (or ``y``) samples — the adapter must reproduce Plotly's binning +(honouring ``xbins``/``nbinsx`` when present, else approximating) and then draw +the result exactly like :mod:`~plotly.io._text.adapters.bar`. Binning is *not +free* and may disagree with the real chart at bin edges — a known fidelity +limitation. numpy is imported lazily inside the body so it stays an optional +dependency. +""" + +from __future__ import annotations + +import math +from typing import List, Optional, Sequence, Tuple + +from plotly.io._text.adapters import ( + AdapterContext, + _coerce_numeric, + _group_offset, + _grouped_density_warning, + _is_finite_number, + _numpy, + register_adapter, +) +from plotly.io._text.canvas import Canvas + + +def _nice_step(raw: float) -> float: + """Round ``raw`` up to a 1/2/2.5/5/10 * 10^k "nice" number. + + Mirrors the round-number bin widths Plotly's autobin favours, so our bins + line up with the real chart more often than naive equal-width slicing. + """ + if raw <= 0: + return 1.0 + mag = 10.0 ** math.floor(math.log10(raw)) + for m in (1.0, 2.0, 2.5, 5.0, 10.0): + if m * mag >= raw: + return m * mag + return 10.0 * mag + + +def _auto_target_bins(n: int) -> int: + """Target bin count for autobinning (square-root rule, clamped).""" + if n <= 1: + return 1 + return max(1, min(60, int(math.ceil(math.sqrt(n))))) + + +def _bin_edges( + samples: Sequence[float], + bins_spec: Optional[dict], + nbins: Optional[int], +) -> List[float]: + """Compute bin edges honouring ``xbins``/``ybins`` then ``nbinsx``/``nbinsy``. + + ``bins_spec`` (Plotly's ``{"start", "end", "size"}``) wins when a ``size`` is + present; otherwise a "nice" step spanning the data range is chosen for the + requested (or auto) number of bins. + """ + if not samples: + return [0.0, 1.0] + + lo, hi = min(samples), max(samples) + + # Explicit xbins with a size -> honour start/end/size. + if isinstance(bins_spec, dict) and bins_spec.get("size"): + size = float(bins_spec["size"]) + start = bins_spec.get("start") + end = bins_spec.get("end") + start = float(start) if start is not None else lo + end = float(end) if end is not None else hi + if size <= 0 or end <= start: + return [lo, hi if hi > lo else lo + 1.0] + edges = [start] + e = start + # +size/2 guard against float drift; ensure the last sample is covered. + while e < end - size * 1e-9: + e += size + edges.append(e) + if edges[-1] < end: + edges.append(edges[-1] + size) + return edges + + if lo == hi: + return [lo - 0.5, hi + 0.5] + + target = int(nbins) if nbins else _auto_target_bins(len(samples)) + target = max(1, target) + step = _nice_step((hi - lo) / target) + start = math.floor(lo / step) * step + edges = [start] + e = start + while e < hi - step * 1e-9: + e += step + edges.append(e) + if edges[-1] <= hi: + edges.append(edges[-1] + step) + return edges + + +def _bin_counts(samples: Sequence[float], edges: Sequence[float]) -> List[int]: + """Count samples per ``[edge_i, edge_{i+1})`` bin (last bin closed on both). + + Uses numpy when importable (fast path, matching its half-open convention), + else a pure-Python bisect — numpy stays an optional dependency. + """ + nbins = len(edges) - 1 + if nbins <= 0: + return [] + + np = _numpy() + if np is not None: + counts, _ = np.histogram(np.asarray(samples, dtype=float), bins=list(edges)) + return [int(c) for c in counts] + + from bisect import bisect_right + + counts = [0] * nbins + edge_list = list(edges) + last = edge_list[-1] + for s in samples: + if s < edge_list[0] or s > last: + continue + if s == last: + counts[-1] += 1 + continue + idx = bisect_right(edge_list, s) - 1 + if 0 <= idx < nbins: + counts[idx] += 1 + return counts + + +def histogram_bins( + trace: dict, +) -> Tuple[List[float], List[int], List[float], str]: + """Return ``(centers, counts, edges, orientation)`` for a histogram trace. + + Vertical histograms bin the ``x`` samples; horizontal ones bin ``y``. Bin + specification comes from ``xbins``/``ybins`` then ``nbinsx``/``nbinsy``, + falling back to an auto "nice" binning. + """ + orientation = trace.get("orientation") or "v" + if orientation == "h": + raw = trace.get("y") + bins_spec = trace.get("ybins") + nbins = trace.get("nbinsy") + else: + raw = trace.get("x") + bins_spec = trace.get("xbins") + nbins = trace.get("nbinsx") + + samples = _coerce_numeric(raw) if raw is not None else [] + samples = [s for s in samples if _is_finite_number(s)] + + edges = _bin_edges(samples, bins_spec, nbins) + counts = _bin_counts(samples, edges) + centers = [(edges[i] + edges[i + 1]) / 2.0 for i in range(len(edges) - 1)] + return centers, counts, edges, orientation + + +def histogram_adapter(trace: dict, canvas: Canvas, ctx: AdapterContext) -> None: + """Bin one ``histogram`` trace and draw it as bars onto ``canvas``. + + Computes bin edges + counts from the raw samples (pure Python or a lazily + imported numpy), then calls :meth:`Canvas.bar` with the bin centres and + counts, honouring ``trace.get("orientation", "v")``. + """ + centers, counts, _edges, orientation = histogram_bins(trace) + if not centers: + return + centers, collided = _group_offset(centers, ctx, orientation, canvas) + if collided: + note = _grouped_density_warning(ctx.mode) + if note not in ctx.warnings: + ctx.warnings.append(note) + canvas.bar( + centers, + [float(c) for c in counts], + orientation=orientation, + base=0.0, + color=ctx.color, + ) + + +register_adapter("histogram", histogram_adapter) diff --git a/plotly/io/_text/adapters/scatter.py b/plotly/io/_text/adapters/scatter.py new file mode 100644 index 00000000000..8ec8d7fe6a4 --- /dev/null +++ b/plotly/io/_text/adapters/scatter.py @@ -0,0 +1,80 @@ +"""``scatter`` / ``scattergl`` adapter. + +The canonical case: lines -> braille via :meth:`Canvas.line`, markers -> distinct +glyphs via :meth:`Canvas.markers`, honouring the trace ``mode`` +(``lines`` / ``markers`` / ``lines+markers``). ``scattergl`` shares this path +(same data model). Knows nothing about braille encoding — only Canvas calls. +""" + +from __future__ import annotations + +from typing import List, Optional, Tuple + +from plotly.io._text.adapters import ( + AdapterContext, + _as_sequence, + _coerce_numeric, + register_adapter, +) +from plotly.io._text.canvas import Canvas + + +def scatter_xy(trace: dict) -> Tuple[List[float], List[float], Optional[str]]: + """Extract paired numeric ``(xs, ys)`` and the draw ``mode`` from a trace. + + A missing ``x`` (or ``y``) is filled with positional indices, categorical + values map to their index, and the two arrays are truncated to a common + length. ``x``/``y`` are normalized via :func:`_as_sequence` first so a + base64 typed-array (numpy data through ``fig.to_dict()``) is decoded rather + than counted by its dict keys. Used both to draw and (by the driver) to + compute data extents, so the two never disagree. + """ + x = _as_sequence(trace.get("x")) + y = _as_sequence(trace.get("y")) + mode = trace.get("mode") + + if x is None and y is None: + return [], [], mode + if x is None: + assert y is not None # guaranteed by the both-None early return above + x = list(range(len(y))) + if y is None: + assert x is not None # x was non-None or was filled in just above + y = list(range(len(x))) + + xs = _coerce_numeric(x) + ys = _coerce_numeric(y) + n = min(len(xs), len(ys)) + return xs[:n], ys[:n], mode + + +def scatter_adapter(trace: dict, canvas: Canvas, ctx: AdapterContext) -> None: + """Draw one ``scatter``/``scattergl`` trace onto ``canvas``. + + Reads ``trace["x"]``/``trace["y"]`` and ``trace["mode"]``; calls + :meth:`Canvas.line` for line segments and/or :meth:`Canvas.markers` + (with ``ctx.glyph``) for markers. + """ + xs, ys, mode = scatter_xy(trace) + if not xs: + return + + points = list(zip(xs, ys)) + + # Plotly's true default mode depends on point count / other traces; "lines" + # is the sensible text-renderer default. Honour explicit lines / markers. + mode = mode or "lines" + draw_line = "lines" in mode + draw_markers = "markers" in mode + if not draw_line and not draw_markers: + # e.g. mode="text" or "none" — fall back to a line so the series shows. + draw_line = True + + if draw_line: + canvas.line(points, color=ctx.color) + if draw_markers: + canvas.markers(points, ctx.glyph, color=ctx.color) + + +register_adapter("scatter", scatter_adapter) +register_adapter("scattergl", scatter_adapter) diff --git a/plotly/io/_text/canvas.py b/plotly/io/_text/canvas.py new file mode 100644 index 00000000000..e327ed39368 --- /dev/null +++ b/plotly/io/_text/canvas.py @@ -0,0 +1,771 @@ +"""Text Canvas — a stateful text drawing surface. + +This module is the *rasterizer* half of the text renderers. It knows **nothing +about Plotly**: it exposes a tiny drawing surface whose primitives accumulate +into an abstract **cell grid**, where each cell is a glyph slot plus optional +colour attributes with *no output encoding baked in*. + +The grid is turned into a string by a per-mode :class:`~plotly.io._text.serializers.Serializer` +(``text-utf`` / ``text-ascii`` / ``text-ansi`` / ``text-html``) — that grid -> +string step is the seam the "one grid, many serializers" design rests on. + +The Canvas signatures are the stable interface the adapters and serializers build +on: the trace adapters (in :mod:`~plotly.io._text.adapters`) drive these methods, +and the serializers consume the resulting grid. + +Design invariants: + +* **Never queries a TTY.** Size is always explicit (``width`` x ``height`` in + character cells, default 80x24). Output is byte-identical in a CI log, a file, + or a pipe. +* **Data coordinates in, cells out.** Drawing primitives take *data-space* + points; the Canvas maps them to cell / sub-cell positions using ranges + established by :meth:`set_ranges` (called directly or via :meth:`frame`). +* **No encoding in the grid.** A cell records *intent* (a braille sub-dot mask, a + bar fill fraction, a marker/frame/label character, an optional colour), never a + finished ``text-utf`` vs ``text-ascii`` glyph. The serializer bakes encoding. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Any, Iterable, List, Optional, Sequence, Tuple, cast + +from plotly.io._text.rasterizer import ( + NEUTRAL_COLOR, + NEUTRAL_RGB, + color_to_rgb, + norm_hex, +) + +# --------------------------------------------------------------------------- +# Coordinate / geometry types +# --------------------------------------------------------------------------- + +#: A single point in *data* coordinates. +Point = Tuple[float, float] +#: An ordered sequence of data-space points (e.g. a line or a marker set). +Points = Sequence[Point] +#: An inclusive ``(min, max)`` range in data coordinates. +Range = Tuple[float, float] + +#: Number of braille sub-columns per character cell (braille is a 2x4 dot grid). +SUBCELL_COLS = 2 +#: Number of braille sub-rows per character cell. +SUBCELL_ROWS = 4 + +#: Default canvas size in character cells. Never inferred from a terminal. +DEFAULT_WIDTH = 80 +DEFAULT_HEIGHT = 24 + + +class CellRole(IntEnum): + """What a cell *means*, so a serializer can encode / degrade it per mode. + + The role tells the serializer which of the payload fields on :class:`Cell` + to read and which glyph set to draw from. + """ + + EMPTY = 0 #: nothing drawn here — renders as blank in every mode. + DOTS = 1 #: braille sub-dot field (lines, scatter, dot-markers): read ``dots``. + MARKER = 2 #: a whole-cell marker glyph chosen by the adapter: read ``char``. + BAR = 3 #: a bar segment: read ``fill`` (0..1 fraction of the cell). + FRAME = 4 #: an axis / frame element (box-drawing): read ``char``. + LABEL = 5 #: literal text (titles, tick labels, notes): read ``char``. + HEATMAP = 6 #: a heatmap/histogram2d cell: read ``fill`` (0..1 density) and + #: ``bg`` (the colorscale-sampled hex); the serializer maps ``fill`` to the + #: shade ramp (``rasterizer.SHADE_RAMP`` / ``SHADE_ASCII``). + + +@dataclass +class Cell: + """One character slot in the grid — abstract intent, not a finished glyph. + + Exactly which fields are meaningful is determined by :attr:`role`: + + ============ ============================================================ + role meaningful payload + ============ ============================================================ + ``EMPTY`` (none) + ``DOTS`` :attr:`dots` — 8-bit 2x4 braille sub-dot bitmask (0..255) + ``MARKER`` :attr:`char` — the semantic marker glyph + ``BAR`` :attr:`fill` — fractional fill of the cell, 0.0..1.0 + ``FRAME`` :attr:`char` — the box-drawing / axis character + ``LABEL`` :attr:`char` — the literal text character + ``HEATMAP`` :attr:`fill` — normalized density 0..1; :attr:`bg` — hex + ============ ============================================================ + + :attr:`fg` / :attr:`bg` are colour hints consumed only by the v2 colour + serializers (``text-ansi`` / ``text-html``); ``None`` means monochrome, which + is what the v1 serializers always emit. + """ + + #: what this cell means; selects which payload field below is read. + role: CellRole = CellRole.EMPTY + #: DOTS: 2x4 braille sub-dot bitmask, see ``rasterizer.BRAILLE_BITS``. + dots: int = 0 + #: BAR: fractional fill of the cell, 0.0..1.0. + fill: float = 0.0 + #: MARKER / FRAME / LABEL: the semantic character (pre-encoding). + char: str = "" + #: colour hint for v2 serializers; None = monochrome. + fg: Optional[str] = None + #: background colour hint for v2 serializers. + bg: Optional[str] = None + + +@dataclass +class CellGrid: + """A ``height`` x ``width`` matrix of :class:`Cell` — the grid hand-off. + + This is the *entire* interface between the Canvas (which fills the grid) and + the serializers (which read it). ``rows[y][x]`` is the cell at column ``x`` + (0 = left) and row ``y`` (0 = top). + """ + + width: int + height: int + rows: List[List[Cell]] = field(default_factory=list) + + @classmethod + def blank(cls, width: int, height: int) -> "CellGrid": + """Return a fresh grid of the given size, every cell ``EMPTY``.""" + return cls( + width=width, + height=height, + rows=[[Cell() for _ in range(width)] for _ in range(height)], + ) + + def cell(self, x: int, y: int) -> Cell: + """Return the cell at column ``x``, row ``y`` (top-left origin).""" + return self.rows[y][x] + + +@dataclass +class Tick: + """A single axis tick: a data-space position and its rendered label.""" + + value: float + label: str + + +class Canvas: + """A stateful text drawing surface backed by an abstract :class:`CellGrid`. + + Usage (from a trace adapter):: + + canvas = Canvas(width=80, height=24) + canvas.frame(x_range=(0, 10), y_range=(-1, 1), title="sin(x)") + canvas.line([(x, math.sin(x)) for x in ...]) + text = canvas.render("text-utf") + + The Canvas is deliberately Plotly-agnostic: adapters translate a figure into + these calls. All primitives take **data coordinates**; call :meth:`frame` + (or :meth:`set_ranges`) first to fix the data->cell mapping. + """ + + def __init__(self, width: int = DEFAULT_WIDTH, height: int = DEFAULT_HEIGHT): + """Create a blank canvas ``width`` x ``height`` character cells. + + Size is **explicit and mandatory-with-default**; the Canvas never reads + ``$COLUMNS`` or probes a TTY. Sub-cell (braille) resolution is + ``width*2`` x ``height*4`` dots. + """ + if width < 1 or height < 1: + raise ValueError("Canvas size must be at least 1x1 cells") + self._width = int(width) + self._height = int(height) + self._grid = CellGrid.blank(self._width, self._height) + # Data ranges (set by set_ranges / frame). None until fixed. + self._x_range: Optional[Range] = None + self._y_range: Optional[Range] = None + # Plot region in character cells (inclusive). Defaults to the whole + # grid; frame() shrinks it to reserve margins for axes and labels. + self._px0 = 0 + self._py0 = 0 + self._px1 = self._width - 1 + self._py1 = self._height - 1 + + # -- geometry ----------------------------------------------------------- + + @property + def width(self) -> int: + """Canvas width in character cells.""" + return self._width + + @property + def height(self) -> int: + """Canvas height in character cells.""" + return self._height + + @property + def grid(self) -> CellGrid: + """The accumulated :class:`CellGrid` (the hand-off to serializers).""" + return self._grid + + def set_ranges(self, x_range: Range, y_range: Range) -> None: + """Fix the data->cell coordinate transform. + + Must be called (directly, or via :meth:`frame`) before any data-space + primitive. ``x_range``/``y_range`` are inclusive ``(min, max)`` data + bounds mapped onto the plot region. Establishes both the character-cell + and the finer braille sub-cell mapping. + """ + self._x_range = (float(x_range[0]), float(x_range[1])) + self._y_range = (float(y_range[0]), float(y_range[1])) + + # -- internal coordinate helpers ---------------------------------------- + + def _require_ranges(self) -> Tuple[Range, Range]: + """Return the fixed ``(x_range, y_range)``, raising if not yet set. + + Returning the (now non-``None``) ranges lets callers bind them to locals + so the coordinate maths type-checks without repeating the None guard. + """ + if self._x_range is None or self._y_range is None: + raise RuntimeError( + "set_ranges() (or frame()) must be called before drawing" + ) + return self._x_range, self._y_range + + @staticmethod + def _frac(value: float, lo: float, hi: float) -> float: + """Fractional position of ``value`` within ``[lo, hi]``, clamped 0..1.""" + span = hi - lo + if span == 0: + return 0.5 + f = (value - lo) / span + if f < 0.0: + return 0.0 + if f > 1.0: + return 1.0 + return f + + def _sub_dims(self) -> Tuple[int, int]: + """Sub-dot (width, height) of the plot region.""" + sw = (self._px1 - self._px0 + 1) * SUBCELL_COLS + sh = (self._py1 - self._py0 + 1) * SUBCELL_ROWS + return sw, sh + + def _data_to_subdot(self, x: float, y: float) -> Tuple[int, int]: + """Map a data-space point to global braille sub-dot ``(scol, srow)``.""" + x_range, y_range = self._require_ranges() + sw, sh = self._sub_dims() + fx = self._frac(x, x_range[0], x_range[1]) + fy = self._frac(y, y_range[0], y_range[1]) + scol = self._px0 * SUBCELL_COLS + round(fx * (sw - 1)) + # y grows up, sub-rows grow down -> invert. + srow = self._py0 * SUBCELL_ROWS + round((1.0 - fy) * (sh - 1)) + return scol, srow + + def _set_dot(self, scol: int, srow: int, color: Optional[str] = None) -> None: + """Light one braille sub-dot, without disturbing frame/label cells. + + When ``color`` is given, the cell's :attr:`Cell.fg` is set to that hex + (consumed only by the v2 colour serializers); ``None`` leaves it + monochrome, so v1 callers are unaffected. + """ + from plotly.io._text.rasterizer import BRAILLE_BITS + + cx = scol // SUBCELL_COLS + cy = srow // SUBCELL_ROWS + if not (0 <= cx < self._width and 0 <= cy < self._height): + return + cell = self._grid.rows[cy][cx] + if cell.role not in (CellRole.EMPTY, CellRole.DOTS): + return # protect axis frame / labels already drawn there + cell.role = CellRole.DOTS + cell.dots |= BRAILLE_BITS[scol % SUBCELL_COLS][srow % SUBCELL_ROWS] + if color is not None: + cell.fg = color + + def _place_text( + self, x: int, y: int, text: str, role: CellRole = CellRole.LABEL + ) -> None: + """Write ``text`` left-to-right starting at cell ``(x, y)`` (clamped).""" + if not (0 <= y < self._height): + return + for i, ch in enumerate(text): + cx = x + i + if 0 <= cx < self._width: + cell = self._grid.rows[y][cx] + cell.role = role + cell.char = ch + + # -- drawing primitives (data coordinates) ------------------------------ + + def line(self, points: Points, *, color: Optional[str] = None) -> None: + """Draw a polyline through ``points`` (data coords) into the grid. + + Consecutive points are connected with straight segments rasterized at + braille sub-cell resolution (``CellRole.DOTS``); the serializer picks the + encoding (Unicode braille for ``text-utf``, the density ramp for + ``text-ascii``). + + ``color`` (a hex string, v2) tags every drawn cell's :attr:`Cell.fg` so + the colour serializers (``text-ansi`` / ``text-html``) can tint the line; + omitting it (the v1 default) leaves the cells monochrome. + """ + self._require_ranges() + pts = list(points) + if not pts: + return + if len(pts) == 1: + scol, srow = self._data_to_subdot(pts[0][0], pts[0][1]) + self._set_dot(scol, srow, color) + return + prev = self._data_to_subdot(pts[0][0], pts[0][1]) + for x, y in pts[1:]: + cur = self._data_to_subdot(x, y) + self._draw_subdot_segment(prev, cur, color) + prev = cur + + def _draw_subdot_segment( + self, a: Tuple[int, int], b: Tuple[int, int], color: Optional[str] = None + ) -> None: + """Bresenham line between two global sub-dot coordinates.""" + x0, y0 = a + x1, y1 = b + dx = abs(x1 - x0) + dy = -abs(y1 - y0) + sx = 1 if x0 < x1 else -1 + sy = 1 if y0 < y1 else -1 + err = dx + dy + while True: + self._set_dot(x0, y0, color) + if x0 == x1 and y0 == y1: + break + e2 = 2 * err + if e2 >= dy: + err += dy + x0 += sx + if e2 <= dx: + err += dx + y0 += sy + + def markers( + self, points: Points, glyph: str, *, color: Optional[str] = None + ) -> None: + """Stamp a whole-cell marker ``glyph`` at each point (data coords). + + Produces ``CellRole.MARKER`` cells. ``glyph`` is the *semantic* marker + character chosen by the adapter (e.g. one per series); a serializer may + substitute a mode-appropriate equivalent (e.g. ``*`` in ``text-ascii``). + + ``color`` (a hex string, v2) tags each marker cell's :attr:`Cell.fg`; + omitting it (the v1 default) leaves the markers monochrome. + """ + self._require_ranges() + for x, y in points: + scol, srow = self._data_to_subdot(x, y) + cx = scol // SUBCELL_COLS + cy = srow // SUBCELL_ROWS + if 0 <= cx < self._width and 0 <= cy < self._height: + cell = self._grid.rows[cy][cx] + cell.role = CellRole.MARKER + cell.char = glyph + if color is not None: + cell.fg = color + + def bar( + self, + positions: Sequence[float], + values: Sequence[float], + *, + orientation: str = "v", + base: float = 0.0, + color: Optional[str] = None, + ) -> None: + """Draw bars as fractionally-filled cells (``CellRole.BAR``). + + ``positions`` are the category centres and ``values`` the bar lengths, in + data coordinates (one entry each, paired). ``orientation`` is ``"v"`` + (vertical, grows in +y) or ``"h"`` (horizontal, grows in +x) — the + adapter passes the figure's own orientation; the Canvas never silently + reorients. ``base`` is the value each bar grows from. Fill fractions let + a bar end mid-cell; the serializer maps fraction -> block ramp + (``full block .. thin``) for ``text-utf`` or ``#`` for ``text-ascii``. + + ``color`` (a hex string, v2) tags every filled bar cell's + :attr:`Cell.fg`; omitting it (the v1 default) leaves the bars monochrome. + """ + self._require_ranges() + if orientation not in ("v", "h"): + raise ValueError("orientation must be 'v' or 'h'") + for pos, val in zip(positions, values): + if orientation == "v": + self._bar_vertical(pos, val, base, color) + else: + self._bar_horizontal(pos, val, base, color) + + def _bar_vertical( + self, pos: float, val: float, base: float, color: Optional[str] = None + ) -> None: + # Which cell column does this bar live in? + x_range, _ = self._require_ranges() + sw, _ = self._sub_dims() + fx = self._frac(pos, x_range[0], x_range[1]) + cx = (self._px0 * SUBCELL_COLS + round(fx * (sw - 1))) // SUBCELL_COLS + # Continuous cell-row edges of the bar span (top = smaller row coord). + top = self._row_coord(max(base, val)) + bottom = self._row_coord(min(base, val)) + for cy in range(self._py0, self._py1 + 1): + overlap = min(bottom, cy + 1) - max(top, cy) + if overlap > 1e-9: + self._set_bar(cx, cy, overlap, "v", color) + + def _bar_horizontal( + self, pos: float, val: float, base: float, color: Optional[str] = None + ) -> None: + _, y_range = self._require_ranges() + _, sh = self._sub_dims() + fy = self._frac(pos, y_range[0], y_range[1]) + cy = (self._py0 * SUBCELL_ROWS + round((1.0 - fy) * (sh - 1))) // SUBCELL_ROWS + left = self._col_coord(min(base, val)) + right = self._col_coord(max(base, val)) + for cx in range(self._px0, self._px1 + 1): + overlap = min(right, cx + 1) - max(left, cx) + if overlap > 1e-9: + self._set_bar(cx, cy, overlap, "h", color) + + def _row_coord(self, y: float) -> float: + """Continuous cell-row edge coordinate of data ``y`` (top=py0).""" + _, y_range = self._require_ranges() + fy = self._frac(y, y_range[0], y_range[1]) + ph = self._py1 - self._py0 + 1 + return self._py0 + (1.0 - fy) * ph + + def _col_coord(self, x: float) -> float: + """Continuous cell-column edge coordinate of data ``x`` (left=px0).""" + x_range, _ = self._require_ranges() + fx = self._frac(x, x_range[0], x_range[1]) + pw = self._px1 - self._px0 + 1 + return self._px0 + fx * pw + + def _set_bar( + self, cx: int, cy: int, fill: float, orient: str, color: Optional[str] = None + ) -> None: + if not (0 <= cx < self._width and 0 <= cy < self._height): + return + cell = self._grid.rows[cy][cx] + if cell.role not in (CellRole.EMPTY, CellRole.BAR): + return + new_fill = 1.0 if fill > 1.0 else fill + # Honesty floor: when two bars collide in one cell (grouped series, or + # more categories/bins than plot-width columns), keep the *tallest* + # fill so a larger value can never be silently clobbered by a later, + # smaller one. The bar adapters separate grouped bars by column offset + # where width allows; this max-merge is the fallback when they still overlap. + cell.fill = max(cell.fill, new_fill) if cell.role == CellRole.BAR else new_fill + cell.role = CellRole.BAR + # BAR cells carry their orientation in ``char`` so the serializer knows + # which block ramp (vertical vs horizontal) to draw the partial cell. + cell.char = orient + if color is not None: + cell.fg = color + + def heatmap( + self, + z: Sequence[Sequence[float]], + *, + colorscale: Optional[object] = None, + ) -> None: + """Fill the plot region with a heatmap of the 2D grid ``z``. + + ``z`` is a 2D sequence of numeric rows (``z[row][col]``, row 0 at the + top). Each character cell in the current plot region is mapped to a + ``z`` sample, given ``CellRole.HEATMAP``, and gets: + + * :attr:`Cell.fill` = the value normalized to ``0..1`` across ``z`` + (min->0, max->1); a serializer maps that to the shade ramp + (:data:`~plotly.io._text.rasterizer.SHADE_RAMP` for unicode modes, + :data:`~plotly.io._text.rasterizer.SHADE_ASCII` for ``text-ascii``); + * :attr:`Cell.bg` = the hex colour sampled from ``colorscale`` at that + normalized value (consumed by the colour serializers). + + ``colorscale`` is a Plotly colorscale — a list of ``[stop, "#hex"]`` + pairs (stops in ``0..1``) or a named scale string; ``None`` defaults to + Viridis (:data:`DEFAULT_COLORSCALE`). Unlike the other primitives this + one draws in *cell space* (it needs no :meth:`set_ranges`): ``z`` is + resampled to the plot-region cell grid directly. + + The heatmap / histogram2d adapters depend on this signature. The internal + ``z``->cell resampling is an implementation detail that may be refined + (e.g. averaging instead of nearest) without changing this API. + """ + rows = [list(r) for r in (z or []) if r is not None] + rows = [r for r in rows if len(r) > 0] + if not rows: + return + scale = _resolve_colorscale(colorscale) + + # Only *finite* numbers set the normalization range and paint a cell: + # NaN / +-inf (and bools) are skipped so they never poison zmin/zmax or + # flow into Cell.fill (which would crash round() in the shade serializer). + flat = [float(v) for r in rows for v in r if _is_finite_number(v)] + if not flat: + return + zmin, zmax = min(flat), max(flat) + span = zmax - zmin + + nrows = len(rows) + region_h = self._py1 - self._py0 + 1 + region_w = self._px1 - self._px0 + 1 + for ry in range(region_h): + zr = min(nrows - 1, int(ry * nrows / region_h)) + row = rows[zr] + ncols = len(row) + cy = self._py0 + ry + for rx in range(region_w): + zc = min(ncols - 1, int(rx * ncols / region_w)) + val = row[zc] + if not _is_finite_number(val): + continue # non-finite sample -> leave the cell EMPTY (blank) + t = 0.5 if span == 0 else (float(val) - zmin) / span + cx = self._px0 + rx + if not (0 <= cx < self._width and 0 <= cy < self._height): + continue + cell = self._grid.rows[cy][cx] + cell.role = CellRole.HEATMAP + cell.fill = t + cell.bg = _sample_colorscale(t, scale) + + def frame( + self, + x_range: Range, + y_range: Range, + *, + x_ticks: Optional[Sequence[Tick]] = None, + y_ticks: Optional[Sequence[Tick]] = None, + title: Optional[str] = None, + x_title: Optional[str] = None, + y_title: Optional[str] = None, + ) -> None: + """Draw the axis frame and fix the data ranges in one call. + + Reserves the margin cells for axes/labels, draws the box, tick marks and + tick labels (``CellRole.FRAME`` / ``CellRole.LABEL``), places the + ``title`` / axis titles, and calls :meth:`set_ranges` so subsequent + primitives map into the plot region. Ticks are supplied by the trace + adapters so the Canvas stays Plotly-agnostic; if omitted the Canvas + derives simple min/max ticks from the ranges. + """ + from plotly.io._text.rasterizer import ( + BOX_CORNER_BL, + BOX_H, + BOX_TICK_X, + BOX_TICK_Y, + BOX_V, + ) + + # Derive default min/max ticks when the adapter supplies none. + if x_ticks is None: + x_ticks = [ + Tick(x_range[0], _fmt_num(x_range[0])), + Tick(x_range[1], _fmt_num(x_range[1])), + ] + if y_ticks is None: + y_ticks = [ + Tick(y_range[0], _fmt_num(y_range[0])), + Tick(y_range[1], _fmt_num(y_range[1])), + ] + + # --- compute margins ------------------------------------------------- + top_margin = 1 if title else 0 + bottom_margin = 1 + 1 + (1 if x_title else 0) # border, x labels, x title + ylabel_w = max((len(t.label) for t in y_ticks), default=0) + ytitle_col = 1 if y_title else 0 + left_margin = ytitle_col + ylabel_w + 1 # y title, y labels, border + right_margin = 1 # room for the right-most x tick label to spill + + # --- fix the plot region + ranges ----------------------------------- + self._px0 = left_margin + self._py0 = top_margin + self._px1 = self._width - 1 - right_margin + self._py1 = self._height - 1 - bottom_margin + if self._px1 < self._px0 or self._py1 < self._py0: + # Need at least a 1x1 plot area inside the reserved margins. + req_w = left_margin + right_margin + 1 + req_h = top_margin + bottom_margin + 1 + usable_w = self._px1 - self._px0 + 1 + usable_h = self._py1 - self._py0 + 1 + raise ValueError( + f"Canvas too small for the requested frame: canvas is " + f"{self._width}x{self._height} cells but the frame needs at " + f"least {req_w}x{req_h} (margins consume " + f"{left_margin + right_margin} cols x " + f"{top_margin + bottom_margin} rows for axis/labels/title, " + f"leaving a {usable_w}x{usable_h} plot area). " + f"Increase width/height or drop titles/tick labels." + ) + self.set_ranges(x_range, y_range) + + border_col = self._px0 - 1 + border_row = self._py1 + 1 + + # --- box: left vertical + bottom horizontal + origin corner --------- + for cy in range(self._py0, self._py1 + 1): + self._set_frame(border_col, cy, BOX_V) + for cx in range(border_col, self._px1 + 1): + self._set_frame(cx, border_row, BOX_H) + self._set_frame(border_col, border_row, BOX_CORNER_BL) + + # --- y ticks: mark + right-aligned label ---------------------------- + for t in y_ticks: + cy = self._value_to_cell_row(t.value) + if self._py0 <= cy <= self._py1: + self._set_frame(border_col, cy, BOX_TICK_Y) + start = border_col - len(t.label) + self._place_text(max(ytitle_col, start), cy, t.label) + + # --- x ticks: mark + centered label --------------------------------- + for t in x_ticks: + cx = self._value_to_cell_col(t.value) + if self._px0 <= cx <= self._px1: + self._set_frame(cx, border_row, BOX_TICK_X) + start = cx - len(t.label) // 2 + start = max(0, min(start, self._width - len(t.label))) + self._place_text(start, border_row + 1, t.label) + + # --- titles ---------------------------------------------------------- + if title: + start = max(0, (self._width - len(title)) // 2) + self._place_text(start, 0, title) + if x_title: + xt_row = border_row + 2 + span0, span1 = self._px0, self._px1 + start = span0 + max(0, (span1 - span0 + 1 - len(x_title)) // 2) + self._place_text(start, xt_row, x_title) + if y_title: + ph = self._py1 - self._py0 + 1 + start_row = self._py0 + max(0, (ph - len(y_title)) // 2) + for i, ch in enumerate(y_title): + cy = start_row + i + if self._py0 <= cy <= self._py1: + cell = self._grid.rows[cy][0] + cell.role = CellRole.LABEL + cell.char = ch + + def _set_frame(self, cx: int, cy: int, char: str) -> None: + if 0 <= cx < self._width and 0 <= cy < self._height: + cell = self._grid.rows[cy][cx] + cell.role = CellRole.FRAME + cell.char = char + + def _value_to_cell_row(self, y: float) -> int: + _, y_range = self._require_ranges() + _, sh = self._sub_dims() + fy = self._frac(y, y_range[0], y_range[1]) + srow = self._py0 * SUBCELL_ROWS + round((1.0 - fy) * (sh - 1)) + return srow // SUBCELL_ROWS + + def _value_to_cell_col(self, x: float) -> int: + x_range, _ = self._require_ranges() + sw, _ = self._sub_dims() + fx = self._frac(x, x_range[0], x_range[1]) + scol = self._px0 * SUBCELL_COLS + round(fx * (sw - 1)) + return scol // SUBCELL_COLS + + # -- output ------------------------------------------------------------- + + def render(self, mode: str = "text-utf") -> str: + """Serialize the accumulated grid to a string for ``mode``. + + Looks up ``mode`` (a ``text-*`` renderer string) in the serializer + registry (:data:`plotly.io._text.serializers.SERIALIZERS`) and returns + ``serializer.serialize(self.grid)``. This is the only place the abstract + grid becomes a concrete encoding. + """ + from plotly.io._text.serializers import get_serializer + + return get_serializer(mode).serialize(self._grid) + + +def _fmt_num(value: float) -> str: + """Format a tick value compactly and deterministically (``%g``-style).""" + if value == int(value): + return str(int(value)) + return f"{value:g}" + + +# --------------------------------------------------------------------------- +# Colorscale sampling (v2, for heatmap). A colorscale is a list of +# ``[stop, "#hex"]`` pairs with stops ascending over ``0..1``. Named scales are +# resolved to such a list. The default is Viridis, coarse-sampled — enough to +# tint a heatmap; this could later defer to plotly's full colorscale resolver +# without changing :meth:`Canvas.heatmap`'s signature. +# --------------------------------------------------------------------------- + +#: A perceptual default colorscale (Viridis), coarse-sampled as ``[stop, hex]``. +DEFAULT_COLORSCALE = [ + [0.0, "#440154"], + [0.25, "#3b528b"], + [0.5, "#21918c"], + [0.75, "#5ec962"], + [1.0, "#fde725"], +] + +#: Minimal named-scale table. This can be extended (or deferred to plotly's +#: colorscale machinery); unknown names fall back to +#: :data:`DEFAULT_COLORSCALE`. +_NAMED_COLORSCALES = { + "viridis": DEFAULT_COLORSCALE, + "greys": [[0.0, "#000000"], [1.0, "#ffffff"]], + "gray": [[0.0, "#000000"], [1.0, "#ffffff"]], +} + + +def _resolve_colorscale(colorscale: Optional[object]) -> List[list]: + """Normalize a colorscale argument to a ``[[stop, hex], ...]`` list. + + Accepts ``None`` (-> :data:`DEFAULT_COLORSCALE`), a named-scale string, or a + list of ``[stop, "#hex"]`` pairs (passed through). Resolving the full plotly + named-scale catalog is left as a future extension. + """ + if colorscale is None: + return DEFAULT_COLORSCALE + if isinstance(colorscale, str): + return _NAMED_COLORSCALES.get(colorscale.lower(), DEFAULT_COLORSCALE) + try: + pairs = [ + [float(stop), str(hexc)] for stop, hexc in cast("Iterable[Any]", colorscale) + ] + except (TypeError, ValueError): + return DEFAULT_COLORSCALE + return pairs or DEFAULT_COLORSCALE + + +def _is_finite_number(v: object) -> bool: + """True for a real, finite numeric value (excludes bool, NaN, +-inf).""" + return ( + isinstance(v, (int, float)) + and not isinstance(v, bool) + and math.isfinite(float(v)) + ) + + +def _sample_colorscale(t: float, scale: List[list]) -> str: + """Return the hex colour for normalized position ``t`` (``0..1``) on ``scale``. + + Linearly interpolates in RGB between the two bounding stops. Deterministic and + dependency-free (no numpy / plotly colour utilities). Colour strings are + parsed tolerantly (``#hex`` *and* ``rgb()/rgba()``); an unparseable stop + colour degrades to :data:`~plotly.io._text.rasterizer.NEUTRAL_COLOR` rather + than raising, so the Canvas surface never crashes a direct caller. + """ + if t <= scale[0][0]: + return norm_hex(scale[0][1]) or NEUTRAL_COLOR + if t >= scale[-1][0]: + return norm_hex(scale[-1][1]) or NEUTRAL_COLOR + for (s0, c0), (s1, c1) in zip(scale, scale[1:]): + if s0 <= t <= s1: + frac = 0.0 if s1 == s0 else (t - s0) / (s1 - s0) + r0, g0, b0 = color_to_rgb(c0) or NEUTRAL_RGB + r1, g1, b1 = color_to_rgb(c1) or NEUTRAL_RGB + r = round(r0 + (r1 - r0) * frac) + g = round(g0 + (g1 - g0) * frac) + b = round(b0 + (b1 - b0) * frac) + return f"#{r:02x}{g:02x}{b:02x}" + return norm_hex(scale[-1][1]) or NEUTRAL_COLOR diff --git a/plotly/io/_text/rasterizer.py b/plotly/io/_text/rasterizer.py new file mode 100644 index 00000000000..c0033a15aca --- /dev/null +++ b/plotly/io/_text/rasterizer.py @@ -0,0 +1,206 @@ +"""Braille + block-char rasterization primitives (built in — no dependencies). + +We deliberately do **not** depend on ``plotli`` / ``drawille`` / ``plotille`` or +any external terminal library: those duplicate the figure/plot layer we already +have in ``go.Figure`` and assume an interactive TTY, whereas these text renderers +are CI-first (no TTY) and must stay portable, dependency-free plain-text output. + +This module owns the low-level glyph tables and the (x, y) -> sub-cell packing +that :class:`~plotly.io._text.canvas.Canvas` builds on. The braille bit layout +below is a fixed constant defined by the Unicode braille standard, not a stub. +""" + +from __future__ import annotations + +import re +from typing import Dict, Optional, Tuple + +#: Unicode braille patterns occupy U+2800..U+28FF; a cell's 8 dots are a 2x4 +#: grid whose set bits are OR-ed onto this base to pick the codepoint. +BRAILLE_BASE = 0x2800 + +#: Braille dot bit for each (sub-column, sub-row) within a 2x4 cell, using the +#: standard Unicode braille numbering. ``BRAILLE_BITS[col][row]``: +#: +#: col 0 col 1 +#: r0 0x01 0x08 +#: r1 0x02 0x10 +#: r2 0x04 0x20 +#: r3 0x40 0x80 +BRAILLE_BITS: Tuple[Tuple[int, int, int, int], Tuple[int, int, int, int]] = ( + (0x01, 0x02, 0x04, 0x40), + (0x08, 0x10, 0x20, 0x80), +) + +#: Vertical block ramp for bars / partial fills, index = eighths filled (0..8). +#: 0 -> space, 8 -> full block. Used by the ``text-utf`` serializer. +BLOCK_RAMP_V = (" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█") + +#: Horizontal block ramp (left-growing), index = eighths filled (0..8). +BLOCK_RAMP_H = (" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█") + +#: ASCII fallback ramp for braille-dot density (popcount 0..8), for ``text-ascii``. +ASCII_DENSITY = (" ", ".", ".", ":", ":", "+", "+", "#", "#") + +#: ASCII fallback ramp for bar fill fraction, index = round(fill * 4), 0..4. +ASCII_BAR_RAMP = (" ", ".", ":", "+", "#") + +# --------------------------------------------------------------------------- +# Box-drawing glyphs for the axis frame (text-utf). The Canvas stamps these as +# ``CellRole.FRAME`` chars; the ascii serializer degrades them via +# :data:`FRAME_ASCII`. Keeping the vocabulary here (not in the Canvas) means the +# frame's glyph set and its ascii fallback stay defined side by side. +# --------------------------------------------------------------------------- + +BOX_V = "│" #: │ vertical axis line +BOX_H = "─" #: ─ horizontal axis line +BOX_CORNER_BL = "└" #: └ bottom-left corner (origin) +BOX_TICK_Y = "┤" #: ┤ y-axis tick mark (on the vertical line) +BOX_TICK_X = "┬" #: ┬ x-axis tick mark (on the horizontal line) + +#: Degrade box-drawing / tick glyphs to the 7-bit ``text-ascii`` palette. +FRAME_ASCII = { + BOX_V: "|", + BOX_H: "-", + BOX_CORNER_BL: "+", + BOX_TICK_Y: "+", + BOX_TICK_X: "+", +} + +# --------------------------------------------------------------------------- +# Per-series marker glyphs. This is the *canonical* palette for the whole +# renderer: the trace adapters import :data:`MARKER_GLYPHS` and +# assign one glyph per series (cycling); the ascii serializer degrades each to +# its distinct 7-bit counterpart via :data:`MARKER_GLYPHS_ASCII` (index-aligned) +# so multi-series ``text-ascii`` plots stay legible instead of collapsing to a +# single ``*``. Keep the two tuples the same length and order. +# --------------------------------------------------------------------------- + +#: Unicode per-series marker glyphs, in assignment order. Canonical home — do +#: not redefine elsewhere; the trace adapters import this. +MARKER_GLYPHS = ("●", "■", "▲", "◆", "▼", "★", "✚", "◇") + +#: 7-bit ASCII counterparts, index-aligned with :data:`MARKER_GLYPHS`. +MARKER_GLYPHS_ASCII = ("o", "#", "^", "x", "v", "*", "+", "@") + +#: Map each Unicode marker glyph to its distinct ascii counterpart. Typed as a +#: plain ``str`` -> ``str`` map so serializers can look up an arbitrary +#: ``Cell.char`` (a ``str``) without a literal-key mismatch. +MARKER_ASCII: Dict[str, str] = dict(zip(MARKER_GLYPHS, MARKER_GLYPHS_ASCII)) + +#: Per-series colour palette for the v2 colour modes (``text-ansi`` / +#: ``text-html``). Plotly's default qualitative colorway (``plotly`` template), +#: as hex, **index-aligned with** :data:`MARKER_GLYPHS` so a series' glyph and +#: its colour come from the same slot. The trace adapters assign one per series +#: (cycling) when the figure doesn't set an explicit single trace colour; the colour rides +#: :attr:`~plotly.io._text.canvas.Cell.fg`. Keep length == ``len(MARKER_GLYPHS)``. +MARKER_COLORS = ( + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", +) + +# --------------------------------------------------------------------------- +# Heatmap density ramp. A HEATMAP cell carries a normalized value +# in ``Cell.fill`` (0..1) and an optional ``Cell.bg`` hex sampled from the +# trace's colorscale; the serializer maps the fill to a shade glyph — the block +# ramp below for ``text-utf`` / ``text-ansi`` / ``text-html``, or the 7-bit +# fallback for ``text-ascii``. Kept beside the other ramps so the density +# vocabulary and its ascii degrade live side by side (like the bar ramps). +# --------------------------------------------------------------------------- + +#: Block-shade density ramp for heatmaps, index 0..4 (space -> full block). +SHADE_RAMP = (" ", "░", "▒", "▓", "█") + +#: 7-bit ASCII fallback for :data:`SHADE_RAMP`, index-aligned (0..4). +SHADE_ASCII = (" ", ".", ":", "+", "#") + + +# --------------------------------------------------------------------------- +# Colour parsing — the single, tolerant home for turning a colour string into an +# ``(r, g, b)`` triple (canonical, imported by both canvas.py and serializers.py +# so the two never drift). Lives here beside the colour tables (MARKER_COLORS, +# the heatmap ramps). Deliberately *tolerant*: the low-level Canvas / serializers +# are the reusable, Plotly-agnostic surface, so an unparseable colour degrades to +# "no colour" (``None``) or a neutral fallback rather than crashing. +# The plotly-colour -> hex bridge (named CSS colours, colorscale name catalog) +# is the trace adapters' job; this only needs ``#hex`` and ``rgb()/rgba()``. +# --------------------------------------------------------------------------- + +#: Neutral mid-grey used when a colour is required but unparseable. +NEUTRAL_COLOR = "#808080" +NEUTRAL_RGB: Tuple[int, int, int] = (128, 128, 128) + +#: ``rgb(...)`` / ``rgba(...)`` head — captures the first three numeric channels. +_RGB_RE = re.compile( + r"^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)", re.IGNORECASE +) + + +def color_to_rgb(color: object) -> Optional[Tuple[int, int, int]]: + """Parse a colour to an ``(r, g, b)`` int triple, or ``None`` if unparseable. + + Accepts ``#rgb`` / ``#rrggbb`` (any case) and ``rgb(...)`` / ``rgba(...)`` + strings (integer or float channels, clamped to 0..255; any alpha is ignored). + Anything else — a named CSS colour, a malformed string, a non-string — returns + ``None`` so the caller can degrade cleanly instead of raising. + """ + if not isinstance(color, str): + return None + s = color.strip() + if not s: + return None + if s.startswith("#"): + h = s[1:] + if len(h) == 3: + h = "".join(ch * 2 for ch in h) + if len(h) != 6: + return None + try: + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + except ValueError: + return None + m = _RGB_RE.match(s) + if m: + try: + vals = [max(0, min(255, round(float(g)))) for g in m.groups()] + except ValueError: + return None + return vals[0], vals[1], vals[2] + return None + + +def norm_hex(color: object) -> Optional[str]: + """Normalize a colour to lowercase ``#rrggbb``, or ``None`` if unparseable. + + De-dup (HTML classes) and byte-stable output need one canonical spelling per + colour (trace palettes mix case, e.g. ``#EF553B``; colorscale samples are + already lowercase; adapters may hand through ``rgb(...)``). + """ + rgb = color_to_rgb(color) + if rgb is None: + return None + return "#%02x%02x%02x" % rgb + + +def braille_char(dots: int) -> str: + """Return the Unicode braille glyph for an 8-bit sub-dot bitmask. + + ``dots`` is the OR of :data:`BRAILLE_BITS` entries for the set sub-dots. + """ + return chr(BRAILLE_BASE + (dots & 0xFF)) + + +def dot_bit(sub_col: int, sub_row: int) -> int: + """Return the braille bit for a sub-dot at ``(sub_col, sub_row)`` in a cell.""" + return BRAILLE_BITS[sub_col][sub_row] + + +def popcount(dots: int) -> int: + """Return the number of set sub-dots in an 8-bit braille bitmask (0..8).""" + return bin(dots & 0xFF).count("1") diff --git a/plotly/io/_text/renderers.py b/plotly/io/_text/renderers.py new file mode 100644 index 00000000000..4e2d926276b --- /dev/null +++ b/plotly/io/_text/renderers.py @@ -0,0 +1,130 @@ +"""``plotly.io`` renderer registration for the ``text-*`` modes. + +Wires the text renderers into plotly.py's renderer system so that +``fig.show(renderer="text-utf")`` (and ``"text-ascii"``, ``"text-ansi"``, +``"text-html"``) work like any built-in renderer string. Each mode is one +:class:`ExternalRenderer` that: + +1. drives :func:`~plotly.io._text.adapters.figure_to_canvas` to draw the figure, +2. serializes the grid via the mode's serializer, +3. writes the result to stdout as **forced UTF-8** (never inheriting the + ``sys.stdout`` locale — Windows CI defaults to cp1252 and would mojibake the + braille; ``text-ascii`` is the escape hatch for locale-hostile sinks). + +An ``ExternalRenderer`` (not a mimetype renderer) is the right base: text output +is printed, not embedded in a notebook mime bundle — matching how ``browser`` is +registered. + +The class name, constructor signature, and :func:`register_text_renderers` hook +are the stable surface this module exposes to :mod:`plotly.io._renderers`. +""" + +from __future__ import annotations + +from plotly.io._base_renderers import ExternalRenderer +from plotly.io._text.canvas import DEFAULT_HEIGHT, DEFAULT_WIDTH + +# Import the trace handler modules so they self-register into ADAPTERS. New +# handler modules are added to this list as they are implemented. +from plotly.io._text.adapters import bar as _bar # noqa: F401 +from plotly.io._text.adapters import heatmap as _heatmap # noqa: F401 +from plotly.io._text.adapters import histogram as _histogram # noqa: F401 +from plotly.io._text.adapters import scatter as _scatter # noqa: F401 + + +class TextRenderer(ExternalRenderer): + """Render a figure as text to stdout for one ``text-*`` mode. + + ``mode`` is the ``plotly.io`` renderer string / serializer key + (``"text-utf"``, ``"text-ascii"``, ...). ``width``/``height`` are the + explicit canvas size in character cells (never inferred from a TTY) and can + be overridden per call via ``pio.show(fig, renderer=..., width=, height=)``. + """ + + def __init__( + self, + mode: str = "text-utf", + width: int = DEFAULT_WIDTH, + height: int = DEFAULT_HEIGHT, + ): + self.mode = mode + self.width = width + self.height = height + + def render(self, fig) -> None: + """Draw ``fig`` and write the text to stdout as forced UTF-8. + + ``fig`` is the figure dict handed in by + :meth:`plotly.io._renderers.RenderersConfig._perform_external_rendering` + (a ``go.Figure`` is coerced first, for direct callers). The grid is + serialized for this renderer's ``mode`` and any degradation notes + collected during the draw are printed after the plot. An undersized + canvas (the Canvas raises ``ValueError``) degrades to a one-line note + instead of crashing out of ``fig.show``. + """ + from plotly.io._text.adapters import ( + figure_to_canvas, + _is_too_small_error, + _too_small_warning, + ) + + fig_dict = fig.to_dict() if hasattr(fig, "to_dict") else fig + + result = figure_to_canvas( + fig_dict, + width=self.width, + height=self.height, + mode=self.mode, + ) + + parts = [] + if result.canvas is not None: + try: + parts.append(result.canvas.render(self.mode)) + except ValueError as exc: + # Defensive: the driver already degrades frame errors, but guard + # a late serialize-time size error too. Only a *genuine* undersize + # signal degrades to the "canvas too small" note — any other + # ValueError re-raises so it isn't mislabelled on a full canvas. + if _is_too_small_error(exc): + result.warnings.append(_too_small_warning(self.mode, exc)) + else: + raise + parts.extend(result.warnings) + + _write_utf8("\n".join(parts) + "\n" if parts else "\n") + + +def _write_utf8(text: str) -> None: + """Write ``text`` to stdout as **forced UTF-8**, never the stdout locale. + + Windows CI defaults ``sys.stdout`` to cp1252, which would mojibake braille / + block glyphs; we go through the underlying byte buffer with an explicit + ``utf-8`` encode. Sinks without a byte buffer (e.g. an in-memory + ``StringIO`` under test) get a plain text write. + """ + import sys + + stdout = sys.stdout + buffer = getattr(stdout, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8", errors="replace")) + buffer.flush() + else: + stdout.write(text) + stdout.flush() + + +def register_text_renderers(renderers) -> None: + """Register the ``text-*`` renderers into a plotly ``renderers`` config. + + Called once from :mod:`plotly.io._renderers` at import time. Registers the + monochrome modes ``text-utf`` (default/recommended) and ``text-ascii``, plus + the colour modes ``text-ansi`` and ``text-html``. + """ + renderers["text-utf"] = TextRenderer(mode="text-utf") + renderers["text-ascii"] = TextRenderer(mode="text-ascii") + # Colour modes. Their renderer strings resolve and are discoverable in + # ``pio.renderers``; each dispatches to the matching serializer. + renderers["text-ansi"] = TextRenderer(mode="text-ansi") + renderers["text-html"] = TextRenderer(mode="text-html") diff --git a/plotly/io/_text/serializers.py b/plotly/io/_text/serializers.py new file mode 100644 index 00000000000..7f3dca2fb8e --- /dev/null +++ b/plotly/io/_text/serializers.py @@ -0,0 +1,396 @@ +"""Grid -> string serializers, one per output mode. + +A **serializer** is the second half of the "one grid, many serializers" +architecture: a total function from a finished +:class:`~plotly.io._text.canvas.CellGrid` to a string, for exactly one ``text-*`` +mode. It is the seam the design rests on — the serializers own grid -> text, and +the renderers select one by renderer string. + +The four modes (monochrome ``text-utf``, ``text-ascii``; colour ``text-ansi``, +``text-html``) differ only in **glyph set x colorizer** over the *same* grid, so +adding a mode is adding a serializer here, never re-rasterizing. + +The :class:`Serializer` interface and the :data:`SERIALIZERS` registry shape are +the stable extension points: each serializer implements ``serialize`` and +registers itself. +""" + +from __future__ import annotations + +import math +from typing import Dict + +from plotly.io._text.canvas import Cell, CellGrid, CellRole +from plotly.io._text.rasterizer import ( + ASCII_BAR_RAMP, + ASCII_DENSITY, + BLOCK_RAMP_H, + BLOCK_RAMP_V, + FRAME_ASCII, + MARKER_ASCII, + SHADE_ASCII, + SHADE_RAMP, + braille_char, + color_to_rgb, + norm_hex, + popcount, +) + + +class Serializer: + """Turn a finished :class:`CellGrid` into a string for one output mode. + + Subclasses set :attr:`name` (the ``plotly.io`` renderer string, e.g. + ``"text-utf"``) and implement :meth:`serialize`. A serializer must be + deterministic and must not query a TTY: the same grid always yields the same + bytes. The v1 serializers emit monochrome plain text and force UTF-8 where + they use non-ASCII glyphs. + """ + + #: The ``plotly.io`` renderer string this serializer answers to. + name: str = "" + + #: Whether output contains non-ASCII (UTF-8) glyphs. ``text-utf`` sets True + #: so the renderer knows to force a UTF-8 write; ``text-ascii`` sets False. + unicode: bool = True + + def serialize(self, grid: CellGrid) -> str: + """Return the string encoding of ``grid`` for this mode. + + Reads each :class:`~plotly.io._text.canvas.Cell` by its + :class:`~plotly.io._text.canvas.CellRole` and emits the mode-appropriate + glyph (braille codepoint, block ramp char, ascii fallback, ...), joining + rows with ``"\\n"``. + """ + raise NotImplementedError + + +#: Registry of serializers keyed by ``text-*`` renderer string (one entry per +#: implemented mode); read by +#: :meth:`plotly.io._text.canvas.Canvas.render` and by the renderer classes. +SERIALIZERS: Dict[str, Serializer] = {} + + +def register_serializer(serializer: Serializer) -> None: + """Register ``serializer`` under its :attr:`~Serializer.name`.""" + SERIALIZERS[serializer.name] = serializer + + +def get_serializer(mode: str) -> Serializer: + """Return the serializer for ``mode`` or raise ``KeyError`` if unknown.""" + return SERIALIZERS[mode] + + +# --------------------------------------------------------------------------- +# v1 serializers — monochrome plain text. text-utf is the default (Unicode +# braille + block); text-ascii is the guaranteed-portable 7-bit floor. +# --------------------------------------------------------------------------- + + +def _bar_index(fill: float, steps: int) -> int: + """Quantise a 0..1 fill fraction to ``0..steps`` (never 0 for fill > 0).""" + idx = round(fill * steps) + if idx <= 0 and fill > 0.0: + idx = 1 + if idx > steps: + idx = steps + return idx + + +def _shade_index(fill: float, steps: int) -> int: + """Quantise a 0..1 heatmap density to ``1..steps`` on a shade ramp. + + A ``HEATMAP`` cell is always a *sample* (every region cell carries data), so + the floor is index 1 (the lightest shade) rather than 0 (blank): even the + minimum-density cell stays visible, which is what makes a heatmap legible in + the monochrome modes. The maximum maps to ``steps`` (full block). + + Defensive: a non-finite ``fill`` (NaN / inf that slipped past the Canvas) + degrades to index 0 (blank) instead of crashing ``round(nan)``. + """ + if not math.isfinite(fill): + return 0 + idx = round(fill * steps) + if idx < 1: + idx = 1 + if idx > steps: + idx = steps + return idx + + +def _utf_glyph(cell: Cell) -> str: + """Return the Unicode glyph for ``cell`` (shared by utf / ansi / html). + + The three Unicode modes draw the *same* glyph grid and differ only in the + colorizer, so the braille / block / shade / frame / marker mapping lives here + once. Colour attributes (:attr:`Cell.fg` / :attr:`Cell.bg`) are not consulted. + """ + role = cell.role + if role == CellRole.EMPTY: + return " " + if role == CellRole.DOTS: + return braille_char(cell.dots) + if role == CellRole.BAR: + ramp = BLOCK_RAMP_H if cell.char == "h" else BLOCK_RAMP_V + return ramp[_bar_index(cell.fill, 8)] + if role == CellRole.HEATMAP: + return SHADE_RAMP[_shade_index(cell.fill, len(SHADE_RAMP) - 1)] + return cell.char or " " # MARKER / FRAME / LABEL + + +def _ascii_glyph(cell: Cell) -> str: + """Return the 7-bit ASCII glyph for ``cell`` (the ``text-ascii`` mapping).""" + role = cell.role + if role == CellRole.EMPTY: + return " " + if role == CellRole.DOTS: + return ASCII_DENSITY[popcount(cell.dots)] + if role == CellRole.BAR: + return ASCII_BAR_RAMP[_bar_index(cell.fill, 4)] + if role == CellRole.HEATMAP: + return SHADE_ASCII[_shade_index(cell.fill, len(SHADE_ASCII) - 1)] + if role == CellRole.FRAME: + return FRAME_ASCII.get(cell.char, "+") + if role == CellRole.MARKER: + ch = cell.char + ascii_ch = MARKER_ASCII.get(ch) + if ascii_ch is not None: + return ascii_ch + if ch and ch.isascii(): + return ch + return "*" + # LABEL + ch = cell.char + return ch if ch and ch.isascii() else "?" + + +class TextUtfSerializer(Serializer): + """Default mode: Unicode braille + block glyphs, monochrome plain text. + + Handles every :class:`CellRole`, including ``HEATMAP`` (the block-shade ramp + :data:`~plotly.io._text.rasterizer.SHADE_RAMP` keyed by :attr:`Cell.fill`), so + a heatmap is legible here even though this mode drops colour. + """ + + name = "text-utf" + unicode = True + + def serialize(self, grid: CellGrid) -> str: + lines = [] + for row in grid.rows: + out = [_utf_glyph(cell) for cell in row] + lines.append("".join(out).rstrip()) + return "\n".join(lines) + + +class TextAsciiSerializer(Serializer): + """Portable floor: 7-bit ASCII only, monochrome. + + Degradation palette (open decision resolved here): + + * ``DOTS`` -> density ramp ``. : + #`` by braille sub-dot popcount + (:data:`~plotly.io._text.rasterizer.ASCII_DENSITY`). + * ``BAR`` -> fill ramp ``. : + #`` by fraction + (:data:`~plotly.io._text.rasterizer.ASCII_BAR_RAMP`). + * ``FRAME`` -> ``| - +`` box fallback + (:data:`~plotly.io._text.rasterizer.FRAME_ASCII`). + * ``MARKER`` -> the distinct 7-bit counterpart of the series glyph + (:data:`~plotly.io._text.rasterizer.MARKER_ASCII`), so multi-series plots + stay distinguishable; an already-ASCII glyph passes through, and a + genuinely unknown non-ASCII glyph falls back to ``*``. + * ``LABEL`` -> the character if ASCII, else ``?``. + * ``HEATMAP``-> the 7-bit shade ramp + (:data:`~plotly.io._text.rasterizer.SHADE_ASCII`) by :attr:`Cell.fill`. + """ + + name = "text-ascii" + unicode = False + + def serialize(self, grid: CellGrid) -> str: + lines = [] + for row in grid.rows: + out = [_ascii_glyph(cell) for cell in row] + lines.append("".join(out).rstrip()) + return "\n".join(lines) + + +register_serializer(TextUtfSerializer()) +register_serializer(TextAsciiSerializer()) + + +# --------------------------------------------------------------------------- +# Colour-mode serializers. Same glyph grid as ``text-utf``; they add a +# colorizer that reads ``Cell.fg`` / ``Cell.bg`` (set by the colour-aware Canvas +# calls and by ``Canvas.heatmap``). +# --------------------------------------------------------------------------- + + +#: ANSI SGR reset — clears every colour attribute back to the terminal default. +ANSI_RESET = "\x1b[0m" + + +def _ansi_prefix(fg, bg) -> str: + """Build the truecolor SGR prefix for a ``(fg, bg)`` pair (absolute colours). + + Each escape sets an *absolute* 24-bit colour, so pairing it with a preceding + :data:`ANSI_RESET` clears any stale attribute (e.g. a previous run's bg). + Colours are parsed tolerantly; an unparseable ``fg``/``bg`` contributes no + escape (that channel renders in the default colour) rather than raising. + """ + parts = [] + fg_rgb = color_to_rgb(fg) if fg is not None else None + if fg_rgb is not None: + r, g, b = fg_rgb + parts.append(f"\x1b[38;2;{r};{g};{b}m") + bg_rgb = color_to_rgb(bg) if bg is not None else None + if bg_rgb is not None: + r, g, b = bg_rgb + parts.append(f"\x1b[48;2;{r};{g};{b}m") + return "".join(parts) + + +def _row_cells(row) -> list: + """(glyph, fg, bg) per cell, with trailing uncoloured blanks trimmed. + + Trimming trailing blank+uncoloured cells keeps colour output as compact as + the monochrome modes' ``rstrip``; a coloured trailing cell (e.g. a heatmap bg) + is kept so its colour is not lost. + """ + cells = [(_utf_glyph(c), c.fg, c.bg) for c in row] + while cells and cells[-1] == (" ", None, None): + cells.pop() + return cells + + +def _runs(cells): + """Yield ``(glyph_text, fg, bg)`` runs batching adjacent same-colour cells.""" + i = 0 + n = len(cells) + while i < n: + _, fg, bg = cells[i] + j = i + buf = [] + while j < n and cells[j][1] == fg and cells[j][2] == bg: + buf.append(cells[j][0]) + j += 1 + yield "".join(buf), fg, bg + i = j + + +class TextAnsiSerializer(Serializer): + """Colour mode: ``text-utf`` glyphs + 24-bit ANSI truecolor escapes (v2). + + Reuses the ``text-utf`` glyph mapping (braille / block / shade ramps, box + frame, markers) and wraps coloured runs in ``\\x1b[38;2;r;g;bm`` (foreground, + from :attr:`Cell.fg`) and ``\\x1b[48;2;r;g;bm`` (background, from + :attr:`Cell.bg`, e.g. heatmap cells). Adjacent same-colour cells are + **run-length-batched** into one escape, and every line ends with a reset + (``\\x1b[0m``) so colour never leaks across lines. A run that transitions to a + different (or no) colour emits a reset first, so a stale background can't bleed + onto later cells. Cells with no colour hint fall back to the default terminal + colour (no escape). Never queries a TTY — the caller opts in by picking this + renderer string. + """ + + name = "text-ansi" + unicode = True + + def serialize(self, grid: CellGrid) -> str: + lines = [] + for row in grid.rows: + cells = _row_cells(row) + parts = [] + active = False # is a non-default colour currently in effect? + for text, fg, bg in _runs(cells): + prefix = _ansi_prefix(fg, bg) + if prefix: # an actual, parseable colour to apply + if active: + parts.append(ANSI_RESET) # clear stale attrs first + parts.append(prefix) + parts.append(text) + active = True + else: # no colour (or unparseable) -> render plain + if active: + parts.append(ANSI_RESET) + active = False + parts.append(text) + if active: + parts.append(ANSI_RESET) + lines.append("".join(parts)) + return "\n".join(lines) + + +def _html_escape(text: str) -> str: + """Escape the HTML-significant characters in glyph text. + + Braille / block / shade glyphs are safe; only the rare ``&``, ``<``, ``>`` in + a label need escaping. + """ + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +class TextHtmlSerializer(Serializer): + """Colour mode: ``text-utf`` glyphs + class-based HTML fragment. + + Emits a self-contained fragment: a scoped + ``" + + # Second pass: batch runs into class-tagged spans. + lines = [] + for cells in grid_cells: + parts = [] + for text, fg, bg in _runs(cells): + esc = _html_escape(text) + classes = [] + fgh = norm_hex(fg) if fg is not None else None + if fgh is not None: + classes.append(f"c{fg_classes[fgh]}") + bgh = norm_hex(bg) if bg is not None else None + if bgh is not None: + classes.append(f"b{bg_classes[bgh]}") + if classes: + parts.append(f'{esc}') + else: # no colour, or an unparseable one -> plain (uncoloured) + parts.append(esc) + lines.append("".join(parts)) + pre = '
' + "\n".join(lines) + "
" + return style + "\n" + pre + + +register_serializer(TextAnsiSerializer()) +register_serializer(TextHtmlSerializer()) diff --git a/plotly/io/_text/tests/__init__.py b/plotly/io/_text/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plotly/io/_text/tests/test_adapters_renderers.py b/plotly/io/_text/tests/test_adapters_renderers.py new file mode 100644 index 00000000000..808f9be4d0a --- /dev/null +++ b/plotly/io/_text/tests/test_adapters_renderers.py @@ -0,0 +1,327 @@ +"""Tests for the figure adapters, driver, and renderer registration. + +Covers: + +* numpy-built figure renders identical to the list-built one (typed-array + decode) — on both the numpy and the numpy-absent fallback paths; +* grouped bars/histograms fan out into sub-columns (no silent overwrite); +* histogram binning on a known input; +* graceful degradation notes (unsupported + mixed figures); +* undersized canvas degrades to a note instead of raising; +* renderer registration + forced-UTF-8 output. + +Sibling to ``test_canvas_serializers.py`` in the same directory. +""" + +import io +import math +import sys + +import pytest + +import plotly.graph_objects as go +import plotly.io as pio +from plotly.io._text import adapters as A +from plotly.io._text.adapters import figure_to_canvas +from plotly.io._text.adapters.histogram import histogram_bins + +np = pytest.importorskip("numpy") + + +# --------------------------------------------------------------------------- +# B1 — typed-array decode: numpy data must render identically to list data. +# --------------------------------------------------------------------------- + + +def _sin_dicts(n=60): + xs = [i * (2 * math.pi) / (n - 1) for i in range(n)] + ys = [math.sin(x) for x in xs] + list_fig = go.Figure(go.Scatter(x=xs, y=ys)).to_dict() + np_fig = go.Figure( + go.Scatter( + x=np.linspace(0, 2 * math.pi, n), y=np.sin(np.linspace(0, 2 * math.pi, n)) + ) + ).to_dict() + return list_fig, np_fig + + +def test_numpy_figure_matches_list_figure_numpy_path(): + list_fig, np_fig = _sin_dicts() + ra = figure_to_canvas(list_fig, width=60, height=20) + rb = figure_to_canvas(np_fig, width=60, height=20) + assert ra.canvas is not None and rb.canvas is not None + a = ra.canvas.render("text-utf") + b = rb.canvas.render("text-utf") + assert a == b + # And it is not the degenerate diagonal: the curve must span multiple rows. + assert b.count("\n") > 3 + + +def test_numpy_figure_matches_list_figure_pure_python_fallback(): + list_fig, np_fig = _sin_dicts() + r_expected = figure_to_canvas(list_fig, width=60, height=20) + assert r_expected.canvas is not None + expected = r_expected.canvas.render("text-utf") + A._FORCE_NO_NUMPY = True + try: + r_got = figure_to_canvas(np_fig, width=60, height=20) + assert r_got.canvas is not None + got = r_got.canvas.render("text-utf") + finally: + A._FORCE_NO_NUMPY = False + assert got == expected + + +def test_typed_array_decode_dtypes(): + for arr in ( + np.array([1, 2, 3], dtype="int8"), + np.array([1, 2, 3], dtype="int32"), + np.array([1.5, 2.5, 3.5], dtype="float32"), + np.array([1.5, 2.5, 3.5], dtype="float64"), + ): + fig = go.Figure(go.Scatter(y=arr)).to_dict() + spec = fig["data"][0]["y"] + assert A.is_typed_array_spec(spec) # sanity: it really is base64-encoded + decoded = A._decode_typed_array(spec) + assert [round(v, 3) for v in decoded] == [round(float(v), 3) for v in arr] + A._FORCE_NO_NUMPY = True + try: + fb = A._decode_typed_array(spec) + finally: + A._FORCE_NO_NUMPY = False + assert [round(v, 3) for v in fb] == [round(v, 3) for v in decoded] + + +# --------------------------------------------------------------------------- +# B2 — grouped bars must show BOTH series. +# --------------------------------------------------------------------------- + + +def test_grouped_bars_show_both_series(): + fig = go.Figure() + fig.add_bar(x=["q1", "q2", "q3"], y=[10, 10, 10], name="A") + fig.add_bar(x=["q1", "q2", "q3"], y=[3, 3, 3], name="B") + r = figure_to_canvas(fig.to_dict(), width=64, height=16) + assert r.canvas is not None + out = r.canvas.render("text-utf") + rows = out.split("\n") + # Tall series A reaches the top rows; short series B only the bottom rows. + top = "\n".join(rows[:5]) + bottom = "\n".join(rows[8:13]) + tall_bars = top.count("█") + + # The short series adds bar cells low down that the tall series' columns + # don't occupy — i.e. more distinct bar columns near the baseline. + def bar_cols(block): + cols = set() + for line in block.split("\n"): + for i, ch in enumerate(line): + if ch in "█▁▂▃▄▅▆▇": + cols.add(i) + return cols + + assert len(bar_cols(bottom)) > len(bar_cols(top)), out + assert tall_bars > 0 + + +def test_grouped_offset_distinct_positions(): + # Two bar series over the same categories get shifted to different x. + fig = go.Figure() + fig.add_bar(x=[0, 1, 2], y=[1, 1, 1]) + fig.add_bar(x=[0, 1, 2], y=[2, 2, 2]) + calls = [] + + class Rec: + def __init__(self, width=60, height=20): + self.width = width + self.height = height + + def frame(self, *a, **k): + pass + + def bar(self, positions, values, *, orientation="v", base=0.0, color=None): + calls.append(list(positions)) + + orig = A.Canvas + A.Canvas = Rec + try: + figure_to_canvas(fig.to_dict(), width=60, height=20) + finally: + A.Canvas = orig + assert len(calls) == 2 + assert calls[0] != calls[1] # the two series are offset apart + # centred: series0 shifted left of the category, series1 shifted right + assert calls[0][0] < 0 < calls[1][0] + + +def test_grouped_density_warning_when_too_narrow(): + fig = go.Figure() + for _ in range(6): + fig.add_bar(x=list(range(20)), y=[1] * 20) + result = figure_to_canvas(fig.to_dict(), width=24, height=12) + assert any("grouped bars exceed" in w for w in result.warnings), result.warnings + + +# --------------------------------------------------------------------------- +# Histogram binning on a known input. +# --------------------------------------------------------------------------- + + +def test_histogram_bins_known_input(): + samples = [1, 2, 2, 3, 3, 3, 4] + trace = go.Histogram( + x=samples, xbins=dict(start=0.5, end=4.5, size=1.0) + ).to_plotly_json() + centers, counts, edges, orient = histogram_bins(trace) + assert edges == [0.5, 1.5, 2.5, 3.5, 4.5] + assert counts == [1, 2, 3, 1] + assert sum(counts) == len(samples) + assert orient == "v" + + +def test_histogram_counts_sum_pure_python_and_numpy(): + rng = [((i * 7919) % 1000) / 100.0 for i in range(300)] + trace = go.Histogram(x=rng).to_plotly_json() + _, counts_np, _, _ = histogram_bins(trace) + A._FORCE_NO_NUMPY = True + try: + _, counts_py, _, _ = histogram_bins(trace) + finally: + A._FORCE_NO_NUMPY = False + assert sum(counts_np) == len(rng) + assert counts_py == counts_np + + +# --------------------------------------------------------------------------- +# Degradation notes — unsupported and mixed figures. +# --------------------------------------------------------------------------- + + +def test_unsupported_trace_degrades(): + result = figure_to_canvas( + go.Figure(go.Pie(labels=["a", "b"], values=[1, 2])).to_dict(), mode="text-utf" + ) + assert result.warnings == ["⚠ pie traces aren't supported by the text renderer"] + # frame still drawn, no crash + assert result.canvas is not None + + +def test_unsupported_trace_ascii_prefix(): + result = figure_to_canvas( + go.Figure(go.Pie(labels=["a"], values=[1])).to_dict(), mode="text-ascii" + ) + assert result.warnings == ["! pie traces aren't supported by the text renderer"] + + +def test_mixed_figure_renders_supported_notes_skipped(): + fig = go.Figure() + fig.add_scatter(x=[0, 1, 2], y=[0, 1, 2], mode="lines") + fig.add_trace(go.Pie(labels=["a", "b"], values=[1, 2])) + fig.add_bar(x=[0, 1], y=[3, 4]) + result = figure_to_canvas(fig.to_dict()) + assert result.canvas is not None + assert result.warnings == ["⚠ pie traces aren't supported by the text renderer"] + # supported traces actually drew something + assert result.canvas.render("text-utf").strip() != "" + + +# --------------------------------------------------------------------------- +# B3 — undersized canvas degrades gracefully. +# --------------------------------------------------------------------------- + + +def test_undersized_canvas_degrades_in_driver(): + result = figure_to_canvas( + go.Figure(go.Scatter(x=[1, 2, 3], y=[1, 2, 3])).to_dict(), width=2, height=2 + ) + assert result.canvas is None + assert len(result.warnings) == 1 + assert "canvas too small to render" in result.warnings[0] + + +def test_undersized_canvas_show_does_not_crash(): + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + pio.show( + go.Figure(go.Scatter(x=[1, 2, 3], y=[1, 2, 3])), + renderer="text-utf", + width=2, + height=2, + ) + finally: + sys.stdout = old + assert "canvas too small to render" in buf.getvalue() + + +# --------------------------------------------------------------------------- +# Registration + multi-series glyphs + forced UTF-8. +# --------------------------------------------------------------------------- + + +def test_renderers_registered(): + assert "text-utf" in pio.renderers + assert "text-ascii" in pio.renderers + + +def test_adapters_registered(): + assert {"scatter", "scattergl", "bar", "histogram"}.issubset(A.ADAPTERS) + + +def test_multi_series_distinct_glyphs(): + fig = go.Figure() + for i in range(3): + fig.add_scatter(x=[0, 1], y=[i, i + 1], mode="markers") + seen = [] + + class Rec: + def __init__(self, width=60, height=20): + self.width = width + self.height = height + + def frame(self, *a, **k): + pass + + def line(self, *a, **k): + pass + + def markers(self, points, glyph, *, color=None): + seen.append(glyph) + + orig = A.Canvas + A.Canvas = Rec + try: + figure_to_canvas(fig.to_dict()) + finally: + A.Canvas = orig + assert len(set(seen)) == 3 + assert seen == list(A.GLYPH_PALETTE[:3]) + + +def test_renderer_forces_utf8_bytes_and_appends_warning(): + class FakeBuffer: + def __init__(self): + self.data = b"" + + def write(self, b): + self.data += b + + def flush(self): + pass + + class FakeStdout: + def __init__(self): + self.buffer = FakeBuffer() + + fake = FakeStdout() + old = sys.stdout + sys.stdout = fake + try: + pio.renderers["text-utf"].render( + go.Figure(go.Pie(labels=["a"], values=[1])).to_dict() + ) + finally: + sys.stdout = old + out = fake.buffer.data.decode("utf-8") + assert "⚠ pie traces aren't supported by the text renderer" in out diff --git a/plotly/io/_text/tests/test_canvas_serializers.py b/plotly/io/_text/tests/test_canvas_serializers.py new file mode 100644 index 00000000000..05dc14554b9 --- /dev/null +++ b/plotly/io/_text/tests/test_canvas_serializers.py @@ -0,0 +1,201 @@ +"""Golden-output snapshot tests for the Canvas + monochrome serializers. + +The Canvas is deterministic and never queries a TTY, so byte-exact snapshots are +a valid contract: any drift in the rasterizer, frame layout, or a serializer's +glyph mapping shows up as a diff here. The adapter/renderer tests live beside +this file in ``test_adapters_renderers.py``. +""" + +import math + +from plotly.io._text.canvas import Canvas, Tick +from plotly.io._text.rasterizer import MARKER_ASCII, MARKER_GLYPHS + + +# --------------------------------------------------------------------------- +# sin(x) framed line — the exit-criterion picture, in both v1 modes. +# --------------------------------------------------------------------------- + + +def _sin_canvas(): + c = Canvas(width=60, height=16) + pts = [(x * 0.1, math.sin(x * 0.1)) for x in range(0, 63)] + c.frame( + x_range=(0, 6.2), + y_range=(-1, 1), + x_ticks=[Tick(0, "0"), Tick(3.14, "pi"), Tick(6.28, "2pi")], + y_ticks=[Tick(-1, "-1"), Tick(0, "0"), Tick(1, "1")], + title="sin(x)", + ) + c.line(pts) + return c + + +SIN_UTF = ( + " sin(x)\n" + " 1┤ ⢀⡠⠒⠉⠉⠉⠑⠒⠢⢄⡀\n" + " │ ⡠⠔⠁ ⠘⠤⡀\n" + " │ ⢀⠔⠁ ⠈⠢⡀\n" + " │ ⡔⠁ ⠘⡄\n" + " │ ⢠⠊ ⠈⠢⡀\n" + " │ ⡰⠁ ⠘⢄\n" + " 0┤⠜ ⢣\n" + " │ ⠣⡀ ⡠⠊\n" + " │ ⠈⢢ ⡰⠁\n" + " │ ⠣⡀ ⢠⠊\n" + " │ ⠈⠢⡀ ⡠⠔⠁\n" + " │ ⠈⠢⡀ ⣀⠔⠁\n" + "-1┤ ⠈⠒⠤⠤⣀⣀⣀⡠⠔⠉\n" + " └┬───────────────────────────┬──────────────────────────┬\n" + " 0 pi 2pi" +) + +SIN_ASCII = ( + " sin(x)\n" + " 1+ ...........\n" + " | ... ...\n" + " | ... ...\n" + " | :. ..\n" + " | .. ...\n" + " | :. ..\n" + " 0+: :\n" + " | :. ..\n" + " | .: :.\n" + " | :. ..\n" + " | ... ...\n" + " | ... ...\n" + "-1+ ..........\n" + " ++---------------------------+--------------------------+\n" + " 0 pi 2pi" +) + + +def test_sin_text_utf_golden(): + assert _sin_canvas().render("text-utf") == SIN_UTF + + +def test_sin_text_ascii_golden(): + assert _sin_canvas().render("text-ascii") == SIN_ASCII + + +def test_render_is_deterministic(): + a = _sin_canvas().render("text-utf") + b = _sin_canvas().render("text-utf") + assert a == b + assert _sin_canvas().render("text-ascii") == _sin_canvas().render("text-ascii") + + +def test_text_ascii_is_pure_7bit(): + out = _sin_canvas().render("text-ascii") + assert out.isascii() + + +# --------------------------------------------------------------------------- +# Bars with fractional fill (block ramp caps the partial top cell). +# --------------------------------------------------------------------------- + + +def _bar_canvas(): + c = Canvas(width=30, height=10) + c.frame( + x_range=(-0.5, 2.5), + y_range=(0, 10), + x_ticks=[Tick(0, "a"), Tick(1, "b"), Tick(2, "c")], + y_ticks=[Tick(0, "0"), Tick(10, "10")], + title="bars", + ) + c.bar([0, 1, 2], [2.5, 7.3, 9.9]) + return c + + +BAR_UTF = ( + " bars\n" + "10┤ ▇\n" + " │ ▁ █\n" + " │ █ █\n" + " │ █ █\n" + " │ █ █\n" + " │ ▆ █ █\n" + " 0┤ █ █ █\n" + " └────┬────────┬───────┬────\n" + " a b c" +) + + +def test_bar_fractional_fill_golden(): + assert _bar_canvas().render("text-utf") == BAR_UTF + + +# --------------------------------------------------------------------------- +# A1 fix: bar collision must max-merge, never shrink (honesty rule). +# --------------------------------------------------------------------------- + + +def test_bar_collision_keeps_tallest_regardless_of_order(): + big_first = Canvas(10, 8) + big_first.frame(x_range=(0, 10), y_range=(0, 100)) + big_first.bar([5.0, 5.05], [100, 1]) + + small_first = Canvas(10, 8) + small_first.frame(x_range=(0, 10), y_range=(0, 100)) + small_first.bar([5.05, 5.0], [1, 100]) + + # Draw order must not change the picture; the tall bar always survives. + assert big_first.render("text-utf") == small_first.render("text-utf") + # And the surviving bar is the tall one (reaches the top rows). + out = big_first.render("text-utf") + assert "█" in out.splitlines()[1] + + +# --------------------------------------------------------------------------- +# A2 fix: multi-series ascii markers stay distinct (no collapse to '*'). +# --------------------------------------------------------------------------- + + +def test_ascii_markers_are_distinct_per_series(): + c = Canvas(width=24, height=8) + c.frame( + x_range=(0, 4), + y_range=(0, 4), + x_ticks=[Tick(0, "0"), Tick(4, "4")], + y_ticks=[Tick(0, "0"), Tick(4, "4")], + ) + c.markers([(1, 1)], MARKER_GLYPHS[0]) + c.markers([(2, 2)], MARKER_GLYPHS[1]) + c.markers([(3, 3)], MARKER_GLYPHS[2]) + out = c.render("text-ascii") + + expected = [MARKER_ASCII[g] for g in MARKER_GLYPHS[:3]] + assert len(set(expected)) == 3 # palette is genuinely distinct + for ch in expected: + assert ch in out + assert "*" not in out # nothing collapsed to the unknown-glyph fallback + + +def test_marker_palette_tables_are_index_aligned(): + from plotly.io._text.rasterizer import MARKER_GLYPHS_ASCII + + assert len(MARKER_GLYPHS) == len(MARKER_GLYPHS_ASCII) + assert len(set(MARKER_GLYPHS_ASCII)) == len(MARKER_GLYPHS_ASCII) + + +# --------------------------------------------------------------------------- +# A3 fix: undersized canvas raises an actionable ValueError. +# --------------------------------------------------------------------------- + + +def test_undersized_frame_raises_actionable_error(): + import pytest + + c = Canvas(width=4, height=3) + with pytest.raises(ValueError) as exc: + c.frame( + x_range=(0, 1), + y_range=(0, 1), + title="too big", + x_title="x", + y_title="y", + ) + msg = str(exc.value) + assert "too small" in msg + assert "4x3" in msg # states the actual canvas size diff --git a/plotly/io/_text/tests/test_v2_adapters.py b/plotly/io/_text/tests/test_v2_adapters.py new file mode 100644 index 00000000000..f0338f6d96e --- /dev/null +++ b/plotly/io/_text/tests/test_v2_adapters.py @@ -0,0 +1,549 @@ +"""Colour-mode tests — colour through the trace adapters, the heatmap / +histogram2d adapter, colour-source precedence, and colour-mode renderer +registration. + +These validate the **adapter surface independently of the serializer bodies**: +colour is asserted by inspecting the drawn grid cells' ``fg`` / ``bg`` (set by the +Canvas), and heatmaps by their ``HEATMAP`` cells. The end-to-end colour-mode +render tests (``text-ansi`` / ``text-html``) and the ``text-utf`` heatmap-shade +test depend on the serializers; each skips cleanly with ``NotImplementedError`` +if a serializer is not yet available, so the adapter-level assertions stand on +their own. + +Sibling to ``test_adapters_renderers.py`` in the same directory. +""" + +import math + +import pytest + +import plotly.graph_objects as go +import plotly.io as pio +from plotly.io._text import adapters as A +from plotly.io._text.adapters import COLOR_PALETTE, figure_to_canvas +from plotly.io._text.adapters import heatmap as H +from plotly.io._text.canvas import Canvas, CellRole + +np = pytest.importorskip("numpy") + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _fg_set(canvas, role): + """Distinct non-empty ``fg`` hints among cells of ``role`` in ``canvas``.""" + return {c.fg for row in canvas.grid.rows for c in row if c.role == role and c.fg} + + +def _heatmap_cells(canvas): + return [c for row in canvas.grid.rows for c in row if c.role == CellRole.HEATMAP] + + +def _fill_matrix(canvas): + """The ``fill`` of every plot cell, rounded — for list-vs-numpy equality.""" + return [ + [round(c.fill, 6) if c.role == CellRole.HEATMAP else None for c in row] + for row in canvas.grid.rows + ] + + +# =========================================================================== +# 1. Colour through the built-in adapters (scatter / bar / histogram). +# =========================================================================== + + +def test_scatter_forwards_palette_color_to_line_and_markers(): + fig = go.Figure(go.Scatter(x=[0, 1, 2, 3], y=[0, 1, 0, 1], mode="lines+markers")) + r = figure_to_canvas(fig.to_dict(), width=40, height=14) + # series 0 -> palette[0]; both the braille line cells and the marker cells + # must carry it (before v2 the adapter forwarded no colour at all). + assert _fg_set(r.canvas, CellRole.DOTS) == {COLOR_PALETTE[0]} + assert _fg_set(r.canvas, CellRole.MARKER) == {COLOR_PALETTE[0]} + + +def test_scatter_explicit_marker_color_wins_over_palette(): + fig = go.Figure( + go.Scatter(x=[0, 1, 2], y=[0, 1, 2], mode="markers", marker_color="#ff0000") + ) + d = fig.to_dict() + expected = A._color_to_hex(d["data"][0]["marker"]["color"]) + r = figure_to_canvas(d, width=40, height=14) + assert _fg_set(r.canvas, CellRole.MARKER) == {expected} + assert expected not in COLOR_PALETTE # really the explicit colour, not palette + + +def test_scatter_explicit_line_color_wins(): + fig = go.Figure( + go.Scatter(x=[0, 1, 2], y=[0, 1, 2], mode="lines", line_color="#0a0b0c") + ) + d = fig.to_dict() + expected = A._color_to_hex(d["data"][0]["line"]["color"]) + r = figure_to_canvas(d, width=40, height=14) + assert _fg_set(r.canvas, CellRole.DOTS) == {expected} + + +def test_multi_series_get_distinct_palette_colors(): + fig = go.Figure() + for i in range(3): + fig.add_scatter(x=[0, 1, 2], y=[i, i + 1, i], mode="markers") + r = figure_to_canvas(fig.to_dict(), width=50, height=18) + seen = _fg_set(r.canvas, CellRole.MARKER) + # three series -> three distinct palette colours actually reached the cells. + assert seen == set(COLOR_PALETTE[:3]) + + +def test_bar_forwards_color_to_cells(): + r = figure_to_canvas( + go.Figure(go.Bar(x=[0, 1, 2], y=[1, 2, 3])).to_dict(), width=40, height=14 + ) + assert _fg_set(r.canvas, CellRole.BAR) == {COLOR_PALETTE[0]} + + +def test_bar_explicit_marker_color_wins(): + d = go.Figure(go.Bar(x=[0, 1, 2], y=[1, 2, 3], marker_color="#123456")).to_dict() + expected = A._color_to_hex(d["data"][0]["marker"]["color"]) + r = figure_to_canvas(d, width=40, height=14) + assert _fg_set(r.canvas, CellRole.BAR) == {expected} + + +def test_histogram_forwards_color_to_cells(): + r = figure_to_canvas( + go.Figure(go.Histogram(x=[1, 1, 2, 2, 2, 3, 3, 4])).to_dict(), + width=40, + height=14, + ) + assert _fg_set(r.canvas, CellRole.BAR) == {COLOR_PALETTE[0]} + + +# =========================================================================== +# 2. Colour-source precedence (_assign_color / _single_trace_color). +# =========================================================================== + + +def test_assign_color_explicit_marker_wins_regardless_of_index(): + # B1: the explicit CSS-name colour is normalized to hex before it can reach a + # cell (the colour serializers parse #hex only) — precedence still holds. + assert A._assign_color({"marker": {"color": "red"}}, 5) == "#ff0000" + + +def test_assign_color_line_color_used(): + assert A._assign_color({"line": {"color": "#abcdef"}}, 2) == "#abcdef" + + +def test_assign_color_normalizes_rgb_and_named(): + assert A._assign_color({"line": {"color": "rgb(255, 0, 0)"}}, 0) == "#ff0000" + assert A._assign_color({"marker": {"color": "steelblue"}}, 1) == "#4682b4" + + +def test_assign_color_unresolvable_falls_back_to_palette(): + # An unparseable colour must never reach a cell as a raw string -> palette. + assert A._assign_color({"marker": {"color": "not-a-color"}}, 2) == COLOR_PALETTE[2] + hsl = {"line": {"color": "hsl(0, 100%, 50%)"}} + assert A._assign_color(hsl, 0) == COLOR_PALETTE[0] + + +def test_color_to_hex_forms(): + assert A._color_to_hex("#ABC") == "#aabbcc" # #rgb -> #rrggbb, lowercased + assert A._color_to_hex("#FF0000") == "#ff0000" + assert A._color_to_hex("rgb(0,128,255)") == "#0080ff" + assert A._color_to_hex("rgba(0,128,255,0.5)") == "#0080ff" # alpha dropped + assert A._color_to_hex("red") == "#ff0000" + assert A._color_to_hex("garbage") is None + assert A._color_to_hex(123) is None + assert A._color_to_hex("") is None + + +def test_every_plotly_named_color_resolves(): + # Full coverage: any CSS name plotly accepts must map to a hex here, so an + # explicit named trace colour never falls through to the palette by accident. + from _plotly_utils.basevalidators import ColorValidator + + for name in ColorValidator.named_colors: + hx = A._color_to_hex(name) + assert hx is not None and hx.startswith("#") and len(hx) == 7, name + + +def test_assign_color_marker_beats_line(): + trace = {"marker": {"color": "#111111"}, "line": {"color": "#222222"}} + assert A._assign_color(trace, 0) == "#111111" + + +def test_assign_color_array_is_not_a_series_color(): + # A per-point colour array is not a single series colour -> palette by index. + assert A._single_trace_color({"marker": {"color": [1, 2, 3]}}) is None + assert A._assign_color({"marker": {"color": [1, 2, 3]}}, 1) == COLOR_PALETTE[1] + + +def test_assign_color_default_palette_cycles(): + n = len(COLOR_PALETTE) + assert A._assign_color({}, 0) == COLOR_PALETTE[0] + assert A._assign_color({}, n) == COLOR_PALETTE[0] # wraps + assert A._assign_color({}, 1) == COLOR_PALETTE[1] + + +# =========================================================================== +# 3. Heatmap adapter — cells, numpy decode, colorscale. +# =========================================================================== + + +def test_heatmap_populates_cells_with_fill_and_bg(): + z = [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]] + r = figure_to_canvas(go.Figure(go.Heatmap(z=z)).to_dict(), width=40, height=14) + cells = _heatmap_cells(r.canvas) + assert cells, "heatmap drew no HEATMAP cells" + fills = [c.fill for c in cells] + assert min(fills) == pytest.approx(0.0) + assert max(fills) == pytest.approx(1.0) + # every heatmap cell carries a sampled hex bg for the colour serializers. + assert all(isinstance(c.bg, str) and c.bg.startswith("#") for c in cells) + + +def test_heatmap_numpy_z_matches_list_z(): + z_list = [[float(v) for v in range(c, c + 4)] for c in (1, 5, 9)] + z_np = np.arange(1, 13, dtype="float64").reshape(3, 4) + a = figure_to_canvas(go.Figure(go.Heatmap(z=z_list)).to_dict(), width=40, height=14) + b = figure_to_canvas(go.Figure(go.Heatmap(z=z_np)).to_dict(), width=40, height=14) + a, b = a.canvas, b.canvas + assert _fill_matrix(a) == _fill_matrix(b) + assert _heatmap_cells(b) # not empty (the numpy blocker would give garbage/none) + + +def test_heatmap_numpy_z_pure_python_fallback_matches(): + z_np = np.arange(1, 13, dtype="float64").reshape(3, 4) + d = go.Figure(go.Heatmap(z=z_np)).to_dict() + expected = _fill_matrix(figure_to_canvas(d, width=40, height=14).canvas) + A._FORCE_NO_NUMPY = True + try: + got = _fill_matrix(figure_to_canvas(d, width=40, height=14).canvas) + finally: + A._FORCE_NO_NUMPY = False + assert got == expected + + +def test_heatmap_2d_typed_array_reshape_roundtrip(): + z_np = np.arange(12, dtype="float64").reshape(3, 4) + spec = go.Figure(go.Heatmap(z=z_np)).to_dict()["data"][0]["z"] + assert A.is_typed_array_spec(spec) # sanity: base64-encoded 2D array + grid = H.heatmap_z({"z": spec}) + assert grid == [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]] + + +def test_heatmap_colorscale_honored_greys_is_grayscale(): + d = go.Figure(go.Heatmap(z=[[0, 1], [2, 3]], colorscale="Greys")).to_dict() + r = figure_to_canvas(d, width=20, height=10) + bgs = {c.bg for c in _heatmap_cells(r.canvas) if c.bg} + # grayscale: r == g == b for every sampled colour. + for hx in bgs: + rr, gg, bb = hx[1:3], hx[3:5], hx[5:7] + assert rr == gg == bb, hx + + +def test_heatmap_default_colorscale_is_viridis_not_gray(): + d = go.Figure(go.Heatmap(z=[[0, 1], [2, 3]])).to_dict() + r = figure_to_canvas(d, width=20, height=10) + bgs = {c.bg for c in _heatmap_cells(r.canvas) if c.bg} + # at least one Viridis colour is not neutral gray (r != g or g != b). + assert any(not (h[1:3] == h[3:5] == h[5:7]) for h in bgs) + + +def test_normalize_colorscale_rgb_to_hex(): + cs = [[0.0, "rgb(0,0,0)"], [1.0, "rgb(255,255,255)"]] + assert H._normalize_colorscale(cs) == [[0.0, "#000000"], [1.0, "#ffffff"]] + + +def test_normalize_colorscale_passthrough_and_bail(): + assert H._normalize_colorscale(None) is None + assert H._normalize_colorscale("Viridis") == "Viridis" # named -> Canvas resolves + # CSS-named stops resolve now that the shared bridge knows the name table. + assert H._normalize_colorscale([[0.0, "chartreuse"], [1.0, "#fff"]]) == [ + [0.0, "#7fff00"], + [1.0, "#ffffff"], + ] + # an unconvertible colour still makes the whole scale bail to None (Canvas default). + assert H._normalize_colorscale([[0.0, "hsl(0,100%,50%)"], [1.0, "#fff"]]) is None + + +# =========================================================================== +# 4. histogram2d adapter — binning + cells + count conservation. +# =========================================================================== + + +def test_histogram2d_populates_cells(): + rs = np.random.RandomState(0) + x = rs.rand(500) + y = rs.rand(500) + d = go.Figure(go.Histogram2d(x=x, y=y, nbinsx=10, nbinsy=10)).to_dict() + r = figure_to_canvas(d, width=40, height=14) + assert _heatmap_cells(r.canvas), "histogram2d drew no HEATMAP cells" + + +def test_histogram2d_to_z_conserves_counts_numpy_and_pure_python(): + xs = [0.1, 0.2, 0.9, 0.9, 0.5] + ys = [0.1, 0.15, 0.9, 0.8, 0.5] + z_np = H._histogram2d_to_z(xs, ys, nbins=4) + assert len(z_np) == 4 and all(len(r) == 4 for r in z_np) + assert sum(sum(r) for r in z_np) == len(xs) + + A._FORCE_NO_NUMPY = True + try: + z_py = H._histogram2d_to_z(xs, ys, nbins=4) + finally: + A._FORCE_NO_NUMPY = False + assert sum(sum(r) for r in z_py) == len(xs) + + +def test_histogram2d_drops_nonfinite_pairs(): + xs, ys = H._finite_xy_pairs({"x": [1.0, float("nan"), 3.0], "y": [1.0, 2.0, None]}) + assert xs == [1.0] and ys == [1.0] + + +def test_hist2d_nbins_honors_nbinsx(): + assert H._hist2d_nbins({"nbinsx": 7}) == 7 + assert H._hist2d_nbins({}) == H.DEFAULT_HIST2D_BINS + + +# =========================================================================== +# 5. Extent / framing for heatmap + histogram2d. +# =========================================================================== + + +def test_heatmap_extent_spans_grid_indices(): + trace = go.Heatmap(z=[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]).to_plotly_json() + extent = A._trace_extent(trace, "heatmap") + assert extent is not None + xs, ys = extent + assert (min(xs), max(xs)) == (0.0, 3.0) # 4 columns -> 0..3 + assert (min(ys), max(ys)) == (0.0, 2.0) # 3 rows -> 0..2 + + +def test_heatmap_extent_uses_explicit_coords(): + trace = go.Heatmap( + z=[[1, 2, 3], [4, 5, 6]], x=[10, 20, 30], y=[100, 200] + ).to_plotly_json() + extent = A._trace_extent(trace, "heatmap") + assert extent is not None + xs, ys = extent + assert (min(xs), max(xs)) == (10.0, 30.0) + assert (min(ys), max(ys)) == (100.0, 200.0) + + +def test_histogram2d_extent_spans_sample_range(): + trace = go.Histogram2d(x=[0.0, 5.0, 10.0], y=[-2.0, 0.0, 3.0]).to_plotly_json() + extent = A._trace_extent(trace, "histogram2d") + assert extent is not None + xs, ys = extent + assert (min(xs), max(xs)) == (0.0, 10.0) + assert (min(ys), max(ys)) == (-2.0, 3.0) + + +def test_heatmap_frame_uses_extent_ticks(): + # The driver frames from the extent: a wide grid must not degrade to (0,1). + z = [[i + j for j in range(6)] for i in range(5)] + r = figure_to_canvas(go.Figure(go.Heatmap(z=z)).to_dict(), width=50, height=16) + assert r.canvas is not None + out = r.canvas.render("text-utf") + assert "5" in out # x extent 0..5 -> a '5' tick label appears + + +# =========================================================================== +# 6. Registration (adapters + v2 renderers). +# =========================================================================== + + +def test_heatmap_and_histogram2d_registered(): + assert A.get_adapter("heatmap") is not None + assert A.get_adapter("histogram2d") is not None + + +def test_v2_renderers_registered_with_mode(): + for mode in ("text-ansi", "text-html"): + assert mode in pio.renderers + assert pio.renderers[mode].mode == mode + + +# =========================================================================== +# 7. End-to-end through the serializers (skips if one is unavailable). +# =========================================================================== + + +def _render_or_skip(canvas, mode): + try: + return canvas.render(mode) + except NotImplementedError: + pytest.skip(f"{mode} serializer not available") + + +def test_heatmap_renders_shade_ramp_text_utf(): + z = [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]] + r = figure_to_canvas(go.Figure(go.Heatmap(z=z)).to_dict(), width=44, height=14) + assert r.canvas is not None + out = r.canvas.render("text-utf") + assert any(ch in out for ch in "░▒▓█"), out + + +def _row_max_fill(canvas, y): + fills = [c.fill for c in canvas.grid.rows[y] if c.role == CellRole.HEATMAP] + return max(fills) if fills else None + + +def test_heatmap_orientation_low_z_row_at_bottom(): + # z[0] is the low row; plotly draws it at the bottom. After the adapter's + # row-reverse, the bottom plot rows should be lighter (low fill) than the top. + z = [[0, 0, 0, 0], [9, 9, 9, 9]] + r = figure_to_canvas(go.Figure(go.Heatmap(z=z)).to_dict(), width=30, height=12) + c = r.canvas + assert c is not None + hm_rows = [y for y in range(c.height) if _row_max_fill(c, y) is not None] + top_fill = _row_max_fill(c, hm_rows[0]) + bottom_fill = _row_max_fill(c, hm_rows[-1]) + assert top_fill is not None and bottom_fill is not None + assert top_fill > bottom_fill + + +def test_color_reaches_ansi_output(): + # Palette colour #636efa -> 24-bit ANSI triple 99;110;250 in the escape. + r = figure_to_canvas( + go.Figure(go.Scatter(x=[0, 1, 2], y=[0, 1, 2], mode="markers")).to_dict(), + width=40, + height=14, + ) + out = _render_or_skip(r.canvas, "text-ansi") + assert "99;110;250" in out, "series palette colour did not reach the ANSI output" + + +def test_heatmap_bg_reaches_ansi_output(): + r = figure_to_canvas( + go.Figure(go.Heatmap(z=[[0, 1], [2, 3]])).to_dict(), width=24, height=10 + ) + out = _render_or_skip(r.canvas, "text-ansi") + # a background escape (48;2;...) appears for the heatmap cells. + assert "48;2;" in out + + +def test_heatmap_renders_html(): + r = figure_to_canvas( + go.Figure(go.Heatmap(z=[[0, 1], [2, 3]])).to_dict(), width=24, height=10 + ) + out = _render_or_skip(r.canvas, "text-html") + assert " 3 + + +# =========================================================================== +# 8. B1 regression — non-hex explicit colours must render, not crash. +# =========================================================================== + + +@pytest.mark.parametrize("color", ["red", "rgb(255,0,0)", "rgba(255,0,0,0.5)"]) +@pytest.mark.parametrize("mode", ["text-ansi", "text-html"]) +def test_non_hex_trace_color_renders_without_crash(color, mode): + # The exact class of bug QA hit: a CSS name / rgb() flowed verbatim into a + # cell and crashed the #hex-only colour serializer. Now it's normalized. + fig = go.Figure( + go.Scatter( + x=[1, 2, 3], y=[1, 2, 3], mode="lines+markers", line=dict(color=color) + ) + ) + r = figure_to_canvas(fig.to_dict(), width=40, height=14) + assert _fg_set(r.canvas, CellRole.DOTS) == {"#ff0000"} # hex reached the cell + out = _render_or_skip(r.canvas, mode) # must not raise + if mode == "text-ansi": + assert "255;0;0" in out + else: # text-html: the hex lands in the scoped style block + assert "ff0000" in out.lower() + + +def test_non_hex_color_full_show_path_no_crash(): + # The literal coordinator repro, driven through the renderer's render(). + class FakeStdout: + def __init__(self): + self.buffer = _FakeBuffer() + + import sys + + fake = FakeStdout() + old = sys.stdout + sys.stdout = fake + try: + pio.renderers["text-ansi"].render( + go.Figure( + go.Scatter(x=[1, 2, 3], y=[1, 2, 3], line=dict(color="red")) + ).to_dict() + ) + finally: + sys.stdout = old + out = fake.buffer.data.decode("utf-8") + assert "255;0;0" in out # rendered the red line, no crash + + +class _FakeBuffer: + def __init__(self): + self.data = b"" + + def write(self, b): + self.data += b + + def flush(self): + pass + + +# =========================================================================== +# 9. B2 regression — a serialize-time error is not mislabelled "canvas too small". +# =========================================================================== + + +def test_is_too_small_error_predicate(): + assert A._is_too_small_error( + ValueError("Canvas too small for the requested frame: ...") + ) + assert A._is_too_small_error(ValueError("Canvas size must be at least 1x1 cells")) + # an unrelated error is NOT a size error. + assert not A._is_too_small_error( + ValueError("invalid literal for int() with base 16: 'rg'") + ) + + +def test_unrelated_serialize_error_surfaces_accurately(monkeypatch): + # A serialize-time ValueError that isn't a size problem must re-raise, not be + # swallowed and reported as the (confidently wrong) "canvas too small" note. + from plotly.io._text import adapters as AD + from plotly.io._text.adapters import DrawResult + + class BoomCanvas(Canvas): + def render(self, mode="text-utf"): + raise ValueError("totally unrelated boom") + + def _fake(*a, **k): + return DrawResult(BoomCanvas(), []) + + monkeypatch.setattr(AD, "figure_to_canvas", _fake) + + import sys + + old = sys.stdout + sys.stdout = FakeStdoutForBoom() + try: + with pytest.raises(ValueError, match="totally unrelated boom"): + pio.renderers["text-utf"].render( + go.Figure(go.Scatter(y=[1, 2, 3])).to_dict() + ) + finally: + sys.stdout = old + + +class FakeStdoutForBoom: + def __init__(self): + self.buffer = _FakeBuffer() diff --git a/plotly/io/_text/tests/test_v2_serializers.py b/plotly/io/_text/tests/test_v2_serializers.py new file mode 100644 index 00000000000..35a4cb65420 --- /dev/null +++ b/plotly/io/_text/tests/test_v2_serializers.py @@ -0,0 +1,318 @@ +"""Behaviour tests for the colour serializers + heatmap visibility. + +Covers the two colour modes (``text-ansi`` 24-bit truecolor, ``text-html`` +class-based fragment) and the ``HEATMAP`` role rendered by the monochrome +modes. The Canvas is deterministic and never queries a TTY, so these assert on +exact escape/markup structure and on byte-identical repeats. +""" + +import re + +from plotly.io._text.canvas import Canvas, Cell, CellGrid, CellRole +from plotly.io._text.rasterizer import SHADE_ASCII, SHADE_RAMP + + +# --------------------------------------------------------------------------- +# Helpers — build small grids directly so a test controls exactly one cell. +# --------------------------------------------------------------------------- + + +def _grid(cells_by_row): + """Build a CellGrid from a list of rows of Cell.""" + h = len(cells_by_row) + w = max(len(r) for r in cells_by_row) + g = CellGrid.blank(w, h) + for y, row in enumerate(cells_by_row): + for x, cell in enumerate(row): + g.rows[y][x] = cell + return g + + +def _serialize(grid, mode): + from plotly.io._text.serializers import get_serializer + + return get_serializer(mode).serialize(grid) + + +# --------------------------------------------------------------------------- +# text-ansi — truecolor escapes, present, reset, run-length-batched. +# --------------------------------------------------------------------------- + + +def _colored_line_canvas(): + c = Canvas(width=30, height=10) + c.frame(x_range=(0, 6.2), y_range=(-1, 1), title="c") + c.line([(x * 0.2, (x * 0.2)) for x in range(0, 31)], color="#636efa") + return c + + +def test_ansi_emits_truecolor_fg_escape(): + out = _serialize( + _grid([[Cell(role=CellRole.DOTS, dots=0x01, fg="#636efa")]]), "text-ansi" + ) + # #636efa -> (99, 110, 250) + assert "\x1b[38;2;99;110;250m" in out + assert out.endswith("\x1b[0m") + + +def test_ansi_resets_at_end_of_each_line(): + grid = _grid( + [ + [Cell(role=CellRole.DOTS, dots=0x01, fg="#636efa")], + [Cell(role=CellRole.DOTS, dots=0x01, fg="#EF553B")], + ] + ) + out = _serialize(grid, "text-ansi") + for line in out.split("\n"): + # Any line that opened a colour must close it. + if "\x1b[38" in line or "\x1b[48" in line: + assert line.endswith("\x1b[0m") + + +def test_ansi_run_length_batches_same_color(): + # Five adjacent same-colour cells -> one fg escape, not five. + row = [Cell(role=CellRole.DOTS, dots=0x01, fg="#636efa") for _ in range(5)] + out = _serialize(_grid([row]), "text-ansi") + assert out.count("\x1b[38;2;99;110;250m") == 1 + # The five glyphs ride under that single escape. + assert out.count("⠁") == 5 + + +def test_ansi_uncolored_cells_emit_no_escape(): + row = [Cell(role=CellRole.DOTS, dots=0x01)] # no fg + out = _serialize(_grid([row]), "text-ansi") + assert "\x1b" not in out + + +def test_ansi_transition_resets_before_uncolored_run(): + # colored, uncolored(blank interior), colored -> stale bg must not bleed. + row = [ + Cell(role=CellRole.HEATMAP, fill=1.0, bg="#440154"), + Cell(role=CellRole.DOTS, dots=0x01), # uncolored interior + Cell(role=CellRole.HEATMAP, fill=1.0, bg="#fde725"), + ] + out = _serialize(_grid([row]), "text-ansi") + # The middle glyph must be preceded by a reset so it isn't painted with the + # first cell's background. + braille = "⠁" + idx = out.index(braille) + assert "\x1b[0m" in out[:idx] + + +def test_ansi_real_figure_line_has_color(): + out = _colored_line_canvas().render("text-ansi") + assert "\x1b[38;2;99;110;250m" in out + assert "\x1b[0m" in out + + +# --------------------------------------------------------------------------- +# text-html — class-based fragment, de-duplicated classes, batched spans. +# --------------------------------------------------------------------------- + + +def test_html_is_style_plus_pre_fragment(): + out = _serialize( + _grid([[Cell(role=CellRole.DOTS, dots=0x01, fg="#636efa")]]), "text-html" + ) + assert out.startswith("