diff --git a/CHANGELOG.md b/CHANGELOG.md index 9efe695..60140f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ semantic versioning. ### Added +- Phase 2 walk + spiral: the first two presets and their layouts. + - `layout.walk_layout` / `heading_angles` — the 2-D turtle walk (step forward, + turn 90°), ported from the Wittgenstein piece's `compute_path`. + - `layout.spiral_layout` / `tangent_angles` — equal-arc-length placement along + an Archimedean spiral. + - `layout.rendered_widths` — headless rendered-width measurement (matplotlib + `TextPath`), the width-step that drives the walk. + - `text_walk` preset — sentences as a space-filling walk, with `"path"` and + `"glyphs"` (calligraphy-on-path) modes; built-in length/position channels. + - `punctuation_spiral` preset — a text's marks on a spiral, accenting logical, + mathematical, and Greek signs in gold. + - `render.frame_axes` helper; `examples/` and docs for both presets. - Phase 1 spine: the four-step pipeline end to end. - `segment` — split text into characters, tokens, or sentences. Sentence splitting defaults to a self-contained, abbreviation-aware offline splitter diff --git a/docs/api/punctuation_spiral.md b/docs/api/punctuation_spiral.md new file mode 100644 index 0000000..b7c6d87 --- /dev/null +++ b/docs/api/punctuation_spiral.md @@ -0,0 +1,7 @@ +# Punctuation spiral + +A text's punctuation and logical signs wound onto an Archimedean spiral. + +::: lexograph.presets.punctuation_spiral.punctuation_spiral + +::: lexograph.presets.punctuation_spiral.is_accent diff --git a/docs/api/spiral.md b/docs/api/spiral.md new file mode 100644 index 0000000..5e767ee --- /dev/null +++ b/docs/api/spiral.md @@ -0,0 +1,7 @@ +# Spiral layout + +The Archimedean spiral: place units evenly by arc length along an outward spiral. + +::: lexograph.layout.spiral.spiral_layout + +::: lexograph.layout.spiral.tangent_angles diff --git a/docs/api/text_walk.md b/docs/api/text_walk.md new file mode 100644 index 0000000..411ab5b --- /dev/null +++ b/docs/api/text_walk.md @@ -0,0 +1,5 @@ +# Text walk + +Each sentence steps forward and turns, space-filling — the 2-D walk. + +::: lexograph.presets.text_walk.text_walk diff --git a/docs/api/walk.md b/docs/api/walk.md new file mode 100644 index 0000000..57704a3 --- /dev/null +++ b/docs/api/walk.md @@ -0,0 +1,7 @@ +# Walk layout + +The turtle walk: step forward by a per-unit length, then turn. + +::: lexograph.layout.walk.walk_layout + +::: lexograph.layout.walk.heading_angles diff --git a/docs/api/widths.md b/docs/api/widths.md new file mode 100644 index 0000000..16a728f --- /dev/null +++ b/docs/api/widths.md @@ -0,0 +1,6 @@ +# Rendered widths + +Measure each unit's rendered text width headlessly — the width-step that drives +the walk. + +::: lexograph.layout.widths.rendered_widths diff --git a/docs/examples/punctuation_spiral.md b/docs/examples/punctuation_spiral.md new file mode 100644 index 0000000..f2972fa --- /dev/null +++ b/docs/examples/punctuation_spiral.md @@ -0,0 +1,15 @@ +# Punctuation spiral + +Every non-alphanumeric mark of the bundled chapter, in reading order, wound onto +an outward Archimedean spiral. Ordinary punctuation is dim warm grey; logical, +mathematical, and Greek signs are picked out in gold. + +```python +--8<-- "examples/punctuation_spiral_demo.py" +``` + +Run it with: + +```bash +uv run python examples/punctuation_spiral_demo.py +``` diff --git a/docs/examples/text_walk.md b/docs/examples/text_walk.md new file mode 100644 index 0000000..c3672ae --- /dev/null +++ b/docs/examples/text_walk.md @@ -0,0 +1,18 @@ +# Text walk + +The bundled chapter as a space-filling turtle walk: each sentence steps forward +by its rendered width and turns 90°. Each step is coloured by its position in the +chapter and weighted by sentence length. + +```python +--8<-- "examples/text_walk_demo.py" +``` + +Run it with: + +```bash +uv run python examples/text_walk_demo.py +``` + +Pass `mode="glyphs"` to set each sentence's text along its segment +(calligraphy-on-path) instead of drawing a coloured ribbon. diff --git a/docs/quickstart.md b/docs/quickstart.md index efeabf4..22d2a17 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -24,9 +24,31 @@ print(text[:52]) # It is a truth universally acknowledged, that a singl ``` +## Draw a preset + +Each preset segments the text, lays the units out, encodes per-unit attributes, and +returns a matplotlib `Figure`. + +```python +from lexograph import load_demo_text, punctuation_spiral, text_walk + +text = load_demo_text() + +# Every punctuation mark and logical sign, wound onto an Archimedean spiral. +spiral = punctuation_spiral(text) +spiral.savefig("punctuation_spiral.png", dpi=150) + +# Each sentence as a step of a space-filling turtle walk, coloured by position. +walk = text_walk(text) +walk.savefig("text_walk.png", dpi=150) +``` + +Both return a `Figure` and never call `show()`, so they display inline in Jupyter and +save cleanly from a script. The `text_walk` colour and size channels accept any +per-sentence array — `length`, `frequency`, a community id, or your own column — which +is the [data contract](index.md#the-data-contract) that keeps lexograph general. + ## Next steps -The preset front ends (punctuation spiral, text walk, recurrence dotplot, concordance) -land over the following build phases. Each one segments this text, lays the units out, -encodes per-unit attributes, and returns a matplotlib `Figure` you can save with -`fig.savefig(...)` or display inline in Jupyter. +The remaining presets (3-D walk, recurrence dotplot, concordance) land over the +following build phases, each on the same spine. diff --git a/examples/punctuation_spiral_demo.py b/examples/punctuation_spiral_demo.py new file mode 100644 index 0000000..060a22e --- /dev/null +++ b/examples/punctuation_spiral_demo.py @@ -0,0 +1,18 @@ +"""Punctuation-spiral demo: a text's marks wound onto an Archimedean spiral. + +Run with: uv run python examples/punctuation_spiral_demo.py +Writes punctuation_spiral.png to the current directory. +""" + +from lexograph import load_demo_text, punctuation_spiral + + +def main() -> None: + """Draw the punctuation of Pride and Prejudice's first chapter as a spiral.""" + fig = punctuation_spiral(load_demo_text(), turns=12.0) + fig.savefig("punctuation_spiral.png", dpi=150) + print("Saved punctuation_spiral.png") + + +if __name__ == "__main__": + main() diff --git a/examples/text_walk_demo.py b/examples/text_walk_demo.py new file mode 100644 index 0000000..db57862 --- /dev/null +++ b/examples/text_walk_demo.py @@ -0,0 +1,24 @@ +"""Text-walk demo: a chapter as a space-filling turtle walk over its sentences. + +Run with: uv run python examples/text_walk_demo.py +Writes text_walk.png to the current directory. +""" + +from lexograph import load_demo_text, segment, text_walk + + +def main() -> None: + """Walk the bundled chapter, colouring each step by its position in the text.""" + text = load_demo_text() + units = segment(text, unit="sentences") + print(f"Walking {len(units)} sentences.") + + # Colour each sentence by where it falls in the chapter (continuous), and let + # the default size channel weight each stroke by sentence length. + fig = text_walk(text, colour=list(range(len(units))), figsize=(10.0, 10.0)) + fig.savefig("text_walk.png", dpi=150) + print("Saved text_walk.png") + + +if __name__ == "__main__": + main() diff --git a/mkdocs.yml b/mkdocs.yml index 3a7c14a..2490e94 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,11 +49,20 @@ nav: - index.md - quickstart.md - Examples: + - examples/punctuation_spiral.md + - examples/text_walk.md - examples/spine_demo.md - Troubleshooting: troubleshooting.md - API Reference: - - api/segment.md - - api/layout.md - - api/channels.md - - api/render.md - - api/datasets.md + - Presets: + - api/punctuation_spiral.md + - api/text_walk.md + - Spine: + - api/segment.md + - api/layout.md + - api/walk.md + - api/spiral.md + - api/widths.md + - api/channels.md + - api/render.md + - api/datasets.md diff --git a/src/lexograph/__init__.py b/src/lexograph/__init__.py index 1b526e9..ba69d3e 100644 --- a/src/lexograph/__init__.py +++ b/src/lexograph/__init__.py @@ -17,19 +17,31 @@ continuous_colors, normalize_size, ) -from lexograph.layout import linear_layout -from lexograph.render import render_path, render_points +from lexograph.layout import ( + linear_layout, + rendered_widths, + spiral_layout, + walk_layout, +) +from lexograph.presets import punctuation_spiral, text_walk +from lexograph.render import frame_axes, render_path, render_points from lexograph.segment import segment __all__ = [ "segment", "linear_layout", + "walk_layout", + "spiral_layout", + "rendered_widths", "normalize_size", "categorical_colors", "continuous_colors", "Channels", "render_points", "render_path", + "frame_axes", + "punctuation_spiral", + "text_walk", "load_demo_text", "__version__", ] diff --git a/src/lexograph/layout/__init__.py b/src/lexograph/layout/__init__.py index 57c0add..30f7752 100644 --- a/src/lexograph/layout/__init__.py +++ b/src/lexograph/layout/__init__.py @@ -1,5 +1,15 @@ """Layout: place ordered units in 2-D or 3-D space (walks, spirals, grids).""" from lexograph.layout.linear import linear_layout +from lexograph.layout.spiral import spiral_layout, tangent_angles +from lexograph.layout.walk import heading_angles, walk_layout +from lexograph.layout.widths import rendered_widths -__all__ = ["linear_layout"] +__all__ = [ + "linear_layout", + "walk_layout", + "heading_angles", + "spiral_layout", + "tangent_angles", + "rendered_widths", +] diff --git a/src/lexograph/layout/spiral.py b/src/lexograph/layout/spiral.py new file mode 100644 index 0000000..fce6405 --- /dev/null +++ b/src/lexograph/layout/spiral.py @@ -0,0 +1,115 @@ +"""The Archimedean spiral layout: place units evenly along an outward spiral. + +Ported from the Wittgenstein piece's ``spiral_xy``. Units are spread by equal +*arc length* (not equal angle) along the Archimedean spiral ``r = r0 + b·theta``, +so the spacing between consecutive units stays visually uniform as the spiral +winds outward. This is the layout behind the punctuation-spiral preset, where +each non-alphanumeric mark is a unit. +""" + +from __future__ import annotations + +import numpy as np + +from lexograph._types import Coords, FloatArray + +__all__ = ["spiral_layout", "tangent_angles"] + +# Resolution of the arc-length integration grid. Large enough that the +# equal-arc-length resampling is smooth for any realistic unit count. +_ARC_SAMPLES = 200_000 + + +def spiral_layout( + n: int, + *, + turns: float = 16.0, + r0: float = 1.0, + r_max: float = 10.0, +) -> Coords: + """Place ``n`` units evenly by arc length along an Archimedean spiral. + + Args: + n: The number of units to place. + turns: How many full revolutions the spiral makes from ``r0`` to + ``r_max``. + r0: The starting radius (the innermost unit sits here). + r_max: The outermost radius (the last unit sits near here). + + Returns: + An ``(n, 2)`` float array of ``(x, y)`` positions, ordered from the + innermost unit outward. + + Raises: + ValueError: If ``n`` is negative, ``turns`` is not positive, or + ``r_max < r0``. + + Contract: + - Returns exactly ``n`` rows. + - Radius increases monotonically from the first unit to the last. + - The output is deterministic in its inputs. + + Examples: + >>> coords = spiral_layout(5, turns=2.0, r0=1.0, r_max=4.0) + >>> coords.shape + (5, 2) + >>> import numpy as np + >>> radii = np.hypot(coords[:, 0], coords[:, 1]) + >>> bool(np.all(np.diff(radii) > 0)) + True + """ + if n < 0: + msg = f"n must be non-negative, got {n}" + raise ValueError(msg) + if turns <= 0: + msg = f"turns must be positive, got {turns}" + raise ValueError(msg) + if r_max < r0: + msg = f"r_max ({r_max}) must be >= r0 ({r0})" + raise ValueError(msg) + if n == 0: + return np.zeros((0, 2), dtype=float) + + theta_max = 2.0 * np.pi * turns + b = (r_max - r0) / theta_max + grid = np.linspace(0.0, theta_max, _ARC_SAMPLES) + r_grid = r0 + b * grid + # Arc length ds = sqrt(r^2 + (dr/dtheta)^2) dtheta, with dr/dtheta = b. + integrand = np.hypot(r_grid, b) + arc = np.concatenate([[0.0], np.cumsum(np.diff(grid) * integrand[:-1])]) + theta = np.interp(np.linspace(0.0, arc[-1], n), arc, grid) + r = r0 + b * theta + x, y = r * np.cos(theta), r * np.sin(theta) + return np.column_stack([x, y]).astype(float) + + +def tangent_angles(coords: Coords) -> FloatArray: + """Return the tangent direction (degrees) of an ordered path at each point. + + Computed from the local gradient of the coordinates, this orients per-unit + glyphs so they follow the curve (used to set the rotation of each mark on + the punctuation spiral). + + Args: + coords: An ``(N, 2)`` array of ordered positions. + + Returns: + A length-``N`` array of tangent angles in degrees. + + Raises: + ValueError: If ``coords`` is not ``(N, 2)``. + + Examples: + >>> import numpy as np + >>> tangent_angles(np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]])).tolist() + [0.0, 0.0, 0.0] + """ + array = np.asarray(coords, dtype=float) + if array.ndim != 2 or array.shape[1] != 2: + msg = f"coords must have shape (N, 2), got {array.shape}" + raise ValueError(msg) + if array.shape[0] == 0: + return np.zeros((0,), dtype=float) + dx = np.gradient(array[:, 0]) + dy = np.gradient(array[:, 1]) + return np.degrees(np.arctan2(dy, dx)) diff --git a/src/lexograph/layout/walk.py b/src/lexograph/layout/walk.py new file mode 100644 index 0000000..e2f21e7 --- /dev/null +++ b/src/lexograph/layout/walk.py @@ -0,0 +1,121 @@ +"""The turtle-walk layout: step forward by a per-unit length, then turn. + +This is the 2-D walk math ported from the Wittgenstein piece's ``compute_path`` +(itself the descendant of the original turtle POC), reduced to plain coordinate +generation. For each unit the turtle steps forward along its heading by that +unit's step length, then rotates by a fixed turn angle (90° to the right by +default — the space-filling rectangular walk). The renderer is rebuilt in +matplotlib; this module only produces coordinates. + +The returned array has one more row than there are steps: it starts at the +origin and appends a vertex after each step, so ``N`` units yield ``N + 1`` +vertices and ``N`` segments. Segment ``i`` belongs to unit ``i``, which keeps a +per-unit colour channel aligned with the path drawn by +:func:`lexograph.render.mpl.render_path`. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +import numpy as np + +from lexograph._types import Coords, FloatArray + +__all__ = ["walk_layout", "heading_angles"] + + +def _rotation(degrees: float) -> np.ndarray: + """Return the 2x2 matrix that rotates a column vector counter-clockwise.""" + theta = np.deg2rad(degrees) + cos, sin = np.cos(theta), np.sin(theta) + return np.array([[cos, -sin], [sin, cos]]) + + +def walk_layout( + steps: Iterable[float], + *, + turn: float = -90.0, + scale: float = 1.0, + start: tuple[float, float] = (0.0, 0.0), + heading: tuple[float, float] = (1.0, 0.0), +) -> Coords: + """Walk a turtle forward by each step length, turning by ``turn`` between steps. + + Args: + steps: One forward step length per unit (e.g. sentence length or rendered + width). Negative steps are allowed (the turtle walks backward). + turn: Degrees to rotate the heading after each step. The default ``-90`` + turns right, giving the space-filling rectangular walk; ``+90`` turns + left. Non-right angles produce curved or spiralling paths. + scale: A multiplier applied to every step length. + start: The starting ``(x, y)`` position (the first vertex). + heading: The initial heading vector; it is normalised internally. + + Returns: + An ``(N + 1, 2)`` float array of vertices, where ``N`` is the number of + steps. Row 0 is ``start``; row ``i + 1`` is the position after step ``i``. + + Raises: + ValueError: If ``heading`` is the zero vector. + + Contract: + - The result has exactly ``N + 1`` rows for ``N`` steps. + - The distance between consecutive vertices equals ``abs(step * scale)`` + for that unit (up to floating-point error). + - The output is deterministic in its inputs. + + Examples: + >>> walk_layout([1.0, 1.0, 1.0]).round(6).tolist() + [[0.0, 0.0], [1.0, 0.0], [1.0, -1.0], [0.0, -1.0]] + """ + lengths = np.asarray(list(steps), dtype=float) + head = np.asarray(heading, dtype=float) + norm = float(np.hypot(head[0], head[1])) + if norm == 0.0: + msg = "heading must be a non-zero vector" + raise ValueError(msg) + head = head / norm + rotation = _rotation(turn) + + pos = np.asarray(start, dtype=float) + vertices = [pos.copy()] + for length in lengths: + pos = pos + head * (length * scale) + vertices.append(pos.copy()) + head = rotation @ head + return np.asarray(vertices) + + +def heading_angles( + steps: Iterable[float], + *, + turn: float = -90.0, + heading: tuple[float, float] = (1.0, 0.0), +) -> FloatArray: + """Return the heading angle (degrees) the turtle travels along for each step. + + Useful for orienting per-unit glyphs along the walk (calligraphy-on-path): + angle ``i`` is the direction of the segment drawn for unit ``i``. + + Args: + steps: One step per unit; only the count is used. + turn: Degrees rotated after each step (see :func:`walk_layout`). + heading: The initial heading vector; normalised internally. + + Returns: + A length-``N`` array of angles in degrees in ``[-180, 180]``. + + Examples: + >>> heading_angles([1, 1, 1]).tolist() + [0.0, -90.0, -180.0] + """ + n = len(list(steps)) + head = np.asarray(heading, dtype=float) + head = head / float(np.hypot(head[0], head[1])) + rotation = _rotation(turn) + angles = [] + for _ in range(n): + angles.append(float(np.round(np.degrees(np.arctan2(head[1], head[0])), 6))) + head = rotation @ head + return np.asarray(angles, dtype=float) diff --git a/src/lexograph/layout/widths.py b/src/lexograph/layout/widths.py new file mode 100644 index 0000000..f87a56a --- /dev/null +++ b/src/lexograph/layout/widths.py @@ -0,0 +1,86 @@ +"""Measure the rendered width of each unit's text, headlessly. + +The Wittgenstein piece's key refinement over the original turtle POC was to step +the walk by each sentence's *rendered width* — the width it actually occupies +when set in a font — instead of a flat ``length / 15``. That version measured +the width inside Blender; here we rebuild it headlessly with matplotlib's +:class:`~matplotlib.textpath.TextPath`, which lays out glyph outlines without a +display, canvas, or font server. The resulting per-unit widths feed straight +into :func:`lexograph.layout.walk.walk_layout` as step lengths. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path + +import numpy as np +from matplotlib.font_manager import FontProperties +from matplotlib.textpath import TextPath + +from lexograph._types import FloatArray + +__all__ = ["rendered_widths"] + + +def _as_font_properties(prop: FontProperties | str | Path | None) -> FontProperties: + """Coerce a font argument into a :class:`FontProperties`.""" + if prop is None: + return FontProperties() + if isinstance(prop, FontProperties): + return prop + return FontProperties(fname=str(prop)) + + +def rendered_widths( + strings: Iterable[str], + *, + prop: FontProperties | str | Path | None = None, + size: float = 12.0, +) -> FloatArray: + """Return the rendered width of each string, set in the given font. + + Args: + strings: One string per unit (typically the sentences). + prop: The font to measure with — a :class:`FontProperties`, a path to a + ``.ttf``/``.otf`` file, or ``None`` for matplotlib's default font. + size: The font size to measure at (in points). + + Returns: + A float array of widths, one per input string. Empty or whitespace-only + strings get a width proportional to their character count so the walk + still advances past them. + + Contract: + - The result has one entry per input string, all non-negative. + - Widths are deterministic for a given font, size, and string. + + Examples: + >>> w = rendered_widths(["i", "wwww"]) + >>> bool(w[1] > w[0]) + True + """ + font = _as_font_properties(prop) + widths: list[float] = [] + for string in strings: + widths.append(_one_width(string, font, size)) + return np.asarray(widths, dtype=float) + + +def _one_width(string: str, font: FontProperties, size: float) -> float: + """Measure one string's width, falling back to a per-character estimate.""" + # Whitespace, empty, and any string whose glyphs cannot be laid out have no + # usable outline extent; advance by a plain per-character estimate so the + # turtle still moves on past them. + estimate = len(string) * size * 0.3 + if string.strip() == "": + return estimate + try: + width = float( + TextPath((0.0, 0.0), string, size=size, prop=font).get_extents().width + ) + except (ValueError, RuntimeError): + return estimate + if not np.isfinite(width) or width < 0.0: + return estimate + return width diff --git a/src/lexograph/presets/__init__.py b/src/lexograph/presets/__init__.py index cf73e44..6a93810 100644 --- a/src/lexograph/presets/__init__.py +++ b/src/lexograph/presets/__init__.py @@ -1 +1,6 @@ """Presets: ready-made points on the segment-layout-encode-render spine.""" + +from lexograph.presets.punctuation_spiral import is_accent, punctuation_spiral +from lexograph.presets.text_walk import text_walk + +__all__ = ["punctuation_spiral", "is_accent", "text_walk"] diff --git a/src/lexograph/presets/punctuation_spiral.py b/src/lexograph/presets/punctuation_spiral.py new file mode 100644 index 0000000..9bc5161 --- /dev/null +++ b/src/lexograph/presets/punctuation_spiral.py @@ -0,0 +1,139 @@ +"""Punctuation-spiral preset: every mark of a text along an Archimedean spiral. + +Each non-alphanumeric, non-whitespace character of the text becomes a unit and +is placed, in reading order, along an outward Archimedean spiral (see +:func:`lexograph.layout.spiral.spiral_layout`). Ordinary punctuation is set in a +dim warm grey; logical, mathematical, and Greek signs are picked out in a gold +accent, set bold and a little larger so they read out of the ribbon. The marks +grow gently in size as the spiral winds outward. + +Ported from the Wittgenstein piece's punctuation-spiral generator, minus the +central portrait (a cosmetic the core does not need). The figure is built +directly, so it stays headless and returns a :class:`matplotlib.figure.Figure`. +""" + +from __future__ import annotations + +import numpy as np +from matplotlib.figure import Figure + +from lexograph.layout.spiral import spiral_layout, tangent_angles +from lexograph.render.mpl import frame_axes +from lexograph.segment.units import characters + +__all__ = ["punctuation_spiral", "is_accent"] + +_BACKGROUND = "#1a1a1f" +_DIM = "#cfc7b3" # ordinary punctuation — warm grey, legible on dark +_ACCENT = "#f0cf7f" # logical / mathematical / Greek — gold +_ACCENT_SCALE = 1.6 # accent marks render this much larger so they pop + +# Logical, mathematical, and Greek signs get the accent colour. The explicit +# set covers the common marks; the codepoint ranges future-proof it. +_ACCENT_CHARS = frozenset("∃∨∼⊃∑≡×′♯♭=~±÷∞∂∇∈∉⊂⊆⊇∧¬∀→↔⇒⇔") +_ACCENT_RANGES = ( + (0x2200, 0x22FF), # Mathematical Operators + (0x2A00, 0x2AFF), # Supplemental Mathematical Operators + (0x0370, 0x03FF), # Greek and Coptic + (0x1F00, 0x1FFF), # Greek Extended + (0x2100, 0x214F), # Letterlike Symbols + (0x27C0, 0x27EF), # Miscellaneous Mathematical Symbols-A +) + + +def is_accent(char: str) -> bool: + """Return whether ``char`` is a logical, mathematical, or Greek sign. + + Args: + char: A single character. + + Returns: + ``True`` if the character should take the gold accent colour. + + Examples: + >>> is_accent("=") + True + >>> is_accent(",") + False + """ + if char in _ACCENT_CHARS: + return True + codepoint = ord(char) + return any(lo <= codepoint <= hi for lo, hi in _ACCENT_RANGES) + + +def _marks(text: str) -> list[str]: + """Punctuation and symbols in order (drop letters, digits, and whitespace).""" + return [c for c in characters(text) if not c.isalnum() and not c.isspace()] + + +def punctuation_spiral( + text: str, + *, + turns: float = 16.0, + size_min: float = 7.0, + size_max: float = 12.0, + figsize: tuple[float, float] = (9.0, 9.0), +) -> Figure: + """Draw a text's punctuation and logical signs as a spiral plate. + + Args: + text: The source text. + turns: How many revolutions the spiral makes. + size_min: Font size (points) of the innermost marks. + size_max: Font size (points) of the outermost marks. + figsize: Figure size in inches. + + Returns: + A :class:`matplotlib.figure.Figure` with one axes, dark-themed. Never + calls ``show()``, so it renders inline in Jupyter and saves with + ``fig.savefig(...)``. + + Raises: + ValueError: If ``text`` contains no punctuation or symbol marks. + + Contract: + - Returns a Figure with exactly one axes. + - Accent marks (logical/mathematical/Greek) are drawn larger and on top. + + Examples: + >>> from lexograph import load_demo_text + >>> fig = punctuation_spiral(load_demo_text()) + >>> type(fig).__name__ + 'Figure' + >>> len(fig.axes) + 1 + """ + marks = _marks(text) + if not marks: + msg = "text contains no punctuation or symbol marks to plot" + raise ValueError(msg) + + n = len(marks) + coords = spiral_layout(n, turns=turns, r0=1.0, r_max=10.0) + angles = tangent_angles(coords) + # Marks grow linearly in size from the centre outward. + sizes = np.linspace(size_min, size_max, n) + + fig = Figure(figsize=figsize, facecolor=_BACKGROUND) + ax = fig.subplots() + ax.set_facecolor(_BACKGROUND) + + for i, mark in enumerate(marks): + accent = is_accent(mark) + ax.text( + coords[i, 0], + coords[i, 1], + mark, + fontsize=sizes[i] * (_ACCENT_SCALE if accent else 1.0), + color=_ACCENT if accent else _DIM, + fontweight="bold" if accent else "normal", + rotation=angles[i], + ha="center", + va="center", + zorder=3 if accent else 2, + ) + + frame_axes(ax, coords, margin=0.08) + fig.tight_layout() + return fig diff --git a/src/lexograph/presets/text_walk.py b/src/lexograph/presets/text_walk.py new file mode 100644 index 0000000..1cb81f6 --- /dev/null +++ b/src/lexograph/presets/text_walk.py @@ -0,0 +1,171 @@ +"""Text-walk preset: each sentence steps forward and turns, space-filling. + +This is the 2-D walk. Each sentence is a unit; the turtle steps forward by the +sentence's rendered width (the Wittgenstein refinement — measured headlessly, +see :func:`lexograph.layout.widths.rendered_widths`) and turns 90° between +sentences. Per-unit attributes drive the visual channels: ``size`` sets the +stroke weight (or glyph size) and ``colour`` tints each step. With no channels +supplied the walk falls back to dependency-free built-ins — stroke by sentence +length, colour by position in the text — so it works with no analysis stack at +all. + +Two modes: ``"path"`` draws the walk as a multi-coloured ribbon; ``"glyphs"`` +sets each sentence's text along its segment (calligraphy-on-path). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Literal, cast + +import numpy as np +from matplotlib.collections import LineCollection +from matplotlib.figure import Figure + +from lexograph.encode.channels import ( + categorical_colors, + continuous_colors, + normalize_size, +) +from lexograph.layout.walk import heading_angles, walk_layout +from lexograph.layout.widths import rendered_widths +from lexograph.render.mpl import frame_axes +from lexograph.segment.units import sentences as split_sentences + +if TYPE_CHECKING: + from matplotlib.font_manager import FontProperties + + from lexograph.encode.channels import RGBA + +__all__ = ["text_walk"] + +WalkMode = Literal["path", "glyphs"] +ColourKind = Literal["auto", "categorical", "continuous"] + + +def _resolve_colours( + colour: Sequence[object] | None, + n: int, + kind: ColourKind, +) -> list[RGBA]: + """Turn a per-unit colour channel into one RGBA per unit.""" + if colour is None: + return continuous_colors(np.arange(n, dtype=float)) + values = list(colour) + if len(values) != n: + msg = f"colour must have one entry per sentence ({n}), got {len(values)}" + raise ValueError(msg) + if kind == "auto": + numeric = all( + isinstance(v, (int, float, np.integer, np.floating)) + and not isinstance(v, bool) + for v in values + ) + kind = "continuous" if numeric else "categorical" + if kind == "continuous": + return continuous_colors(cast("list[float]", values)) + return categorical_colors(values) + + +def text_walk( + text: str, + *, + colour: Sequence[object] | None = None, + colour_kind: ColourKind = "auto", + size: Sequence[float] | None = None, + mode: WalkMode = "path", + font: FontProperties | str | Path | None = None, + font_size: float = 12.0, + width_step: bool = True, + turn: float = -90.0, + background: str = "white", + figsize: tuple[float, float] = (10.0, 10.0), +) -> Figure: + """Draw a text as a space-filling turtle walk over its sentences. + + Args: + text: The source text. + colour: One value per sentence for the colour channel (category labels or + numeric values). ``None`` colours by position in the text. + colour_kind: How to read ``colour``: ``"categorical"``, ``"continuous"``, + or ``"auto"`` (numeric values continuous, everything else categorical). + size: One scalar per sentence for the size channel. ``None`` sizes by + sentence length. + mode: ``"path"`` draws a multi-coloured ribbon; ``"glyphs"`` sets each + sentence's text along its segment. + font: Font to measure widths and (in glyph mode) draw with. + font_size: Base font size in points for width measurement and glyphs. + width_step: If ``True``, step by each sentence's rendered width; if + ``False``, step by its character count. + turn: Degrees to turn between sentences (``-90`` is the rectangular walk). + background: Figure and axes background colour. + figsize: Figure size in inches. + + Returns: + A :class:`matplotlib.figure.Figure` with one axes. Never calls ``show()``. + + Raises: + ValueError: If the text has fewer than two sentences, or a channel length + does not match the sentence count. + + Contract: + - Returns a Figure with exactly one axes. + - The colour and size channels stay aligned with the sentences. + + Examples: + >>> from lexograph import load_demo_text + >>> fig = text_walk(load_demo_text()) + >>> type(fig).__name__ + 'Figure' + """ + units = split_sentences(text) + n = len(units) + if n < 2: + msg = f"need at least two sentences to walk, got {n}" + raise ValueError(msg) + + if width_step: + steps = rendered_widths(units, prop=font, size=font_size) + else: + steps = np.array([len(u) for u in units], dtype=float) + coords = walk_layout(steps, turn=turn) + + colours = _resolve_colours(colour, n, colour_kind) + raw_size = [float(s) for s in size] if size is not None else [len(u) for u in units] + if len(raw_size) != n: + msg = f"size must have one entry per sentence ({n}), got {len(raw_size)}" + raise ValueError(msg) + weight = normalize_size(raw_size, lo=0.0, hi=1.0) + + fig = Figure(figsize=figsize, facecolor=background) + ax = fig.subplots() + ax.set_facecolor(background) + + if mode == "glyphs": + angles = heading_angles(steps, turn=turn) + mids = (coords[:-1] + coords[1:]) / 2.0 + glyph_sizes = font_size * (0.6 + 2.4 * weight) + for i, unit in enumerate(units): + ax.text( + mids[i, 0], + mids[i, 1], + unit, + fontsize=glyph_sizes[i], + color=colours[i], + rotation=angles[i], + rotation_mode="anchor", + ha="center", + va="center", + fontproperties=font, + ) + else: + segments = list(np.stack([coords[:-1], coords[1:]], axis=1)) + linewidths = 0.6 + 6.0 * weight + ax.add_collection( + LineCollection(segments, colors=colours, linewidths=linewidths) + ) + + frame_axes(ax, coords) + fig.tight_layout() + return fig diff --git a/src/lexograph/render/__init__.py b/src/lexograph/render/__init__.py index b42d023..6f6eae2 100644 --- a/src/lexograph/render/__init__.py +++ b/src/lexograph/render/__init__.py @@ -1,5 +1,5 @@ """Render: draw a laid-out, encoded text as a matplotlib Figure.""" -from lexograph.render.mpl import render_path, render_points +from lexograph.render.mpl import frame_axes, render_path, render_points -__all__ = ["render_points", "render_path"] +__all__ = ["render_points", "render_path", "frame_axes"] diff --git a/src/lexograph/render/mpl.py b/src/lexograph/render/mpl.py index 3d2d206..3a1593e 100644 --- a/src/lexograph/render/mpl.py +++ b/src/lexograph/render/mpl.py @@ -21,7 +21,7 @@ from lexograph._types import Coords from lexograph.encode.channels import RGBA, Channels -__all__ = ["render_points", "render_path"] +__all__ = ["render_points", "render_path", "frame_axes"] _DEFAULT_POINT_COLOR = "#1f77b4" _DEFAULT_LINE_COLOR = "#333333" @@ -36,8 +36,26 @@ def _coords_2d(coords: Coords) -> np.ndarray: return array -def _frame(ax: Axes, array: np.ndarray, *, margin: float = 0.05) -> None: - """Equalise the aspect ratio, hide the axes, and fit the data with a margin.""" +def frame_axes(ax: Axes, coords: Coords, *, margin: float = 0.05) -> None: + """Equalise the aspect ratio, hide the axes, and fit ``coords`` with a margin. + + A small helper shared by the renderers and presets: it makes an axes show a + spatial figure (equal aspect, no ticks or spines) framed to the data. + + Args: + ax: The axes to configure. + coords: An ``(N, 2)`` array the limits are fitted to. + margin: Fractional padding added around the data extent. + + Examples: + >>> import numpy as np + >>> from matplotlib.figure import Figure + >>> ax = Figure().subplots() + >>> frame_axes(ax, np.array([[0.0, 0.0], [1.0, 1.0]])) + >>> ax.get_aspect() + 1.0 + """ + array = np.asarray(coords, dtype=float) ax.set_aspect("equal") ax.axis("off") if array.shape[0] == 0: @@ -130,7 +148,7 @@ def render_points( c=(colors if colors is not None else _DEFAULT_POINT_COLOR), ) - _frame(ax, array) + frame_axes(ax, array) fig.tight_layout() return fig @@ -196,7 +214,7 @@ def render_path( else: ax.plot(array[:, 0], array[:, 1], color=color, linewidth=linewidth) - _frame(ax, array) + frame_axes(ax, array) fig.tight_layout() return fig diff --git a/tests/test_presets.py b/tests/test_presets.py new file mode 100644 index 0000000..4f9685f --- /dev/null +++ b/tests/test_presets.py @@ -0,0 +1,60 @@ +import pytest +from matplotlib.figure import Figure + +from lexograph import punctuation_spiral, segment, text_walk +from lexograph.presets.punctuation_spiral import is_accent + + +class TestPunctuationSpiral: + """The punctuation spiral plots every mark, accenting logical signs.""" + + def test_returns_figure(self, demo_text: str) -> None: + fig = punctuation_spiral(demo_text) + assert isinstance(fig, Figure) + assert len(fig.axes) == 1 + + def test_draws_one_text_per_mark(self, demo_text: str) -> None: + marks = [c for c in demo_text if not c.isalnum() and not c.isspace()] + fig = punctuation_spiral(demo_text) + assert len(fig.axes[0].texts) == len(marks) + + def test_no_marks_raises(self) -> None: + with pytest.raises(ValueError, match="no punctuation"): + punctuation_spiral("letters only no marks here") + + def test_is_accent(self) -> None: + assert is_accent("=") + assert is_accent("∀") + assert not is_accent(",") + assert not is_accent(";") + + +class TestTextWalk: + """The text walk turns each sentence into a step of a space-filling path.""" + + def test_returns_figure_path_mode(self, demo_text: str) -> None: + fig = text_walk(demo_text) + assert isinstance(fig, Figure) + assert len(fig.axes[0].collections) == 1 + + def test_glyph_mode_draws_text(self, demo_text: str) -> None: + fig = text_walk(demo_text, mode="glyphs") + assert len(fig.axes[0].texts) == len(segment(demo_text)) + + def test_char_step_matches_width_step_shape(self, demo_text: str) -> None: + fig = text_walk(demo_text, width_step=False) + assert isinstance(fig, Figure) + + def test_custom_categorical_colour(self, demo_text: str) -> None: + units = segment(demo_text) + labels = [i % 3 for i in range(len(units))] + fig = text_walk(demo_text, colour=labels, colour_kind="categorical") + assert isinstance(fig, Figure) + + def test_too_few_sentences_raises(self) -> None: + with pytest.raises(ValueError, match="at least two sentences"): + text_walk("Only one sentence here.") + + def test_wrong_colour_length_raises(self, demo_text: str) -> None: + with pytest.raises(ValueError, match="one entry per sentence"): + text_walk(demo_text, colour=[0, 1, 2]) diff --git a/tests/test_spiral.py b/tests/test_spiral.py new file mode 100644 index 0000000..469abba --- /dev/null +++ b/tests/test_spiral.py @@ -0,0 +1,59 @@ +import numpy as np +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from lexograph.layout.spiral import spiral_layout, tangent_angles + + +class TestSpiralLayout: + """Units are spread by equal arc length along an outward Archimedean spiral.""" + + def test_shape(self) -> None: + assert spiral_layout(10).shape == (10, 2) + + def test_radius_increases(self) -> None: + coords = spiral_layout(20, turns=3.0, r0=1.0, r_max=8.0) + radii = np.hypot(coords[:, 0], coords[:, 1]) + assert np.all(np.diff(radii) > 0) + + def test_equal_arc_length_spacing(self) -> None: + coords = spiral_layout(40, turns=4.0, r0=1.0, r_max=10.0) + steps = np.hypot(np.diff(coords[:, 0]), np.diff(coords[:, 1])) + # Equal-arc-length resampling keeps consecutive gaps near-uniform. + assert steps.std() / steps.mean() < 0.05 + + def test_empty(self) -> None: + assert spiral_layout(0).shape == (0, 2) + + def test_negative_raises(self) -> None: + with pytest.raises(ValueError, match="non-negative"): + spiral_layout(-1) + + def test_bad_turns_raises(self) -> None: + with pytest.raises(ValueError, match="turns must be positive"): + spiral_layout(5, turns=0.0) + + def test_bad_radius_raises(self) -> None: + with pytest.raises(ValueError, match="r_max"): + spiral_layout(5, r0=10.0, r_max=1.0) + + @given(st.integers(min_value=1, max_value=300)) + def test_count_matches(self, n: int) -> None: + assert spiral_layout(n).shape == (n, 2) + + +class TestTangentAngles: + """Tangent angles follow the local direction of an ordered path.""" + + def test_horizontal_path(self) -> None: + coords = np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]) + assert tangent_angles(coords).tolist() == [0.0, 0.0, 0.0] + + def test_one_per_point(self) -> None: + coords = spiral_layout(15) + assert tangent_angles(coords).shape == (15,) + + def test_bad_shape_raises(self) -> None: + with pytest.raises(ValueError, match=r"shape \(N, 2\)"): + tangent_angles(np.zeros((3, 3))) diff --git a/tests/test_walk.py b/tests/test_walk.py new file mode 100644 index 0000000..03ee3bc --- /dev/null +++ b/tests/test_walk.py @@ -0,0 +1,65 @@ +import numpy as np +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from lexograph.layout.walk import heading_angles, walk_layout + +step_lists = st.lists( + st.floats(min_value=0.0, max_value=1e3, allow_nan=False, allow_infinity=False), + min_size=1, + max_size=80, +) + + +class TestWalkLayout: + """The turtle steps forward then turns; geometry is exact and deterministic.""" + + def test_unit_square(self) -> None: + coords = walk_layout([1.0, 1.0, 1.0]).round(6) + assert coords.tolist() == [ + [0.0, 0.0], + [1.0, 0.0], + [1.0, -1.0], + [0.0, -1.0], + ] + + def test_returns_n_plus_one_vertices(self) -> None: + assert walk_layout([1, 2, 3, 4]).shape == (5, 2) + + def test_left_turn_mirrors_right(self) -> None: + right = walk_layout([1, 1, 1], turn=-90.0) + left = walk_layout([1, 1, 1], turn=90.0) + # Left turn is the right turn reflected across the x-axis. + assert np.allclose(right[:, 1], -left[:, 1]) + + def test_zero_heading_raises(self) -> None: + with pytest.raises(ValueError, match="non-zero"): + walk_layout([1, 2], heading=(0.0, 0.0)) + + @given(step_lists) + def test_segment_lengths_match_steps(self, steps: list[float]) -> None: + coords = walk_layout(steps, scale=1.0) + seg = np.hypot(np.diff(coords[:, 0]), np.diff(coords[:, 1])) + assert np.allclose(seg, np.abs(steps), atol=1e-6) + + @given(step_lists) + def test_deterministic(self, steps: list[float]) -> None: + assert np.array_equal(walk_layout(steps), walk_layout(steps)) + + @given(step_lists) + def test_bounded_box(self, steps: list[float]) -> None: + coords = walk_layout(steps) + reach = float(np.sum(np.abs(steps))) + assert np.all(np.abs(coords) <= reach + 1e-6) + + +class TestHeadingAngles: + """Heading rotates by the turn angle each step.""" + + def test_right_turn_sequence(self) -> None: + assert heading_angles([1, 1, 1]).tolist() == [0.0, -90.0, -180.0] + + @given(step_lists) + def test_one_angle_per_step(self, steps: list[float]) -> None: + assert heading_angles(steps).shape == (len(steps),) diff --git a/tests/test_widths.py b/tests/test_widths.py new file mode 100644 index 0000000..95693cf --- /dev/null +++ b/tests/test_widths.py @@ -0,0 +1,42 @@ +import string as _string + +import numpy as np +from hypothesis import given, settings +from hypothesis import strategies as st + +from lexograph.layout.widths import rendered_widths + +# Printable ASCII keeps TextPath layout fast and deterministic across platforms. +printable_text = st.text(alphabet=_string.printable, min_size=0, max_size=20) + + +class TestRenderedWidths: + """Rendered width grows with content and is deterministic per font/size.""" + + def test_wider_string_is_wider(self) -> None: + widths = rendered_widths(["i", "wwwwww"]) + assert widths[1] > widths[0] + + def test_one_per_string(self) -> None: + strings = ["alpha", "beta", "gamma"] + assert rendered_widths(strings).shape == (len(strings),) + + def test_size_scales_width(self) -> None: + small = rendered_widths(["hello"], size=8.0)[0] + large = rendered_widths(["hello"], size=24.0)[0] + assert large > small + + def test_whitespace_still_advances(self) -> None: + assert rendered_widths([" "])[0] > 0.0 + + def test_deterministic(self) -> None: + a = rendered_widths(["The quick brown fox."]) + b = rendered_widths(["The quick brown fox."]) + assert np.array_equal(a, b) + + @settings(max_examples=40, deadline=None) + @given(st.lists(printable_text, min_size=1, max_size=12)) + def test_all_finite_and_non_negative(self, strings: list[str]) -> None: + widths = rendered_widths(strings) + assert np.all(np.isfinite(widths)) + assert np.all(widths >= 0.0)