From 241639d2cc18a9b93019c5d4894e8ef93f6dcb73 Mon Sep 17 00:00:00 2001 From: Marlow Date: Sun, 25 Oct 2026 07:38:17 -0500 Subject: [PATCH 1/4] test(fx): preserve omitted-knob predecessor bytes at d128c241 --- .../com/lattex/fx/absent-knobs-golden.html | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/test/resources/com/lattex/fx/absent-knobs-golden.html diff --git a/src/test/resources/com/lattex/fx/absent-knobs-golden.html b/src/test/resources/com/lattex/fx/absent-knobs-golden.html new file mode 100644 index 0000000..e193d9c --- /dev/null +++ b/src/test/resources/com/lattex/fx/absent-knobs-golden.html @@ -0,0 +1,54 @@ + + + + + + + From d4d8f677831f4bf5a383a77d80a45f16fa73b654 Mon Sep 17 00:00:00 2001 From: Marlow Date: Sun, 25 Oct 2026 07:45:53 -0500 Subject: [PATCH 2/4] Add bounded bolts and intensity parser and container stamps --- src/main/java/com/lattex/api/LatteX.java | 28 +++-- .../java/com/lattex/parse/EffectSpec.java | 53 ++++++++- .../com/lattex/parse/LxOptionsParser.java | 32 +++++- .../com/lattex/api/ContainerDriftTest.java | 49 ++++++++ .../com/lattex/api/FxContainerAttrsTest.java | 97 +++++++++++++++- .../java/com/lattex/parse/LxMacroTest.java | 105 ++++++++++++++++++ 6 files changed, 347 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/lattex/api/LatteX.java b/src/main/java/com/lattex/api/LatteX.java index 5c5108c..3be5489 100644 --- a/src/main/java/com/lattex/api/LatteX.java +++ b/src/main/java/com/lattex/api/LatteX.java @@ -815,14 +815,15 @@ private static String openTag(EffectSpec fx, Semantics sem, String glyphmap, Str * Stafficy's {@code /docs} math seam is the reference consumer). * *

Returns {@code ""} when the source carries no top-level {@code \lx[fx.*]} - * annotations, else a leading-space-prefixed run of only the five - * {@code data-lx-fx-*} attributes (enter/hover/click/duration/glow-color, in that - * order) — never {@code data-lx-intent}/{@code concept}/{@code data.*}/ + * annotations, else a leading-space-prefixed run of only the seven + * {@code data-lx-fx-*} attributes (enter/hover/click/duration/glow-color/bolts/intensity, + * in that order) — never {@code data-lx-intent}/{@code concept}/{@code data.*}/ * {@code aria-*}, which ride {@link #renderStyledHtml}'s own container instead. * Every value is parse-time validated (effects are the closed {@code Effect} * vocabulary, duration matches {@code \d{1,5}ms}, glow-color is - * {@code currentColor} or a canonical hex literal), so no raw author string - * reaches an attribute. + * {@code currentColor} or a canonical hex literal, bolts/intensity are bounded ASCII + * decimals), so no raw author string reaches an attribute. Omitted numeric knobs + * produce no attributes; runtime defaults remain 2 bolts and 100 percent intensity. * * @param latex the LaTeX math source (optionally the {@code \lx[…]{…}} macro) * @return {@code ""} or a leading-space-prefixed {@code data-lx-fx-*} attribute run @@ -870,7 +871,7 @@ public static String fxContainerAttrs(String latex) { * The render-coupled seam record: the self-contained SVG document plus the container * attributes derived from the SAME parse+layout, as a key→value map. The consumer (the * Stafficy {@code /docs} markdown converter) stamps each entry onto ITS OWN wrapper - * element; the key set is pinned by the container-output contract (the five + * element; the key set is pinned by the container-output contract (the seven * {@code data-lx-fx-*} plus {@code data-lx-glyphmap}), so nothing re-encodes and the * reflective bridge stays name-agnostic. Values are parse-validated (effects: closed * vocabulary; duration: {@code \d{1,5}ms}; glow: {@code currentColor|#rrggbb}; glyphmap: @@ -888,7 +889,7 @@ public record RenderedMath(String svg, Map containerAttrs) { public RenderedMath { java.util.Objects.requireNonNull(svg, "svg"); // Order-PRESERVING defensive copy: the historical stamp order (enter/hover/ - // click, duration, glow, glyphmap) is load-bearing — the deprecated string + // click, duration, glow, explicit numeric knobs, glyphmap) is load-bearing — the deprecated string // seam is byte-identical to this map joined in iteration order. Map.copyOf // would be immutable but randomize iteration, breaking that equivalence, so // copy through a LinkedHashMap and wrap unmodifiable. @@ -943,7 +944,8 @@ public static java.util.Optional tryRenderMath(String latex) { * THE single container-attribute producer: {@link #fxAttrs} (the string form * {@link #openTag} and the deprecated seam join) derives from this map, so the record * form and the string form cannot drift. Insertion order is the historical stamp order - * (enter/hover/click, duration, glow, glyphmap) so the joined string stays byte-identical. + * (enter/hover/click, duration, glow, explicit numeric knobs, glyphmap) so omitted knobs + * leave the joined string byte-identical to the predecessor. */ private static Map containerAttrMap(EffectSpec fx, String glyphmap, String groupmap) { Map attrs = new java.util.LinkedHashMap<>(); @@ -953,6 +955,12 @@ private static Map containerAttrMap(EffectSpec fx, String glyphm } fx.durationValue().ifPresent(v -> attrs.put("data-lx-fx-duration", v)); fx.glowColorValue().ifPresent(c -> attrs.put("data-lx-fx-glow-color", c.svgValue())); + if (fx.bolts() != null) { + attrs.put("data-lx-fx-bolts", Integer.toString(fx.bolts())); + } + if (fx.intensity() != null) { + attrs.put("data-lx-fx-intensity", Integer.toString(fx.intensity())); + } if (glyphmap != null && !glyphmap.isEmpty()) { attrs.put("data-lx-glyphmap", glyphmap); } @@ -965,14 +973,14 @@ private static Map containerAttrMap(EffectSpec fx, String glyphm /** * The shared fx-attribute stamping source consumed by BOTH {@link #openTag} and * {@link #fxContainerAttrs} — one producer, so the wrapper form and the seam form - * cannot drift. Emits only the container contract's five {@code data-lx-fx-*} + * cannot drift. Emits only the container contract's seven {@code data-lx-fx-*} * attributes, each value validated at parse time. */ private static String fxAttrs(EffectSpec fx) { // Derived from containerAttrMap — the ONE producer (record seam + string form share // it, so they cannot drift). Values are parse-validated; the glow colour's svgValue() // is currentColor or a canonical #rrggbb literal — a safe attribute VALUE. - // Empty sidecars: fxAttrs stamps only the fx-* / duration / glow attrs; openTag + // Empty sidecars: fxAttrs stamps only the fx-* attrs, including numeric knobs; openTag // stamps the glyphmap/groupmap sidecars separately (and the deprecated seam never // carried them). StringBuilder sb = new StringBuilder(); diff --git a/src/main/java/com/lattex/parse/EffectSpec.java b/src/main/java/com/lattex/parse/EffectSpec.java index 5795f88..63acc65 100644 --- a/src/main/java/com/lattex/parse/EffectSpec.java +++ b/src/main/java/com/lattex/parse/EffectSpec.java @@ -23,9 +23,18 @@ * without evaluating anything symbolic. * @param substituteVar the {@code fx.substitute-var} variable to replace: exactly one * letter, or {@code null} to auto-detect the single distinct letter in the body + * @param bolts explicit {@code fx.bolts} in 1..8, or {@code null} for runtime default 2 + * @param intensity explicit {@code fx.intensity} in 0..300 percent, or {@code null} + * for runtime default 100; zero disables lightning/storm */ public record EffectSpec(Map effects, String duration, Color glowColor, - String substituteTo, String substituteVar) { + String substituteTo, String substituteVar, Integer bolts, Integer intensity) { + + /** Preserves the original public constructor and its omitted-knob state. */ + public EffectSpec(Map effects, String duration, Color glowColor, + String substituteTo, String substituteVar) { + this(effects, duration, glowColor, substituteTo, substituteVar, null, null); + } /** {@code fx.duration} grammar: 1–5 digits followed by the {@code ms} unit. */ private static final Pattern DURATION_PATTERN = Pattern.compile("^\\d{1,5}ms$"); @@ -40,6 +49,8 @@ public record EffectSpec(Map effects, String duration, Color gl public EffectSpec { effects = Map.copyOf(effects); + validateIntegerOption("fx.bolts", bolts, 1, 8); + validateIntegerOption("fx.intensity", intensity, 0, 300); if (duration != null && !DURATION_PATTERN.matcher(duration).matches()) { throw new MathSyntaxException( "invalid fx.duration: \"" + duration + "\" (expected like 250ms)"); @@ -58,7 +69,41 @@ public record EffectSpec(Map effects, String duration, Color gl } } - /** The empty spec: no effects, no duration, no glow colour, no substitution. */ + /** Strict ASCII decimal, bounded during accumulation so overflow never escapes. */ + static int parseIntegerOption(String key, String value, int min, int max) { + if (value.isEmpty()) { + throw invalidIntegerOption(key, value, min, max); + } + int parsed = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c < '0' || c > '9') { + throw invalidIntegerOption(key, value, min, max); + } + int digit = c - '0'; + if (parsed > max / 10 || (parsed == max / 10 && digit > max % 10)) { + throw invalidIntegerOption(key, value, min, max); + } + parsed = parsed * 10 + digit; + } + if (parsed < min) { + throw invalidIntegerOption(key, value, min, max); + } + return parsed; + } + + private static void validateIntegerOption(String key, Integer value, int min, int max) { + if (value != null && (value < min || value > max)) { + throw invalidIntegerOption(key, value.toString(), min, max); + } + } + + private static MathSyntaxException invalidIntegerOption(String key, String value, int min, int max) { + return new MathSyntaxException("invalid " + key + ": \"" + value + + "\" (expected integral ASCII decimal " + min + ".." + max + ")"); + } + + /** The empty spec: all effect options, including numeric knobs, omitted. */ public static EffectSpec none() { return new EffectSpec(Map.of(), null, null, null, null); } @@ -91,9 +136,9 @@ public Optional substituteVarCodePoint() { return Optional.ofNullable(substituteVar).map(v -> v.codePointAt(0)); } - /** Whether any effect, duration, glow colour, or substitution option was specified. */ + /** Whether any effect option, including an explicit numeric knob, was specified. */ public boolean isEmpty() { return effects.isEmpty() && duration == null && glowColor == null - && substituteTo == null && substituteVar == null; + && substituteTo == null && substituteVar == null && bolts == null && intensity == null; } } diff --git a/src/main/java/com/lattex/parse/LxOptionsParser.java b/src/main/java/com/lattex/parse/LxOptionsParser.java index bf22a9d..1cc2ab3 100644 --- a/src/main/java/com/lattex/parse/LxOptionsParser.java +++ b/src/main/java/com/lattex/parse/LxOptionsParser.java @@ -148,6 +148,8 @@ private static LxOptions parseLxOptions(String raw, String body) { Color glowColor = null; String substituteTo = null; String substituteVar = null; + Integer bolts = null; + Integer intensity = null; String intent = null; String concept = null; String a11yLabel = null; @@ -180,7 +182,31 @@ private static LxOptions parseLxOptions(String raw, String body) { i = skipWs(raw, i + 1); // past '=' // value: quoted or bare String value; - if (i < n && raw.charAt(i) == '"') { + if (key.equals("fx.bolts") || key.equals("fx.intensity")) { + // Keep malformed numeric spans intact until the keyed validator runs. + // In particular, `2 3` and `"2"junk` must not escape as a generic + // separator error. Strip only insignificant OUTSIDE whitespace/quotes. + int vs = i; + boolean inQuote = false; + while (i < n) { + char c = raw.charAt(i); + if (c == '"') { + inQuote = !inQuote; + } else if (c == ',' && !inQuote) { + break; + } + i++; + } + int end = i; + while (end > vs && isWhitespace(raw.charAt(end - 1))) { + end--; + } + value = raw.substring(vs, end); + if (value.length() >= 2 && value.charAt(0) == '"' + && value.charAt(value.length() - 1) == '"') { + value = value.substring(1, value.length() - 1); + } + } else if (i < n && raw.charAt(i) == '"') { int vs = ++i; while (i < n && raw.charAt(i) != '"') { i++; @@ -235,6 +261,8 @@ private static LxOptions parseLxOptions(String raw, String body) { case "fx.hover" -> effects.put(Trigger.HOVER, Effect.parse(value)); case "fx.click" -> effects.put(Trigger.CLICK, Effect.parse(value)); case "fx.duration" -> duration = value; // validated in EffectSpec + case "fx.bolts" -> bolts = EffectSpec.parseIntegerOption(key, value, 1, 8); + case "fx.intensity" -> intensity = EffectSpec.parseIntegerOption(key, value, 0, 300); // The glow/lightning halo colour. Validated through the same // Color boundary as style.color (accepts #rgb/#rrggbb/currentColor, // fails loud otherwise); it becomes a data-lx-fx-glow-color VALUE on @@ -338,7 +366,7 @@ private static LxOptions parseLxOptions(String raw, String body) { return new LxOptions( new RenderOptions(scale, color, mathStyle), - new EffectSpec(effects, duration, glowColor, substituteTo, substituteVar), + new EffectSpec(effects, duration, glowColor, substituteTo, substituteVar, bolts, intensity), new Semantics(intent, concept, a11yLabel, data)); } diff --git a/src/test/java/com/lattex/api/ContainerDriftTest.java b/src/test/java/com/lattex/api/ContainerDriftTest.java index 090d9f3..ea03768 100644 --- a/src/test/java/com/lattex/api/ContainerDriftTest.java +++ b/src/test/java/com/lattex/api/ContainerDriftTest.java @@ -31,6 +31,7 @@ class ContainerDriftTest { "aria-label", "data-lx-fx-enter", "data-lx-fx-hover", "data-lx-fx-click", "data-lx-fx-duration", "data-lx-fx-glow-color", + "data-lx-fx-bolts", "data-lx-fx-intensity", "data-lx-glyphmap", "data-lx-groupmap", // The graph-* metadata (LxOptionsParser stamps `graph.*` options as hyphenated // `data-lx-graph-` for the host-side plotter). These are HYPHENATED, so they @@ -232,12 +233,60 @@ void substituteMarkerAndVarmapRideTheAllowList() { assertNoInjection(tag, "substitute marker"); } + @Test + void numericFxNamesAreExactMembersAndTheirValuesAreBoundedAsciiDecimals() { + for (String effect : List.of("lightning", "storm")) { + for (int[] values : new int[][]{{1, 0}, {2, 100}, {8, 300}}) { + String latex = "\\lx[fx.hover=" + effect + ", fx.bolts=" + values[0] + + ", fx.intensity=" + values[1] + "]{x}"; + String tag = openTagOf(LatteX.renderStyledHtml(latex)); + List names = attrNames(tag); + for (String name : List.of("data-lx-fx-bolts", "data-lx-fx-intensity")) { + assertTrue(names.contains(name), "explicit knob must actually stamp " + name); + assertTrue(ALLOWED_EXACT.contains(name), "exact entry is load-bearing: " + name); + assertTrue(!DATA_ATTR.matcher(name).matches(), "identifier lane stays closed: " + name); + } + assertValue(tag, "data-lx-fx-bolts", "[1-8]"); + assertValue(tag, "data-lx-fx-intensity", "0|[1-9][0-9]?|[12][0-9]{2}|300"); + assertTrue(tag.contains("data-lx-fx-bolts=\"" + values[0] + "\""), tag); + assertTrue(tag.contains("data-lx-fx-intensity=\"" + values[1] + "\""), tag); + for (String name : names) { + assertTrue(isAllowed(name), "numeric knob drift: " + name); + } + assertNoInjection(tag, latex); + } + } + for (String name : List.of("data-lx-fx-strikes", "data-lx-fx-arc", + "data-lx-fx-lightning-bolts", "data-lx-fx-intensity-extra")) { + assertTrue(!isAllowed(name), "no broad hyphenated-name admission: " + name); + } + } + + @Test + void numericKnobInjectionIsRejectedBeforeAnyContainerCanBeStamped() { + for (String effect : List.of("lightning", "storm")) { + for (String key : List.of("fx.bolts", "fx.intensity")) { + for (String value : List.of("\"2 onload=evil\"", "\"