@@ -1471,6 +1471,263 @@ def apply_edits_list(self, edits: list):
14711471 self .edits .append (edit )
14721472 self ._refresh ()
14731473
1474+ # ------------------------------------------------------------------
1475+ # Headless Web API — for CoPaper.ai and other web backends
1476+ # ------------------------------------------------------------------
1477+
1478+ def render_to_base64 (self , dpi : int = 300 , fmt : str = 'png' ) -> str :
1479+ """Render the figure to a base64-encoded string.
1480+
1481+ Parameters
1482+ ----------
1483+ dpi : int, default 300
1484+ Resolution for the rendered image.
1485+ fmt : str, default 'png'
1486+ Image format ('png', 'svg', 'pdf').
1487+
1488+ Returns
1489+ -------
1490+ str
1491+ Base64-encoded image data.
1492+ """
1493+ import base64
1494+ import io
1495+ buf = io .BytesIO ()
1496+ self .fig .savefig (buf , format = fmt , dpi = dpi ,
1497+ bbox_inches = 'tight' ,
1498+ facecolor = self .fig .get_facecolor ())
1499+ buf .seek (0 )
1500+ return base64 .b64encode (buf .read ()).decode ('ascii' )
1501+
1502+ def get_editable_schema (self ) -> dict :
1503+ """Export schema of all editable properties and current values.
1504+
1505+ Returns a dict that a web frontend can use to dynamically
1506+ generate an editing panel. Each element includes its current
1507+ value, role, and whether it's editable.
1508+
1509+ Returns
1510+ -------
1511+ dict
1512+ Schema with axes, figure-level settings, font presets,
1513+ and theme options.
1514+ """
1515+ from .themes import list_themes
1516+
1517+ axes_schema = []
1518+ for i , ax in enumerate (self .fig .get_axes ()):
1519+ # Lines
1520+ lines_schema = []
1521+ for j , line in enumerate (ax .get_lines ()):
1522+ role = self .artist_roles .get (id (line ), ArtistRole .COSMETIC )
1523+ editable = role not in (
1524+ ArtistRole .DATA , ArtistRole .FIT , ArtistRole .CI
1525+ ) if self .protect_data else True
1526+ lines_schema .append ({
1527+ 'index' : j ,
1528+ 'label' : line .get_label () or f'line{ j } ' ,
1529+ 'role' : role .name ,
1530+ 'editable' : editable ,
1531+ 'color' : line .get_color (),
1532+ 'linewidth' : line .get_linewidth (),
1533+ 'linestyle' : line .get_linestyle (),
1534+ 'alpha' : line .get_alpha (),
1535+ })
1536+
1537+ # Collections (scatter, fill_between, etc.)
1538+ collections_schema = []
1539+ for j , coll in enumerate (ax .collections ):
1540+ role = self .artist_roles .get (id (coll ), ArtistRole .COSMETIC )
1541+ editable = role not in (
1542+ ArtistRole .DATA , ArtistRole .FIT , ArtistRole .CI
1543+ ) if self .protect_data else True
1544+ collections_schema .append ({
1545+ 'index' : j ,
1546+ 'role' : role .name ,
1547+ 'editable' : editable ,
1548+ 'alpha' : coll .get_alpha (),
1549+ })
1550+
1551+ # Spines
1552+ spines = {}
1553+ for name , spine in ax .spines .items ():
1554+ spines [name ] = spine .get_visible ()
1555+
1556+ # Grid
1557+ gridlines = ax .xaxis .get_gridlines ()
1558+ grid_on = gridlines [0 ].get_visible () if gridlines else False
1559+
1560+ # Legend
1561+ legend = ax .get_legend ()
1562+ legend_info = None
1563+ if legend is not None :
1564+ legend_info = {
1565+ 'visible' : legend .get_visible (),
1566+ 'loc' : legend ._loc if hasattr (legend , '_loc' ) else 'best' ,
1567+ }
1568+
1569+ axes_schema .append ({
1570+ 'index' : i ,
1571+ 'title' : {
1572+ 'text' : ax .get_title (),
1573+ 'fontsize' : ax .title .get_fontsize (),
1574+ 'color' : ax .title .get_color (),
1575+ },
1576+ 'xlabel' : {
1577+ 'text' : ax .get_xlabel (),
1578+ 'fontsize' : ax .xaxis .label .get_fontsize (),
1579+ },
1580+ 'ylabel' : {
1581+ 'text' : ax .get_ylabel (),
1582+ 'fontsize' : ax .yaxis .label .get_fontsize (),
1583+ },
1584+ 'xlim' : list (ax .get_xlim ()),
1585+ 'ylim' : list (ax .get_ylim ()),
1586+ 'lines' : lines_schema ,
1587+ 'collections' : collections_schema ,
1588+ 'spines' : spines ,
1589+ 'grid' : grid_on ,
1590+ 'legend' : legend_info ,
1591+ })
1592+
1593+ # Figure-level
1594+ w , h = self .fig .get_size_inches ()
1595+
1596+ return {
1597+ 'axes' : axes_schema ,
1598+ 'figure' : {
1599+ 'width' : float (w ),
1600+ 'height' : float (h ),
1601+ 'dpi' : int (self .fig .dpi ),
1602+ 'facecolor' : self .fig .get_facecolor (),
1603+ },
1604+ 'font_presets' : list (FONT_PRESETS .keys ()),
1605+ 'themes' : list_themes (),
1606+ 'protect_data' : self .protect_data ,
1607+ }
1608+
1609+ def apply_actions (self , actions : list ):
1610+ """Apply a list of action-based edit commands.
1611+
1612+ This is the primary API for web frontends. Each action is a
1613+ dict with an ``action`` key specifying the method to call.
1614+
1615+ Parameters
1616+ ----------
1617+ actions : list of dict
1618+ Each dict has ``action`` plus method-specific params.
1619+ Supported actions::
1620+
1621+ {"action": "set_title", "text": "...", "ax_index": 0}
1622+ {"action": "set_xlabel", "text": "...", "ax_index": 0}
1623+ {"action": "set_ylabel", "text": "...", "ax_index": 0}
1624+ {"action": "set_fontsize", "target": "title", "size": 12, "ax_index": 0}
1625+ {"action": "set_color", "target": "title", "color": "#333", "ax_index": 0}
1626+ {"action": "set_dpi", "dpi": 300}
1627+ {"action": "set_figsize", "width": 8, "height": 6}
1628+ {"action": "set_grid", "visible": true, "ax_index": 0}
1629+ {"action": "set_spine_visible", "spine": "top", "visible": false, "ax_index": 0}
1630+ {"action": "apply_font_preset", "preset": "CJK Journal", "ax_index": 0}
1631+ {"action": "apply_theme", "theme": "clean"}
1632+ {"action": "set_legend_visible", "visible": true, "ax_index": 0}
1633+ {"action": "set_line_color", "line_index": 0, "color": "#E74C3C", "ax_index": 0}
1634+ {"action": "set_line_alpha", "line_index": 0, "alpha": 0.8, "ax_index": 0}
1635+
1636+ Raises
1637+ ------
1638+ ValueError
1639+ If an unknown action is encountered.
1640+ """
1641+ _dispatch = {
1642+ 'set_title' : lambda a : self .set_title (
1643+ a ['text' ], ax_index = a .get ('ax_index' , 0 )),
1644+ 'set_xlabel' : lambda a : self .set_xlabel (
1645+ a ['text' ], ax_index = a .get ('ax_index' , 0 )),
1646+ 'set_ylabel' : lambda a : self .set_ylabel (
1647+ a ['text' ], ax_index = a .get ('ax_index' , 0 )),
1648+ 'set_fontsize' : lambda a : self .set_fontsize (
1649+ a ['target' ], a ['size' ], ax_index = a .get ('ax_index' , 0 )),
1650+ 'set_color' : lambda a : self .set_color (
1651+ a ['target' ], a ['color' ], ax_index = a .get ('ax_index' , 0 )),
1652+ 'set_dpi' : lambda a : self .set_dpi (a ['dpi' ]),
1653+ 'set_figsize' : lambda a : self .set_figsize (
1654+ a ['width' ], a ['height' ]),
1655+ 'set_grid' : lambda a : self .set_grid (
1656+ a ['visible' ], ax_index = a .get ('ax_index' , 0 )),
1657+ 'set_spine_visible' : lambda a : self .set_spine_visible (
1658+ a ['spine' ], a ['visible' ], ax_index = a .get ('ax_index' , 0 )),
1659+ 'apply_font_preset' : lambda a : self .apply_font_preset (
1660+ a ['preset' ], ax_index = a .get ('ax_index' , 0 )),
1661+ 'apply_theme' : lambda a : self .apply_theme (a ['theme' ]),
1662+ 'set_legend_visible' : lambda a : self .set_legend_visible (
1663+ a ['visible' ], ax_index = a .get ('ax_index' , 0 )),
1664+ 'set_line_color' : lambda a : self .set_color (
1665+ f"line{ a ['line_index' ]} " , a ['color' ],
1666+ ax_index = a .get ('ax_index' , 0 )),
1667+ 'set_line_alpha' : lambda a : self .set_alpha (
1668+ f"line{ a ['line_index' ]} " , a ['alpha' ],
1669+ ax_index = a .get ('ax_index' , 0 )),
1670+ 'set_linewidth' : lambda a : self .set_linewidth (
1671+ a ['line_index' ], a ['width' ],
1672+ ax_index = a .get ('ax_index' , 0 )),
1673+ 'set_linestyle' : lambda a : self .set_linestyle (
1674+ a ['line_index' ], a ['style' ],
1675+ ax_index = a .get ('ax_index' , 0 )),
1676+ }
1677+
1678+ for action in actions :
1679+ name = action .get ('action' )
1680+ handler = _dispatch .get (name )
1681+ if handler is None :
1682+ raise ValueError (
1683+ f"Unknown action: { name !r} . "
1684+ f"Supported: { sorted (_dispatch .keys ())} " )
1685+ handler (action )
1686+
1687+ def export_state (self ) -> dict :
1688+ """Export the full editor state for persistence.
1689+
1690+ Returns a dict containing edits and figure metadata,
1691+ suitable for storing in a database or sending over the wire.
1692+ Pair with ``import_state()`` to restore edits on a fresh figure.
1693+
1694+ Returns
1695+ -------
1696+ dict
1697+ Serialized state with edits, figure settings, and metadata.
1698+ """
1699+ w , h = self .fig .get_size_inches ()
1700+ return {
1701+ 'version' : 1 ,
1702+ 'edits' : self .to_edits_list (),
1703+ 'figure' : {
1704+ 'width' : float (w ),
1705+ 'height' : float (h ),
1706+ 'dpi' : int (self .fig .dpi ),
1707+ },
1708+ 'code' : self .generate_code (include_comment = False ),
1709+ }
1710+
1711+ def import_state (self , state : dict ):
1712+ """Restore editor state from a previously exported dict.
1713+
1714+ Parameters
1715+ ----------
1716+ state : dict
1717+ State dict from ``export_state()``.
1718+ """
1719+ # Apply figure-level settings first
1720+ fig_state = state .get ('figure' , {})
1721+ if 'width' in fig_state and 'height' in fig_state :
1722+ self .set_figsize (fig_state ['width' ], fig_state ['height' ])
1723+ if 'dpi' in fig_state :
1724+ self .set_dpi (fig_state ['dpi' ])
1725+
1726+ # Replay edits
1727+ edits = state .get ('edits' , [])
1728+ if edits :
1729+ self .apply_edits_list (edits )
1730+
14741731 def summary (self ) -> str :
14751732 """Return a summary of all edits."""
14761733 if not self .edits :
0 commit comments