Skip to content

Feature: Pure-TikZ export backend via custom Matplotlib Renderer #50

@yueswater

Description

@yueswater

Description

Add a custom Matplotlib rendering backend to econ_viz that converts canvas output (indifference curves, budget lines, equilibrium points from Cobb–Douglas and related models) directly into pure TikZ code — compilable under LaTeX with \usepackage{tikz} only, no pgfplots, no matplot2tikz dependency. The motivation is to let instructors and researchers paste the generated TikZ straight into textbooks, slides, and journal submissions.

Today econ_viz only exports via fig.savefig (PNG / PDF / SVG — see econ_viz/io/exporter.py). The proposal is to add an ExportFormat.TEX option backed by a TikzRenderer subclass of matplotlib.backend_bases.RendererBase that intercepts draw_path and emits \draw / \filldraw commands.

Tracked as an umbrella below — individual sub-problems can be split off as child issues if any single one grows.

Current Implementation

There is no TikZ code in the repo yet. The existing export path is minimal:

# econ_viz/io/exporter.py
def save_figure(fig, *, path, dpi, close=False, **kwargs):
    ExportFormat.from_path(path)          # PNG | PDF | SVG
    fig.savefig(path, dpi=dpi, transparent=True, bbox_inches='tight', **kwargs)

Proposed renderer skeleton (illustrative — not yet merged):

from matplotlib.backend_bases import RendererBase

class TikzRenderer(RendererBase):
    def __init__(self, width, height, scale=0.5):
        super().__init__()
        self.width, self.height = width, height
        self.scale = scale
        self._commands: list[str] = []

    def draw_path(self, gc, path, transform, rgbFace=None):
        for poly in path.to_polygons(transform):
            pts = ' -- '.join(
                f'({x*self.scale:.4f},{y*self.scale:.4f})' for x, y in poly
            )
            cmd = r'\filldraw' if rgbFace is not None else r'\draw'
            self._commands.append(f'{cmd} {pts};')

    def get_canvas_width_height(self):
        return self.width, self.height

Visual Bug — Path Closing Artifact

The naive draw_path implementation above joins vertices with --, which for non-filled line plots (indifference curves, offer curves, Engel curves) produces a spurious diagonal segment from the last sampled point back to the first.

Root cause. Path.to_polygons() returns closed polygon sequences by default — it appends the starting vertex to the end so that fill operations can use the vertex list directly. For filled polygons this duplicate edge is invisible (hidden under the fill). For open curves emitted via \draw, the duplicate vertex becomes a visible line that cuts across the figure — e.g. from the top-left of an indifference curve back to its lower-right tail. Readers immediately flag this as a rendering error.

Three secondary problems compound on the same export path:

  • Coordinate scaling. Matplotlib delivers display coordinates (points / pixels). TikZ works natively in cm. Without a conversion factor, a typical 6×4 in figure explodes into a 15+ cm picture that breaks page layout. A configurable scale (default ≈ 0.5) is required, ideally derivable from fig.get_size_inches().
  • Fill vs draw dispatch. rgbFace is not None must select \filldraw[fill=...] and emit the RGB color; rgbFace is None must stay as \draw and honour the GraphicsContext stroke color, line width, and dash pattern.
  • Unicode minus sign. Matplotlib formats negative axis labels with U+2212 (), which pdfLaTeX rejects (Package inputenc Error: Unicode char \u8:−). Every string that flows through draw_text must normalize U+2212 → ASCII -, plus en/em dashes, prime marks, and non-breaking space.

Proposed Solution

All four fixes live inside a new econ_viz/io/backend_tikz.py module.

1. De-duplicate closing vertex for non-filled paths

def draw_path(self, gc, path, transform, rgbFace=None):
    for poly in path.to_polygons(transform):
        if rgbFace is None and len(poly) > 2 and np.allclose(poly[0], poly[-1]):
            poly = poly[:-1]                         # strip auto-appended vertex
        pts = ' -- '.join(
            f'({x*self.scale:.4f},{y*self.scale:.4f})' for x, y in poly
        )
        self._commands.append(f'{self._cmd(gc, rgbFace)} {pts};')

The np.allclose guard ensures filled shapes (budget-set polygons, shaded regions) still close correctly — those arrive with rgbFace set, so the branch is skipped.

2. Coordinate scaling

Expose scale as a constructor argument (default 0.5, cm per display unit after Matplotlib's internal DPI) and surface an override at the Canvas.save(..., tikz_scale=...) layer. Optionally derive a default from fig.get_size_inches() so a 6×4 in figure maps to a reasonable 7.5×5 cm TikZ picture.

3. Fill vs draw command builder

def _cmd(self, gc, rgbFace):
    stroke = _rgb_to_tikz(gc.get_rgb())
    lw     = gc.get_linewidth()
    if rgbFace is None:
        return rf'\draw[color={stroke}, line width={lw:.2f}pt]'
    fill = _rgb_to_tikz(rgbFace)
    return rf'\filldraw[color={stroke}, fill={fill}, line width={lw:.2f}pt]'

Register all colors via \definecolor in the preamble so the emitted snippet is self-contained.

4. Unicode sanitization for draw_text

_UNICODE_FIXES = str.maketrans({
    '\u2212': '-',     # MINUS SIGN
    '\u2013': '--',    # EN DASH
    '\u2014': '---',   # EM DASH
    '\u00a0': '~',     # NO-BREAK SPACE
})

def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
    safe = s.translate(_UNICODE_FIXES)
    body = f'${safe}$' if ismath else safe
    self._commands.append(
        rf'\node[anchor=base, rotate={angle:.1f}] at '
        rf'({x*self.scale:.4f},{y*self.scale:.4f}) {{{body}}};'
    )

Acceptance Criteria

  • Canvas(...).save('plot.tex') produces a standalone TikZ snippet that compiles under pdfLaTeX with only \usepackage{tikz}.
  • Indifference curves render as open paths — no diagonal closing artifact.
  • Filled regions (budget sets, shaded areas) still render as closed polygons with correct stroke and fill color.
  • Axis labels with negative numbers compile without Unicode errors.
  • Golden test: round-trip a Cobb–Douglas example through PDF and TikZ and visually compare within tolerance.

Remaining Work

Sub-issues track §1–§4 above. Still outstanding at the integration level:

  • Wire ExportFormat.TEX into econ_viz/enums/extension.py and
    save_figure dispatch
  • Example under examples/tikz_export.py
  • Regression tests under tests/test_tikz_backend.py

Notes

  • Open question: should the emitted snippet include a full
    \begin{tikzpicture} ... \end{tikzpicture} wrapper, or only the inner
    \draw commands to let downstream documents control the environment?
    Leaning toward the former with an opt-out flag.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions