From 557aa42c0f8aaddf9d1d34bbf0bd4686c17c26f5 Mon Sep 17 00:00:00 2001 From: Ben Huddart Date: Fri, 7 Aug 2026 21:00:16 +0100 Subject: [PATCH 1/3] fix(parser): keep both sides of a stacked-bar group raw, not just one 'bar dN from dM ...' is GLE's stacked-bar form -- dN draws on top of dM. _parse_bar_command tokenizes it to two dataset-shaped words with no 'from'-awareness, so it already fell into the existing "no BarSeries model for a shared bar group" raw-passthrough path (Finding 17's grouped 'bar d1,d2 fill c1,c2' form). But an EARLIER, independent 'bar dM fill ...' statement for the same dataset had, by then, already been modeled as its own BarSeries: its regenerated 'data' line only defines dM, so the still-raw 'bar dN from dM ...' line was left referencing a dN nothing defined. GLE rejected the round-tripped script outright ("bar dataset dN not defined") -- a compile failure, not a cosmetic difference, on any manual-style bar chart using GLE's stacked-bar syntax (GLEstudio's S8 rendered-fidelity corpus: graph/fig/gc_bargraph1.gle). A tempting narrower fix -- synthesize a second 'data f.dat dN=cX,cY' line just for the orphaned dataset -- turns out to be unsound: GLEWriter always rewrites an owned series' data file with exactly the columns that series uses (add_bar_chart/_write_columns), so the sibling's modeled BarSeries would silently truncate the physical file down to its own columns, leaving the synthesized reference pointing past the end of a file gleplot itself just shrank. Caught empirically while diagnosing this: a naive version of that fix only "worked" because a manual copy-back of the pristine data file was masking the truncation in ad hoc testing. Fix instead in pass 1 (_parse_graph_block): scan the whole graph block for 'bar dN from dM ...' up front and remember both names in the new '_bar_stack_datasets' set, before pass 2 dispatches anything. When pass 2 later reaches an independent, single-name 'bar dM fill ...' for a name in that set, it now stays raw too. Both sides of the stack end up unconsumed, the existing 'data'-statement reconciliation restores the whole original line verbatim, and gleplot never touches the underlying data file at all -- GLE reads its real, complete bytes at compile time. Finding 20 test battery: the stacked pair kept raw together (body order, warnings, no stale "may not resolve" note, unmodified data file on save), independent non-stacked bars from the same file unaffected, the single-dataset case byte-identical, a save->parse->save fixed-point regression, and a gle-marked end-to-end test compiling the original and round-tripped reproducer with real GLE and asserting pixel-identical renders. Manually verified against the actual GLE-manual reproduction (graph/fig/gc_bargraph1.gle): compiles clean and renders pixel-identical to the original. Fixed-point battery green (exemption set empty), full suite green (2565 passed / 2 skipped, +5 over baseline), gle-marked suite green, black clean on the added lines, no new flake8 or mypy findings. Co-Authored-By: Claude Sonnet 5 --- src/gleplot/parser/recognizer.py | 70 +++++- tests/parser/test_recognizer_adversarial.py | 258 ++++++++++++++++++++ 2 files changed, 327 insertions(+), 1 deletion(-) diff --git a/src/gleplot/parser/recognizer.py b/src/gleplot/parser/recognizer.py index 1f2a07d..058602e 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( diff --git a/tests/parser/test_recognizer_adversarial.py b/tests/parser/test_recognizer_adversarial.py index cbbb473..02b1bfc 100644 --- a/tests/parser/test_recognizer_adversarial.py +++ b/tests/parser/test_recognizer_adversarial.py @@ -1955,3 +1955,261 @@ 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" + ) From 1ac6f97a964404257b84cb9796f4bba2e3092678 Mon Sep 17 00:00:00 2001 From: Ben Huddart Date: Fri, 7 Aug 2026 21:19:50 +0100 Subject: [PATCH 2/3] fix(parser): recognize bare lstyle as a line, and stop leaking marker None Two bugs conspired to turn 'd1 lstyle 2 color red' (the GLE manual's own 'nomiss' example -- no 'line' keyword at all) into a compile failure on round trip: GLE reports "invalid marker name 'None'" (GLEstudio's S8 rendered-fidelity corpus: graph/fig/gc_nomiss.gle). Root cause, in the 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 -- graph2.cpp's own draw-a-line test is 'dp[dn]->line || dp[dn]->lstyle[0] != 0'. has_line only ever became True for the literal 'line' keyword, so a bare 'lstyle' recovered as linestyle="none" with no marker. That fed a marker=None, linestyle="none" series into a real, independent writer bug: GLEWriter.add_plot_line's no-line branch interpolated `marker` into an f-string with no `if marker:` guard, unlike its own line+marker branch and every other marker-emitting site in the writer (add_errorbar, add_errorbar_from_file). The literal text "marker None" reached the script. This branch is also reachable directly from the scripting API (ax.plot(x, y, linestyle='none') with no marker -- a real, if pointless, degenerate case matplotlib itself allows), independent of any GLE import, confirming the writer needed its own fix, not only the recognizer's. Fixed both: 'lstyle' now sets has_line, matching GLE's own semantics; and add_plot_line's no-line branch only emits a marker clause `elif marker:`, emitting nothing at all when a series has neither a line nor a marker (there is nothing to draw). Audited every other marker-emitting site for the same class of bug per the reviewer's instruction. Found and fixed a second, related drop while building the repro: 'd2 nomiss lstyle 1 marker diamond ...', when recovered as a reference-mode FileSeries (a hand-written file with no gleplot metadata block), silently lost its marker entirely on save -- GLEWriter.add_plot_line_from_file never had a marker/markersize parameter at all, and Figure._render_axes never passed one through. This is not a "None leak" (no literal "None" reached the script) but the same family of gap in the writer's dN sub-command emission the reviewer's diagnosis named, and it was necessary to get pixel parity on the actual manual figure's d2 curve, not merely a compiling script. color/lstyle sites elsewhere in the writer were already correctly guarded (`if color is not None`, no unguarded Optional lstyle interpolation anywhere) -- marker was the only leaking field. Finding 21 test battery: bare-lstyle-is-a-line at the object-model level, the manual's exact nomiss shape with no "None" anywhere in warnings or output, a dedicated writer-level regression for the scripting-API degenerate case (independent of any .gle file), a save->parse->save fixed-point regression, and a gle-marked end-to-end test compiling the original and round-tripped reproducer with real GLE and asserting pixel-identical renders (explicit 'set hei' and 'lwidth' pin gleplot's own unrelated defaults so they cannot mask a real difference). Manually verified against the actual GLE-manual reproduction (graph/fig/gc_nomiss.gle): both the original and the round-tripped script now compile cleanly (previously the round trip failed outright). Not fixed here, out of scope: the manual figure's own 'nomiss' qualifier (ignore missing values so a gapped line draws unbroken) is itself silently dropped -- unmodeled anywhere in the recognizer, on d1 or d2 alike -- so the real gc_nomiss.gle's d2 curve still regains a gap at its missing value after round-trip, and the figure's overall placement also shifts slightly (gleplot's auto-margins vs. the manual's own manual_graph_mode macro). Both compile cleanly either way; flagged as a separate, pre-existing finding rather than folded into this fix. Fixed-point battery green (exemption set empty), full suite green (2570 passed / 2 skipped, +5 over prior commit), gle-marked suite green, black clean on the added lines, no new flake8 or mypy findings. Co-Authored-By: Claude Sonnet 5 --- src/gleplot/figure.py | 2 + src/gleplot/parser/recognizer.py | 11 + src/gleplot/writer.py | 24 ++- tests/parser/test_recognizer_adversarial.py | 225 ++++++++++++++++++++ 4 files changed, 260 insertions(+), 2 deletions(-) diff --git a/src/gleplot/figure.py b/src/gleplot/figure.py index a613365..248f760 100644 --- a/src/gleplot/figure.py +++ b/src/gleplot/figure.py @@ -2083,6 +2083,8 @@ 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), ) elif series_type == "bar": writer.add_bar_from_file( diff --git a/src/gleplot/parser/recognizer.py b/src/gleplot/parser/recognizer.py index 058602e..555a4c6 100644 --- a/src/gleplot/parser/recognizer.py +++ b/src/gleplot/parser/recognizer.py @@ -2689,6 +2689,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 diff --git a/src/gleplot/writer.py b/src/gleplot/writer.py index 280c4fb..41698fd 100644 --- a/src/gleplot/writer.py +++ b/src/gleplot/writer.py @@ -1700,10 +1700,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 +2182,18 @@ 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, ): - """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``. + """ d_main = f"d{self.dataset_index}" self.dataset_index += 1 @@ -2200,6 +2217,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 02b1bfc..8ecb739 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 @@ -2213,3 +2214,227 @@ def test_finding20_stacked_bar_reproduction_renders_with_parity(tmp_path): 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" + ) From bce920bf9ee6ec52038f6091e9a86ae4b70e1740 Mon Sep 17 00:00:00 2001 From: Ben Huddart Date: Fri, 7 Aug 2026 21:49:42 +0100 Subject: [PATCH 3/3] fix(parser): recognize and round-trip GLE's 'nomiss' dataset qualifier _scan_series_attrs had no branch for 'nomiss' at all -- the token loop's generic fallthrough silently dropped it, with no field on the object model and no round-trip, even though the writer's own 'marker None' fix (Finding 21) already used the manual's gc_nomiss.gle as a reproducer. That fixture's data has no actual missing value, so it never exercised the keyword itself: both the original and a round-tripped script compile fine either way, so nothing but a rendered-pixel comparison against real missing data catches the drop. Adds a `nomiss: bool` field to LineSeries/ScatterSeries and the FileSeries 'line' variant, recognizes the keyword in _scan_series_attrs, and re-emits it from GLEWriter.add_plot_line/add_plot_line_from_file. Proven against the GLE manual's own tut3.dat missing-value row with a real GLE compile + pixel-parity round trip. Co-Authored-By: Claude Sonnet 5 --- src/gleplot/axes.py | 2 + src/gleplot/figure.py | 2 + src/gleplot/parser/recognizer.py | 7 + src/gleplot/series.py | 8 + src/gleplot/writer.py | 19 ++- tests/parser/test_recognizer_adversarial.py | 153 ++++++++++++++++++++ 6 files changed, 187 insertions(+), 4 deletions(-) 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 248f760..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"), @@ -2085,6 +2086,7 @@ def _write_axes_content( 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 555a4c6..939c26e 100644 --- a/src/gleplot/parser/recognizer.py +++ b/src/gleplot/parser/recognizer.py @@ -2625,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), @@ -2648,6 +2649,7 @@ def _scan_series_attrs(self, toks) -> dict: a = { "has_line": False, "smooth": False, + "nomiss": False, "color": None, "lwidth": None, "lstyle": None, @@ -2674,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 @@ -3048,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 41698fd..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 @@ -2184,6 +2191,7 @@ def add_plot_line_from_file( 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. @@ -2193,6 +2201,9 @@ def add_plot_line_from_file( ``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 @@ -2206,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 == ":": diff --git a/tests/parser/test_recognizer_adversarial.py b/tests/parser/test_recognizer_adversarial.py index 8ecb739..f9f5174 100644 --- a/tests/parser/test_recognizer_adversarial.py +++ b/tests/parser/test_recognizer_adversarial.py @@ -2438,3 +2438,156 @@ def test_finding21_nomiss_reproduction_renders_with_parity(tmp_path): 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" + )