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
Remaining Work
Sub-issues track §1–§4 above. Still outstanding at the integration level:
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.
Description
Add a custom Matplotlib rendering backend to
econ_vizthat 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, nopgfplots, nomatplot2tikzdependency. The motivation is to let instructors and researchers paste the generated TikZ straight into textbooks, slides, and journal submissions.Today
econ_vizonly exports viafig.savefig(PNG / PDF / SVG — seeecon_viz/io/exporter.py). The proposal is to add anExportFormat.TEXoption backed by aTikzRenderersubclass ofmatplotlib.backend_bases.RendererBasethat interceptsdraw_pathand emits\draw/\filldrawcommands.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:
Proposed renderer skeleton (illustrative — not yet merged):
Visual Bug — Path Closing Artifact
The naive
draw_pathimplementation 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:
cm. Without a conversion factor, a typical 6×4 in figure explodes into a 15+ cm picture that breaks page layout. A configurablescale(default ≈0.5) is required, ideally derivable fromfig.get_size_inches().rgbFace is not Nonemust select\filldraw[fill=...]and emit the RGB color;rgbFace is Nonemust stay as\drawand honour theGraphicsContextstroke color, line width, and dash pattern.−), which pdfLaTeX rejects (Package inputenc Error: Unicode char \u8:−). Every string that flows throughdraw_textmust 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.pymodule.1. De-duplicate closing vertex for non-filled paths
The
np.allcloseguard ensures filled shapes (budget-set polygons, shaded regions) still close correctly — those arrive withrgbFaceset, so the branch is skipped.2. Coordinate scaling
Expose
scaleas a constructor argument (default0.5, cm per display unit after Matplotlib's internal DPI) and surface an override at theCanvas.save(..., tikz_scale=...)layer. Optionally derive a default fromfig.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
Register all colors via
\definecolorin the preamble so the emitted snippet is self-contained.4. Unicode sanitization for
draw_textAcceptance Criteria
Canvas(...).save('plot.tex')produces a standalone TikZ snippet that compiles under pdfLaTeX with only\usepackage{tikz}.Remaining Work
Sub-issues track §1–§4 above. Still outstanding at the integration level:
ExportFormat.TEXintoecon_viz/enums/extension.pyandsave_figuredispatchexamples/tikz_export.pytests/test_tikz_backend.pyNotes
\begin{tikzpicture} ... \end{tikzpicture}wrapper, or only the inner\drawcommands to let downstream documents control the environment?Leaning toward the former with an opt-out flag.