diff --git a/src/gleplot/axes.py b/src/gleplot/axes.py index a95fc52..1094cff 100644 --- a/src/gleplot/axes.py +++ b/src/gleplot/axes.py @@ -925,6 +925,7 @@ def plot( markersize=gle_markersize, linestyle=linestyle, linewidth=linewidth, + nomiss=False, label=label, yaxis=yaxis, # 'y' or 'y2' offset=float(offset), @@ -1248,6 +1249,7 @@ def line_from_file( color=gle_color, linestyle=linestyle, linewidth=float(linewidth), + nomiss=False, label=label, yaxis=yaxis, ) diff --git a/src/gleplot/figure.py b/src/gleplot/figure.py index a613365..634012f 100644 --- a/src/gleplot/figure.py +++ b/src/gleplot/figure.py @@ -2028,6 +2028,7 @@ def _write_axes_content( label=series_data["label"], marker=series_data.get("marker"), markersize=series_data.get("markersize", 0.1), + nomiss=series_data.get("nomiss", False), yaxis=series_data.get("yaxis", "y"), offset=series_data.get("offset", 0.0), column_names=series_data.get("column_names"), @@ -2083,6 +2084,9 @@ def _write_axes_content( linewidth=fs_data.get("linewidth", 1.0), label=fs_data.get("label"), yaxis=fs_data.get("yaxis", "y"), + marker=fs_data.get("marker"), + markersize=fs_data.get("markersize", 0.1), + nomiss=fs_data.get("nomiss", False), ) elif series_type == "bar": writer.add_bar_from_file( diff --git a/src/gleplot/parser/recognizer.py b/src/gleplot/parser/recognizer.py index 1f2a07d..939c26e 100644 --- a/src/gleplot/parser/recognizer.py +++ b/src/gleplot/parser/recognizer.py @@ -265,7 +265,7 @@ import re from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union, cast import numpy as np @@ -319,6 +319,24 @@ _DATASET_RE = re.compile(r"^d\d+$", re.IGNORECASE) +def _bar_stack_names(toks: List[Token]) -> Optional[Tuple[str, str]]: + """``(dN, dM)`` for a ``bar dN from dM ...`` statement, else ``None``. + + GLE's stacked-bar form (the manual's 'stacked bar chart' example): ``dN`` + draws stacked on top of ``dM``. Detected up front, in pass 1, so an + EARLIER, independent ``bar dM fill ...`` statement can still be + recognized as part of this later stack -- see ``_bar_stack_datasets`` + and its use in :meth:`_Recognizer._parse_bar_command`. + """ + words = [t.value.lower() for t in toks] + for i, word in enumerate(words): + if word == "from" and 0 < i < len(words) - 1: + prev_word, next_word = words[i - 1], words[i + 1] + if _DATASET_RE.match(prev_word) and _DATASET_RE.match(next_word): + return prev_word, next_word + return None + + @dataclass class RecognizedFigure: """Result of :func:`parse_gle_figure`. @@ -1157,6 +1175,14 @@ def _parse_graph_block(self, block: GraphBlock, marker_cfg, smooth_flags) -> dic # consumed indirectly through the alias -- see _parse_let_command # and the 'data'-statement reconciliation in _parse_graph_block. "_let_source_datasets": set(), + # Both dataset names of every 'bar dN from dM ...' (GLE's + # stacked-bar form) found ANYWHERE in this block, collected up + # front in pass 1 -- so an EARLIER, independent 'bar dM fill + # ...' statement can still recognize dM as part of a later + # stack and stay raw instead of being modeled twice over (once + # as its own BarSeries, once folded into the stack's raw text). + # See _parse_bar_command. + "_bar_stack_datasets": set(), } # This graph block's own (begin, end) line span -- the fallback # location for a note about the block as a whole (no single statement @@ -1218,6 +1244,16 @@ def _parse_graph_block(self, block: GraphBlock, marker_cfg, smooth_flags) -> dic # already known here in pass 1). self._parse_let_command(_words_and_values(child), datasets, info) continue + if kw == "bar": + # Look ahead for GLE's stacked-bar form ('bar dN from dM + # ...') anywhere in the block, before pass 2 decides + # whether an EARLIER, independent 'bar dM fill ...' + # becomes its own BarSeries (see _parse_bar_command). No + # 'continue': pass 2 still needs to dispatch this + # statement itself. + stacked = _bar_stack_names(_words_and_values(child)) + if stacked is not None: + cast(set, info["_bar_stack_datasets"]).update(stacked) if kw is not None and _DATASET_RE.match(kw): name = kw if name not in merged_attr_toks: @@ -1335,6 +1371,22 @@ def _parse_graph_block(self, block: GraphBlock, marker_cfg, smooth_flags) -> dic # whole original line would duplicate the modeled portion; # dropping it silently loses the rest. Surface it instead of # guessing. + # + # This case is now rare in practice: the one recurring + # source of it -- a 'bar dM fill ...' modeled independently + # while a LATER 'bar dN from dM ...' (GLE's stacked-bar + # form) needed dM to stay raw alongside dN -- is headed off + # up front by ``_bar_stack_datasets`` (see + # _parse_bar_command), which keeps BOTH names raw so this + # statement is fully unconsumed instead of mixed. A + # genuinely mixed statement still reaching here has no + # narrower fix available: synthesizing a second 'data' + # clause for just the orphaned names would reference columns + # the modeled series' OWN regenerated sidecar no longer + # carries (GLEWriter always rewrites an owned series' data + # file with exactly the columns it uses -- see + # GLEWriter.add_bar_chart/_write_columns), which would + # silently corrupt the file instead of fixing the reference. self._note( ImportCategory.DATA, "'" @@ -2238,6 +2290,22 @@ def _parse_bar_command(self, toks, datasets, info, stmt=None): if d_name is None or d_name not in datasets: info["passthrough"].append(self._bar_fill_passthrough_line(toks, stmt)) return + if d_name in info["_bar_stack_datasets"]: + # This dataset ALSO appears in a 'bar dN from dM ...' statement + # elsewhere in the block (pass 1's look-ahead, ``_bar_stack_names``) + # -- GLE's stacked-bar relationship has no BarSeries model, so + # this otherwise-ordinary single-dataset 'bar' must stay raw + # alongside it rather than being modeled independently. Modeling + # it anyway would have the writer regenerate ITS dataset's own + # '.dat' sidecar with only the columns IT uses (GLEWriter always + # rewrites an owned series' data file that way -- see + # add_bar_chart/_write_columns), silently truncating away the + # column(s) the stack statement still needs from that same + # file. The stack statement's own "multi-dataset bar group" note + # already explains the group to the no-silent-drops contract, so + # none is duplicated here. + info["passthrough"].append(self._bar_fill_passthrough_line(toks, stmt)) + return info["_key_suppress_datasets"].add(d_name) data_file, xcol, ycol = datasets[d_name] loaded = self._load_series( @@ -2557,6 +2625,7 @@ def _parse_series_command( markersize=markersize, linestyle=linestyle, linewidth=linewidth, + nomiss=attrs["nomiss"], label=attrs["label"], yaxis="y2" if attrs["y2axis"] else "y", offset=info["_dataset_offsets"].get(d_name, 0.0), @@ -2580,6 +2649,7 @@ def _scan_series_attrs(self, toks) -> dict: a = { "has_line": False, "smooth": False, + "nomiss": False, "color": None, "lwidth": None, "lstyle": None, @@ -2606,6 +2676,10 @@ def _scan_series_attrs(self, toks) -> dict: a["smooth"] = True i += 1 continue + if w == "nomiss": + a["nomiss"] = True + i += 1 + continue if w == "color" and i + 1 < m: val, nxt = _collect_color(toks, i + 1) a["color"] = val @@ -2621,6 +2695,17 @@ def _scan_series_attrs(self, toks) -> dict: if v is not None: a["lstyle"] = int(v) a["linestyle"] = LSTYLE_TO_MATPLOTLIB.get(int(v), "-") + # GLE draws a line whenever EITHER the 'line' keyword is + # given OR 'lstyle' names a style at all (GLE's own + # draw-a-line test, graph2.cpp: 'dp[dn]->line || + # dp[dn]->lstyle[0] != 0') -- 'dN lstyle 2 color red' + # with no 'line' keyword anywhere (the GLE manual's own + # nomiss example) is a real, visible line, not a bare + # dataset. Missing this made has_line False, which fed + # linestyle="none" into the model and, downstream, an + # unconditional 'marker None' from the writer's + # no-line branch (see add_plot_line). + a["has_line"] = True i = nxt continue i += 2 @@ -2969,6 +3054,7 @@ def _build_file_series( if attrs["lwidth"] is not None else 1.0 ), + nomiss=attrs["nomiss"], label=attrs["label"], yaxis="y2" if attrs["y2axis"] else "y", ) diff --git a/src/gleplot/series.py b/src/gleplot/series.py index d0721c6..818b22c 100644 --- a/src/gleplot/series.py +++ b/src/gleplot/series.py @@ -442,6 +442,11 @@ class _XYSeries(Series): markersize: float linestyle: str linewidth: float + #: GLE's ``nomiss`` qualifier: draw the line through a missing value + #: instead of leaving a gap (GLE default: gap). Only meaningful when a + #: line is drawn, but preserved verbatim on scatter-only series too so a + #: hand-written file that sets it never loses the setting on re-save. + nomiss: bool label: Optional[str] yaxis: str offset: float @@ -600,6 +605,9 @@ class FileSeries(Series): markersize: float linestyle: str linewidth: float + #: ``'line'`` variant only: GLE's ``nomiss`` qualifier (see + #: ``_XYSeries.nomiss``). + nomiss: bool label: Optional[str] capsize: Optional[float] yaxis: str diff --git a/src/gleplot/writer.py b/src/gleplot/writer.py index 280c4fb..b042ea3 100644 --- a/src/gleplot/writer.py +++ b/src/gleplot/writer.py @@ -1594,6 +1594,7 @@ def add_plot_line( label: Optional[str] = None, marker: Optional[str] = None, markersize: float = 0.1, + nomiss: bool = False, yaxis: str = "y", offset: float = 0.0, column_names: Optional[List[str]] = None, @@ -1624,6 +1625,10 @@ def add_plot_line( GLE marker name markersize : float Marker size for GLE (msize) + nomiss : bool + When the line has missing values, draw through them instead of + leaving a gap (GLE's ``nomiss`` qualifier). No effect when the + series draws no line. yaxis : str Which y-axis to use: 'y' (left, default) or 'y2' (right) column_names : list of str, optional @@ -1683,6 +1688,8 @@ def add_plot_line( if has_line: # Line plot; ``smooth`` only when opted in (see _line_token). line_cmd += self._line_token() + if nomiss: + line_cmd += " nomiss" line_cmd += f" color {color} lwidth {self._format_number(gle_lwidth)}" # Use configured line styles from style config. (A stray second @@ -1700,10 +1707,17 @@ def add_plot_line( if marker: # Marker overlaid on the line (line+markers). line_cmd += f" marker {marker} msize {self._format_number(markersize)}" - else: + elif marker: # No line: marker-only (scatter). Preserve the historical token # order ``marker msize color ``. line_cmd += f" marker {marker} msize {self._format_number(markersize)} color {color}" + # else: neither a line nor a marker (e.g. ``ax.plot(x, y, + # linestyle='none')`` with no marker -- a real, if pointless, + # degenerate case matplotlib itself allows) -- the series draws + # nothing, so nothing is emitted here. This branch used to be + # unconditional and interpolated ``marker`` even when it was + # ``None``, putting the literal text "marker None" into the script + # (GLE then rejects it: "invalid marker name 'None'"). # Add y2axis directive if using secondary y-axis if yaxis == "y2": @@ -2175,8 +2189,22 @@ def add_plot_line_from_file( linewidth: float = 1.0, label: Optional[str] = None, yaxis: str = "y", + marker: Optional[str] = None, + markersize: float = 0.1, + nomiss: bool = False, ): - """Add a line series that references columns in an external data file.""" + """Add a line series that references columns in an external data file. + + ``marker``/``markersize`` overlay a marker on the line (GLE natively + supports both on one dataset) -- this function is only ever called + for a series that already has a line (see ``_build_file_series``'s + ``has_line`` branch; a no-line, marker-only reference is emitted via + ``add_errorbar_from_file`` instead), so there is no separate + no-line/marker-only case to guard here, unlike ``add_plot_line``. + + ``nomiss`` : draw the line through a missing value instead of + leaving a gap (GLE's ``nomiss`` qualifier). + """ d_main = f"d{self.dataset_index}" self.dataset_index += 1 @@ -2189,10 +2217,10 @@ def add_plot_line_from_file( else: gle_lwidth = linewidth_pt_to_cm(linewidth) - line_cmd = ( - f" {d_main}{self._line_token()} color {color} " - f"lwidth {self._format_number(gle_lwidth)}" - ) + line_cmd = f" {d_main}{self._line_token()}" + if nomiss: + line_cmd += " nomiss" + line_cmd += f" color {color} lwidth {self._format_number(gle_lwidth)}" if linestyle == "--": line_cmd += f" lstyle {self.style.line_style_dashed}" elif linestyle == ":": @@ -2200,6 +2228,9 @@ def add_plot_line_from_file( elif linestyle == "-.": line_cmd += f" lstyle {self.style.line_style_dashdot}" + if marker: + line_cmd += f" marker {marker} msize {self._format_number(markersize)}" + if yaxis == "y2": line_cmd += " y2axis" diff --git a/tests/parser/test_recognizer_adversarial.py b/tests/parser/test_recognizer_adversarial.py index cbbb473..f9f5174 100644 --- a/tests/parser/test_recognizer_adversarial.py +++ b/tests/parser/test_recognizer_adversarial.py @@ -13,6 +13,7 @@ import numpy as np import pytest +import gleplot as glp from gleplot import Figure from gleplot.parser.recognizer import parse_gle_figure @@ -1955,3 +1956,638 @@ def test_finding19_trailing_amove_reproduction_renders_with_parity(tmp_path): f"amove-only strip (diff bbox {diff_bbox}) -- the trailing amove's " "current point was not preserved" ) + + +# --------------------------------------------------------------------------- # +# Finding 20 -- a stacked-bar sibling's dataset silently undefined after +# round-trip (gate-review MAJOR/rendered-fidelity finding, GLEstudio S8 +# corpus: graph/fig/gc_bargraph1.gle). +# +# A bare 'data f.dat' with more than two columns already registers d1..dN +# (see _parse_data_command's auto-mapping path -- col1=x, cols 2..N=y per the +# GLE manual; this fix does not touch it). The bug was in '_parse_bar_command' +# instead: 'bar d2 from d1 fill white' -- GLE's *stacked*-bar form -- has no +# 'from'-awareness there, so it tokenizes to two dataset-shaped words ('d2', +# 'd1') and takes the same no-shared-model path as a genuine grouped +# 'bar d1,d2 fill c1,c2' (Finding 17): kept fully raw. That would have been +# fine on its own (Finding 17's "fully orphaned -> restore the whole 'data' +# line" reconciliation already handles it) -- except the EARLIER, independent +# 'bar d1 fill gray20' statement had ALREADY been modeled as its own +# BarSeries by the time pass 2 reached the 'from' statement. That series' +# regenerated 'data' line only defines d1 (the writer only ever knows about +# the columns ITS OWN modeled series uses), so the still-raw +# 'bar d2 from d1 fill white' line was left referencing a d2 nothing +# defined: GLE rejected the regenerated script with "bar dataset d2 not +# defined" -- a compile failure, not merely a cosmetic difference, so the +# round trip never even reached image comparison. +# +# A tempting narrower fix -- synthesize a second 'data f.dat d2=c1,c3' line +# just for the orphaned d2 -- turns out to be unsound: GLEWriter.add_bar_chart +# always REWRITES an owned series' data file with exactly the columns that +# series uses (_write_columns), so d1's modeled BarSeries would silently +# truncate the physical file down to its own 2 columns, leaving the +# synthesized 'c3' reference pointing past the end of a file gleplot itself +# just shrank. Verified empirically while diagnosing this finding: the naive +# fix compiles, but only because a manual copy-back of the pristine data +# file was masking the truncation in ad hoc testing. +# +# The correct fix is up-front, in pass 1: scan the whole graph block for +# 'bar dN from dM ...' statements before dispatching anything, and remember +# BOTH names in ``_bar_stack_datasets``. When pass 2 later reaches an +# independent, single-name 'bar dM fill ...' for a name in that set, it stays +# raw too -- exactly like Finding 17's grouped form -- so BOTH sides of the +# stack end up unconsumed, the 'data' statement reconciliation restores the +# WHOLE original line verbatim (Finding 17's existing, tested path), and the +# underlying data file is never touched by gleplot at all: GLE reads its +# real, complete, untruncated bytes at compile time. +# --------------------------------------------------------------------------- # + +_MULTI_DAT = "1 3 5\n2 4 6\n3 2 4\n" + + +def test_finding20_stacked_bar_sibling_kept_raw_alongside_its_stack(tmp_path): + """The reviewer's exact shape: a lone 'bar d1 ...' would, on its own, + become a real BarSeries; the LATER 'bar d2 from d1 ...' (GLE's + stacked-bar form) has no shared model and must stay raw (Finding 17). + Once the look-ahead recognizes the stack, d1's statement must join it + as raw too, instead of being modeled independently and having its + 'data' line no longer cover d2. + """ + src = ( + "size 8 6\n" + "begin graph\n" + ' data "multi.dat"\n' + " bar d1 fill gray20\n" + " bar d2 from d1 fill white\n" + "end graph\n" + ) + p = _write(tmp_path, "bargroup.gle", src, {"multi.dat": _MULTI_DAT}) + rec = parse_gle_figure(p) + ax = rec.figure.axes_list[0] + assert ax.bars == [] # neither d1 nor d2 became a BarSeries + joined = "\n".join(ax.passthrough) + assert ' data "multi.dat"' in joined + assert "bar d1 fill gray20" in joined + assert "bar d2 from d1 fill white" in joined + # Original body order preserved: the 'data' statement, then both 'bar' + # statements, d1 before d2. + assert ( + joined.index('data "multi.dat"') + < joined.index("bar d1 fill gray20") + < joined.index("bar d2 from d1") + ) + assert any(w.startswith("structure:") and "d2,d1" in w for w in rec.warnings) + # No "mixes datasets"/"may not resolve" note: the statement ended up + # FULLY unconsumed, not partially -- the case that note exists for. + assert not any( + "mixes datasets" in w or "may not resolve" in w for w in rec.warnings + ) + + out = tmp_path / "out.gle" + rec.figure.savefig_gle(str(out)) + text = out.read_text(encoding="utf-8") + + # The ORIGINAL bare 'data' statement is restored verbatim -- gleplot + # never claims ownership of this file, so it is never rewritten/ + # truncated, and GLE's own default auto-mapping (d1=c1,c2, d2=c1,c3) + # still applies at compile time exactly as it did in the original. + assert 'data "multi.dat"' in text + assert "d1=c1,c2" not in text + assert "d2=c1,c3" not in text + + +def test_finding20_independent_non_stacked_bars_unaffected(tmp_path): + """Sanity check on the look-ahead's precision: two ORDINARY, independent + 'bar' statements sharing one bare multi-column 'data' statement -- no + 'from' anywhere -- must still both become real, independently-modeled + BarSeries. The stacked-bar detection must not sweep up unrelated 'bar' + statements just because they reference datasets from the same file. + """ + src = ( + "size 8 6\n" + "begin graph\n" + ' data "multi.dat"\n' + " bar d1 fill gray20\n" + " bar d2 fill white\n" + "end graph\n" + ) + p = _write(tmp_path, "twobars.gle", src, {"multi.dat": _MULTI_DAT}) + rec = parse_gle_figure(p) + ax = rec.figure.axes_list[0] + assert len(ax.bars) == 2 + assert ax.passthrough == [] + assert not any( + w.startswith("structure:") and "bar group" in w for w in rec.warnings + ) + + +def test_finding20_single_dataset_bare_data_unaffected(tmp_path): + """The common case -- a bare 'data' whose only registered dataset IS the + one modeled series, with no sibling 'bar ... from ...' anywhere -- must + be untouched by this fix: nothing is ever added to + ``_bar_stack_datasets``, so the single-name path behaves exactly as + before. + """ + src = ( + "size 8 6\n" + "begin graph\n" + ' data "one.dat"\n' + " bar d1 fill gray20\n" + "end graph\n" + ) + p = _write(tmp_path, "single_bar.gle", src, {"one.dat": "1 3\n2 4\n3 2\n"}) + rec = parse_gle_figure(p) + ax = rec.figure.axes_list[0] + assert len(ax.bars) == 1 + out = tmp_path / "out.gle" + rec.figure.savefig_gle(str(out)) + text = out.read_text(encoding="utf-8") + + assert text.count("data one.dat") == 1 + assert not any( + "mixes datasets" in w or "may not resolve" in w for w in rec.warnings + ) + + +def test_finding20_resave_is_byte_exact_fixed_point(tmp_path): + src = ( + "size 8 6\n" + "begin graph\n" + ' data "multi.dat"\n' + " bar d1 fill gray20\n" + " bar d2 from d1 fill white\n" + "end graph\n" + ) + p = _write(tmp_path, "fp20.gle", src, {"multi.dat": _MULTI_DAT}) + rec1 = parse_gle_figure(p) + out1 = tmp_path / "out1.gle" + rec1.figure.savefig_gle(str(out1)) + text1 = out1.read_text(encoding="utf-8") + + rec2 = parse_gle_figure(out1) + out2 = tmp_path / "out2.gle" + rec2.figure.savefig_gle(str(out2)) + text2 = out2.read_text(encoding="utf-8") + + assert text1 == text2 + + +@pytest.mark.gle +def test_finding20_stacked_bar_reproduction_renders_with_parity(tmp_path): + """End-to-end regression: compile the reviewer's reproducer with real + GLE, round-trip it through gleplot, compile the round-trip, and compare + the two renders pixel for pixel. Before the fix, the round trip did not + compile AT ALL ("bar dataset d2 not defined"), so this comparison was + never reached. + + The whole graph is passthrough after the fix (see the finding's header + note), so the underlying data file is never rewritten -- the one + remaining source of legitimate preamble drift is the writer's own + page-global 'set hei', which always restates gleplot's default + regardless of what the figure uses. An explicit 'set hei 0.42328' + (12pt, from fontsize_pt_to_cm) matching that default removes it too, so + a real difference in the stack's rendering cannot hide behind + unrelated drift: the two full-page renders must be pixel-identical. + """ + import shutil + import subprocess + + gle_exe = shutil.which("gle") or r"C:\Program Files\GLE\bin\gle.exe" + if not Path(gle_exe).exists(): + pytest.skip("GLE not installed") + + PIL_Image = pytest.importorskip("PIL.Image") + from PIL import ImageChops + + src = ( + "size 8 6\n" + "set hei 0.42328\n" + "amove 1 1\n" + "begin graph\n" + " size 6 4\n" + " xaxis min 0 max 4\n" + " yaxis min 0 max 8\n" + ' data "multi.dat"\n' + " bar d1 fill gray20\n" + " bar d2 from d1 fill white\n" + "end graph\n" + ) + orig_dir = tmp_path / "orig" + orig_dir.mkdir() + orig = _write(orig_dir, "repro.gle", src, {"multi.dat": _MULTI_DAT}) + + res_orig = subprocess.run( + [gle_exe, "-d", "png", "-o", str(orig_dir / "out.png"), str(orig)], + capture_output=True, + text=True, + cwd=str(orig_dir), + ) + assert ( + res_orig.returncode == 0 + ), f"GLE failed:\n{res_orig.stdout}\n{res_orig.stderr}" + + rt_dir = tmp_path / "rt" + rt_dir.mkdir() + (rt_dir / "multi.dat").write_text(_MULTI_DAT, encoding="utf-8") + rec = parse_gle_figure(orig) + rt_gle = rt_dir / "rt.gle" + rec.figure.savefig_gle(str(rt_gle)) + + res_rt = subprocess.run( + [gle_exe, "-d", "png", "-o", str(rt_dir / "out.png"), str(rt_gle)], + capture_output=True, + text=True, + cwd=str(rt_dir), + ) + assert res_rt.returncode == 0, ( + "round-tripped script failed to compile -- the orphaned d2 dataset " + f"is likely undefined again:\n{res_rt.stdout}\n{res_rt.stderr}" + ) + + im_orig = PIL_Image.open(orig_dir / "out.png").convert("RGB") + im_rt = PIL_Image.open(rt_dir / "out.png").convert("RGB") + assert im_orig.size == im_rt.size + + diff_bbox = ImageChops.difference(im_orig, im_rt).getbbox() + assert diff_bbox is None, ( + "round-tripped render diverges from the original " + f"(diff bbox {diff_bbox}) -- the orphaned dataset's stacked bar " + "segment was not preserved" + ) + + +# --------------------------------------------------------------------------- # +# Finding 21 -- a marker-less, line-less series re-emitted as literal +# ``marker None`` (gate-review MAJOR/rendered-fidelity finding, GLEstudio S8 +# corpus: graph/fig/gc_nomiss.gle). +# +# Two independent bugs conspired, and both are fixed here: +# +# 1. Root cause (recognizer, ``_scan_series_attrs``): GLE draws a line for a +# ``dN`` display command when EITHER the ``line`` keyword is given OR +# ``lstyle`` names a style at all -- ``dp[dn]->line || dp[dn]->lstyle[0] +# != 0`` in GLE's own ``graph2.cpp``. The recognizer's ``has_line`` only +# ever became ``True`` for the literal ``line`` keyword, so +# ``d1 lstyle 2 color red`` (the GLE manual's own nomiss example -- no +# ``line`` keyword at all, yet a real, visible dotted line) recovered as +# ``linestyle="none"``, marker ``None``. +# +# 2. Writer bug (``GLEWriter.add_plot_line``'s no-line branch): unconditional +# ``f" marker {marker} ..."`` with no ``if marker:`` guard -- the sibling +# branch (line+marker) and every other marker-emitting site in the writer +# (``add_errorbar``, ``add_errorbar_from_file``) already guard it. Bug 1 +# fed a ``linestyle="none"``, ``marker=None`` series into this branch and +# it emitted the literal text ``marker None``, which GLE rejects +# ("invalid marker name 'None'") -- a compile failure. The same branch is +# also reachable directly from the scripting API +# (``ax.plot(x, y, linestyle='none')`` with no marker -- matplotlib +# itself allows this, a real if pointless degenerate case), independent +# of any GLE import, confirming this needed a writer-level fix and not +# only a recognizer one. +# --------------------------------------------------------------------------- # + +# 3 columns (col1=x, col2=d1's y, col3=d2's y), matching the GLE manual's +# own tut3.dat this figure reads (graph/fig/gc_nomiss.gle references +# tutorial/fig/tut3.dat) -- a bare 'data' statement auto-maps d1=c1,c2 AND +# d2=c1,c3 only when the file actually has 3 columns; a 2-column file would +# auto-map d1 alone and leave 'd2 ...' an unresolved dataset reference +# (preserved as raw passthrough), never reaching the code this finding +# fixes at all. +_NOMISS_DAT = "1 2 1\n2 6 5\n3 4 3\n4 5 4\n" + + +def test_finding21_bare_lstyle_without_line_keyword_is_a_line(tmp_path): + """'d1 lstyle 2 color red', no 'line' keyword anywhere: GLE's own rule + (line OR lstyle set) makes this a real line, dotted (lstyle 2). Before + the fix this recovered as linestyle="none" with no marker. + + Import-mode (``_meta``) so the object model exposes a real ``LineSeries`` + with its own ``linestyle``/``marker`` fields to assert on directly, + rather than the reference-mode ``FileSeries`` a hand-written file with + no metadata block would recover as (see the manual-shaped test below, + which exercises that path instead). + """ + src = _meta("nomiss.dat") + ( + "size 7 4\n" + "begin graph\n" + ' data "nomiss.dat"\n' + " d1 lstyle 2 color red\n" + "end graph\n" + ) + p = _write(tmp_path, "bare_lstyle.gle", src, {"nomiss.dat": _NOMISS_DAT}) + rec = parse_gle_figure(p) + ax = rec.figure.axes_list[0] + assert len(ax.lines) == 1 + assert ax.scatters == [] + line = ax.lines[0] + assert line["linestyle"] == ":" # GLE lstyle 2 is dotted + assert line["marker"] is None + assert line["color"] == "red" + + +def test_finding21_manual_nomiss_reproduction_no_marker_none_leak(tmp_path): + """The manual's exact 'nomiss' shape: d1 has no marker and no 'line' + keyword (bare 'lstyle'); d2 has both 'nomiss' and a real marker. Neither + the regenerated script nor the import warnings may contain the literal + text "marker None". + """ + src = ( + "size 7 4\n" + "begin graph\n" + ' title "nomiss"\n' + ' data "nomiss.dat"\n' + " d1 lstyle 2 color red\n" + " d2 nomiss lstyle 1 marker diamond msize .2 color blue\n" + "end graph\n" + ) + p = _write(tmp_path, "nomiss.gle", src, {"nomiss.dat": _NOMISS_DAT}) + rec = parse_gle_figure(p) + assert not any("None" in w for w in rec.warnings) + + out = tmp_path / "out.gle" + rec.figure.savefig_gle(str(out)) + text = out.read_text(encoding="utf-8") + + assert "None" not in text + assert "marker None" not in text + # d1 still draws its dotted line; d2 still keeps its real marker. + assert "lstyle 2" in text + assert "marker diamond" in text + + +def test_finding21_writer_omits_marker_clause_for_lineless_markerless_series(): + """Writer-level regression, independent of the recognizer/GLE import: + ``ax.plot(x, y, linestyle='none')`` with NO marker is a real, if + pointless, degenerate case matplotlib itself allows (a LineSeries with + marker=None reaches GLEWriter.add_plot_line's no-line branch). Before + the fix that branch unconditionally interpolated ``marker`` into an + f-string, so it emitted the literal text "marker None" for this case + too -- purely a scripting-API bug, no GLE file involved. + """ + fig = glp.figure(data_prefix="f21") + ax = fig.add_subplot(111) + ax.plot([0, 1, 2], [1, 2, 3], linestyle="none") + text, _files = fig._generate_gle_with_files() + + assert "None" not in text + assert "marker" not in text # nothing to draw -- no marker clause at all + + +def test_finding21_resave_is_byte_exact_fixed_point(tmp_path): + src = ( + "size 7 4\n" + "begin graph\n" + ' data "nomiss.dat"\n' + " d1 lstyle 2 color red\n" + " d2 nomiss lstyle 1 marker diamond msize .2 color blue\n" + "end graph\n" + ) + p = _write(tmp_path, "fp21.gle", src, {"nomiss.dat": _NOMISS_DAT}) + rec1 = parse_gle_figure(p) + out1 = tmp_path / "out1.gle" + rec1.figure.savefig_gle(str(out1)) + text1 = out1.read_text(encoding="utf-8") + + rec2 = parse_gle_figure(out1) + out2 = tmp_path / "out2.gle" + rec2.figure.savefig_gle(str(out2)) + text2 = out2.read_text(encoding="utf-8") + + assert text1 == text2 + + +@pytest.mark.gle +def test_finding21_nomiss_reproduction_renders_with_parity(tmp_path): + """End-to-end regression: compile the manual's 'nomiss' reproducer with + real GLE, round-trip it through gleplot, compile the round-trip, and + compare the two renders pixel for pixel. Before the fix, the round trip + did not compile AT ALL ("invalid marker name 'None'"), so this + comparison was never reached. + + An explicit 'set hei' matching gleplot's own default (12pt == + 0.42328cm), explicit 'amove'+'size'+axis-range graph placement, and an + explicit 'lwidth' matching gleplot's own default line width (1.5pt == + 0.05292cm) remove every OTHER source of legitimate preamble/line-style + drift -- neither original line specifies 'lwidth' at all, so GLE's own + default line width would otherwise differ subtly from what an + unspecified linewidth recovers as on the model -- so a real difference + in d1's line rendering cannot hide behind them. + """ + import shutil + import subprocess + + gle_exe = shutil.which("gle") or r"C:\Program Files\GLE\bin\gle.exe" + if not Path(gle_exe).exists(): + pytest.skip("GLE not installed") + + PIL_Image = pytest.importorskip("PIL.Image") + from PIL import ImageChops + + src = ( + "size 8 6\n" + "set hei 0.42328\n" + "amove 1 1\n" + "begin graph\n" + " size 6 4\n" + " xaxis min 1 max 4\n" + " yaxis min 0 max 6\n" + ' data "nomiss.dat"\n' + " d1 lstyle 2 lwidth 0.05292 color red\n" + " d2 nomiss lstyle 1 lwidth 0.05292 marker diamond msize .2 color blue\n" + "end graph\n" + ) + orig_dir = tmp_path / "orig" + orig_dir.mkdir() + orig = _write(orig_dir, "repro.gle", src, {"nomiss.dat": _NOMISS_DAT}) + + res_orig = subprocess.run( + [gle_exe, "-d", "png", "-o", str(orig_dir / "out.png"), str(orig)], + capture_output=True, + text=True, + cwd=str(orig_dir), + ) + assert ( + res_orig.returncode == 0 + ), f"GLE failed:\n{res_orig.stdout}\n{res_orig.stderr}" + + rt_dir = tmp_path / "rt" + rt_dir.mkdir() + (rt_dir / "nomiss.dat").write_text(_NOMISS_DAT, encoding="utf-8") + rec = parse_gle_figure(orig) + rt_gle = rt_dir / "rt.gle" + rec.figure.savefig_gle(str(rt_gle)) + + res_rt = subprocess.run( + [gle_exe, "-d", "png", "-o", str(rt_dir / "out.png"), str(rt_gle)], + capture_output=True, + text=True, + cwd=str(rt_dir), + ) + assert res_rt.returncode == 0, ( + "round-tripped script failed to compile -- 'marker None' is likely " + f"back:\n{res_rt.stdout}\n{res_rt.stderr}" + ) + + im_orig = PIL_Image.open(orig_dir / "out.png").convert("RGB") + im_rt = PIL_Image.open(rt_dir / "out.png").convert("RGB") + assert im_orig.size == im_rt.size + + diff_bbox = ImageChops.difference(im_orig, im_rt).getbbox() + assert diff_bbox is None, ( + "round-tripped render diverges from the original " + f"(diff bbox {diff_bbox}) -- d1's bare-lstyle line was not " + "preserved" + ) + + +# --------------------------------------------------------------------------- # +# The 'nomiss' qualifier itself (GLE manual, graph.tex): "If a dataset has +# missing values GLE will not draw a line to the next real value ... use +# nomiss to avoid this." Finding 21 above fixed 'lstyle set with no line +# keyword' and the writer's 'marker None' leak using the manual's own +# gc_nomiss.gle (graph/fig/gc_nomiss.gle + tutorial/fig/tut3.dat) as a +# reproducer -- but that fixture's data has no actual missing value, so it +# never exercised the 'nomiss' keyword itself. '_scan_series_attrs' had no +# branch for it at all: the token loop's generic 'i += 1' fallthrough +# silently dropped it, with no field on the model and no round-trip. Both +# the original and a round-tripped script compiled fine either way (GLE +# accepts a dataset with or without 'nomiss'), so no existing compile-only +# check ever caught it -- only a rendered-pixel comparison against data that +# actually has a missing value does, which is what these tests add. +# --------------------------------------------------------------------------- # + +# tut3.dat's own rows (gle-manual/tutorial/fig/tut3.dat), missing marked '-' +# in row 3 for both d1's and d2's y column -- GLE's own worked example. +_NOMISS_MISSING_DAT = "1 2 1\n2 6 5\n3 - -\n4 5 4\n5 9 8\n" + + +def test_nomiss_keyword_recognized_and_reemitted_only_on_its_own_series(tmp_path): + """'d2 nomiss lstyle 1 ...' recovers with nomiss=True on d2's model + entry; d1 (bare 'lstyle 2', no 'nomiss') recovers with nomiss=False. + Re-saving emits 'nomiss' on d2's dataset line only. + """ + src = ( + "size 7 4\n" + "begin graph\n" + ' data "nomiss.dat"\n' + " d1 lstyle 2 color red\n" + " d2 nomiss lstyle 1 marker diamond msize .2 color blue\n" + "end graph\n" + ) + p = _write(tmp_path, "nomiss.gle", src, {"nomiss.dat": _NOMISS_MISSING_DAT}) + rec = parse_gle_figure(p) + ax = rec.figure.axes_list[0] + d1, d2 = ax.file_series + assert d1["nomiss"] is False + assert d2["nomiss"] is True + + out = tmp_path / "out.gle" + rec.figure.savefig_gle(str(out)) + text = out.read_text(encoding="utf-8") + + lines = [ln for ln in text.splitlines() if ln.strip().startswith(("d1", "d2"))] + d1_line = next(ln for ln in lines if ln.strip().startswith("d1")) + d2_line = next(ln for ln in lines if ln.strip().startswith("d2")) + assert "nomiss" not in d1_line + assert "nomiss" in d2_line + + +def test_nomiss_resave_is_byte_exact_fixed_point(tmp_path): + src = ( + "size 7 4\n" + "begin graph\n" + ' data "nomiss.dat"\n' + " d1 lstyle 2 color red\n" + " d2 nomiss lstyle 1 marker diamond msize .2 color blue\n" + "end graph\n" + ) + p = _write(tmp_path, "fpnm.gle", src, {"nomiss.dat": _NOMISS_MISSING_DAT}) + rec1 = parse_gle_figure(p) + out1 = tmp_path / "out1.gle" + rec1.figure.savefig_gle(str(out1)) + text1 = out1.read_text(encoding="utf-8") + + rec2 = parse_gle_figure(out1) + out2 = tmp_path / "out2.gle" + rec2.figure.savefig_gle(str(out2)) + text2 = out2.read_text(encoding="utf-8") + + assert text1 == text2 + + +@pytest.mark.gle +def test_nomiss_reproduction_with_real_missing_value_renders_with_parity(tmp_path): + """End-to-end regression with the manual's own missing-value data: d1 (no + 'nomiss') draws a real gap at the missing point, d2 ('nomiss') draws + through it unbroken. Compile the original with real GLE, round-trip it + through gleplot, compile the round-trip, and compare pixel for pixel. + + Before the fix, the round trip silently dropped 'nomiss' from d2, so + d2 would gap on re-compile where the original did not -- both compile + successfully either way, so only this pixel comparison catches it. + """ + import shutil + import subprocess + + gle_exe = shutil.which("gle") or r"C:\Program Files\GLE\bin\gle.exe" + if not Path(gle_exe).exists(): + pytest.skip("GLE not installed") + + PIL_Image = pytest.importorskip("PIL.Image") + from PIL import ImageChops + + src = ( + "size 8 6\n" + "set hei 0.42328\n" + "amove 1 1\n" + "begin graph\n" + " size 6 4\n" + " xaxis min 1 max 5\n" + " yaxis min 0 max 10\n" + ' data "nomiss.dat"\n' + " d1 lstyle 2 lwidth 0.05292 color red\n" + " d2 nomiss lstyle 1 lwidth 0.05292 marker diamond msize .2 color blue\n" + "end graph\n" + ) + orig_dir = tmp_path / "orig" + orig_dir.mkdir() + orig = _write(orig_dir, "repro.gle", src, {"nomiss.dat": _NOMISS_MISSING_DAT}) + + res_orig = subprocess.run( + [gle_exe, "-d", "png", "-o", str(orig_dir / "out.png"), str(orig)], + capture_output=True, + text=True, + cwd=str(orig_dir), + ) + assert ( + res_orig.returncode == 0 + ), f"GLE failed:\n{res_orig.stdout}\n{res_orig.stderr}" + + rt_dir = tmp_path / "rt" + rt_dir.mkdir() + (rt_dir / "nomiss.dat").write_text(_NOMISS_MISSING_DAT, encoding="utf-8") + rec = parse_gle_figure(orig) + rt_gle = rt_dir / "rt.gle" + rec.figure.savefig_gle(str(rt_gle)) + + res_rt = subprocess.run( + [gle_exe, "-d", "png", "-o", str(rt_dir / "out.png"), str(rt_gle)], + capture_output=True, + text=True, + cwd=str(rt_dir), + ) + assert ( + res_rt.returncode == 0 + ), f"round-tripped script failed to compile:\n{res_rt.stdout}\n{res_rt.stderr}" + + im_orig = PIL_Image.open(orig_dir / "out.png").convert("RGB") + im_rt = PIL_Image.open(rt_dir / "out.png").convert("RGB") + assert im_orig.size == im_rt.size + + diff_bbox = ImageChops.difference(im_orig, im_rt).getbbox() + assert diff_bbox is None, ( + "round-tripped render diverges from the original " + f"(diff bbox {diff_bbox}) -- d2's 'nomiss' was likely dropped on " + "round-trip, so it now gaps at the missing value where the " + "original drew through it" + )