├── mpl_ascii ├── artists │ ├── __init__.py │ ├── Text.py │ ├── Polygon.py │ ├── LineCollection.py │ ├── Line2D.py │ ├── QuadContourSet.py │ ├── PathCollection.py │ ├── Annotation.py │ ├── PolyCollection.py │ ├── types.py │ ├── XAxis.py │ ├── YAxis.py │ ├── Rectangle.py │ ├── Legend.py │ ├── transform_helpers.py │ └── Spine.py ├── rendering │ ├── __init__.py │ ├── canvas.py │ ├── canvas_manager.py │ └── render.py ├── presentation │ ├── __init__.py │ ├── visibility.py │ ├── color_policy.py │ └── glyphs.py ├── layout │ ├── constants.py │ ├── __init__.py │ ├── transforms.py │ ├── discrete_point.py │ ├── render_plan.py │ ├── layout_shape.py │ ├── layout_text.py │ └── rasterize.py ├── scene │ ├── geometry │ │ ├── __init__.py │ │ ├── point.py │ │ ├── affine.py │ │ └── matrix.py │ ├── __init__.py │ ├── fingerprint.py │ ├── store.py │ └── entities.py ├── parsing │ ├── __init__.py │ ├── transform.py │ ├── shape.py │ └── figure.py └── __init__.py ├── assets ├── scatter.png ├── bar_chart.png └── double_plot.png ├── requirements.txt ├── pyproject.toml ├── examples ├── fill_between.py ├── spines.py ├── contours.py ├── polygons.py ├── rectangles.py └── basic.py ├── LICENSE ├── Makefile ├── CONTRIBUTING.md ├── tests └── accepted │ ├── contours.txt │ ├── fill_between.txt │ ├── basic.txt │ ├── spines.txt │ ├── polygons.txt │ └── rectangles.txt ├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── CHANGELOG.md └── README.md /mpl_ascii/artists/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mpl_ascii/rendering/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mpl_ascii/presentation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mpl_ascii/layout/constants.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | RESERVED_CHARS = {" ", "/", "\\"} -------------------------------------------------------------------------------- /assets/scatter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriscave/mpl_ascii/HEAD/assets/scatter.png -------------------------------------------------------------------------------- /assets/bar_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriscave/mpl_ascii/HEAD/assets/bar_chart.png -------------------------------------------------------------------------------- /assets/double_plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriscave/mpl_ascii/HEAD/assets/double_plot.png -------------------------------------------------------------------------------- /mpl_ascii/scene/geometry/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Handles the basic math for working with positions, sizes, and transformations in 2D space. 3 | """ -------------------------------------------------------------------------------- /mpl_ascii/layout/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Figures out where things should go on the screen by converting coordinates into the space used for rendering. 3 | """ -------------------------------------------------------------------------------- /mpl_ascii/parsing/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Takes a figure from Matplotlib and breaks it down into a format this system can understand and work with. 3 | """ -------------------------------------------------------------------------------- /mpl_ascii/scene/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Describes everything that could appear in the final image — like points, lines, and axes — and how they're connected. 3 | """ -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Library requirements 2 | matplotlib >= 3.5.3 3 | rich >= 13.7.1 4 | 5 | # Test requirements 6 | pytest >= 7.4.4 7 | 8 | # Build requirements 9 | setuptools >= 62 10 | setuptools_scm >= 7.1.0 11 | twine >= 4.0.2 12 | build >= 1.1.1 -------------------------------------------------------------------------------- /mpl_ascii/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mpl_ascii.rendering.canvas_manager import FigureCanvasAscii, FigureManagerAscii 4 | 5 | 6 | FigureCanvasAscii.manager_class = FigureManagerAscii 7 | FigureCanvas = FigureCanvasAscii 8 | FigureManager = FigureManagerAscii 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /mpl_ascii/artists/Text.py: -------------------------------------------------------------------------------- 1 | from matplotlib.text import Text 2 | 3 | from mpl_ascii.artists.transform_helpers import Point, to_mapping 4 | from mpl_ascii.artists.types import TextElement 5 | 6 | 7 | def parse(text: Text) -> TextElement: 8 | 9 | s = text.get_text() 10 | 11 | x, y = text.get_position() 12 | 13 | anchor = Point(x,y) 14 | 15 | halign = text.get_horizontalalignment() 16 | valign = text.get_verticalalignment() 17 | 18 | 19 | return TextElement(s, anchor, to_mapping(text.get_transform()), halign, valign) -------------------------------------------------------------------------------- /mpl_ascii/scene/geometry/point.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | 4 | from mpl_ascii.scene.fingerprint import Fingerprintable, f8 5 | 6 | @dataclass(frozen=True) 7 | class Point2d(Fingerprintable): 8 | x: float 9 | y: float 10 | 11 | def __add__(self, p: Point2d) -> Point2d: 12 | return Point2d(self.x + p.x, self.y + p.y) 13 | 14 | def __repr__(self) -> str: 15 | return f"({self.x:.2f}, {self.y:.2f})" 16 | 17 | def fingerprint(self) -> bytes: 18 | return f"pt({f8(self.x)},{f8(self.y)})".encode() 19 | -------------------------------------------------------------------------------- /mpl_ascii/parsing/transform.py: -------------------------------------------------------------------------------- 1 | from mpl_ascii.artists.transform_helpers import AffineMap, Mapping 2 | from mpl_ascii.scene.geometry.affine import AffineMap2d 3 | from mpl_ascii.scene.geometry.matrix import Matrix2d 4 | from mpl_ascii.scene.geometry.point import Point2d 5 | 6 | 7 | def parse_mapping(mapping: Mapping) -> AffineMap2d: 8 | 9 | if isinstance(mapping, AffineMap): 10 | mat = Matrix2d(mapping.linear.a, mapping.linear.b, mapping.linear.c, mapping.linear.d) 11 | p = Point2d(mapping.translation.x, mapping.translation.y) 12 | return AffineMap2d(mat, p) 13 | 14 | -------------------------------------------------------------------------------- /mpl_ascii/layout/transforms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | 4 | from mpl_ascii.scene.geometry.affine import AffineMap2d 5 | from mpl_ascii.scene.geometry.point import Point2d 6 | 7 | 8 | @dataclass 9 | class TransformableShape: 10 | points: tuple[Point2d,...] 11 | lines: tuple[tuple[Point2d, Point2d],...] 12 | 13 | def apply_transform(self, T: AffineMap2d) -> TransformableShape: 14 | new_points = tuple(T(p) for p in self.points) 15 | new_lines = tuple((T(l[0]), T(l[1])) for l in self.lines) 16 | return TransformableShape(new_points, new_lines) 17 | -------------------------------------------------------------------------------- /mpl_ascii/scene/fingerprint.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import ABC, abstractmethod 3 | from hashlib import sha256 4 | from typing import Iterable 5 | 6 | ROUND = 8 # float precision for stable IDs 7 | 8 | def f8(x: float) -> float: 9 | return round(float(x), ROUND) 10 | 11 | def combine(parts: Iterable[bytes]) -> str: 12 | h = sha256() 13 | for p in parts: 14 | h.update(b"|") # delimiter to avoid ambiguity 15 | h.update(p) 16 | return h.hexdigest() 17 | 18 | class Fingerprintable(ABC): 19 | 20 | @abstractmethod 21 | def fingerprint(self) -> bytes: 22 | raise NotImplementedError() 23 | -------------------------------------------------------------------------------- /mpl_ascii/scene/store.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Type, TypeVar 5 | 6 | from mpl_ascii.scene.entities import Identifable 7 | 8 | T = TypeVar("T") 9 | 10 | @dataclass 11 | class Store: 12 | id2obj: dict[str, Identifable] 13 | 14 | @classmethod 15 | def empty(cls) -> Store: 16 | return Store({}) 17 | 18 | def add(self, obj: Identifable) -> Store: 19 | self.id2obj[obj.identifier()] = obj 20 | return self 21 | 22 | def get(self, ident: str, ttype: Type[T]) -> T: 23 | obj = self.id2obj[ident] 24 | if not isinstance(obj, ttype): 25 | raise TypeError(f"{ident} is not of type {ttype.__name__}") 26 | return obj 27 | 28 | 29 | -------------------------------------------------------------------------------- /mpl_ascii/artists/Polygon.py: -------------------------------------------------------------------------------- 1 | from matplotlib.patches import Polygon 2 | 3 | from mpl_ascii.artists.transform_helpers import Point, to_mapping 4 | from mpl_ascii.artists.types import Color, LineMark, PointMark, Shape 5 | 6 | 7 | def parse(obj: Polygon) -> Shape: 8 | 9 | 10 | points = [PointMark(Point(p[0], p[1])) for p in obj.get_xy()] 11 | 12 | lines = [ 13 | LineMark(a.point, b.point) for a,b in zip(points[:-1], points[1:]) 14 | ] 15 | 16 | fill_color = Color(*obj.get_facecolor()) 17 | edge_color = Color(*obj.get_edgecolor()) 18 | 19 | return Shape( 20 | points, 21 | lines, 22 | to_mapping(obj.axes.transData), 23 | point_color=edge_color, 24 | line_color=edge_color, 25 | fill=fill_color 26 | ) -------------------------------------------------------------------------------- /mpl_ascii/artists/LineCollection.py: -------------------------------------------------------------------------------- 1 | from matplotlib.collections import LineCollection 2 | import matplotlib.lines as mlines 3 | 4 | from mpl_ascii.artists.types import Shape 5 | from mpl_ascii.artists import Line2D 6 | 7 | 8 | def parse(obj: LineCollection) -> list[Shape]: 9 | 10 | lw = obj.get_linewidths() 11 | colors = obj.get_colors() 12 | T = obj.get_transform() 13 | shapes: list[Shape] = [] 14 | for i, seg in enumerate(obj.get_segments()): 15 | (x0, y0), (x1, y1) = seg 16 | ln = mlines.Line2D([x0, x1], [y0, y1], 17 | transform=T, 18 | linewidth=lw[i % len(lw)], 19 | color=colors[i % len(colors)]) 20 | shapes.append(Line2D.parse(ln)) 21 | 22 | return shapes -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=62", "setuptools_scm>=7.1.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | requires-python = ">= 3.7" 7 | name = "mpl_ascii" 8 | dependencies = [ 9 | "matplotlib>=3.5.3", 10 | "rich>=13.7.1" 11 | ] 12 | dynamic = ["version"] 13 | 14 | description = "A matplotlib backend that produces plots using only ASCII characters" 15 | license = {text = "MIT License"} 16 | readme = "README.md" 17 | keywords = ["matplotlib", "plotting", "ASCII"] 18 | authors = [ 19 | {name = "Chris Cave"} 20 | ] 21 | maintainers = [ 22 | {name = "Chris Cave"} 23 | ] 24 | 25 | [tool.setuptools_scm] 26 | 27 | [project.urls] 28 | Homepage = "https://github.com/chriscave/mpl_ascii" 29 | 30 | [tool.setuptools] 31 | packages = ["mpl_ascii"] -------------------------------------------------------------------------------- /mpl_ascii/layout/discrete_point.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | 4 | from mpl_ascii.scene.geometry.point import Point2d 5 | 6 | 7 | @dataclass(frozen=True) 8 | class DiscretePoint: 9 | x: int 10 | y: int 11 | 12 | @classmethod 13 | def from_point2d(cls, p: Point2d) -> DiscretePoint: 14 | return cls(int(round(p.x)), int(round(p.y))) 15 | 16 | def get_row_column_coords(self, height: int, width: int) -> tuple[int, int]: 17 | row = height - self.y - 1 18 | column = self.x 19 | 20 | return row, column 21 | 22 | def __add__(self, other: DiscretePoint) -> DiscretePoint: 23 | return DiscretePoint(self.x + other.x, self.y + other.y) 24 | 25 | 26 | def __repr__(self) -> str: 27 | return f"{self.x, self.y}" 28 | 29 | -------------------------------------------------------------------------------- /mpl_ascii/artists/Line2D.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | from matplotlib.colors import to_rgba 3 | from matplotlib.lines import Line2D 4 | 5 | from mpl_ascii.artists.transform_helpers import AffineMap, Point, to_mapping 6 | from mpl_ascii.artists.types import LineMark, PointMark, Shape, Color 7 | 8 | 9 | def parse(obj: Line2D) -> Shape: 10 | x_data = obj.get_xdata() 11 | y_data = obj.get_ydata() 12 | 13 | color = to_rgba(obj.get_color()) # type: ignore 14 | 15 | 16 | points = [ 17 | PointMark(Point(x,y)) for x,y in zip(x_data, y_data) 18 | ] 19 | lines = [ 20 | LineMark(a.point, b.point) for a,b in zip(points[:-1], points[1:]) 21 | ] 22 | 23 | return Shape( 24 | points=points, 25 | lines=lines, 26 | mapping=to_mapping(obj.get_transform()), 27 | point_color=Color(*color), 28 | line_color=Color(*color), 29 | ) -------------------------------------------------------------------------------- /mpl_ascii/artists/QuadContourSet.py: -------------------------------------------------------------------------------- 1 | from matplotlib.contour import QuadContourSet 2 | 3 | from mpl_ascii.artists.transform_helpers import Point, to_mapping 4 | from mpl_ascii.artists.types import Color, LineMark, PointMark, Shape 5 | 6 | 7 | def parse(obj: QuadContourSet) -> list[Shape]: 8 | 9 | shapes: list[Shape] = [] 10 | for array, color in zip(obj.allsegs, obj.get_edgecolor()): 11 | 12 | if len(array) == 0: 13 | continue 14 | 15 | for sec in array: 16 | 17 | points = [PointMark(Point(*data)) for data in sec] 18 | lines = [ 19 | LineMark(a.point, b.point) for a,b in zip(points[:-1], points[1:]) 20 | ] 21 | 22 | shapes.append( 23 | Shape(points, lines, to_mapping(obj.axes.transData), point_color=Color(*color), line_color=Color(*color)) 24 | ) 25 | 26 | return shapes -------------------------------------------------------------------------------- /mpl_ascii/artists/PathCollection.py: -------------------------------------------------------------------------------- 1 | from matplotlib.collections import PathCollection 2 | from numpy.typing import NDArray 3 | from itertools import zip_longest 4 | 5 | from mpl_ascii.artists.transform_helpers import Point, to_mapping 6 | from mpl_ascii.artists.types import Color, ColorMap, Shape, PointMark 7 | 8 | def parse(obj: PathCollection) -> Shape: 9 | points: NDArray = obj.get_offsets() # type: ignore 10 | parsed_points: list[PointMark] = [] 11 | facecolors: NDArray = obj.get_facecolor() # type: ignore 12 | 13 | color_map = ColorMap.from_mpl_colormap(obj.get_cmap()) 14 | for p, c in zip_longest(points, facecolors, fillvalue=facecolors[0]): 15 | point = Point(p[0], p[1]) 16 | mark = PointMark(point, color=Color(*c)) 17 | parsed_points.append(mark) 18 | 19 | return Shape( 20 | parsed_points, 21 | [], 22 | to_mapping(obj.get_offset_transform()), 23 | point_color=Color(*facecolors[0]), 24 | line_color=None, 25 | ) 26 | 27 | 28 | -------------------------------------------------------------------------------- /mpl_ascii/artists/Annotation.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from matplotlib.text import Annotation 3 | from matplotlib.transforms import BboxTransformTo 4 | 5 | from mpl_ascii.artists.transform_helpers import Point, to_mapping 6 | from mpl_ascii.artists.types import TextElement 7 | 8 | 9 | def parse(obj: Annotation) -> TextElement: 10 | 11 | text = obj.get_text() 12 | x: float 13 | y: float 14 | x,y = obj.xy # type: ignore 15 | anchor = Point(x,y) # type: ignore 16 | 17 | if isinstance(obj.xycoords, Callable): 18 | return TextElement( 19 | text, 20 | anchor, 21 | to_mapping(BboxTransformTo(obj.xycoords(obj))), 22 | "center", 23 | "center" 24 | ) 25 | 26 | if isinstance(obj.xycoords, str): 27 | if obj.xycoords == "data": 28 | return TextElement( 29 | text, 30 | anchor, 31 | to_mapping(obj.axes.transData), 32 | "center", 33 | "center" 34 | ) -------------------------------------------------------------------------------- /examples/fill_between.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import matplotlib as mpl 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | 6 | 7 | 8 | if __name__ == "__main__": 9 | parser = argparse.ArgumentParser(allow_abbrev=False) 10 | parser.add_argument("--out", type=str, required=False) 11 | parser.add_argument("--ascii", action="store_true") 12 | 13 | args = parser.parse_args() 14 | out = args.out 15 | asci = args.ascii 16 | 17 | if asci: 18 | mpl.use("module://mpl_ascii") 19 | 20 | 21 | x = np.arange(0.0, 2, 0.01) 22 | y1 = np.sin(2 * np.pi * x) 23 | y2 = 0.8 * np.sin(4 * np.pi * x) 24 | 25 | fig, (ax1, ax2, ax3) = plt.subplots(3, 1, sharex=True, figsize=(8, 8)) 26 | 27 | ax1.fill_between(x, y1) 28 | ax1.set_title('fill between y1 and 0') 29 | 30 | ax2.fill_between(x, y1, 1) 31 | ax2.set_title('fill between y1 and 1') 32 | 33 | ax3.fill_between(x, y1, y2) 34 | ax3.set_title('fill between y1 and y2') 35 | ax3.set_xlabel('x') 36 | fig.tight_layout() 37 | 38 | plt.show() 39 | 40 | if out: 41 | fig.savefig(out) 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 chriscave 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mpl_ascii/artists/PolyCollection.py: -------------------------------------------------------------------------------- 1 | from matplotlib.collections import PolyCollection 2 | from matplotlib.patches import Polygon 3 | 4 | from mpl_ascii.artists.Polygon import parse as parse_polygon 5 | from mpl_ascii.artists.transform_helpers import Point, to_mapping 6 | from mpl_ascii.artists.types import Color, LineMark, PointMark, Shape 7 | 8 | def parse(obj: PolyCollection): 9 | paths = obj.get_paths() 10 | shapes: list[Shape] = [] 11 | 12 | 13 | 14 | for p in paths: 15 | 16 | points = [PointMark(Point(p[0], p[1])) for p in p.vertices] 17 | 18 | 19 | lines = [ 20 | LineMark(a.point, b.point) for a,b in zip(points[:-1], points[1:]) 21 | ] 22 | 23 | facecolor = Color(*obj.get_facecolor()[0]) if len(obj.get_facecolor()) > 0 else None 24 | # edgecolor = Color(*obj.get_edgecolor()[0]) if len(obj.get_edgecolor()) > 0 else None 25 | 26 | shapes.append( 27 | Shape( 28 | points, 29 | lines, 30 | to_mapping(obj.axes.transData), 31 | point_color=facecolor, 32 | line_color=facecolor, 33 | fill=facecolor 34 | )) 35 | 36 | return shapes 37 | -------------------------------------------------------------------------------- /mpl_ascii/scene/geometry/affine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import ABC 3 | from dataclasses import dataclass 4 | 5 | from mpl_ascii.scene.fingerprint import Fingerprintable 6 | from mpl_ascii.scene.geometry.matrix import Matrix2d 7 | from mpl_ascii.scene.geometry.point import Point2d 8 | 9 | 10 | class Mapping2d(ABC): 11 | 12 | def __call__(self, other: Point2d) -> Point2d: 13 | raise NotImplementedError() 14 | 15 | 16 | @dataclass(frozen=True) 17 | class AffineMap2d(Fingerprintable, Mapping2d): 18 | linear: Matrix2d 19 | translation: Point2d 20 | 21 | def inverse(self) -> AffineMap2d: 22 | A_inv = self.linear.inverse() 23 | Ab = A_inv * self.translation 24 | b_inv = Point2d(-Ab.x, -Ab.y) 25 | return AffineMap2d(A_inv, b_inv) 26 | 27 | def fingerprint(self) -> bytes: 28 | return b"T(" + self.linear.fingerprint() + b"," + self.translation.fingerprint() + b")" 29 | 30 | def __call__(self, other: Point2d) -> Point2d: 31 | return self.linear * other + self.translation 32 | 33 | def __matmul__(self, other: AffineMap2d) -> AffineMap2d: 34 | new_A = self.linear @ other.linear 35 | new_b = self.linear * other.translation + self.translation 36 | return AffineMap2d(new_A, new_b) 37 | 38 | def __repr__(self) -> str: 39 | row1 = f"| {self.linear.a:4.3f} {self.linear.b:4.3f} | + | {self.translation.x:4.3f} |" 40 | row2 = f"| {self.linear.c:4.3f} {self.linear.d:4.3f} | | {self.translation.y:4.3f} |" 41 | return f"{row1}\n{row2}" 42 | 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DATA_DIR=data 2 | ACCEPTANCE_DIR=tests/accepted 3 | TEST_LOCATION:=$(DATA_DIR)/tests 4 | $(shell mkdir -p $(ACCEPTANCE_DIR)) 5 | $(shell mkdir -p $(TEST_LOCATION)) 6 | 7 | 8 | MPL_ASCII_FILES=$(shell find mpl_ascii -name *.py) 9 | 10 | EXAMPLES_PY_FILES=$(wildcard examples/*.py) 11 | EXAMPLES_FILE_NAMES_WO_EXT=$(basename $(notdir $(EXAMPLES_PY_FILES))) 12 | ALL_PLOTS_TXT=$(addsuffix .txt,$(EXAMPLES_FILE_NAMES_WO_EXT)) 13 | 14 | ALL_PLOTS_NAMES=$(basename $(notdir $(EXAMPLES_PY_FILES))) 15 | 16 | .SECONDARY: accept 17 | 18 | $(ACCEPTANCE_DIR)/%.txt: $(MPL_ASCII_FILES) examples/%.py 19 | @mkdir -p $$(dirname $@) 20 | @python -m examples.$* --ascii --out $@ 21 | 22 | ascii.%: 23 | python -m examples.$* --ascii 24 | 25 | png.%: 26 | python -m examples.$* 27 | 28 | .PHONY: accept all 29 | 30 | accept: $(addprefix $(ACCEPTANCE_DIR)/,$(ALL_PLOTS_TXT)) 31 | @true 32 | 33 | 34 | test-%.success: $(ACCEPTANCE_DIR)/%.txt 35 | @mkdir -p $(TEST_LOCATION) 36 | @if [ -n "$$(git diff $<)" ]; then \ 37 | echo "\033[1m[\033[1;31mTEST FAILED:\033[1;93m $<\033[0m\033[1m]\033[0m"; \ 38 | exit 1; \ 39 | fi; 40 | @echo "\033[1m[\033[1;32mSUCCESS:\033[1;93m $<\033[0m\033[1m]\033[0m" 41 | 42 | test: $(patsubst %,test-%.success,$(ALL_PLOTS_NAMES)) 43 | 44 | venv-dev: 45 | eval "$$(pyenv init -)"; \ 46 | pyenv shell 3.11; \ 47 | python -m venv $@; \ 48 | . $@/bin/activate; \ 49 | pip install -r requirements.txt 50 | 51 | venv-%: 52 | eval "$$(pyenv init -)"; \ 53 | pyenv shell $*; \ 54 | python -m venv $@; \ 55 | . $@/bin/activate; \ 56 | pip install -r requirements.txt 57 | 58 | 59 | -------------------------------------------------------------------------------- /mpl_ascii/presentation/visibility.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from mpl_ascii.layout.layout_shape import Edge, Vertex 4 | from mpl_ascii.presentation.color_policy import DisplayColors 5 | from mpl_ascii.scene.entities import ParsedPointMark, ParsedShape 6 | 7 | def is_endpoint(pm: ParsedPointMark, shape: ParsedShape) -> bool: 8 | return pm.p in [lm.a for lm in shape.lines] + [lm.b for lm in shape.lines] 9 | 10 | 11 | @dataclass 12 | class Visibility: 13 | points: list[str] 14 | edges: list[str] 15 | fill: bool 16 | 17 | def decide_visibility(shape: ParsedShape, display_colors: DisplayColors) -> Visibility: 18 | draw_points: list[str] = [] 19 | draw_edges: list[str] = [] 20 | draw_fill: bool = False 21 | 22 | lw = shape.style_context.line_width 23 | for pm in shape.points: 24 | ident = Vertex.id_from_point_mark(pm) 25 | point_display_color = display_colors.point_colors.get(ident) 26 | if lw == 0 and is_endpoint(pm, shape): 27 | continue 28 | elif point_display_color is None and pm.override is None: 29 | continue 30 | else: 31 | draw_points.append(ident) 32 | 33 | for lm in shape.lines: 34 | ident = Edge.id_from_line_mark(lm) 35 | if lw == 0: 36 | continue 37 | elif display_colors.line_color is None and lm.override is None: 38 | continue 39 | else: 40 | draw_edges.append(ident) 41 | 42 | if display_colors.fill_color is not None: 43 | draw_fill = True 44 | 45 | return Visibility(draw_points, draw_edges, draw_fill) 46 | -------------------------------------------------------------------------------- /mpl_ascii/rendering/canvas.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | 4 | from abc import ABC, abstractmethod 5 | 6 | 7 | from mpl_ascii.presentation.glyphs import Glyph, PointGlyph 8 | from mpl_ascii.layout.discrete_point import DiscretePoint 9 | 10 | 11 | class Renderable(ABC): 12 | @abstractmethod 13 | def render(self, canvas: AsciiCanvas) -> AsciiCanvas: 14 | pass 15 | 16 | 17 | @dataclass 18 | class RenderableElement(Renderable): 19 | points: list[PointGlyph] 20 | 21 | 22 | def render(self, canvas: AsciiCanvas) -> AsciiCanvas: 23 | for p in self.points: 24 | canvas.add(p.p, p.glyph) 25 | 26 | return canvas 27 | @dataclass 28 | class AsciiCanvas: 29 | height: int 30 | width: int 31 | array: list[list[Glyph]] 32 | 33 | @classmethod 34 | def initialise(cls, height:int, width:int): 35 | blank_array = [[Glyph.blank() for _ in range(width)] for _ in range(height)] 36 | return cls(height, width, blank_array) 37 | 38 | def add(self, point: DiscretePoint, glyph: Glyph) -> AsciiCanvas: 39 | r, c = point.get_row_column_coords(self.height, self.width) 40 | self.array[r][c] = glyph 41 | return self 42 | 43 | 44 | def __str__(self) -> str: 45 | res: list[str] = [] 46 | for row in self.array: 47 | res.append("".join([str(glyph) for glyph in row])) 48 | 49 | return "\n".join(res) 50 | 51 | def __rich__(self) -> str: 52 | res: list[str] = [] 53 | for row in self.array: 54 | res.append("".join([glyph.__rich__() for glyph in row])) 55 | 56 | return "\n".join(res) 57 | 58 | 59 | -------------------------------------------------------------------------------- /mpl_ascii/scene/geometry/matrix.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import annotations 3 | from dataclasses import dataclass 4 | 5 | from mpl_ascii.scene.fingerprint import Fingerprintable, f8 6 | from mpl_ascii.scene.geometry.point import Point2d 7 | 8 | 9 | @dataclass(frozen=True) 10 | class Matrix2d(Fingerprintable): 11 | a: float 12 | b: float 13 | c: float 14 | d: float 15 | 16 | def __mul__(self, p: Point2d) -> Point2d: 17 | 18 | x_new = self.a * p.x + self.b * p.y 19 | y_new = self.c * p.x + self.d * p.y 20 | 21 | return Point2d(x_new, y_new) 22 | 23 | def __matmul__(self, other: Matrix2d) -> Matrix2d: 24 | new_a = self.a * other.a + self.b * other.c 25 | new_b = self.a * other.b + self.b * other.d 26 | new_c = self.c * other.a + self.d * other.c 27 | new_d = self.c * other.b + self.d * other.d 28 | return Matrix2d(new_a, new_b, new_c, new_d) 29 | 30 | def __repr__(self) -> str: 31 | row1 = f"| {self.a:4.3f} {self.b:4.3f} |" 32 | row2 = f"| {self.c:4.3f} {self.d:4.3f} |" 33 | return f"{row1}\n{row2}" 34 | 35 | 36 | def det(self) -> float: 37 | return self.a * self.d - self.b * self.c 38 | 39 | 40 | def inverse(self) -> Matrix2d: 41 | det = self.det() 42 | if det == 0: 43 | raise ValueError(f"Matrix {self} is not invertible (determinant is 0).") 44 | inv_det = 1.0 / det 45 | return Matrix2d( 46 | self.d * inv_det, -self.b * inv_det, 47 | -self.c * inv_det, self.a * inv_det 48 | ) 49 | 50 | def fingerprint(self) -> bytes: 51 | return f"M({f8(self.a)},{f8(self.b)},{f8(self.c)},{f8(self.d)})".encode() -------------------------------------------------------------------------------- /mpl_ascii/artists/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | from typing import Callable, Literal, Union 4 | 5 | from matplotlib.colors import Colormap 6 | 7 | 8 | from mpl_ascii.artists.transform_helpers import Mapping, Point 9 | 10 | 11 | 12 | @dataclass 13 | class PointMark: 14 | point: Point 15 | char_override: Union[str, None] = None 16 | color: Union[Color, None] = None 17 | 18 | @dataclass 19 | class LineMark: 20 | a: Point 21 | b: Point 22 | char_override: Union[str, None] = None 23 | 24 | 25 | @dataclass 26 | class ColorMap: 27 | _call: Callable[[float], Color] 28 | 29 | @classmethod 30 | def from_mpl_colormap(cls, cmap: Colormap) -> ColorMap: 31 | def call(x: float): 32 | return Color(*cmap(x)) 33 | return cls(call) 34 | 35 | def __call__(self, x: float) -> Color: 36 | return self._call(x) 37 | 38 | 39 | 40 | @dataclass 41 | class Shape: 42 | points: list[PointMark] 43 | lines: list[LineMark] 44 | mapping: Mapping 45 | line_width: Union[float, None] = None 46 | point_color: Union[Color, None] = None 47 | line_color: Union[Color, None] = None 48 | fill: Union[Color, None] = None 49 | override_zorder: Union[float, None] = None 50 | 51 | @dataclass 52 | class TextElement: 53 | text: str 54 | anchor: Point 55 | transform: Mapping 56 | horizontal_alignment: Literal["left", "center", "right"] 57 | vertical_alignment: Literal["top", "center_baseline", "center", "baseline", "bottom"] 58 | orientation: Literal["horizontal", "vertical"] = "horizontal" 59 | 60 | @dataclass 61 | class Color: 62 | red: float 63 | green: float 64 | blue: float 65 | alpha: float 66 | 67 | -------------------------------------------------------------------------------- /mpl_ascii/layout/render_plan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass, field 3 | from typing import Iterator 4 | from mpl_ascii.layout.constants import RESERVED_CHARS 5 | 6 | 7 | @dataclass 8 | class CharGenerator: 9 | exclude: set[str] = field(default_factory=lambda: RESERVED_CHARS) 10 | _chars: list[str] = field(init=False) 11 | _index: int = field(init=False, default=0) 12 | 13 | def __post_init__(self): 14 | ascii_range = [chr(i) for i in range(33, 127)] # Printable ASCII 15 | self._chars = [c for c in ascii_range if c not in self.exclude] 16 | 17 | if not self._chars: 18 | raise ValueError("No characters available for generation.") 19 | 20 | def __iter__(self) -> Iterator[str]: 21 | return self 22 | 23 | def __next__(self) -> str: 24 | if not self._chars: 25 | raise StopIteration 26 | 27 | char = self._chars[self._index] 28 | self._index = (self._index + 1) % len(self._chars) 29 | return char 30 | 31 | @dataclass 32 | class CharMap: 33 | color2char: dict[str, str] 34 | char_generator: CharGenerator 35 | 36 | 37 | @classmethod 38 | def empty(cls): 39 | return cls({}, CharGenerator()) 40 | 41 | def resolve_char(self, hex_color: str) -> str: 42 | if hex_color not in self.color2char: 43 | self.color2char[hex_color] = next(self.char_generator) 44 | return self.color2char[hex_color] 45 | 46 | def get_char2color(self) -> dict[str, str]: 47 | return {char: color for color, char in self.color2char.items()} 48 | 49 | 50 | @dataclass 51 | class CharResolver: 52 | point_char_map: CharMap = field(init=False) 53 | lines_char_map: CharMap = field(init=False) 54 | fill_char_map: CharMap = field(init=False) 55 | 56 | -------------------------------------------------------------------------------- /examples/spines.py: -------------------------------------------------------------------------------- 1 | 2 | import argparse 3 | import matplotlib as mpl 4 | import matplotlib.pyplot as plt 5 | import numpy as np 6 | 7 | mpl.use("module://mpl_ascii") 8 | 9 | 10 | if __name__ == "__main__": 11 | parser = argparse.ArgumentParser(allow_abbrev=False) 12 | parser.add_argument("--out", type=str, required=False) 13 | parser.add_argument("--ascii", action="store_true") 14 | 15 | args = parser.parse_args() 16 | out = args.out 17 | asci = args.ascii 18 | 19 | if asci: 20 | mpl.use("module://mpl_ascii") 21 | 22 | x = np.linspace(0, 2*np.pi, 100) 23 | y = 2 * np.sin(x) 24 | 25 | fig, ax_dict = plt.subplot_mosaic( 26 | [['center', 'outward'], 27 | ['axes', 'data']] 28 | , figsize=(10,8)) 29 | fig.suptitle('Spine positions') 30 | 31 | 32 | ax = ax_dict['center'] 33 | ax.set_title("'center'") 34 | ax.plot(x, y) 35 | ax.spines[['left', 'bottom']].set_position('center') 36 | ax.spines[['top', 'right']].set_visible(False) 37 | 38 | ax = ax_dict['outward'] 39 | ax.set_title("'outward'") 40 | ax.plot(x, y) 41 | ax.spines[['left', 'bottom']].set_position(('outward',-50)) 42 | # ax.spines[['top', 'right']].set_visible(False) 43 | 44 | ax = ax_dict['axes'] 45 | ax.set_title("'axes' (0.2, 0.2)") 46 | ax.plot(x, y) 47 | ax.spines.left.set_position(('axes', 0.2)) 48 | ax.spines.bottom.set_position(('axes', 0.2)) 49 | ax.spines[['top', 'right']].set_visible(False) 50 | 51 | ax = ax_dict['data'] 52 | ax.set_title("'data' (1, 2)") 53 | ax.plot(x, y) 54 | ax.spines.left.set_position(('data', 1)) 55 | ax.spines.bottom.set_position(('data', 2)) 56 | ax.spines[['top', 'right']].set_visible(False) 57 | 58 | fig.tight_layout() 59 | 60 | plt.show() 61 | 62 | 63 | if out: 64 | fig.savefig(out) -------------------------------------------------------------------------------- /mpl_ascii/artists/XAxis.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Union 3 | from matplotlib.axis import XAxis 4 | 5 | from mpl_ascii.artists.transform_helpers import Point, to_mapping 6 | from mpl_ascii.artists.types import PointMark, Shape, TextElement 7 | 8 | 9 | 10 | def parse(obj: XAxis) -> list[Union[Shape, TextElement]]: 11 | 12 | x_left, x_right = obj.axes.get_xlim() 13 | 14 | x_max = max(x_left, x_right) 15 | x_min = min(x_left, x_right) 16 | 17 | ticks: list[Shape] = [] 18 | 19 | for t in obj.get_major_ticks(): 20 | 21 | if t.get_loc() < x_min or t.get_loc() > x_max: 22 | continue 23 | 24 | 25 | tickline = t.tick1line or t.tick2line 26 | ticks.append(Shape( 27 | [PointMark(Point(t.get_loc(), 0), chr(0x252C))], 28 | [], 29 | to_mapping(tickline.get_transform().get_affine()) 30 | )) 31 | 32 | label = obj.get_label() 33 | label_text = label.get_text() 34 | pos = label.get_position() 35 | label_tran = label.get_transform() 36 | 37 | 38 | halign = label.get_horizontalalignment() 39 | valign = label.get_verticalalignment() 40 | 41 | tick_labels = obj.get_ticklabels() 42 | tick_label_elements: list[TextElement] = [] 43 | for tl in tick_labels: 44 | x,y = tl.get_position() 45 | if x x_max: 46 | continue 47 | 48 | el = TextElement( 49 | tl.get_text(), 50 | Point(x,y), 51 | to_mapping(tl.get_transform()), 52 | tl.get_horizontalalignment(), # type: ignore 53 | tl.get_verticalalignment() # type: ignore 54 | ) 55 | tick_label_elements.append(el) 56 | 57 | return [ 58 | TextElement( 59 | label_text, 60 | Point(*pos), 61 | to_mapping(label_tran), 62 | halign, # type: ignore 63 | valign, # type: ignore 64 | ) 65 | ] + tick_label_elements + ticks -------------------------------------------------------------------------------- /examples/contours.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import matplotlib as mpl 3 | import numpy as np 4 | 5 | 6 | if __name__ == "__main__": 7 | parser = argparse.ArgumentParser(allow_abbrev=False) 8 | parser.add_argument("--out", type=str, required=False) 9 | parser.add_argument("--ascii", action="store_true") 10 | 11 | args = parser.parse_args() 12 | out = args.out 13 | asci = args.ascii 14 | 15 | if asci: 16 | mpl.use("module://mpl_ascii") 17 | 18 | # data from https://allisonhorst.github.io/palmerpenguins/ 19 | 20 | import matplotlib.pyplot as plt 21 | 22 | delta = 0.025 23 | x = np.arange(-3.0, 3.0, delta) 24 | y = np.arange(-2.0, 2.0, delta) 25 | X, Y = np.meshgrid(x, y) 26 | Z1 = np.exp(-X**2 - Y**2) 27 | Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2) 28 | Z = (Z1 - Z2) * 2 29 | 30 | fig, axes = plt.subplots(1,2, figsize=(10,4)) 31 | ax = axes[0] 32 | CS = ax.contour(X, Y, Z) 33 | ax.clabel(CS, inline=True, fontsize=10) 34 | ax.set_title('Simplest default with labels') 35 | 36 | 37 | x = np.linspace(-5, 5, 100) 38 | y = np.linspace(-5, 5, 100) 39 | X, Y = np.meshgrid(x, y) 40 | 41 | # Define two functions to generate contour data 42 | Z1 = np.sin(np.sqrt(X**2 + Y**2)) 43 | Z2 = np.cos(np.sqrt(X**2 + Y**2)) 44 | 45 | # Create a figure and axis object 46 | 47 | ax = axes[1] 48 | 49 | # Create the contour plots 50 | contour1 = ax.contour(X, Y, Z1, colors='blue') 51 | contour2 = ax.contour(X, Y, Z2, colors='red') 52 | 53 | # Add labels and title 54 | ax.set_title('Contour Plots of Two Functions') 55 | ax.set_xlabel('X-axis') 56 | ax.set_ylabel('Y-axis') 57 | 58 | # Add a legend 59 | h1,_ = contour1.legend_elements() 60 | h2,_ = contour2.legend_elements() 61 | ax.legend([h1[0], h2[0]], ['sin(sqrt(x^2 + y^2))', 'cos(sqrt(x^2 + y^2))']) 62 | 63 | 64 | fig.tight_layout() 65 | 66 | plt.show() 67 | 68 | if out: 69 | fig.savefig(out) 70 | -------------------------------------------------------------------------------- /examples/polygons.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import matplotlib as mpl 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | from matplotlib.patches import Polygon 6 | 7 | 8 | 9 | 10 | if __name__ == "__main__": 11 | parser = argparse.ArgumentParser(allow_abbrev=False) 12 | parser.add_argument("--out", type=str, required=False) 13 | parser.add_argument("--ascii", action="store_true") 14 | 15 | args = parser.parse_args() 16 | out = args.out 17 | asci = args.ascii 18 | 19 | if asci: 20 | mpl.use("module://mpl_ascii") 21 | 22 | # Create a figure with 2x2 subplots 23 | fig, axes = plt.subplots(2, 2, figsize=(10, 8)) 24 | 25 | # Define polygon vertices for different shapes 26 | triangle = np.array([[0.2, 0.2], [0.8, 0.2], [0.5, 0.8]]) 27 | pentagon = np.array([[0.5, 0.2], [0.8, 0.4], [0.7, 0.7], [0.3, 0.7], [0.2, 0.4]]) 28 | hexagon = np.array([[0.5, 0.2], [0.7, 0.3], [0.7, 0.6], [0.5, 0.7], [0.3, 0.6], [0.3, 0.3]]) 29 | star = np.array([[0.5, 0.9], [0.4, 0.6], [0.1, 0.5], [0.4, 0.4], [0.5, 0.1], 30 | [0.6, 0.4], [0.9, 0.5], [0.6, 0.6]]) 31 | 32 | # Array of polygons and their properties 33 | polygons = [ 34 | (triangle, 'cyan', 'Triangle', axes[0, 0]), 35 | (pentagon, 'lime', 'Pentagon', axes[0, 1]), 36 | (hexagon, 'yellow', 'Hexagon', axes[1, 0]), 37 | (star, 'magenta', 'Star', axes[1, 1]) 38 | ] 39 | 40 | # Add polygons to subplots 41 | for poly_verts, color, title, ax in polygons: 42 | polygon = Polygon(poly_verts, closed=True, facecolor=color, alpha=0.5, edgecolor='black') 43 | ax.add_patch(polygon) 44 | ax.set_title(title) 45 | ax.set_xlim(0, 1) 46 | ax.set_ylim(0, 1) 47 | ax.set_aspect('equal') 48 | ax.grid(True, linestyle='--', alpha=0.7) 49 | 50 | # Adjust layout to prevent overlap 51 | fig.tight_layout() 52 | 53 | # Display the plot 54 | plt.show() 55 | 56 | 57 | if out: 58 | fig.savefig(out) 59 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `mpl_ascii` 2 | 3 | Thank you for your interest in contributing to `mpl_ascii`! Here are some guidelines to help you get started. 4 | 5 | ## Project Goals 6 | 7 | This repository is designed around four core principles: 8 | 9 | - **Accessible**: The ASCII output should work across different terminals and environments, requiring no special dependencies beyond Python 10 | - **Seamless**: Users should be able to drop in the backend with minimal code changes (`matplotlib.use('mpl_ascii')`) and get meaningful output 11 | - **Scalable**: The architecture should handle complex matplotlib figures with multiple artists and elements efficiently 12 | - **Contributor-friendly**: Adding support for new matplotlib artists should be straightforward with clear patterns to follow 13 | 14 | ## Pull Requests 15 | 16 | - **Fork the Repository**: Please make PRs from your own fork of the repository. 17 | 18 | ## Setting Up Your Environment 19 | 20 | - **pyenv**: We use pyenv to manage multiple Python versions. Ensure you have pyenv installed and set up before proceeding. 21 | - **Creating Virtual Environments**: You can create virtual environments using venv with the following commands: 22 | 23 | ```bash 24 | make venv-dev 25 | make venv-3.9 26 | make venv-3.10 27 | ``` 28 | 29 | ## Running Tests and Examples 30 | 31 | - **make accept**: This command runs all example plots and saves the figures to text files. Ensure you run make accept and commit the resulting text files as the last step before submitting your PR. This ensures the example text files are up to date with the current state of the code and prevents build failures. 32 | 33 | ## Summary 34 | 35 | 1. 🏗️ Fork the repository. 36 | 1. 🐍 Use pyenv to manage Python versions. 37 | 1. 📦 Create virtual environments with make venv-dev, make venv-3.7, make venv-3.8, etc. 38 | 1. ✅ Run make accept and commit the resulting text files as the last step before submitting your PR to ensure the example text files are up to date and to prevent build failures. 39 | 40 | Thank you for contributing! -------------------------------------------------------------------------------- /mpl_ascii/artists/YAxis.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Union 3 | from matplotlib.axis import YAxis 4 | from matplotlib.lines import Line2D 5 | 6 | from mpl_ascii.artists.transform_helpers import Point, to_mapping 7 | from mpl_ascii.artists.types import PointMark, Shape, TextElement 8 | 9 | def parse(obj: YAxis) -> list[Union[Shape, TextElement]]: 10 | 11 | 12 | y_bottom, y_top = obj.axes.get_ylim() 13 | 14 | y_max = max(y_bottom, y_top) 15 | y_min = min(y_bottom, y_top) 16 | 17 | ticks: list[Shape] = [] 18 | 19 | def ticks_on_right_side() -> bool: 20 | tick = obj.get_major_ticks()[0] 21 | return tick.tick2line.get_visible() 22 | 23 | 24 | for t in obj.get_major_ticks(): 25 | if t.get_loc() < y_min or t.get_loc() > y_max: 26 | continue 27 | 28 | tickline = t.tick1line 29 | char = chr(0x2524) 30 | if ticks_on_right_side(): 31 | tickline = t.tick2line 32 | char = chr(0x251C) 33 | 34 | ticks.append(Shape( 35 | [PointMark(Point(*tickline.get_xydata()[0]), char)], 36 | [], 37 | to_mapping(tickline.get_transform().get_affine()) 38 | )) 39 | 40 | label = obj.get_label() 41 | label_text = label.get_text() 42 | pos = label.get_position() 43 | label_tran = label.get_transform() 44 | 45 | tick_labels = obj.get_ticklabels() 46 | tick_label_elements: list[TextElement] = [] 47 | 48 | for tl in tick_labels: 49 | x,y = tl.get_position() 50 | 51 | if y y_max: 52 | continue 53 | el = TextElement( 54 | tl.get_text(), 55 | Point(x,y), 56 | to_mapping(tl.get_transform()), 57 | tl.get_horizontalalignment(), # type: ignore 58 | tl.get_verticalalignment() # type: ignore 59 | ) 60 | 61 | tick_label_elements.append(el) 62 | 63 | axis_label = TextElement( 64 | label_text, 65 | Point(*pos), 66 | to_mapping(label_tran), 67 | "left" if ticks_on_right_side() else "right", 68 | "center", 69 | orientation="vertical" 70 | ) 71 | 72 | return [axis_label] + tick_label_elements + ticks -------------------------------------------------------------------------------- /mpl_ascii/artists/Rectangle.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | from matplotlib.patches import Rectangle 3 | 4 | from mpl_ascii.artists.transform_helpers import AffineMap, Point, to_mapping 5 | from mpl_ascii.artists.types import Color, LineMark, PointMark, Shape 6 | 7 | def parse(obj: Rectangle) -> Shape: 8 | 9 | facecolor = obj.get_facecolor() 10 | facecolor = Color(*facecolor) 11 | line_color = Color(*obj.get_edgecolor()) 12 | point_color = Color(*obj.get_edgecolor()) 13 | 14 | 15 | mapping = cast(AffineMap, to_mapping(obj.get_transform())) 16 | 17 | bl_char = chr(0x2514) # └ 18 | br_char = chr(0x2518) # ┘ 19 | tl_char = chr(0x250C) # ┌ 20 | tr_char = chr(0x2510) # ┐ 21 | 22 | if y_axis_is_flipped(mapping): 23 | bl_char, br_char, tl_char, tr_char = tl_char, tr_char, bl_char, br_char 24 | 25 | if x_axis_is_flipped(mapping): 26 | bl_char, br_char, tl_char, tr_char = br_char, bl_char, tr_char, tl_char 27 | 28 | bl = PointMark(Point(0,0), bl_char) 29 | br = PointMark(Point(1,0), br_char) 30 | tl = PointMark(Point(0,1), tl_char) 31 | tr = PointMark(Point(1,1), tr_char) 32 | 33 | 34 | hline1 = LineMark(bl.point, br.point, chr(0x2500)) # ─ horizontal line 35 | hline2 = LineMark(tl.point, tr.point, chr(0x2500)) # ─ horizontal line 36 | 37 | vline1 = LineMark(bl.point, tl.point, chr(0x2502)) # │ vertical line 38 | vline2 = LineMark(br.point, tr.point, chr(0x2502)) # │ vertical line 39 | 40 | if abs(obj.get_angle()) > 1e-3: 41 | bl = PointMark(Point(0,0)) 42 | br = PointMark(Point(1,0)) 43 | tl = PointMark(Point(0,1)) 44 | tr = PointMark(Point(1,1)) 45 | 46 | hline1 = LineMark(bl.point, br.point) 47 | hline2 = LineMark(tl.point, tr.point) 48 | vline1 = LineMark(bl.point, tl.point) 49 | vline2 = LineMark(br.point, tr.point) 50 | 51 | 52 | return Shape( 53 | [bl, br, tl, tr], 54 | [hline1, hline2, vline1, vline2], 55 | mapping, 56 | line_width=obj.get_linewidth(), 57 | fill=facecolor, 58 | line_color=line_color, 59 | point_color=point_color 60 | ) 61 | 62 | 63 | def y_axis_is_flipped(map: AffineMap): 64 | return map.linear.d < 0 65 | 66 | def x_axis_is_flipped(map: AffineMap): 67 | return map.linear.a < 0 -------------------------------------------------------------------------------- /mpl_ascii/presentation/color_policy.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Union 3 | 4 | import numpy as np 5 | from mpl_ascii.layout.layout_shape import Vertex 6 | from mpl_ascii.scene.entities import ParsedColor, ParsedColorMap, ParsedShape 7 | 8 | @dataclass 9 | class DisplayColors: 10 | point_colors: dict[str, Union[str, None]] 11 | line_color: Union[str, None] 12 | fill_color: Union[str, None] 13 | 14 | 15 | def filter_unusable(c: Union[ParsedColor, None]) -> Union[ParsedColor, None]: 16 | res: Union[ParsedColor, None] = c 17 | 18 | if c is None or c.alpha == 0: 19 | res = None 20 | 21 | return res 22 | 23 | def apply_theme_constrast(c: ParsedColor) -> str: 24 | 25 | res: str = c.hex_color 26 | eps = 1e-3 27 | if c.red < eps and c.green < eps and c.blue < eps: 28 | 29 | res = ParsedColor.to_hex(1.,1.,1.) 30 | 31 | return res 32 | 33 | def qualify_color_for_display(color: Union[ParsedColor, None]) -> Union[str, None]: 34 | c = filter_unusable(color) 35 | return None if c is None else apply_theme_constrast(c) 36 | 37 | 38 | def coalese_color(hex_color: str, color_map: ParsedColorMap) -> str: 39 | """Coalesce a single hex color using the colormap.""" 40 | linspace: list[float] = np.linspace(0,1,10).tolist() 41 | 42 | x = color_map.approx_inverse(ParsedColor.from_hex(hex_color)) 43 | y = 0 44 | for y in linspace: 45 | if y > x: 46 | break 47 | 48 | return color_map(y).hex_color 49 | 50 | def qualify_for_display(parsed_shape: ParsedShape) -> DisplayColors: 51 | default_display_point_color = qualify_color_for_display(parsed_shape.style_context.point_color) 52 | display_point_colors: dict[str, Union[str, None]] = {} 53 | 54 | for pm in parsed_shape.points: 55 | ident = Vertex.id_from_point_mark(pm) 56 | color = qualify_color_for_display(pm.color) if pm.color else default_display_point_color 57 | display_point_colors[ident] = color 58 | 59 | # Process line color 60 | display_line_color = qualify_color_for_display(parsed_shape.style_context.line_color) 61 | 62 | # Process fill color 63 | display_fill_color = qualify_color_for_display(parsed_shape.style_context.fill_color) 64 | 65 | return DisplayColors( 66 | display_point_colors, 67 | display_line_color, 68 | display_fill_color 69 | ) 70 | 71 | -------------------------------------------------------------------------------- /mpl_ascii/parsing/shape.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from mpl_ascii.artists.types import Color, LineMark, Point, PointMark, Shape, TextElement 3 | from mpl_ascii.scene.geometry.point import Point2d 4 | from mpl_ascii.parsing.transform import parse_mapping 5 | from mpl_ascii.scene.entities import ParsedColor, ParsedLineMark, ParsedPointMark, ParsedShape, ParsedText, StyleContext 6 | 7 | 8 | def parse_point(p: Point) -> Point2d: 9 | return Point2d(p.x, p.y) 10 | 11 | def parse_line(line: tuple[Point, Point]) -> tuple[Point2d, Point2d]: 12 | return (parse_point(line[0]), parse_point(line[1])) 13 | 14 | def parse_point_mark(pm: PointMark) -> ParsedPointMark: 15 | color = parse_color(pm.color) if pm.color else None 16 | return ParsedPointMark(parse_point(pm.point), pm.char_override, color) 17 | 18 | def parse_line_mark(lm: LineMark) -> ParsedLineMark: 19 | return ParsedLineMark(parse_point(lm.a), parse_point(lm.b), lm.char_override) 20 | 21 | def parse_shape(shape: Shape, zorder: float, insert_order: float) -> ParsedShape: 22 | 23 | point_color = parse_color(shape.point_color) if shape.point_color else None 24 | line_color = parse_color(shape.line_color) if shape.line_color else None 25 | fill_color = parse_color(shape.fill) if shape.fill else None 26 | 27 | 28 | return ParsedShape( 29 | tuple(parse_point_mark(p) for p in shape.points), 30 | tuple(parse_line_mark(l) for l in shape.lines), 31 | StyleContext( 32 | point_color, 33 | line_color, 34 | fill_color, 35 | line_width=shape.line_width, 36 | ), 37 | zorder=shape.override_zorder or zorder, 38 | transform2display=parse_mapping(shape.mapping), 39 | insert_order=insert_order 40 | ) 41 | 42 | def parse_color(color: Color) -> ParsedColor: 43 | return ParsedColor( 44 | color.red, 45 | color.green, 46 | color.blue, 47 | color.alpha, 48 | ) 49 | 50 | def parse_text(text_elmt: TextElement, zorder: float, insert_order: float) -> ParsedText: 51 | return ParsedText( 52 | text=text_elmt.text, 53 | anchor=parse_point(text_elmt.anchor), 54 | orientation=text_elmt.orientation, 55 | horizontal_alignment=text_elmt.horizontal_alignment, 56 | vertical_alignment=text_elmt.vertical_alignment, 57 | zorder=zorder, 58 | transform2display=parse_mapping(text_elmt.transform), 59 | insert_order=insert_order 60 | ) 61 | -------------------------------------------------------------------------------- /tests/accepted/contours.txt: -------------------------------------------------------------------------------- 1 | 2 | Simplest default with labels Contour Plots of Two Functions 3 | ┌────────────────$$──────────#──────────#──────────┐ )((─(─(─(─(())((─))───))))))))────))(())((─(─(((((() 4 | │ $$ ### """""" ## │ )((((((()))))))))))))))))))┌──────────────────────┐) 5 | │ $$$ # "" −1.0 ## │ 4 ((((())))))))))))))))))))))│( sin(sqrt(x^2 + y^2))│( 6 | 1.5 ┤ $$$ # "" !!!!!!! " ## │ ((())))))))))))))))))))))))│) cos(sqrt(x^2 + y^2))│( 7 | │ $$$ # "" ! ! " # │ ()))))))))))))))(((((((((((└──────────────────────┘( 8 | 1.0 ┤ $$$##""! ! " # │ )))))))))))))((((((((())))))))((((((((())))))))))))) 9 | │ %%%%%%0.5$$##−1.5 !!! " # │ 2 )))))))))))(((((())))))))))))))))))((((((()))))))))) 10 | 0.5 ┤ %% &&&&&&%%0.0#""" """ ## │ Y )))))))))(((((())))))))))))))))))))))(((((())))))))) 11 | │ %% &&& ''&&&%%$−0.5"""" ## │ - ))))))))(((((())))))))))))))))))))))))(((((()))))))) 12 | │ % & '' '''1.0%$$$ ###### │ a )))))()(( ((())))))))))))))))))))))))()(((((()()) )) 13 | 0.0 ┤ % & ' '' & %%$$ │ x 0 )) ))()(((((()()) )))()(((((()()) )))()(( ((()()) )) 14 | │ % & 1.5 ' && % $$ │ i )))))))(((((())))))))))))))))))))))))))(((((()())))) 15 | −0.5 ┤ %% &&& '''''' && % $$$ │ s │)))))))(((((())))))))))))))))))))))))(((((()))))))) 16 | │ %% &&& &&& % $$$ │ )))))))))(((((())))))))))))))))))))))(((((())))))))) 17 | │ %%% &&&& %% $$$ │ −2 )))))))))))((((((())))))))))))))))((((((())))))))))) 18 | −1.0 ┤ %%%%%%%%%%%% $$ │ ))))))))))))))(((((((((((((((((((((((()))))))))))))) 19 | │ $$ │ ()))))))))))))))))(((((((((((((((()))))))))))))))))( 20 | −1.5 ┤ $$ │ −4 (((()))))))))))))))))))))))))))))))))))))))))))))((( 21 | │ $$$│ (((((())))))))))))))))))))))))))))))))))))))))(((((( 22 | −2.0 ┤────────┬───────┬────────┬───────┬────────┬──────$$ )(((((((())))))))))))))))))))))))))))))))))(((((((() 23 | 24 | −3 −2 −1 0 1 2 −4 −2 0 2 4 25 | X-axis 26 | -------------------------------------------------------------------------------- /mpl_ascii/rendering/canvas_manager.py: -------------------------------------------------------------------------------- 1 | import io 2 | from typing import Union 3 | from matplotlib.backend_bases import FigureCanvasBase, FigureManagerBase 4 | from matplotlib.figure import Figure 5 | 6 | from mpl_ascii.rendering.canvas import AsciiCanvas 7 | from mpl_ascii.artists.transform_helpers import AffineMap, Matrix 8 | from mpl_ascii.artists.types import Point 9 | from mpl_ascii.parsing.figure import from_figure 10 | from mpl_ascii.rendering.render import draw_figure 11 | from mpl_ascii.scene.store import Store 12 | 13 | from matplotlib.backends.backend_agg import FigureCanvasAgg 14 | 15 | ENABLE_COLORS: bool = True 16 | 17 | class FigureCanvasAscii(FigureCanvasBase): 18 | def __init__(self, figure: Figure) -> None: 19 | super().__init__(figure) 20 | self.figure_canvas: Figure = figure 21 | 22 | 23 | def force_draw(self): 24 | # This forces a draw of the figure. It ensures that locations that are set automatically by matplotlib are updated. 25 | # Some examples of this are: 26 | # - tick locations 27 | # - legend location 28 | ascii_canvas = self.figure_canvas.canvas 29 | try: 30 | FigureCanvasAgg(self.figure_canvas) 31 | self.figure_canvas.canvas.draw() # type: ignore 32 | finally: 33 | self.figure_canvas.set_canvas(ascii_canvas) 34 | 35 | 36 | 37 | def draw_canvas(self) -> AsciiCanvas: 38 | 39 | self.force_draw() 40 | width, height = self.get_canvas_width_height() 41 | M = Matrix(width-1, 0,0,height-1) 42 | figure2ascii_canvas_transform = AffineMap(M, Point(0,0)) 43 | self.figure2ascii_canvas_transform = figure2ascii_canvas_transform 44 | 45 | store = Store.empty() 46 | parsed_figure = from_figure(self.figure_canvas, store) 47 | store.add(parsed_figure) 48 | 49 | canvas = draw_figure(parsed_figure, height, width, store) 50 | 51 | return canvas 52 | 53 | def get_canvas_width_height(self) -> tuple[int, int]: 54 | width_in, height_in = self.figure_canvas.get_size_inches() # type: ignore 55 | 56 | 57 | ascii_char_width_height_ratio = 1.8 58 | 59 | scale = 12 60 | 61 | chars_per_inch_x = 1 * scale 62 | chars_per_inch_y = chars_per_inch_x / ascii_char_width_height_ratio 63 | 64 | ascii_canvas_width = int(width_in * chars_per_inch_x) 65 | ascii_canvas_height = int(height_in * chars_per_inch_y) 66 | 67 | return ascii_canvas_width, ascii_canvas_height 68 | 69 | def print_txt(self, writable: Union[str, io.BytesIO], **kwargs): 70 | 71 | if isinstance(writable, str): 72 | with open(writable, "w") as f: 73 | f.write(str(self.draw_canvas())) 74 | else: 75 | writable.write(str(self.draw_canvas()).encode()) 76 | 77 | 78 | class FigureManagerAscii(FigureManagerBase): 79 | def __init__(self, canvas: FigureCanvasAscii, num: Union[int, str]): 80 | super().__init__(canvas, num) 81 | 82 | self.ascii_canvas: FigureCanvasAscii = canvas 83 | 84 | def show(self) -> None: 85 | 86 | canvas = self.ascii_canvas.draw_canvas() 87 | 88 | if ENABLE_COLORS == True: 89 | from rich.console import Console 90 | console = Console() 91 | console.print(canvas, highlight=False) 92 | 93 | else: 94 | print(canvas) 95 | -------------------------------------------------------------------------------- /mpl_ascii/rendering/render.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import annotations 3 | from mpl_ascii.layout.layout_text import layout_text 4 | from mpl_ascii.presentation.glyphs import Glyph, PointGlyph, resolve_glyphs 5 | from mpl_ascii.layout.layout_shape import Edge, FillPoint, ShapeLayout, Vertex, layout_shape 6 | from mpl_ascii.presentation.visibility import decide_visibility 7 | from mpl_ascii.scene.geometry.affine import AffineMap2d 8 | from mpl_ascii.scene.geometry.matrix import Matrix2d 9 | from mpl_ascii.scene.geometry.point import Point2d 10 | from mpl_ascii.rendering.canvas import AsciiCanvas, Renderable, RenderableElement 11 | from mpl_ascii.layout.render_plan import CharMap 12 | from mpl_ascii.scene.entities import ParsedFigure, ParsedShape, ParsedText 13 | from mpl_ascii.scene.store import Store 14 | from mpl_ascii.presentation.color_policy import qualify_for_display 15 | 16 | 17 | def draw_shape(resolved_glyphs: dict[str, Glyph], shape_layout: ShapeLayout): 18 | point_glyphs2: list[PointGlyph] = [] 19 | for ident, glyph in resolved_glyphs.items(): 20 | shape_element = shape_layout.get(ident) 21 | if isinstance(shape_element, Vertex) or isinstance(shape_element, FillPoint): 22 | point_glyphs2.append(PointGlyph(shape_element.p, glyph)) 23 | elif isinstance(shape_element, Edge): # type: ignore 24 | point_glyphs2 += [PointGlyph(rp, glyph) for rp in shape_element.raster_points] 25 | 26 | return RenderableElement(point_glyphs2) 27 | 28 | 29 | def draw_figure( 30 | figure: ParsedFigure, 31 | ascii_canvas_height: int, 32 | ascii_canvas_width: int, 33 | store: Store, 34 | ) -> AsciiCanvas: 35 | 36 | renderable_objs: list[tuple[Renderable, float, float]] = [] 37 | 38 | char_map = CharMap.empty() 39 | 40 | M = Matrix2d(ascii_canvas_width-1, 0,0,ascii_canvas_height-1) 41 | 42 | figure2ascii_canvas_transform = AffineMap2d(M, Point2d(0,0)) 43 | display2figure_transform = figure.figure2display_tranform.inverse() 44 | 45 | for shape_id in figure.shapes: 46 | 47 | if shape_id in figure.background_patches: 48 | continue 49 | 50 | parsed_shape = store.get(shape_id, ParsedShape) 51 | transform = figure2ascii_canvas_transform @ display2figure_transform @ parsed_shape.local2display_transform() 52 | 53 | shape_layout = layout_shape(parsed_shape, transform) 54 | dc = qualify_for_display(parsed_shape) 55 | vis = decide_visibility(parsed_shape, dc) 56 | id2glyph = resolve_glyphs(parsed_shape, vis, dc, char_map, shape_layout) 57 | renderable_shape = draw_shape(id2glyph, shape_layout) 58 | 59 | renderable_objs.append( 60 | (renderable_shape, parsed_shape.zorder, parsed_shape.insert_order) 61 | ) 62 | 63 | for ident in figure.texts: 64 | 65 | text = store.get(ident, ParsedText) 66 | transform = figure2ascii_canvas_transform @ display2figure_transform @ text.local2display_transform() 67 | 68 | points = layout_text(text, transform, ascii_canvas_height, ascii_canvas_width) 69 | 70 | renderable_objs.append((RenderableElement(points), text.zorder, text.insert_order)) 71 | 72 | canvas = AsciiCanvas.initialise(ascii_canvas_height, ascii_canvas_width) 73 | 74 | 75 | renderable_objs = sorted(renderable_objs, key=lambda x: (x[1], x[2])) 76 | for obj, _, _ in renderable_objs: 77 | obj.render(canvas) 78 | 79 | 80 | return canvas 81 | -------------------------------------------------------------------------------- /mpl_ascii/presentation/glyphs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | from typing import Union 4 | 5 | 6 | from mpl_ascii.layout.layout_shape import Edge, ShapeLayout, Vertex 7 | from mpl_ascii.presentation.color_policy import DisplayColors 8 | from mpl_ascii.presentation.visibility import Visibility 9 | from mpl_ascii.layout.render_plan import CharMap 10 | from mpl_ascii.layout.discrete_point import DiscretePoint 11 | from mpl_ascii.scene.entities import ParsedColor, ParsedShape 12 | 13 | 14 | 15 | @dataclass 16 | class Glyph: 17 | char: str 18 | color: Union[str, None] 19 | 20 | @classmethod 21 | def blank(cls): 22 | return cls(" ", None) 23 | 24 | def __str__(self) -> str: 25 | return self.char 26 | 27 | def __rich__(self) -> str: 28 | if self.color: 29 | return f"[{self.color}]{self.char}[/{self.color}]" 30 | return self.char 31 | 32 | 33 | @dataclass 34 | class PointGlyph: 35 | p: DiscretePoint 36 | glyph: Glyph 37 | 38 | 39 | def resolve_glyphs( 40 | parsed_shape: ParsedShape, 41 | visible: Visibility, 42 | display_colors: DisplayColors, 43 | char_map: CharMap, 44 | shape_layout: ShapeLayout, 45 | )-> dict[str, Glyph]: 46 | 47 | def is_white(c: Union[ParsedColor, None]) -> bool: 48 | if c is None: 49 | return False 50 | eps: float = 1e-3 51 | return c.red > 1-eps and c.green > 1-eps and c.blue > 1-eps 52 | 53 | visible_parsed_pms = [pm for pm in parsed_shape.points if Vertex.id_from_point_mark(pm) in visible.points] 54 | res: dict[str, Glyph] = {} 55 | for pm in visible_parsed_pms: 56 | ident = Vertex.id_from_point_mark(pm) 57 | hex_point_color = display_colors.point_colors.get(ident) 58 | if pm.override: 59 | res[ident] = Glyph(pm.override, hex_point_color) 60 | continue 61 | 62 | if hex_point_color: 63 | if is_white(parsed_shape.style_context.point_color): 64 | res[ident] = Glyph.blank() 65 | 66 | else: 67 | point_char = char_map.resolve_char(hex_point_color) 68 | res[ident] = Glyph(point_char, hex_point_color) 69 | 70 | 71 | visible_parsed_lms = [lm for lm in parsed_shape.lines if Edge.id_from_line_mark(lm) in visible.edges] 72 | hex_edge_color = display_colors.line_color 73 | for lm in visible_parsed_lms: 74 | ident = Edge.id_from_line_mark(lm) 75 | if lm.override: 76 | res[ident] = Glyph(lm.override, hex_edge_color) 77 | continue 78 | 79 | if hex_edge_color: 80 | if is_white(parsed_shape.style_context.line_color): 81 | res[ident] = Glyph.blank() 82 | else: 83 | line_char = char_map.resolve_char(hex_edge_color) 84 | res[ident] = Glyph(line_char, hex_edge_color) 85 | 86 | hex_fill_color = display_colors.fill_color 87 | if visible.fill: 88 | fill_points = shape_layout.get_all_fill() 89 | fill_idents = [fp.id for fp in fill_points] 90 | 91 | if is_white(parsed_shape.style_context.fill_color): 92 | res.update({ident: Glyph.blank() for ident in fill_idents}) 93 | 94 | elif hex_fill_color: 95 | fill_char = char_map.resolve_char(hex_fill_color) 96 | res.update({ident: Glyph(fill_char, hex_fill_color) for ident in fill_idents}) 97 | 98 | return res 99 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | release: 12 | types: ["published"] 13 | 14 | jobs: 15 | test: 16 | 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | python -m pip install pytest 33 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 34 | - name: Acceptance test 35 | run: | 36 | make test 37 | 38 | build-and-publish: 39 | needs: test 40 | if: github.ref == 'refs/heads/main' || github.event_name == 'release' 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | with: 45 | fetch-depth: 0 46 | - name: Set up Python 47 | uses: actions/setup-python@v5 48 | with: 49 | python-version: '3.10' 50 | 51 | - name: Install packaging tools 52 | run: | 53 | python -m pip install --upgrade pip 54 | python -m pip install build twine 55 | 56 | - name: Build package 57 | run: | 58 | python -m build 59 | 60 | - name: Publish to Private PyPI 61 | run: | 62 | python -m twine upload --repository-url https://pypi.fury.io/chriscave/ -u ${{ secrets.GEMFURY_UPLOAD_PYPI_API }} -p dist/* 63 | 64 | download-and-test: 65 | needs: build-and-publish 66 | runs-on: ubuntu-latest 67 | strategy: 68 | fail-fast: false 69 | matrix: 70 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 71 | steps: 72 | - name: Set up Python ${{ matrix.python-version }} 73 | uses: actions/setup-python@v5 74 | with: 75 | python-version: ${{ matrix.python-version }} 76 | - name: Install dependencies 77 | run: | 78 | python -m pip install --upgrade pip 79 | pip install --pre --index-url https://${{ secrets.GEMFURY_DOWNLOAD_PYPI_API }}:@pypi.fury.io/chriscave/ --extra-index-url https://pypi.org/simple mpl_ascii 80 | - name: Test package 81 | run: | 82 | python -c "import mpl_ascii" 83 | 84 | release-to-pypi: 85 | needs: download-and-test 86 | if: github.event_name == 'release' && github.event.action == 'published' 87 | runs-on: ubuntu-latest 88 | steps: 89 | - uses: actions/checkout@v4 90 | - name: Set up Python 91 | uses: actions/setup-python@v5 92 | with: 93 | python-version: '3.10' 94 | - name: Install packaging tools 95 | run: | 96 | python -m pip install --upgrade pip 97 | pip install build 98 | - name: Build package 99 | run: python -m build 100 | - name: Publish to PyPI 101 | uses: pypa/gh-action-pypi-publish@release/v1 102 | with: 103 | password: ${{ secrets.PYPI_API_TOKEN }} -------------------------------------------------------------------------------- /mpl_ascii/artists/Legend.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Union, cast 3 | from matplotlib.collections import PathCollection 4 | from matplotlib.colors import to_rgba 5 | from matplotlib.legend import Legend 6 | from matplotlib.lines import Line2D 7 | from matplotlib.patches import Rectangle 8 | from matplotlib.transforms import Transform 9 | 10 | from mpl_ascii.artists.transform_helpers import AffineMap, Point, get_figure2ascii_transform, to_mapping 11 | from mpl_ascii.artists.types import Color, LineMark, PointMark, Shape, TextElement 12 | 13 | 14 | 15 | 16 | def parse(obj: Legend) -> list[Union[Shape, TextElement]]: 17 | 18 | x0, y0, _, _ = obj.get_frame().get_bbox().bounds 19 | figure2ascii = get_figure2ascii_transform(obj.get_figure()) 20 | figure2display: Transform = obj.get_figure().transFigure # type: ignore 21 | mapping = cast(AffineMap, to_mapping(figure2display)) @ figure2ascii.inverse() 22 | mapping = AffineMap(mapping.linear, Point(x0,y0)) 23 | 24 | 25 | 26 | shapes: list[Shape] = [] 27 | texts: list[TextElement] = [] 28 | 29 | longest_legend_label = max([len(t.get_text()) for t in obj.get_texts()]) 30 | artist_width = 1 31 | artist_sep = 1 32 | left_pad = 0 33 | right_pad = 1 34 | top_pad = 1 35 | 36 | height = len(obj.get_texts()) + top_pad 37 | if obj.get_title().get_text(): 38 | height += 1 39 | width = max(longest_legend_label + artist_width + artist_sep, len(obj.get_title().get_text())) + left_pad + right_pad 40 | 41 | 42 | bl = PointMark(Point(0,0), chr(0x2514)) # └ 43 | br = PointMark(Point(width,0), chr(0x2518)) # ┘ 44 | tl = PointMark(Point(0,height), chr(0x250C)) # ┌ 45 | tr = PointMark(Point(width,height), chr(0x2510)) # ┐ 46 | 47 | 48 | hline1 = LineMark(bl.point, br.point, chr(0x2500)) # ─ horizontal line 49 | hline2 = LineMark(tl.point, tr.point, chr(0x2500)) # ─ horizontal line 50 | 51 | vline1 = LineMark(bl.point, tl.point, chr(0x2502)) # │ vertical line 52 | vline2 = LineMark(br.point, tr.point, chr(0x2502)) # │ vertical line 53 | 54 | background_box = Shape( 55 | [bl, br, tl, tr], 56 | [hline1, hline2, vline1, vline2], 57 | mapping, 58 | fill=Color(1,1,1,1) 59 | ) 60 | shapes.append(background_box) 61 | 62 | title = TextElement( 63 | obj.get_title().get_text(), 64 | anchor=Point(0, height-top_pad), 65 | transform=mapping, 66 | horizontal_alignment="left", 67 | vertical_alignment="center" 68 | ) 69 | texts.append(title) 70 | 71 | color = None 72 | 73 | for i, (art, text) in enumerate(zip(reversed(obj.legend_handles), reversed(obj.get_texts()))): # type: ignore 74 | if isinstance(art, PathCollection): 75 | color = Color(*art.get_facecolor()[0]) # type: ignore 76 | if isinstance(art, Rectangle): 77 | color = Color(*art.get_facecolor()) # type: ignore 78 | if isinstance(art, Line2D): 79 | color = Color(*to_rgba(art.get_color())) 80 | 81 | 82 | point = Point(1,i+1) 83 | shape = Shape( 84 | points = [PointMark(point)], 85 | lines = [], 86 | mapping=mapping, 87 | point_color=color 88 | ) 89 | text = TextElement( 90 | text.get_text(), 91 | anchor=point + Point(1,0), 92 | transform=mapping, 93 | horizontal_alignment='left', 94 | vertical_alignment='center' 95 | ) 96 | shapes.append(shape) 97 | texts.append(text) 98 | 99 | 100 | return shapes + texts -------------------------------------------------------------------------------- /examples/rectangles.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import matplotlib as mpl 3 | import matplotlib.patches as patches 4 | from matplotlib import colors 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | 8 | # This demonstrates: 9 | # - a horizontal bar chart with error bars that 10 | # have black color originally but is now displayed as white 11 | # - a histogram with narrow bars with a long y label 12 | # - Rectangles rotatedt 13 | # - use a character for lines that is auto assigned and not overriden. 14 | # - Has fill but no line color. 15 | 16 | if __name__ == "__main__": 17 | parser = argparse.ArgumentParser(allow_abbrev=False) 18 | parser.add_argument("--out", type=str, required=False) 19 | parser.add_argument("--ascii", action="store_true") 20 | 21 | args = parser.parse_args() 22 | out = args.out 23 | asci = args.ascii 24 | 25 | if asci: 26 | mpl.use("module://mpl_ascii") 27 | 28 | 29 | # Fixing random state for reproducibility 30 | np.random.seed(19680801) 31 | 32 | fig, axes = plt.subplots(2,2, figsize=(10,8)) 33 | 34 | fig.suptitle("It's all about rectangles") 35 | 36 | 37 | ax = axes[0,0] 38 | people = ('Tom', 'Dick', 'Harry', 'Slim', 'Jim') 39 | y_pos = np.arange(len(people)) 40 | performance = 3 + 10 * np.random.rand(len(people)) 41 | error = np.random.rand(len(people)) 42 | 43 | ax.barh(y_pos, performance, xerr=error, align='center') 44 | 45 | ax.set_yticks(y_pos, labels=people) 46 | ax.invert_yaxis() 47 | ax.set_xlabel('Performance') 48 | ax.set_title('My horizontal bar chart with error bars') 49 | 50 | rng = np.random.default_rng(19680801) 51 | 52 | 53 | mu = 106 54 | sigma = 17 55 | x = rng.normal(loc=mu, scale=sigma, size=420) 56 | 57 | num_bins = 42 58 | 59 | ax = axes[0,1] 60 | n, bins, p = ax.hist(x, num_bins, density=True) 61 | 62 | 63 | y = ((1 / (np.sqrt(2 * np.pi) * sigma)) * 64 | np.exp(-0.5 * (1 / sigma * (bins - mu))**2)) 65 | ax.plot(bins, y, '--') 66 | ax.set_xlabel('Value') 67 | ax.set_ylabel('Probability density') 68 | ax.set_title('Histogram of normal distribution sample: ' 69 | fr'$\mu={mu:.0f}$, $\sigma={sigma:.0f}$') 70 | 71 | 72 | 73 | rect1 = patches.Rectangle( 74 | (0.3, 0.3), 75 | 0.4, 76 | 0.2, 77 | angle=30, 78 | linewidth=2, 79 | edgecolor='blue', 80 | facecolor='orange', 81 | ) 82 | rect2 = patches.Rectangle( 83 | (0.6, 0.6), 84 | 0.3, 85 | 0.15, 86 | angle=-10, 87 | edgecolor="none", 88 | facecolor="green", 89 | alpha=0.5 90 | ) 91 | 92 | 93 | ax = axes[1,0] 94 | ax.add_patch(rect1) 95 | ax.add_patch(rect2) 96 | ax.set_title("Rotated Rectangles") 97 | 98 | 99 | 100 | ax.set_xlim(0, 1) 101 | ax.set_ylim(0, 1) 102 | ax.set_aspect('equal') 103 | 104 | rng = np.random.default_rng(19680801) 105 | 106 | N_points = 100000 107 | n_bins = 20 108 | 109 | 110 | dist1 = rng.standard_normal(N_points) 111 | dist2 = 0.4 * rng.standard_normal(N_points) + 5 112 | 113 | ax = axes[1,1] 114 | ax.set_title("Colorful Histograms") 115 | 116 | 117 | 118 | N, bins, patches = ax.hist(dist1, bins=n_bins) 119 | 120 | 121 | fracs = N / N.max() 122 | 123 | 124 | 125 | norm = colors.Normalize(fracs.min(), fracs.max()) 126 | 127 | 128 | for thisfrac, thispatch in zip(fracs, patches): 129 | color = plt.cm.viridis(norm(thisfrac)) 130 | thispatch.set_facecolor(color) 131 | 132 | 133 | fig.tight_layout() 134 | 135 | plt.show() 136 | 137 | if out: 138 | fig.savefig(out) -------------------------------------------------------------------------------- /mpl_ascii/layout/layout_shape.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | from typing import Union 4 | 5 | from mpl_ascii.layout.rasterize import rasterize_line, scanline_fill 6 | from mpl_ascii.layout.discrete_point import DiscretePoint 7 | from mpl_ascii.scene.entities import ParsedLineMark, ParsedPointMark, ParsedShape 8 | from mpl_ascii.scene.fingerprint import combine 9 | from mpl_ascii.scene.geometry.affine import AffineMap2d 10 | 11 | 12 | @dataclass 13 | class Vertex: 14 | id: str 15 | p: DiscretePoint 16 | 17 | @staticmethod 18 | def id_from_point_mark(pm: ParsedPointMark): 19 | parts: list[bytes] = [] 20 | parts.append(pm.fingerprint()) 21 | return f"v:{combine(parts)[:8]}" 22 | @dataclass 23 | class Edge: 24 | id: str 25 | a: DiscretePoint 26 | b: DiscretePoint 27 | raster_points: list[DiscretePoint] 28 | 29 | @staticmethod 30 | def id_from_line_mark(lm: ParsedLineMark): 31 | parts: list[bytes] = [] 32 | parts.append(lm.fingerprint()) 33 | return f"e:{combine(parts)[:8]}" 34 | 35 | @dataclass 36 | class FillPoint: 37 | id: str 38 | p: DiscretePoint 39 | 40 | 41 | @dataclass 42 | class ShapeLayout: 43 | id2obj: dict[str, Union[Vertex, Edge, FillPoint]] 44 | 45 | def add(self, obj: Union[Vertex, Edge, FillPoint]) -> ShapeLayout: 46 | self.id2obj[obj.id] = obj 47 | return self 48 | 49 | def get(self, ident: str) -> Union[Vertex, Edge, FillPoint]: 50 | obj = self.id2obj[ident] 51 | return obj 52 | 53 | def get_all_vertices(self) -> list[Vertex]: 54 | vertices: list[Vertex] = [] 55 | for _,v in self.id2obj.items(): 56 | if isinstance(v, Vertex): 57 | vertices.append(v) 58 | 59 | return vertices 60 | 61 | def get_all_edges(self) -> list[Edge]: 62 | edges: list[Edge] = [] 63 | for _,e in self.id2obj.items(): 64 | if isinstance(e, Edge): 65 | edges.append(e) 66 | 67 | return edges 68 | 69 | def get_all_fill(self) -> list[FillPoint]: 70 | fill_points: list[FillPoint] = [] 71 | for _,fp in self.id2obj.items(): 72 | if isinstance(fp, FillPoint): 73 | fill_points.append(fp) 74 | 75 | return fill_points 76 | 77 | 78 | @classmethod 79 | def empty(cls): 80 | return cls({}) 81 | 82 | 83 | def layout_shape(shape: ParsedShape, T: AffineMap2d) -> ShapeLayout: 84 | shape_layout = ShapeLayout.empty() 85 | 86 | vertices: list[str] = [] 87 | edges: list[str] = [] 88 | fill_vertices: list[str] = [] 89 | for pm in shape.points: 90 | dp = DiscretePoint.from_point2d(T(pm.p)) 91 | ident = Vertex.id_from_point_mark(pm) 92 | shape_layout.add(Vertex(ident, dp)) 93 | vertices.append(ident) 94 | 95 | 96 | for lm in shape.lines: 97 | a, b = (DiscretePoint.from_point2d(T(lm.a)), DiscretePoint.from_point2d(T(lm.b))) 98 | raster_points = rasterize_line(a, b) 99 | ident = Edge.id_from_line_mark(lm) 100 | shape_layout.add(Edge(ident, a, b, raster_points)) 101 | edges.append(ident) 102 | 103 | 104 | fill_color = shape.style_context.fill_color 105 | fill_points: list[DiscretePoint] = [] 106 | if fill_color: 107 | all_vertices = shape_layout.get_all_vertices() 108 | all_edges = shape_layout.get_all_edges() 109 | fill_points = scanline_fill([v.p for v in all_vertices], [(e.a, e.b) for e in all_edges]) 110 | 111 | for i, p in enumerate(fill_points): 112 | ident = f"fill_color_{i}" 113 | shape_layout.add(FillPoint(ident, p)) 114 | fill_vertices.append(ident) 115 | 116 | return shape_layout 117 | -------------------------------------------------------------------------------- /examples/basic.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import matplotlib as mpl 3 | from matplotlib.axes import Axes 4 | import matplotlib.pyplot as plt 5 | import numpy as np 6 | 7 | 8 | # This demonstates 9 | # - Barchart 10 | # - Data coordinate annotations on barcharts 11 | # - Legend width depends on legend label text 12 | # - Scatter plot 13 | # - Legend width depends on legend title 14 | # - Barchart stacked 15 | # - Annotations that are callable 16 | # - Legend without a title 17 | 18 | 19 | if __name__ == "__main__": 20 | parser = argparse.ArgumentParser(allow_abbrev=False) 21 | parser.add_argument("--ascii", action="store_true") 22 | parser.add_argument("--out", type=str, required=False) 23 | 24 | 25 | args = parser.parse_args() 26 | out = args.out 27 | asc = args.ascii 28 | 29 | if asc: 30 | mpl.use("module://mpl_ascii") 31 | 32 | fig, axes = plt.subplots(2,2, figsize=(10,8)) 33 | 34 | # data from https://allisonhorst.github.io/palmerpenguins/ 35 | fig.suptitle("Examples of basic plots") 36 | 37 | 38 | labels = ['G1', 'G2', 'G3', 'G4', 'G5'] 39 | men_means = [20, 34, 30, 35, 27] 40 | women_means = [25, 32, 34, 20, 25] 41 | 42 | x = np.arange(len(labels)) # the label locations 43 | width = 0.35 # the width of the bars 44 | 45 | ax: Axes = axes[0,0] 46 | 47 | rects1 = ax.bar(x - width/2, men_means, width, label='Men') 48 | rects2 = ax.bar(x + width/2, women_means, width, label='Women') 49 | 50 | # Add some text for labels, title and custom x-axis tick labels, etc. 51 | ax.set_ylabel('Scores') 52 | ax.set_xlabel("Groups") 53 | ax.set_title('Scores by group and gender') 54 | ax.set_xticks(x, labels) 55 | ax.legend(title="Gender") 56 | 57 | ax.bar_label(rects1, padding=3) 58 | ax.bar_label(rects2, padding=3) 59 | 60 | 61 | ax = axes[0,1] 62 | np.random.seed(0) 63 | x = np.random.rand(40) 64 | y = np.random.rand(40) 65 | colors = np.random.choice(['red', 'green', 'blue', 'yellow'], size=40) 66 | color_labels = ['Red', 'Green', 'Blue', 'Yellow'] # Labels corresponding to colors 67 | 68 | # Create a scatter plot 69 | for color, label in zip(['red', 'green', 'blue', 'yellow'], color_labels): 70 | # Plot each color as a separate scatter plot to enable legend tracking 71 | idx = np.where(colors == color) 72 | ax.scatter(x[idx], y[idx], color=color, label=label) 73 | 74 | # Set title and labels 75 | 76 | ax.set_title('Scatter Plot with 4 Different Colors') 77 | ax.set_xlabel('X axis') 78 | ax.set_ylabel('Y axis') 79 | 80 | # Add a legend 81 | ax.legend(title='Point Colors') 82 | 83 | ax = axes[1,0] 84 | t = np.arange(0.0, 2.0, 0.01) 85 | s = 1 + np.sin(2 * np.pi * t) 86 | c = 1 + np.cos(2 * np.pi * t) 87 | 88 | ax.plot(t, s) 89 | ax.plot(t, c) 90 | 91 | ax.set(xlabel='time (s)', ylabel='voltage (mV)', 92 | title='About as simple as it gets, folks') 93 | 94 | ax = axes[1,1] 95 | species = ('Adelie', 'Chinstrap', 'Gentoo') 96 | sex_counts = { 97 | 'Male': np.array([73, 34, 61]), 98 | 'Female': np.array([73, 34, 58]), 99 | } 100 | width = 0.6 # the width of the bars: can also be len(x) sequence 101 | 102 | bottom = np.zeros(3) 103 | 104 | for sex, sex_count in sex_counts.items(): 105 | p = ax.bar(species, sex_count, width, label=sex, bottom=bottom) 106 | bottom += sex_count 107 | 108 | ax.bar_label(p, label_type='center') 109 | 110 | ax.set_title('Number of penguins by sex') 111 | ax.legend() 112 | ax.set_xlabel("Penguines") 113 | ax.set_ylabel("Count") 114 | 115 | 116 | fig.tight_layout() 117 | 118 | 119 | plt.show() 120 | 121 | if out: 122 | fig.savefig(out) 123 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | venv-*/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | 163 | ### mpl_ascii specific 164 | data/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.0.0] 2025-09-06 9 | 10 | ### Added 11 | - Support for subplots. 12 | - Size of plots are automatically scaled to size specified in mpl frontend. 13 | - Support for spines. 14 | - Support for polygons. 15 | - Support for annotations. 16 | 17 | ### Changed 18 | - Improved styling for axis frames. 19 | - Legend is plotted similiarly using best location. 20 | 21 | ### Removed 22 | - Removed support for python versions 3.7, 3.8. 23 | - Removed support for violin plots and box plots. 24 | - Removed support for QuadMesh (such as colorbars). 25 | - Removed support for setting height and width of ascii plot with `AXES_WIDTH` and `AXES_HEIGHT` 26 | 27 | ## [0.10.0] 2024-07-01 28 | 29 | ### Added 30 | - Axes width and height can now be adjusted through environment variables called `AXES_WIDTH` and `AXES_HEIGHT`. 31 | 32 | ## [0.9.1] 2024-06-15 33 | 34 | ### Changed 35 | 36 | - Updated tick rendering logic to prevent overlap. Xticks now alternate direction or extend downwards, with possible truncation for both xticks and yticks. 37 | - Changed axes labels, title and legend so they are now centered. 38 | 39 | ## [0.9.0] 2024-06-11 40 | 41 | ### Added 42 | 43 | - Add support for `figure.show()` which will now display the text plot of the figure. 44 | 45 | ## [0.8.0] 2024-06-10 46 | 47 | ### Added 48 | 49 | - Add support for contour plots. 50 | - Add support for text objects in plot. 51 | - Add error handling for when the plots are not part of the library. 52 | 53 | ## [0.7.2] 2024-05-30 54 | 55 | ### Fixed 56 | 57 | - Fixed the padding between the colorbar and the axes when saving to a txt file. 58 | 59 | ## [0.7.1] 2024-05-30 60 | 61 | ### Fixed 62 | 63 | - Fix position of yaxis labels on colorbar axis so they are on the right side instead of the left side. 64 | 65 | ## [0.7.0] 2024-05-26 66 | 67 | ### Added 68 | 69 | - Add support for color bars on scatter plots 70 | 71 | ## [0.6.4] 2024-05-26 72 | 73 | ### Fixed 74 | 75 | - Add fix so if there is a `None` value in only one axis then the line plot does not raise an error and instead skips over it. 76 | 77 | ## [0.6.3] 2024-05-24 78 | 79 | ### Fixed 80 | 81 | - Line plots now handle None values as matplotlib does. If a `None` value is passed into a line plot then it has line break. 82 | 83 | ## [0.6.2] 2024-05-19 84 | 85 | ### Fixed 86 | 87 | - `mpl_ascii` is now compatible with matplotlib 3.9. 88 | 89 | ## [0.6.1] 2024-05-11 90 | 91 | ### Fixed 92 | 93 | - Contour plots will return empty frame instead of raising an error. 94 | 95 | ## [0.6.0] 2024-05-10 96 | 97 | ### Added 98 | 99 | - Add support for violin plots 100 | 101 | ### Fixed 102 | 103 | - Fixed empty line markers with box plots. 104 | 105 | ## [0.5.0] 2024-05-10 106 | 107 | ### Added 108 | 109 | - Added support for python 3.7+ 110 | 111 | ## [0.4.0] 2024-05-05 112 | 113 | ### Added 114 | 115 | - Added support for errorbars and line markers on line plots. 116 | 117 | ## [0.3.0] - 2024-04-30 118 | 119 | ### Added 120 | 121 | - You can now enable/disable colors to the ascii plots by setting the global variable `mpl_ascii.ENABLE_COLOR`. It is set to `True` by default. 122 | 123 | ## [0.2.0] - 2024-04-28 124 | 125 | ### Added 126 | 127 | - The width and height of each axes can be set using `mpl_ascii.AXES_WIDTH` and `mpl_ascii.AXES_HEIGHT`. It defaults to 150 characters in width and 40 characters in height. 128 | 129 | ## [0.1.0] - 2024-04-25 130 | 131 | ### Added 132 | 133 | When using the `mpl_ascii` backend then `plt.show()` will print the plot as a string consisting of basic ASCII characters. This is supported for: 134 | 135 | - Bar charts 136 | - Horizontal bar charts 137 | - Line plots 138 | - Scatter plots 139 | 140 | You can also save figures as a text file. You can do this by using the savefig `figure.savefig("my_figure.txt")` and this will save the ASCII plot as a txt file. 141 | 142 | 143 | -------------------------------------------------------------------------------- /mpl_ascii/artists/transform_helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import ABC 3 | from dataclasses import dataclass 4 | 5 | from matplotlib.figure import Figure 6 | from matplotlib.transforms import Transform 7 | 8 | 9 | @dataclass 10 | class Point: 11 | x: float 12 | y: float 13 | 14 | def __repr__(self) -> str: 15 | return f"({self.x:.2f}, {self.y:.2f})" 16 | 17 | def __add__ (self, other: Point) -> Point: 18 | return Point(self.x + other.x, self.y + other.y) 19 | 20 | 21 | def get_figure2ascii_transform(fig: Figure) -> AffineMap: 22 | return fig.canvas.figure2ascii_canvas_transform # type: ignore 23 | 24 | 25 | def to_mapping(transform: Transform) -> Mapping: 26 | 27 | affine = transform.get_affine() # type: ignore 28 | 29 | if affine is not None: 30 | M: NDArray[np.float64] = affine.get_matrix() # type: ignore 31 | mat = Matrix(M[0,0], M[0,1], M[1,0], M[1,1]) # type: ignore 32 | t = AffineMap(mat, Point(M[0,2], M[1,2])) # type: ignore 33 | return t 34 | 35 | else: 36 | raise Exception("Expected Affine2D transform") 37 | 38 | 39 | def blend_affine_maps(x_mapping: AffineMap, y_mapping: AffineMap) -> AffineMap: 40 | 41 | A_x = x_mapping.linear 42 | if not A_x.b == 0: 43 | raise Exception(f"Can not blend because {x_mapping} depends on y coordinates") 44 | 45 | A_y = y_mapping.linear 46 | if not A_y.c == 0: 47 | raise Exception(f"Can not blend because {y_mapping} depends on x coordinates") 48 | 49 | new_A = Matrix(A_x.a, 0, 0, A_y.d) 50 | new_b = Point(x_mapping.translation.x, y_mapping.translation.y) 51 | 52 | return AffineMap(new_A, new_b) 53 | 54 | @dataclass(frozen=True) 55 | class Matrix: 56 | a: float 57 | b: float 58 | c: float 59 | d: float 60 | 61 | def __mul__(self, p: Point) -> Point: 62 | 63 | x_new = self.a * p.x + self.b * p.y 64 | y_new = self.c * p.x + self.d * p.y 65 | 66 | return Point(x_new, y_new) 67 | 68 | def __matmul__(self, other: Matrix) -> Matrix: 69 | new_a = self.a * other.a + self.b * other.c 70 | new_b = self.a * other.b + self.b * other.d 71 | new_c = self.c * other.a + self.d * other.c 72 | new_d = self.c * other.b + self.d * other.d 73 | return Matrix(new_a, new_b, new_c, new_d) 74 | 75 | def __repr__(self) -> str: 76 | row1 = f"| {self.a:4.3f} {self.b:4.3f} |" 77 | row2 = f"| {self.c:4.3f} {self.d:4.3f} |" 78 | return f"{row1}\n{row2}" 79 | 80 | 81 | def det(self) -> float: 82 | return self.a * self.d - self.b * self.c 83 | 84 | 85 | def inverse(self) -> Matrix: 86 | det = self.det() 87 | if det == 0: 88 | raise ValueError(f"Matrix {self} is not invertible (determinant is 0).") 89 | inv_det = 1.0 / det 90 | return Matrix( 91 | self.d * inv_det, -self.b * inv_det, 92 | -self.c * inv_det, self.a * inv_det 93 | ) 94 | 95 | class Mapping(ABC): 96 | 97 | def __call__(self, point: Point) -> Point: 98 | raise NotImplementedError() 99 | 100 | 101 | @dataclass(frozen=True) 102 | class AffineMap(Mapping): 103 | linear: Matrix 104 | translation: Point 105 | 106 | def inverse(self) -> AffineMap: 107 | A_inv = self.linear.inverse() 108 | Ab = A_inv * self.translation 109 | b_inv = Point(-Ab.x, -Ab.y) 110 | return AffineMap(A_inv, b_inv) 111 | 112 | def __call__(self, other: Point) -> Point: 113 | return self.linear * other + self.translation 114 | 115 | def __matmul__(self, other: AffineMap) -> AffineMap: 116 | new_A = self.linear @ other.linear 117 | new_b = self.linear * other.translation + self.translation 118 | return AffineMap(new_A, new_b) 119 | 120 | def __repr__(self) -> str: 121 | row1 = f"| {self.linear.a:4.3f} {self.linear.b:4.3f} | + | {self.translation.x:4.3f} |" 122 | row2 = f"| {self.linear.c:4.3f} {self.linear.d:4.3f} | | {self.translation.y:4.3f} |" 123 | return f"{row1}\n{row2}" 124 | 125 | def translate(self, point: Point) -> AffineMap: 126 | return AffineMap(self.linear, self.translation + point) 127 | 128 | @classmethod 129 | def identity(cls): 130 | A = Matrix(1,0,0,1) 131 | b = Point(0,0) 132 | return cls(A, b) -------------------------------------------------------------------------------- /mpl_ascii/layout/layout_text.py: -------------------------------------------------------------------------------- 1 | from mpl_ascii.presentation.glyphs import Glyph, PointGlyph 2 | from mpl_ascii.layout.discrete_point import DiscretePoint 3 | from mpl_ascii.scene.entities import ParsedText 4 | from mpl_ascii.scene.geometry.affine import AffineMap2d 5 | 6 | 7 | def clamp(v: int, lo: int, hi: int) -> int: 8 | return max(lo, min(v, hi)) 9 | 10 | def layout_horiztonal_text(parsed_text: ParsedText, anchor: DiscretePoint, canvas_height: int, canvas_width: int) -> list[PointGlyph]: 11 | W, H = canvas_width, canvas_height 12 | L = len(parsed_text.text) 13 | 14 | # start from anchor 15 | x, y = anchor.x, anchor.y 16 | 17 | direction = "right" 18 | 19 | # --- horizontal alignment --- 20 | if parsed_text.horizontal_alignment == "left": 21 | direction = "right" 22 | x = clamp(x + 1, 0, max(W - 1, 0)) # keep full text on canvas when drawing → right 23 | 24 | elif parsed_text.horizontal_alignment == "right": 25 | direction = "left" 26 | # when drawing ← left, the starting x must be at least L-1 to keep full text on 27 | lo = min(L - 1, W - 1) 28 | x = clamp(x - 1, lo, W - 1) 29 | 30 | elif parsed_text.horizontal_alignment == "center": 31 | direction = "right" 32 | x = clamp(x - (L // 2), 0, max(W - L, 0)) 33 | 34 | # --- vertical alignment --- 35 | 36 | if parsed_text.vertical_alignment == "top": 37 | y = clamp(y - 1, 0, H - 1) 38 | elif parsed_text.vertical_alignment in ("center_baseline", "center"): 39 | y = clamp(y + 0, 0, H - 1) 40 | elif parsed_text.vertical_alignment in ("baseline", "bottom"): 41 | y = clamp(y + 1, 0, H - 1) 42 | 43 | point_glyphs: list[PointGlyph] = [] 44 | 45 | if direction == "right": 46 | for i, char in enumerate(parsed_text.text): 47 | dp = DiscretePoint(x+i, y) 48 | point_glyphs.append(PointGlyph(dp, Glyph(char, None))) 49 | 50 | if direction == "left": 51 | for i, char in enumerate(reversed(parsed_text.text)): 52 | dp = DiscretePoint(x-i, y) 53 | point_glyphs.append(PointGlyph(dp, Glyph(char, None))) 54 | 55 | return point_glyphs 56 | 57 | 58 | def layout_vertical_text(parsed_text: ParsedText, anchor: DiscretePoint, canvas_height: int, canvas_width: int) -> list[PointGlyph]: 59 | W, H = canvas_width, canvas_height 60 | L = len(parsed_text.text) 61 | 62 | # start from anchor 63 | x, y = anchor.x, anchor.y 64 | 65 | direction = "down" 66 | if parsed_text.horizontal_alignment == "left": 67 | x = clamp(x + 1, 0, W - 1) # anchor is left of text → draw one col to the right 68 | elif parsed_text.horizontal_alignment == "right": 69 | x = clamp(x - 1, 0, W - 1) # anchor is right of text → draw one col to the left 70 | elif parsed_text.horizontal_alignment == "center": 71 | x = clamp(x + 0, 0, W - 1) # same column 72 | 73 | if parsed_text.vertical_alignment == "top": 74 | direction = "down" 75 | y = clamp(y + 1, 0, max(H - 1, 0)) # draw ↓ from just below anchor 76 | elif parsed_text.vertical_alignment in ("center_baseline", "center"): 77 | direction = "down" 78 | y = clamp(y + (L // 2), 0, max(H - 1, 0)) # center the run around anchor 79 | 80 | elif parsed_text.vertical_alignment in ("baseline", "bottom"): 81 | direction = "up" 82 | lo = min(L - 1, H - 1) # need at least L-1 rows above 83 | y = clamp(y - 1, lo, H - 1) 84 | 85 | 86 | point_glyphs: list[PointGlyph] = [] 87 | 88 | if direction == "down": 89 | for i, char in enumerate(parsed_text.text): 90 | dp = DiscretePoint(x, y-i) 91 | point_glyphs.append(PointGlyph(dp, Glyph(char, None))) 92 | 93 | if direction == "up": 94 | for i, char in enumerate(reversed(parsed_text.text)): 95 | dp = DiscretePoint(x, y+i) 96 | point_glyphs.append(PointGlyph(dp, Glyph(char, None))) 97 | 98 | return point_glyphs 99 | 100 | 101 | def layout_text(text: ParsedText, T: AffineMap2d, canvas_height: int, canvas_width: int) -> list[PointGlyph]: 102 | 103 | anchor = DiscretePoint.from_point2d(T(text.anchor)) 104 | 105 | glyphs: list[PointGlyph] = [] 106 | 107 | if text.orientation == "horizontal": 108 | glyphs = layout_horiztonal_text(text, anchor, canvas_height, canvas_width) 109 | 110 | if text.orientation == "vertical": 111 | glyphs = layout_vertical_text(text, anchor, canvas_height, canvas_width) 112 | 113 | return glyphs 114 | -------------------------------------------------------------------------------- /mpl_ascii/layout/rasterize.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | 4 | from mpl_ascii.layout.discrete_point import DiscretePoint 5 | 6 | @dataclass 7 | class EdgeInfo: 8 | y_max: int 9 | x_of_y_min: float 10 | slope_inverse: float 11 | 12 | 13 | def scanline_fill(vertices: list[DiscretePoint], edges: list[tuple[DiscretePoint, DiscretePoint]]) -> list[DiscretePoint]: 14 | 15 | all_edge_points = {p for e in edges for p in rasterize_line(e[0], e[1])} 16 | 17 | if not vertices: 18 | return [] 19 | 20 | vertices = sorted(vertices, key=lambda p: p.y) 21 | y_min = vertices[0].y 22 | y_max = vertices[-1].y 23 | 24 | # Step 1: Build Edge Table 25 | edge_table: dict[int, list[EdgeInfo]] = {} 26 | 27 | for edge in edges: 28 | p1, p2 = edge 29 | 30 | # Skip horizontal edges 31 | if p1.y == p2.y: 32 | continue 33 | 34 | # Make sure p1 is the lower point (smaller y) 35 | if p1.y > p2.y: 36 | p1, p2 = p2, p1 37 | 38 | y_min_edge = p1.y 39 | y_max_edge = p2.y 40 | x_of_y_min = p1.x 41 | 42 | # Calculate inverse slope (dx/dy) 43 | if p2.y - p1.y != 0: # Should never be 0 due to horizontal edge check 44 | slope_inverse = (p2.x - p1.x) / (p2.y - p1.y) 45 | else: 46 | continue 47 | 48 | edge_info = EdgeInfo(y_max_edge, x_of_y_min, slope_inverse) 49 | 50 | # Add edge to edge table at its y_min 51 | if y_min_edge not in edge_table: 52 | edge_table[y_min_edge] = [] 53 | edge_table[y_min_edge].append(edge_info) 54 | 55 | 56 | active_list: list[EdgeInfo] = [] 57 | fill: list[DiscretePoint] = [] 58 | 59 | for y in range(y_min, y_max): 60 | # Step 3: Add new edges from edge table to active list 61 | if y in edge_table: 62 | active_list.extend(edge_table[y]) 63 | 64 | # Step 4: Remove completed edges (yMax <= current y) 65 | active_list = [edge for edge in active_list if edge.y_max > y] 66 | 67 | # Step 5: Sort active list by x_of_y_min 68 | active_list.sort(key=lambda edge: edge.x_of_y_min) 69 | 70 | # Step 6: Fill between pairs of edges 71 | for i in range(0, len(active_list) - 1, 2): 72 | if i + 1 < len(active_list): 73 | x_start = int(round(active_list[i].x_of_y_min)) 74 | x_end = int(round(active_list[i + 1].x_of_y_min)) 75 | 76 | # Fill pixels between the edges 77 | for x in range(x_start+1, x_end): 78 | fp = DiscretePoint(x, y) 79 | if fp in all_edge_points: # Ensure fill is not overwriting edge points 80 | continue 81 | fill.append(DiscretePoint(x, y)) 82 | 83 | # Step 7: Update x_of_y_min for next scanline 84 | for edge in active_list: 85 | edge.x_of_y_min += edge.slope_inverse 86 | 87 | return fill 88 | 89 | 90 | def rasterize_lines(lines: list[tuple[DiscretePoint, DiscretePoint]]) -> list[DiscretePoint]: 91 | 92 | discrete_lines: list[DiscretePoint] = [] 93 | 94 | for line in lines: 95 | discrete_lines += bresenham_line(line[0], line[1]) 96 | 97 | return discrete_lines 98 | 99 | def rasterize_line(a: DiscretePoint, b: DiscretePoint) -> list[DiscretePoint]: 100 | 101 | return bresenham_line(a, b) 102 | 103 | 104 | 105 | def bresenham_line(p: DiscretePoint, q: DiscretePoint) -> list[DiscretePoint]: 106 | x0, y0 = p.x, p.y 107 | x1, y1 = q.x, q.y 108 | 109 | dx = x1 - x0 110 | dy = y1 - y0 111 | 112 | # Determine how steep the line is 113 | is_steep = abs(dy) > abs(dx) 114 | 115 | # Rotate line if it's steep 116 | if is_steep: 117 | x0, y0 = y0, x0 118 | x1, y1 = y1, x1 119 | 120 | # Swap start and end points if necessary 121 | if x0 > x1: 122 | x0, x1 = x1, x0 123 | y0, y1 = y1, y0 124 | 125 | # Recalculate differences 126 | dx = x1 - x0 127 | dy = y1 - y0 128 | 129 | # Calculate error 130 | error = int(dx / 2.0) 131 | ystep = 1 if y0 < y1 else -1 132 | 133 | # Iterate over bounding box generating points between start and end 134 | y = y0 135 | points: list[DiscretePoint] = [] 136 | for x in range(x0, x1 + 1): 137 | coord = DiscretePoint(y, x) if is_steep else DiscretePoint(x, y) 138 | points.append(coord) 139 | error -= abs(dy) 140 | if error < 0: 141 | y += ystep 142 | error += dx 143 | 144 | return [point for point in points if point != p and point != q] 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mpl_ascii 2 | 3 | A matplotlib backend that produces plots using only ASCII characters. It is available for python 3.9+. 4 | 5 | ## Quick start 6 | 7 | Install `mpl_ascii` using pip 8 | 9 | ```bash 10 | pip install mpl_ascii 11 | ``` 12 | 13 | To use mpl_ascii, add to your python program 14 | 15 | ```python 16 | import matplotlib as mpl 17 | 18 | mpl.use("module://mpl_ascii") 19 | ``` 20 | 21 | When you use `plt.show()` then it will print the plots as strings that consists of ASCII characters. 22 | 23 | If you want to save a figure to a `.txt` file then just use `figure.savefig("my_figure.txt")` 24 | 25 | See more information about using backends here: https://matplotlib.org/stable/users/explain/figure/backends.html 26 | 27 | ## Examples 28 | 29 | ### Bar chart 30 | 31 | 32 | ```python 33 | import matplotlib.pyplot as plt 34 | import matplotlib as mpl 35 | 36 | mpl.use("module://mpl_ascii") 37 | 38 | import matplotlib.pyplot as plt 39 | 40 | # Example data 41 | fruits = ['apple', 'blueberry', 'cherry', 'orange'] 42 | counts = [10, 15, 7, 5] 43 | colors = ['red', 'blue', 'red', 'orange'] # Colors corresponding to each fruit 44 | 45 | fig, ax = plt.subplots() 46 | 47 | # Plot each bar individually 48 | for fruit, count, color in zip(fruits, counts, colors): 49 | ax.bar(fruit, count, color=color, label=color) 50 | 51 | # Display the legend 52 | ax.legend(title='Fruit color') 53 | 54 | plt.show() 55 | ``` 56 | 57 | ![bar chart with color](assets/bar_chart.png) 58 | 59 | ### Scatter plot 60 | 61 | 62 | ```python 63 | import matplotlib.pyplot as plt 64 | import numpy as np 65 | import matplotlib as mpl 66 | 67 | 68 | mpl.use("module://mpl_ascii") 69 | 70 | np.random.seed(0) 71 | x = np.random.rand(40) 72 | y = np.random.rand(40) 73 | colors = np.random.choice(['red', 'green', 'blue', 'yellow'], size=40) 74 | color_labels = ['Red', 'Green', 'Blue', 'Yellow'] # Labels corresponding to colors 75 | 76 | # Create a scatter plot 77 | fig, ax = plt.subplots() 78 | for color, label in zip(['red', 'green', 'blue', 'yellow'], color_labels): 79 | # Plot each color as a separate scatter plot to enable legend tracking 80 | idx = np.where(colors == color) 81 | ax.scatter(x[idx], y[idx], color=color, label=label) 82 | 83 | # Set title and labels 84 | ax.set_title('Scatter Plot with 4 Different Colors') 85 | ax.set_xlabel('X axis') 86 | ax.set_ylabel('Y axis') 87 | 88 | # Add a legend 89 | ax.legend(title='Point Colors') 90 | plt.show() 91 | ``` 92 | 93 | ![Scatter plot with color](assets/scatter.png) 94 | 95 | ### Line plot 96 | 97 | ```python 98 | import matplotlib.pyplot as plt 99 | import numpy as np 100 | import matplotlib as mpl 101 | import mpl_ascii 102 | 103 | 104 | mpl.use("module://mpl_ascii") 105 | 106 | 107 | # Data for plotting 108 | t = np.arange(0.0, 2.0, 0.01) 109 | s = 1 + np.sin(2 * np.pi * t) 110 | c = 1 + np.cos(2 * np.pi * t) 111 | 112 | fig, ax = plt.subplots() 113 | ax.plot(t, s) 114 | ax.plot(t, c) 115 | 116 | ax.set(xlabel='time (s)', ylabel='voltage (mV)', 117 | title='About as simple as it gets, folks') 118 | 119 | plt.show() 120 | ``` 121 | ![Double plot with colors](assets/double_plot.png) 122 | 123 | You can find more examples in the `tests/accepted` folder. 124 | 125 | ## Use cases 126 | 127 | ### Using Version Control for Plots 128 | 129 | Handling plots with version control can pose challenges, especially when dealing with binary files. Here are some issues you might encounter: 130 | 131 | - Binary Files: Committing binary files like PNGs can significantly increase your repository’s size. They are also difficult to compare (diff) and can lead to complex merge conflicts. 132 | 133 | - SVG Files: Although SVGs are more version control-friendly than binary formats, they can still cause problems: 134 | - Large or complex graphics can result in excessively large SVG files. 135 | - Diffs can be hard to interpret. 136 | 137 | To mitigate these issues, ASCII plots serve as an effective alternative: 138 | 139 | - Size: ASCII representations are much smaller in size. 140 | - Version Control Compatibility: They are straightforward to diff and simplify resolving merge conflicts. 141 | 142 | 143 | This package acts as a backend for Matplotlib, enabling you to continue creating plots in your usual formats (PNG, SVG) during development. When you’re ready to commit your plots to a repository, simply switch to the `mpl_ascii` backend to convert them into ASCII format. 144 | 145 | ## Feedback 146 | 147 | Please help make this package better by: 148 | - reporting bugs. 149 | - making feature requests. Matplotlib is an enormous library and this supports only a part of it. Let me know if there particular charts that you would like to be converted to ASCII 150 | - letting me know what you use this for. 151 | 152 | If you want to tell me about any of the above just use the Discussions tab for now. 153 | 154 | Thanks for reading and I hope you will like these plots as much as I do :-) 155 | -------------------------------------------------------------------------------- /tests/accepted/fill_between.txt: -------------------------------------------------------------------------------- 1 | 2 | fill between y1 and 0 3 | ┌─────────────────────────────────────────────────────────────────────────────────────┐ 4 | 1.0 ┤ !!!!!! !!!!! │ 5 | │ !!!!!!!!!! !!!!!!!!! │ 6 | │ !!!!!!!!!!!! !!!!!!!!!!!! │ 7 | 0.5 ┤ !!!!!!!!!!!!!! !!!!!!!!!!!!!!! │ 8 | │ !!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!! │ 9 | │ !!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!! │ 10 | 0.0 ┤ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! │ 11 | │ !!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!! │ 12 | │ !!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!! │ 13 | −0.5 ┤ !!!!!!!!!!!!!!!! !!!!!!!!!!!!!!! │ 14 | │ !!!!!!!!!!!! !!!!!!!!!!!!! │ 15 | │ !!!!!!!!!! !!!!!!!!!!! │ 16 | −1.0 ┤ !!!!!! !!!!!!! │ 17 | └───┬─────────┬─────────┬────────┬─────────┬─────────┬─────────┬─────────┬─────────┬──┘ 18 | fill between y1 and 1 19 | 20 | 1.0 ┤─────────────────────────────────────────────────────────────────────────────────────┐ 21 | │ !!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!! │ 22 | │ !!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!! │ 23 | 0.5 ┤ !!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!! │ 24 | │ !!!! !!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!! │ 25 | │ !! !!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!! │ 26 | 0.0 ┤ !! !!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!! │ 27 | │ !!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!! │ 28 | │ !!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!! │ 29 | −0.5 ┤ !!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!! │ 30 | │ !!!!!!!!!!!!!! !!!!!!!!!!!!! │ 31 | │ !!!!!!!!!! !!!!!!!!!!! │ 32 | −1.0 ┤ !!!!!!!! !!!!!!! │ 33 | └───┬─────────┬─────────┬────────┬─────────┬─────────┬─────────┬─────────┬─────────┬──┘ 34 | fill between y1 and y2 35 | 36 | 1.0 ┤─────────────────────────────────────────────────────────────────────────────────────┐ 37 | │ !!!!!!!! !!!!!!!! │ 38 | │ !!!!!!!!!!!!! !!!! !!!!!!!!!!!!! !!!!! │ 39 | 0.5 ┤ !!! !!!!!!!!!! !!!!!! !!! !!!!!!!!!! !!!!!! │ 40 | │ !!! !!!!!!!!!! !!!!!!!! !! !!!!!!!!!!! !!!!!!! │ 41 | │ !! !!!!!!!!!! !!!!!!!!!! !! !!!!!!!!!!! !!!!!!!!! │ 42 | 0.0 ┤ !! !!!!!!!!!!!!!!!!!!!!! !! !!!!!!!!!!!!!!!!!!!!! │ 43 | │ !!!!!!!!!!!!!!!!!!!!! !! !!!!!!!!!!!!!!!!!!!! ! │ 44 | │ !!!!!!!!!! !!!!!!!!!!! !! !!!!!!!!! !!!!!!!!!!! !! │ 45 | │ !!!!!!!! !!!!!!!!!! !!! !!!!!!! !!!!!!!!!!! !! │ 46 | −0.5 ┤ !!!!!! !!!!!!!!!! !!! !!!!!!! !!!!!!!!!! !!! │ 47 | │ !!!! !!!!!!!!!!!!! !!!!! !!!!!!!!!!!!! │ 48 | │ !!!!!!!! !!!!!!!! │ 49 | −1.0 ┤───┬─────────┬─────────┬────────┬─────────┬─────────┬─────────┬─────────┬─────────┬──┘ 50 | 51 | 0.00 0.25 0.50 0.75 1.00 1.25 1.50 1.75 2.00 52 | x 53 | -------------------------------------------------------------------------------- /mpl_ascii/parsing/figure.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import annotations 3 | 4 | import os 5 | import importlib.util 6 | from typing import Iterable, Union, cast 7 | from matplotlib.axes import Axes 8 | from matplotlib.figure import Figure 9 | from matplotlib.transforms import Transform 10 | from matplotlib.artist import Artist 11 | 12 | 13 | from mpl_ascii.artists.transform_helpers import to_mapping 14 | from mpl_ascii.artists.types import Shape, TextElement 15 | from mpl_ascii.parsing.shape import parse_shape, parse_text 16 | from mpl_ascii.parsing.transform import parse_mapping 17 | from mpl_ascii.scene.entities import ParsedFigure, ParsedLineMark, ParsedShape, ParsedText 18 | from mpl_ascii.scene.geometry.point import Point2d 19 | from mpl_ascii.scene.store import Store 20 | 21 | 22 | 23 | def from_figure(figure: Figure, store: Store) -> ParsedFigure: 24 | 25 | 26 | figure_transform = cast(Transform, figure.transFigure) # type: ignore 27 | 28 | transform = parse_mapping(to_mapping(figure_transform)) 29 | 30 | parsed_shapes: list[ParsedShape] = [] 31 | 32 | parsed_texts: list[ParsedText] = [] 33 | 34 | 35 | for i, c in enumerate(figure.get_children()): 36 | shapes, texts = parse_artist(c, i) 37 | parsed_shapes += shapes 38 | parsed_texts += texts 39 | 40 | for s in parsed_shapes: 41 | store.add(s) 42 | 43 | for t in parsed_texts: 44 | store.add(t) 45 | 46 | 47 | background_patches = tuple([s.identifier() for s in parsed_shapes if is_backgroud_patch(s, figure)]) 48 | 49 | parsed_figure = ParsedFigure( 50 | figure2display_tranform=transform, 51 | shapes=tuple(p.identifier() for p in parsed_shapes), 52 | texts=tuple(t.identifier() for t in parsed_texts), 53 | background_patches=background_patches 54 | ) 55 | 56 | return parsed_figure 57 | 58 | def parse_artist(artist: Artist, insert_order: float) -> tuple[list[ParsedShape], list[ParsedText]]: 59 | 60 | parsed_shapes: list[ParsedShape] = [] 61 | parsed_texts: list[ParsedText] = [] 62 | # print(artist) 63 | if isinstance(artist, Axes): 64 | ax = artist 65 | 66 | # This is here because some Annotations do not have the correct scale 67 | ax.autoscale_view() 68 | 69 | children: list[Artist] = cast(list[Artist], ax.get_children()) # type: ignore 70 | 71 | 72 | for j, c in enumerate(children): 73 | shapes, texts = parse_artist(c, float(f"{int(insert_order)}.{j}")) 74 | parsed_shapes += shapes 75 | parsed_texts += texts 76 | 77 | return parsed_shapes, parsed_texts 78 | 79 | 80 | cls_name = type(artist).__name__ 81 | mpl_ascii_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 82 | artists_dir = os.path.join(mpl_ascii_dir, "artists") 83 | path = os.path.join(artists_dir, f"{cls_name}.py") 84 | if not os.path.exists(path): 85 | return ([],[]) 86 | 87 | module_name = f"mpl_ascii.artists.{cls_name}" 88 | spec = importlib.util.spec_from_file_location(module_name, path) 89 | if spec is None or spec.loader is None: 90 | return ([],[]) 91 | module = importlib.util.module_from_spec(spec) 92 | spec.loader.exec_module(module) 93 | 94 | if not hasattr(module, "parse"): 95 | return ([],[]) 96 | zorder = artist.get_zorder() 97 | 98 | elements = module.parse(artist) 99 | 100 | if not isinstance(elements, Iterable): 101 | elements = [elements] 102 | 103 | elements = cast(list[Union[Shape, TextElement]], elements) 104 | for el in elements: 105 | if isinstance(el, Shape): 106 | shape = el 107 | parsed_shape = parse_shape(shape, zorder, insert_order) 108 | parsed_shapes.append(parsed_shape) 109 | 110 | elif isinstance(el, TextElement): # type: ignore 111 | text = el 112 | parsed_text = parse_text(text, zorder, insert_order) 113 | parsed_texts.append(parsed_text) 114 | else: 115 | continue 116 | 117 | return parsed_shapes, parsed_texts 118 | 119 | def is_backgroud_patch(shape: ParsedShape, figure: Figure) -> bool: 120 | figure_transform = parse_mapping(to_mapping(figure.transFigure)) 121 | 122 | axes_transforms = [parse_mapping(to_mapping(ax.transAxes)) for ax in figure.get_axes()] 123 | 124 | return ( 125 | is_rectangle(shape) and 126 | ( 127 | (shape.local2display_transform() == figure_transform) 128 | or (shape.local2display_transform() in axes_transforms) 129 | ) 130 | ) 131 | 132 | 133 | def is_rectangle(shape: ParsedShape) -> bool: 134 | corners = Point2d(0.,0.), Point2d(0.,1.), Point2d(1.,0.), Point2d(1.,1.) 135 | shape_points = [pm.p for pm in shape.points] 136 | lines = ( 137 | ParsedLineMark(a=Point2d(0.00, 0.00), b=Point2d(1.00, 0.00), override='─'), 138 | ParsedLineMark(a=Point2d(0.00, 1.00), b=Point2d(1.00, 1.00), override='─'), 139 | ParsedLineMark(a=Point2d(0.00, 0.00), b=Point2d(0.00, 1.00), override='│'), 140 | ParsedLineMark(a=Point2d(1.00, 0.00), b=Point2d(1.00, 1.00), override='│') 141 | ) 142 | 143 | has_corners = all(c in shape_points for c in corners) and len(shape_points) == 4 144 | has_edges = lines == shape.lines 145 | 146 | return has_corners and has_edges -------------------------------------------------------------------------------- /tests/accepted/basic.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | Examples of basic plots 4 | Scores by group and gender Scatter Plot with 4 Different Colors 5 | 6 | 35 ┤──────────────────────────────┌35┐──┌───────┐────┐ 1.0 ┤─────────────────────────────────────────────────┐ 7 | │ ┌34┐ ┌34┐ │!!│ │Gender │ │ │ & % ┌────────────┐ 8 | │ │!!┌32─┐ │""│ │!!│ │! Men │ │ │ │Point Colors│ 9 | 30 ┤ │!!│"""│ ┌30─│""│ │!!│ │" Women│ │ │ # │# Red │ 10 | │ │!!│"""│ │!!!│""│ │!!│ └───────┘ │ 0.8 ┤ # │$ Green │ 11 | │ │!!│"""│ │!!!│""│ │!!│ 27─┐ │ │ # │% Blue │ 12 | S 25 ┤ 25─┐ │!!│"""│ │!!!│""│ │!!│ │!!┌25┐ │ Y │ & & & │& Yellow │ 13 | c │ │""│ │!!│"""│ │!!!│""│ │!!│ │!!│""│ │ │ $ & & └────────────┘ 14 | o 20 ┤ ┌20─│""│ │!!│"""│ │!!!│""│ │!!┌20─┐ │!!│""│ │ a0.6 ┤ $ │ 15 | r │ │!!!│""│ │!!│"""│ │!!!│""│ │!!│"""│ │!!│""│ │ x │ │ 16 | e │ │!!!│""│ │!!│"""│ │!!!│""│ │!!│"""│ │!!│""│ │ i │ % │ 17 | s 15 ┤ │!!!│""│ │!!│"""│ │!!!│""│ │!!│"""│ │!!│""│ │ s0.4 ┤ % % │ 18 | │ │!!!│""│ │!!│"""│ │!!!│""│ │!!│"""│ │!!│""│ │ │ # % # │ 19 | │ │!!!│""│ │!!│"""│ │!!!│""│ │!!│"""│ │!!│""│ │ │ # %& │ 20 | 10 ┤ │!!!│""│ │!!│"""│ │!!!│""│ │!!│"""│ │!!│""│ │ │ # % │ 21 | │ │!!!│""│ │!!│"""│ │!!!│""│ │!!│"""│ │!!│""│ │ 0.2 ┤ $ & % │ 22 | 5 ┤ │!!!│""│ │!!│"""│ │!!!│""│ │!!│"""│ │!!│""│ │ │ % $ # % % & │ 23 | │ │!!!│""│ │!!│"""│ │!!!│""│ │!!│"""│ │!!│""│ │ │ % # %% │ 24 | │ │!!!│""│ │!!│"""│ │!!!│""│ │!!│"""│ │!!│""│ │ │ % # │ 25 | 0 ┤─────┬──┘─────┬───┘─────┬──┘──└──┬───┘──└──┬──┘──┘ 0.0 ┤─┬────────┬────────┬─────────┬────────┬─────────┬┘ 26 | G1 G2 G3 G4 G5 0.0 0.2 0.4 0.6 0.8 1.0 27 | 28 | About as simple as it gets, folks Number of penguins by sex 29 | 30 | ┌─────────────────────────────────────────────────┐ ┌────────────────────────────────────┌────────┐───┐ 31 | 2.00 ┤ """ !!! """ !!!! "" │ │ ┌──────────┐ │! Male │ │ 32 | │ " ! !! " " ! !! " │ 140 ┤ │""""""""""│ │" Female│ │ 33 | 1.75 ┤ ""! !! "" "" ! " │ │ │""""""""""│ └────────┘ │ 34 | v │ !"" ! " !" ! "" │ 120 ┤ │""""""""""│ ┌──────────┐ │ 35 | o 1.50 ┤ ! " ! " ! " !! " │ │ │""""73""""│ │""""""""""│ │ 36 | l │ ! "" ! " !! " ! "" │ │ │""""""""""│ │""""""""""│ │ 37 | t 1.25 ┤ ! " !! " ! "" ! " │ 100 ┤ │""""""""""│ │""""""""""│ │ 38 | a │ ! " ! " ! " ! " │ C │ │""""""""""│ │"""58"""""│ │ 39 | g │ !! "" ! "" ! " ! " │ o 80 ┤ │""""""""""│ │""""""""""│ │ 40 | e 1.00 ┤ ! " ! " ! " !! " ! │ u │ └──────────┘ │""""""""""│ │ 41 | │ "" ! " ! " ! "" ! │ n │ │!!!!!!!!!!│ ┌─────────┐ │""""""""""│ │ 42 | ( 0.75 ┤ " !! " ! " ! " ! │ t 60 ┤ │!!!!!!!!!!│ │"""""""""│ └──────────┘ │ 43 | m │ " ! " !! " ! "" ! │ │ │!!!!!!!!!!│ │"""34""""│ │!!!!!!!!!!│ │ 44 | V 0.50 ┤ " !!" ! "" ! " ! │ 40 ┤ │!!!!!!!!!!│ │"""""""""│ │!!!!!!!!!!│ │ 45 | ) │ " !" !! " !" ! │ │ │!!!!73!!!!│ └─────────┘ │!!!61!!!!!│ │ 46 | 0.25 ┤ " "" ! "" "! ! │ │ │!!!!!!!!!!│ │!!!!!!!!!│ │!!!!!!!!!!│ │ 47 | │ "" ""! !! "" ""! ! │ 20 ┤ │!!!!!!!!!!│ │!!!34!!!!│ │!!!!!!!!!!│ │ 48 | 0.00 ┤ """" !!!! """" !!!!! │ │ │!!!!!!!!!!│ │!!!!!!!!!│ │!!!!!!!!!!│ │ 49 | └─┬─────┬─────┬────┬─────┬─────┬────┬─────┬─────┬─┘ 0 ┤───────┬────────────────┬───────────└────┬─────┘─┘ 50 | 51 | 0.00 0.25 0.50 0.75 1.00 1.25 1.50 1.75 2.00 Adelie Chinstrap Gentoo 52 | time (s) Penguines 53 | -------------------------------------------------------------------------------- /tests/accepted/spines.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | Spine positions 4 | 'center' 'outward' 5 | 6 | ┌ ┌────────────────────────────────────────────────────────┐ 7 | !!!!!!!! 2.0 ┤ 2.0 ┤ !!!!!!!! │ 8 | !! !! │ │ !! !! │ 9 | !! !! 1.5 ┤ 1.5 ┤! !! │ 10 | !! !! │ ! ! │ 11 | !! !! │ !│ ! │ 12 | !! !.0 ┤ 1.0! ┤ ! │ 13 | ! ! │ ! │ !! │ 14 | ! 0.! ┤ 0!5 ┤ !! │ 15 | !! !│ !! │ ! │ 16 | ! !! ! │ !! │ 17 | └─!────────┬───────┬────0.0┬!──────┬───────┬────────┬─!──┐ !0.0 ┤ ! !! │ 18 | 0 1 2 3│!! 4 5 !! │ ! !! │ 19 | −0.5 ┤ !! ! −0.5 ┤ ! ! │ 20 | │ ! ! │ ! ! │ 21 | │ ! ! │ !! !! │ 22 | −1.0 ┤ ! ! −1.0 ┤ !! ! │ 23 | │ ! !! └──┬───────┬───────┬────────┬─────!!┬───────┬───!!──┬────┘ 24 | −1.5 ┤ ! ! −1.5 ┤ !! !! │ 25 | │ !! !! 0 │ 1 2 3 !! 5 !! 6 │ 26 | │ !!!! !!! │ !!!! !!! │ 27 | −2.0 ┤ !!! −2.0 ┤ !!! │ 28 | 'axes' (0.2, 0.2) └ 'data' (1, 2) ┘ 29 | 30 | ┌ ┌ 31 | 2.0 ┤ !!!!! └──┬───2.0─┤─!!!!!!┬────────┬───────┬───────┬───────┬────┐ 32 | !! !!! │!! !! 33 | !! !! 0 !! 2 !! 3 4 5 6 34 | 1.! ┤ ! 1.!!┤ !! 35 | ! │ ! !! │ !! 36 | !.0 ┤ ! !!0 ┤ !! 37 | !! │ ! !! │ ! 38 | ! │ !! ! │ ! 39 | ! 0.5 ┤ !! ! 0.5 ┤ ! 40 | ! │ ! !! │ ! 41 | ! 0.0 ┤ ! ! ! 0.0 ┤ ! ! 42 | │ ! !! │ ! ! 43 | │ ! ! │ !! ! 44 | −0.5 ┤ ! !! −0.5 ┤ !! !! 45 | │ ! !! │ ! !! 46 | −1.0 ┤ !! !! −1.0 ┤ ! ! 47 | │ !! !! │ ! ! 48 | └─┬────────┬───────┬───────┬──────!!───────┬───!!───┬────┐ │ ! ! 49 | 0 −1.5 ┤ 2 3 !! 5 !! 6 −1.5 ┤ !! ! 50 | │ !! !!! │ !!! !! 51 | −2.0 ┤ !!!!!! −2.0 ┤ !!!!! 52 | └ └ 53 | -------------------------------------------------------------------------------- /tests/accepted/polygons.txt: -------------------------------------------------------------------------------- 1 | 2 | Triangle Pentagon 3 | 1.0 ┤───────────────────────────────────────┐ 1.0 ┤───────────────────────────────────────┐ 4 | │ │ │ │ 5 | │ │ │ │ 6 | │ │ │ │ 7 | │ │ │ │ 8 | 0.8 ┤ ! │ 0.8 ┤ │ 9 | │ !"! │ │ │ 10 | │ !"""! │ │ !!!!!!!!!!!!!!!!! │ 11 | │ !"""""! │ │ !#################! │ 12 | 0.6 ┤ !"""""""! │ 0.6 ┤ !#################! │ 13 | │ !"""""""""! │ │ !###################! │ 14 | │ !"""""""""""! │ │ !#####################! │ 15 | │ !"""""""""""! │ │ !#####################! │ 16 | 0.4 ┤ !"""""""""""""! │ 0.4 ┤ !!#####################!! │ 17 | │ !"""""""""""""""! │ │ !!#################!! │ 18 | │ !"""""""""""""""""! │ │ !!!############!! │ 19 | │ !"""""""""""""""""""! │ │ !!#######!!! │ 20 | │ !"""""""""""""""""""""! │ │ !!###!! │ 21 | 0.2 ┤ !!!!!!!!!!!!!!!!!!!!!!!!! │ 0.2 ┤ !!! │ 22 | │ │ │ │ 23 | │ │ │ │ 24 | │ │ │ │ 25 | 0.0 ┤───────┬───────┬───────┬───────┬───────┬ 0.0 ┤───────┬───────┬───────┬───────┬───────┬ 26 | 27 | 0.0 0.2 0.Hexagon.6 0.8 1.0 0.0 0.2 0.4Star 0.6 0.8 1.0 28 | 29 | 1.0 ┤───────────────────────────────────────┐ 1.0 ┤───────────────────────────────────────┐ 30 | │ │ │ │ 31 | │ │ │ ! │ 32 | │ │ │ !%! │ 33 | 0.8 ┤ │ 0.8 ┤ !%! │ 34 | │ │ │ !%%%! │ 35 | │ !!! │ │ !%%%! │ 36 | │ !!$$$!!! │ │ !%%%%%! │ 37 | │ !!!$$$$$$$$!! │ │ !%%%%%! │ 38 | 0.6 ┤ !!$$$$$$$$$$$$$!! │ 0.6 ┤ !!!%%%%%%%!!!! │ 39 | │ !$$$$$$$$$$$$$$$! │ │ !!!!!!%%%%%%%%%%%%%%!!!!!! │ 40 | │ !$$$$$$$$$$$$$$$! │ │ !!!!%%%%%%%%%%%%%%%%%%%%%%%%%%!!! │ 41 | │ !$$$$$$$$$$$$$$$! │ │ !!!!!!%%%%%%%%%%%%%%!!!!!! │ 42 | 0.4 ┤ !$$$$$$$$$$$$$$$! │ 0.4 ┤ !!!%%%%%%%!!!! │ 43 | │ !$$$$$$$$$$$$$$$! │ │ !%%%%%! │ 44 | │ !!!$$$$$$$$$$$$!! │ │ !%%%%%! │ 45 | │ !!!!$$$$!!!! │ │ !%%%! │ 46 | 0.2 ┤ !!!! │ 0.2 ┤ !%! │ 47 | │ │ │ !%! │ 48 | │ │ │ ! │ 49 | │ │ │ │ 50 | 0.0 ┤───────┬───────┬───────┬───────┬───────┬ 0.0 ┤───────┬───────┬───────┬───────┬───────┬ 51 | 52 | 0.0 0.2 0.4 0.6 0.8 1.0 0.0 0.2 0.4 0.6 0.8 1.0 53 | -------------------------------------------------------------------------------- /tests/accepted/rectangles.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | It's all about rectangles 4 | My horizontal bar chart with error bars Histogram of normal distribution sample: $\mu=106$, $\sigma=17$ 5 | 6 | ┌────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────┐ 7 | │──────────────────────────────────┐ │ P0.030 ┤ ┌┐ │ 8 | Tom ┤!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"""""" │ r │ ┌┐ ││ │ 9 | │!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!│ │ o │ ││┌─┐││ │ 10 | │───────────────────────────────────┐ │ b0.025 ┤ ┌┐ │││!│││ │ 11 | │!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!│ │ a │ ││┌######│ │ 12 | Dick ┤!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!""" │ b │ ┌┌│##│┌│!┌│# │ 13 | │───────────────────────────────────┘ │ i0.020 ┤ ││#│││││!│││##┐ │ 14 | │──────────────────────────────────┐ │ l │ │#│┌││││!│││ │# │ 15 | │!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!│ │ i │ ┌┐┌┐#│││││││!│││ │┌#┐ │ 16 | Harry ┤!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"" │ t0.015 ┤ │││#││││││││!│││ │││# │ 17 | │──────────────────────────────────┘ │ y │ ││#│││││││││!│││ │││#┌┐ │ 18 | │─────────────────────────────┐ │ │ │#││││││││││!│││ ││││#│ ┌─┐ │ 19 | Slim ┤!!!!!!!!!!!!!!!!!!!!!!!!!!"""""" │ d0.010 ┤ #│││││││││││!│││ │││││# │!│ │ 20 | │!!!!!!!!!!!!!!!!!!!!!!!!!!!!!│ │ e │ #│┌││││││││││!│││┌│││││┌# │!│ │ 21 | │─────────────────────────────┘ │ n │ ┌┐┌─#┐││││││││││││!││┌│││││││┌###│ │ 22 | │───────────────────────────────────────────┐ │ s0.005 ┤ │┌##││││││││││││││!│││││││││││││!# │ 23 | Jim ┤!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"""""" │ i │ ┌┌┐┌┌##│!││││││││││││││!│││││││││││┌│!│##┐ │ 24 | │!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!│ │ t │ ││###│││!││││││││││││││!│││││││││││││!│ │## │ 25 | ┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬ y0.000 ┤┬###──└└└└─┬┘────────┬──────────┬─────────┬──##─┘ 26 | 27 | 0 2 4 6 8 10 12 14 60 80 100 120 140 28 | Performance Value 29 | Rotated Rectangles Colorful Histograms 30 | 31 | 1.0 ┤───────────────────────────────────┐ ┌────────────────────────────────────────────────┐ 32 | │ │ 17500 ┤ ┌─┐──┐ │ 33 | │ │ │ │.│00│ │ 34 | │ │ │ │.│00│ │ 35 | 0.8 ┤ │ 15000 ┤ ┌─│.│00┌─┐ │ 36 | │ │ │ │-│.│00│-│ │ 37 | │ $$ &&&&& │ 12500 ┤ │-│.│00│-│ │ 38 | │ $$$%%$ &&&&&&&&&&& │ │ │-│.│00│-│ │ 39 | 0.6 ┤ $$$$%%%%%%$ &&&&& │ │ │-│.│00│-│ │ 40 | │ $$$%%%%%%%%%%%$ │ 10000 ┤ ┌─│-│.│00│-┌─┐ │ 41 | │ $$%%%%%%%%%%%%%%$$ │ │ │,│-│.│00│-│,│ │ 42 | │ $%%%%%%%%%%%$$$ │ 7500 ┤ │,│-│.│00│-│,│ │ 43 | 0.4 ┤ $%%%%%%$$$$ │ │ │,│-│.│00│-│,│ │ 44 | │ $%%$$$ │ │ │,│-│.│00│-│,│ │ 45 | │ $$ │ 5000 ┤ ┌──│,│-│.│00│-│,┌─┐ │ 46 | │ │ │ │++│,│-│.│00│-│,│1│ │ 47 | 0.2 ┤ │ │ │++│,│-│.│00│-│,│1│ │ 48 | │ │ 2500 ┤ ┌─│++│,│-│.│00│-│,│1┌──┐ │ 49 | │ │ │ │*│++│,│-│.│00│-│,│1│22│ │ 50 | 0.0 ┤──────┬───────┬──────┬──────┬──────┬ 0 ┤────┬───┌─┌─└┬└──└─└─└─┬─────────┬─────────┬────┘ 51 | 52 | 0.0 0.2 0.4 0.6 0.8 1.0 −4 −2 0 2 4 53 | -------------------------------------------------------------------------------- /mpl_ascii/scene/entities.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from dataclasses import dataclass, field 5 | from typing import Callable, Literal, Union 6 | 7 | from matplotlib.colors import to_rgba 8 | 9 | from mpl_ascii.scene.fingerprint import Fingerprintable, combine, f8 10 | from mpl_ascii.scene.geometry.affine import AffineMap2d 11 | from mpl_ascii.scene.geometry.point import Point2d 12 | 13 | 14 | @dataclass(frozen=True) 15 | class ParsedColor(Fingerprintable): 16 | red: float 17 | green: float 18 | blue: float 19 | alpha: float 20 | 21 | def fingerprint(self) -> bytes: 22 | return f"color({f8(self.red)},{f8(self.green)},{f8(self.blue)},{f8(self.alpha)})".encode() 23 | 24 | @property 25 | def hex_color_with_alpha(self) -> str: 26 | r = int(self.red * 255) 27 | g = int(self.green * 255) 28 | b = int(self.blue * 255) 29 | a = int(self.alpha * 255) 30 | return "#{:02x}{:02x}{:02x}{:02x}".format(r, g, b, a) 31 | 32 | @property 33 | def hex_color(self) -> str: 34 | return self.to_hex(self.red, self.green, self.blue) 35 | 36 | @staticmethod 37 | def to_hex(r: float,g: float, b:float) -> str: 38 | r = int(r * 255) 39 | g = int(g * 255) 40 | b = int(b * 255) 41 | return "#{:02x}{:02x}{:02x}".format(r, g, b) 42 | 43 | @classmethod 44 | def from_hex(cls, hex: str) -> ParsedColor: 45 | return cls(*to_rgba(hex)) 46 | 47 | def __repr__(self) -> str: 48 | return self.hex_color_with_alpha 49 | 50 | @dataclass(frozen=True) 51 | class ParsedColorMap(Fingerprintable): 52 | _call: Callable[[float], ParsedColor] 53 | 54 | def fingerprint(self) -> bytes: 55 | sample_points = [0.0, 0.25, 0.5, 0.75, 1.0] 56 | parts: list[bytes] = [] 57 | for x in sample_points: 58 | color = self(x) 59 | parts.append(f"s({f8(x)}):{color.fingerprint().decode()}".encode()) 60 | return f"cmap({','.join(p.decode() for p in parts)})".encode() 61 | 62 | def approx_inverse(self, target_color: ParsedColor, resolution: int = 10) -> float: 63 | min_distance = float('inf') 64 | best_x = 0.0 65 | 66 | for i in range(resolution + 1): 67 | x = i / resolution 68 | color = self(x) 69 | 70 | # Calculate Euclidean distance in RGB space 71 | distance = ( 72 | (color.red - target_color.red) ** 2 + 73 | (color.green - target_color.green) ** 2 + 74 | (color.blue - target_color.blue) ** 2 75 | ) ** 0.5 76 | 77 | if distance < min_distance: 78 | min_distance = distance 79 | best_x = x 80 | 81 | return best_x 82 | 83 | def __call__(self, x: float) -> ParsedColor: 84 | return self._call(x) 85 | 86 | 87 | 88 | 89 | @dataclass(frozen=True) 90 | class StyleContext(Fingerprintable): 91 | point_color: Union[ParsedColor, None] 92 | line_color: Union[ParsedColor, None] 93 | fill_color: Union[ParsedColor, None] 94 | line_width: Union[float, None] 95 | 96 | 97 | def fingerprint(self) -> bytes: 98 | parts: list[bytes] = [b"ptc()", b"lc()", b"fc()", b"lw()", b"cmap()"] 99 | if self.point_color: 100 | parts[0] = b"ptc(" +self.point_color.fingerprint() + b")" 101 | if self.line_color: 102 | parts[1] = b"lc(" +self.line_color.fingerprint() + b")" 103 | 104 | if self.fill_color: 105 | parts[2] = b"fc(" +self.fill_color.fingerprint() + b")" 106 | 107 | if self.line_width: 108 | parts[3] = f"lw({self.line_width})".encode() 109 | 110 | parts_str = [p.decode() for p in parts] 111 | return f"stylectx({','.join(parts_str)})".encode() 112 | 113 | class Identifable(ABC): 114 | 115 | @abstractmethod 116 | def identifier(self) -> str: 117 | raise NotImplementedError() 118 | 119 | class TransformAware(ABC): 120 | 121 | @abstractmethod 122 | def local2display_transform(self) -> AffineMap2d: 123 | raise NotImplementedError() 124 | 125 | 126 | @dataclass(frozen=True) 127 | class ParsedPointMark(Fingerprintable): 128 | p: Point2d 129 | override: Union[str, None] 130 | color: Union[ParsedColor, None] 131 | 132 | def fingerprint(self) -> bytes: 133 | override = f"ov({self.override})" if self.override else "ov()" 134 | return f"ppm({self.p.fingerprint().decode()}, {override})".encode() 135 | 136 | def __repr__(self) -> str: 137 | return f"{self.p}, {self.override}" 138 | 139 | @dataclass(frozen=True) 140 | class ParsedLineMark(Fingerprintable): 141 | a: Point2d 142 | b: Point2d 143 | override: Union[str, None] 144 | 145 | def fingerprint(self) -> bytes: 146 | override = f"ov({self.override})" if self.override else "ov()" 147 | return f"plm({self.a.fingerprint().decode(), self.b.fingerprint().decode(), override})".encode() 148 | 149 | @dataclass(frozen=True) 150 | class ParsedShape(Identifable, Fingerprintable, TransformAware): 151 | id: str = field(init=False) 152 | points: tuple[ParsedPointMark,...] 153 | lines: tuple[ParsedLineMark,...] 154 | style_context: StyleContext 155 | zorder: float 156 | transform2display: AffineMap2d 157 | insert_order: float 158 | 159 | def __post_init__(self): 160 | 161 | parts: list[bytes] = [] 162 | 163 | for p in self.points: 164 | parts.append(p.fingerprint()) 165 | for lm in self.lines: 166 | parts.append(lm.fingerprint()) 167 | 168 | parts.append(self.style_context.fingerprint()) 169 | parts.append(f"z({f8(self.zorder)})".encode()) 170 | parts.append(self.transform2display.fingerprint()) 171 | parts.append(f"io({self.insert_order})".encode()) 172 | 173 | 174 | fid = combine(parts)[:8] 175 | object.__setattr__(self, "id", f"shape:{fid}") 176 | 177 | 178 | def fingerprint(self) -> bytes: 179 | return self.identifier().encode() 180 | 181 | def identifier(self) -> str: 182 | return self.id 183 | 184 | def local2display_transform(self) -> AffineMap2d: 185 | return self.transform2display 186 | 187 | @dataclass(frozen=True) 188 | class ParsedFigure(Identifable, Fingerprintable, TransformAware): 189 | id: str = field(init=False) 190 | figure2display_tranform: AffineMap2d 191 | shapes: tuple[str,...] 192 | texts: tuple[str,...] 193 | background_patches: tuple[str,...] 194 | 195 | def __post_init__(self): 196 | parts: list[bytes] = [] 197 | 198 | for s_id in self.shapes: 199 | parts.append(s_id.encode()) 200 | 201 | for t_id in self.texts: 202 | parts.append(t_id.encode()) 203 | 204 | for b_id in self.background_patches: 205 | parts.append(b_id.encode()) 206 | 207 | parts.append(self.figure2display_tranform.fingerprint()) 208 | fid = combine(parts)[:8] 209 | object.__setattr__(self, "id", f"fig:{fid}") 210 | 211 | 212 | def fingerprint(self) -> bytes: 213 | return self.identifier().encode() 214 | 215 | def identifier(self) -> str: 216 | return self.id 217 | 218 | def local2display_transform(self) -> AffineMap2d: 219 | return self.figure2display_tranform 220 | 221 | @dataclass(frozen=True) 222 | class ParsedText(Identifable, Fingerprintable, TransformAware): 223 | id: str = field(init=False) 224 | text: str 225 | anchor: Point2d 226 | orientation: Literal["horizontal", "vertical"] 227 | horizontal_alignment: Literal["left", "center", "right"] 228 | vertical_alignment: Literal["top", "center_baseline", "center", "baseline", "bottom"] 229 | zorder: float 230 | transform2display: AffineMap2d 231 | insert_order: float 232 | 233 | def __post_init__(self): 234 | parts: list[bytes] = [] 235 | 236 | parts.append(self.text.encode()) 237 | parts.append(self.anchor.fingerprint()) 238 | parts.append(f"halign({self.horizontal_alignment})".encode()) 239 | parts.append(f"valign({self.vertical_alignment})".encode()) 240 | parts.append(f"z({f8(self.zorder)})".encode()) 241 | parts.append(self.transform2display.fingerprint()) 242 | parts.append(f"io({self.insert_order})".encode()) 243 | parts.append(f"orientation({self.orientation})".encode()) 244 | 245 | 246 | fid = combine(parts)[:8] 247 | object.__setattr__(self, "id", f"text:{fid}") 248 | 249 | 250 | def fingerprint(self) -> bytes: 251 | return self.identifier().encode() 252 | 253 | def identifier(self) -> str: 254 | return self.id 255 | 256 | def local2display_transform(self) -> AffineMap2d: 257 | return self.transform2display 258 | 259 | -------------------------------------------------------------------------------- /mpl_ascii/artists/Spine.py: -------------------------------------------------------------------------------- 1 | from typing import Union, cast 2 | from matplotlib.spines import Spine 3 | 4 | from mpl_ascii.artists.transform_helpers import AffineMap, Point, blend_affine_maps, to_mapping 5 | from mpl_ascii.artists.types import LineMark, PointMark, Shape 6 | 7 | # chr(0x250C) # ┌ 8 | # chr(0x2510) # ┐ 9 | # chr(0x250C) # ┌ 10 | # chr(0x2510) # ┐ 11 | 12 | def parse(obj: Spine) -> Union[Shape, None]: 13 | 14 | spty: str = obj.spine_type 15 | 16 | 17 | position_type: str = "" 18 | amount: float = 0. 19 | 20 | # Skip visibility check for colorbar spines - they're always meant to be visible 21 | # TODO: this should be using a more reliable method than obj.get_visible() 22 | if not obj.get_visible() and not hasattr(obj.axes, '_colorbar'): 23 | return 24 | 25 | 26 | pos = obj.get_position() 27 | 28 | if isinstance(pos, tuple): 29 | position_type, amount = obj.get_position() 30 | 31 | if pos == "center": 32 | position_type, amount = "axes", 0.5 33 | 34 | if pos == "zero": 35 | position_type, amount = "data", 0. 36 | 37 | 38 | if position_type == "outward": 39 | 40 | # Outward points need to be comnverted to pixels to get the right display coordinates. 41 | amount_px = amount * obj.get_figure().dpi / 72.0 42 | 43 | if spty == "left": 44 | point_a = Point(0, 0) 45 | point_b = Point(0, 1) 46 | 47 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transAxes)) 48 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transAxes)) 49 | 50 | mapping_x = AffineMap.identity().translate(Point(-amount_px, 0)) @ mapping_x 51 | 52 | blended_map = blend_affine_maps(mapping_x, mapping_y) 53 | 54 | return Shape( 55 | points = [PointMark(point_a, chr(0x2514)), PointMark(point_b,chr(0x250C))], 56 | lines = [LineMark(point_a, point_b, chr(0x2502))], 57 | mapping=blended_map, 58 | override_zorder=1. 59 | ) 60 | 61 | if spty == "right": 62 | point_a = Point(1, 0) 63 | point_b = Point(1, 1) 64 | 65 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transAxes)) 66 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transAxes)) 67 | 68 | mapping_x = AffineMap.identity().translate(Point(-amount_px, 0)) @ mapping_x 69 | 70 | blended_map = blend_affine_maps(mapping_x, mapping_y) 71 | 72 | return Shape( 73 | points = [PointMark(point_a, chr(0x2518)), PointMark(point_b,chr(0x2510))], 74 | lines = [LineMark(point_a, point_b, chr(0x2502))], 75 | mapping=blended_map, 76 | override_zorder=1. 77 | ) 78 | 79 | if spty == "top": 80 | point_a = Point(0, 1) 81 | point_b = Point(1, 1) 82 | 83 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transAxes)) 84 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transAxes)) 85 | 86 | mapping_y = AffineMap.identity().translate(Point(0, -amount_px)) @ mapping_y 87 | 88 | blended_map = blend_affine_maps(mapping_x, mapping_y) 89 | 90 | return Shape( 91 | points = [PointMark(point_a, chr(0x250C)), PointMark(point_b,chr(0x2510))], 92 | lines = [LineMark(point_a, point_b, chr(0x2500))], 93 | mapping=blended_map, 94 | override_zorder=1. 95 | ) 96 | 97 | if spty == "bottom": 98 | point_a = Point(0, 0) 99 | point_b = Point(1, 0) 100 | 101 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transAxes)) 102 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transAxes)) 103 | 104 | blended_map = blend_affine_maps(mapping_x, mapping_y) 105 | m = AffineMap.identity().translate(Point(0, -amount_px)) @ blended_map 106 | 107 | return Shape( 108 | points = [PointMark(point_a, chr(0x2514)), PointMark(point_b,chr(0x2518))], 109 | lines = [LineMark(point_a, point_b, chr(0x2500))], 110 | mapping=m, 111 | override_zorder=1. 112 | ) 113 | 114 | if position_type == "axes": 115 | if spty == "left": 116 | point_a = Point(amount, 0) 117 | point_b = Point(amount, 1) 118 | 119 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transAxes)) 120 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transAxes)) 121 | 122 | blended_map = blend_affine_maps(mapping_x, mapping_y) 123 | 124 | return Shape( 125 | points = [PointMark(point_a, chr(0x2514)), PointMark(point_b,chr(0x250C))], 126 | lines = [LineMark(point_a, point_b, chr(0x2502))], 127 | mapping=blended_map, 128 | override_zorder=1. 129 | ) 130 | 131 | if spty == "right": 132 | point_a = Point(amount, 0) 133 | point_b = Point(amount, 1) 134 | 135 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transAxes)) 136 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transAxes)) 137 | 138 | blended_map = blend_affine_maps(mapping_x, mapping_y) 139 | 140 | return Shape( 141 | points = [PointMark(point_a, chr(0x2518)), PointMark(point_b,chr(0x2510))], 142 | lines = [LineMark(point_a, point_b, chr(0x2502))], 143 | mapping=blended_map, 144 | override_zorder=1. 145 | ) 146 | 147 | if spty == "top": 148 | point_a = Point(0, amount) 149 | point_b = Point(1, amount) 150 | 151 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transAxes)) 152 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transAxes)) 153 | 154 | blended_map = blend_affine_maps(mapping_x, mapping_y) 155 | 156 | return Shape( 157 | points = [PointMark(point_a, chr(0x250C)), PointMark(point_b,chr(0x2510))], 158 | lines = [LineMark(point_a, point_b, chr(0x2500))], 159 | mapping=blended_map, 160 | override_zorder=1. 161 | ) 162 | 163 | if spty == "bottom": 164 | point_a = Point(0, amount) 165 | point_b = Point(1, amount) 166 | 167 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transAxes)) 168 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transAxes)) 169 | 170 | blended_map = blend_affine_maps(mapping_x, mapping_y) 171 | 172 | return Shape( 173 | points = [PointMark(point_a, chr(0x2514)), PointMark(point_b,chr(0x2510))], 174 | lines = [LineMark(point_a, point_b, chr(0x2500))], 175 | mapping=blended_map, 176 | override_zorder=1. 177 | ) 178 | 179 | if position_type == "data": 180 | if spty == "left": 181 | point_a = Point(amount, 0) 182 | point_b = Point(amount, 1) 183 | 184 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transAxes)) 185 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transData)) 186 | 187 | 188 | blended_map = blend_affine_maps(mapping_x, mapping_y) 189 | 190 | return Shape( 191 | points = [PointMark(point_a, chr(0x2514)), PointMark(point_b,chr(0x250C))], 192 | lines = [LineMark(point_a, point_b, chr(0x2502))], 193 | mapping=blended_map, 194 | override_zorder=1. 195 | ) 196 | 197 | if spty == "right": 198 | point_a = Point(amount, 0) 199 | point_b = Point(amount, 1) 200 | 201 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transAxes)) 202 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transData)) 203 | 204 | 205 | blended_map = blend_affine_maps(mapping_x, mapping_y) 206 | 207 | return Shape( 208 | points = [PointMark(point_a, chr(0x2518)), PointMark(point_b,chr(0x2510))], 209 | lines = [LineMark(point_a, point_b, chr(0x2502))], 210 | mapping=blended_map, 211 | override_zorder=1. 212 | ) 213 | 214 | if spty == "top": 215 | point_a = Point(0, amount) 216 | point_b = Point(1, amount) 217 | 218 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transData)) 219 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transAxes)) 220 | 221 | 222 | blended_map = blend_affine_maps(mapping_x, mapping_y) 223 | 224 | return Shape( 225 | points = [PointMark(point_a, chr(0x250C)), PointMark(point_b,chr(0x2510))], 226 | lines = [LineMark(point_a, point_b, chr(0x2500))], 227 | mapping=blended_map, 228 | override_zorder=1. 229 | ) 230 | 231 | if spty == "bottom": 232 | point_a = Point(0, amount) 233 | point_b = Point(1, amount) 234 | 235 | mapping_y = cast(AffineMap, to_mapping(obj.axes.transData)) 236 | mapping_x = cast(AffineMap, to_mapping(obj.axes.transAxes)) 237 | 238 | blended_map = blend_affine_maps(mapping_x, mapping_y) 239 | 240 | return Shape( 241 | points = [PointMark(point_a, chr(0x2514)), PointMark(point_b,chr(0x2510))], 242 | lines = [LineMark(point_a, point_b, chr(0x2500))], 243 | mapping=blended_map, 244 | override_zorder=1. 245 | ) 246 | 247 | return None --------------------------------------------------------------------------------