refactor(interp): extract PRINT formatting into PrintFormat
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 3 Sep 2026 21:00:38 +0000 (00:00 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 3 Sep 2026 21:00:38 +0000 (00:00 +0300)
The classic number style (SINGLE 7-digit / DOUBLE 16-digit, D±xx/E±xx
scientific, sign padding), the PRINT USING field walker, and the
14-column zone arithmetic lived in Interpreter; the zone math was
duplicated in two places with different wrap rules (console wraps at
its column count, file output at 255).

PrintFormat now owns all of it: number()/singleNumber(), usingFormat()
with an item-value callback (items still evaluate in the interpreter),
numericField(), and nextZone() shared by the console printZoneAdvance
and IoSubsystem's PRINT # zones — the wrap rule is now an explicit
parameter at both call sites instead of two divergent inline copies.

Verification: 93/93 tests green; CHECK (PRINT USING), FLPT (STR$/HEX$)
and BAR (PRINT USING + records) re-run pixel-identical.

src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
src/main/java/eu/svjatoslav/crtbasic/interp/PrintFormat.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/io/IoSubsystem.java

index 454fabb..def421b 100644 (file)
@@ -1228,7 +1228,7 @@ public final class Interpreter {
         for (final Ast.PrintStmt.Item item : s.items()) {
             final Object value = eval(item.expr());
             vga.print(value instanceof String text ? text
-                    : basicNumber(num(value, s.line()), exprIsDouble(item.expr())));
+                    : PrintFormat.number(num(value, s.line()), exprIsDouble(item.expr())));
             if (item.separatorAfter() == ',') {
                 printZoneAdvance();
             }
@@ -1238,119 +1238,6 @@ public final class Interpreter {
         }
     }
 
-    /**
-     * PRINT USING: walks the format left to right; literal characters print
-     * as-is ('_' escapes the next char), each format field consumes one
-     * item in order. String fields: '!' first char, '&' whole string,
-     * '\\...\\' fixed width. Numeric fields: runs of '#', '.', '$', '*',
-     * '+', '-', ',' — right-justified, decimals rounded half-even, '$$'
-     * floats the dollar sign, '*' fills with asterisks, overflow prints
-     * '%' plus the number. When items outlast the format, QB restarts the
-     * format after a newline; the current examples never do.
-     */
-    private String usingFormatted(final Ast.PrintStmt s) {
-        final String format = str(eval(s.usingFormat()), s.line());
-        final StringBuilder out = new StringBuilder();
-        int itemIndex = 0;
-        int pos = 0;
-        // Literals print as walked; a FIELD consumes the next item. The
-        // walk stops at the first field with no item left — trailing
-        // literals after the last consumed item still print.
-        while (pos < format.length()) {
-            final char c = format.charAt(pos);
-            if (c == '_' && pos + 1 < format.length()) {
-                out.append(format.charAt(pos + 1));
-                pos += 2;
-                continue;
-            }
-            final boolean isStringField = c == '!' || c == '&' || c == '\\';
-            final boolean isNumericField = "#.*$+-,".indexOf(c) >= 0;
-            if ((isStringField || isNumericField) && itemIndex >= s.items().size()) {
-                break;
-            }
-            if (c == '!' || c == '&') {
-                final String text = str(eval(s.items().get(itemIndex).expr()), s.line());
-                out.append(c == '!' ? (text.isEmpty() ? " " : text.charAt(0)) : text);
-                itemIndex++;
-                pos++;
-                continue;
-            }
-            if (c == '\\') {
-                final int close = format.indexOf('\\', pos + 1);
-                final int width = close < 0 ? 2 : close - pos + 1;
-                final String text = str(eval(s.items().get(itemIndex).expr()), s.line());
-                out.append(text.length() >= width ? text.substring(0, width)
-                        : text + " ".repeat(width - text.length()));
-                itemIndex++;
-                pos = close < 0 ? format.length() : close + 1;
-                continue;
-            }
-            if ("#.*$+-,".indexOf(c) >= 0) {
-                final int start = pos;
-                while (pos < format.length() && "#.,$*^".indexOf(format.charAt(pos)) >= 0) {
-                    pos++;
-                }
-                if (pos < format.length() && "+-".indexOf(format.charAt(pos)) >= 0) {
-                    pos++;
-                }
-                final Object value = eval(s.items().get(itemIndex).expr());
-                out.append(formatNumericField(format.substring(start, pos),
-                        num(value, s.line())));
-                itemIndex++;
-                continue;
-            }
-            out.append(c);
-            pos++;
-        }
-        return out.toString();
-    }
-
-    /** Formats one number into one PRINT USING numeric field. */
-    private static String formatNumericField(final String spec, final double value) {
-        final boolean starFill = spec.indexOf('*') >= 0;
-        final boolean dollar = spec.indexOf('$') >= 0;
-        final boolean dollarFloat = spec.startsWith("$$") || spec.contains("**$");
-        final boolean trailingMinus = spec.endsWith("-");
-        final int dot = spec.indexOf('.');
-        int slots = 0;
-        int decimals = 0;
-        for (int i = 0; i < spec.length(); i++) {
-            final char c = spec.charAt(i);
-            if (c == '#') {
-                if (dot >= 0 && i > dot) {
-                    decimals++;
-                } else {
-                    slots++;
-                }
-            }
-        }
-        if (dollarFloat) {
-            slots++; // the floating '$' claims one digit slot
-        }
-        final java.math.BigDecimal rounded = new java.math.BigDecimal(Math.abs(value))
-                .setScale(decimals, java.math.RoundingMode.HALF_EVEN);
-        final String plain = rounded.stripTrailingZeros().toPlainString();
-        final String intDigits = plain.contains(".")
-                ? plain.substring(0, plain.indexOf('.')) : plain;
-        String fracDigits = decimals == 0 ? ""
-                : String.format(java.util.Locale.ROOT, "%." + decimals + "f", rounded)
-                .split("\\.")[1];
-        if (intDigits.length() > slots) {
-            // Overflow: '%' plus the unfielded number.
-            return "%" + formatNumber(value);
-        }
-        // The field's total width is the format spec's character count;
-        // a negative sign lives inside the field (no extra slot).
-        final String sign = value < 0 ? "-" : "";
-        final String digits = (dot >= 0 ? intDigits + "." + fracDigits : intDigits);
-        final String core = sign + (dollar ? "$" : "") + digits;
-        final int pad = Math.max(0, spec.length() - core.length());
-        final String padded = (starFill ? "*" : " ").repeat(pad) + core;
-        return trailingMinus && value < 0
-                ? (starFill ? "*" : " ").repeat(pad) + digits + "-"
-                : padded;
-    }
-
     /**
      * Statically determines whether a PRINT expression has type DOUBLE:
      * numbers are formatted by the expression's type (16 significant digits
@@ -1369,15 +1256,23 @@ public final class Interpreter {
     /** ',' in PRINT: advance to the next 14-column print zone. */
     private void printZoneAdvance() {
         final int col = vga.cursorColumn();
-        final int columns = vga.columns();
-        final int target = ((col - 1) / 14 + 1) * 14 + 1;
-        if (target > columns) {
+        final int target = PrintFormat.nextZone(col, vga.columns());
+        if (target < 0) {
             vga.newLine();
         } else {
             vga.print(" ".repeat(target - col));
         }
     }
 
+    /** PRINT USING: the walk lives in PrintFormat; items evaluate here. */
+    private String usingFormatted(final Ast.PrintStmt s) {
+        return PrintFormat.usingFormat(str(eval(s.usingFormat()), s.line()),
+                s.items().size(),
+                i -> eval(s.items().get(i).expr()),
+                v -> str(v, s.line()),
+                v -> num(v, s.line()));
+    }
+
     private void execLine(final Ast.LineStmt s) {
         // Omitted start point: draw from the graphics cursor (QB45's
         // LINE -(x, y) form). With -STEP the endpoint is relative to the start.
@@ -1794,7 +1689,7 @@ public final class Interpreter {
                 continue;
             }
             if (field.type().equalsIgnoreCase("STRING")) {
-                String text = value instanceof String s ? s : formatNumber(num(value, line));
+                String text = value instanceof String s ? s : PrintFormat.singleNumber(num(value, line));
                 if (text.length() > field.stringLength()) {
                     text = text.substring(0, field.stringLength());
                 } else if (text.length() < field.stringLength()) {
@@ -2189,7 +2084,7 @@ public final class Interpreter {
             }
             case "STR$" -> {
                 final double v = num(singleArgValue(c), c.line());
-                final String s = formatNumber(v);
+                final String s = PrintFormat.singleNumber(v);
                 return v >= 0 ? " " + s : s;
             }
             case "LEN" -> {
@@ -2907,7 +2802,7 @@ public final class Interpreter {
                 final Object value = eval(item.expr());
                 items.add(new IoSubsystem.PrintItem(
                         value instanceof String text ? text
-                                : basicNumber(num(value, s.line()), exprIsDouble(item.expr())),
+                                : PrintFormat.number(num(value, s.line()), exprIsDouble(item.expr())),
                         item.separatorAfter()));
             }
         }
@@ -2938,107 +2833,6 @@ public final class Interpreter {
         return vga.textBackground();
     }
 
-    /** PRINT formatting: leading space if non-negative, trailing space. */
-    static String basicNumber(final double value) {
-        return basicNumber(value, false);
-    }
-
-    /** PRINT formatting for a numeric expression of known static type. */
-    static String basicNumber(final double value, final boolean isDouble) {
-        final String s = isDouble ? formatDouble16(value) : formatNumber(value);
-        return (value >= 0 ? " " + s : s) + " ";
-    }
-
-    /**
-     * Formats a DOUBLE expression in the classic style: 16 significant
-     * digits of the value's exact decimal expansion, rounded half-even at
-     * the 17th digit, trailing zeros trimmed; scientific notation (D±xx)
-     * outside the fixed range.
-     *
-     * <p>Known gap: some classic implementations of this dialect misprint
-     * certain values by 1-2 ulp in a bit-pattern-dependent way (STR$(592#)
-     * gives "591.9999999999999", STR$(500#) "499.9999999999999", but 321#
-     * and 12714# print exactly there). The exact cause resists modeling —
-     * every constant-error hypothesis fails on same-decade pairs
-     * (300 vs 321, 321 vs 592). This formatter prints the mathematically
-     * correct 16 digits instead.</p>
-     */
-    private static String formatDouble16(final double value) {
-        if (value == 0) {
-            return "0";
-        }
-        final String sign = value < 0 ? "-" : "";
-        final java.math.BigDecimal bd = new java.math.BigDecimal(Math.abs(value));
-        final java.math.BigDecimal rounded = bd.round(
-                new java.math.MathContext(16, java.math.RoundingMode.HALF_EVEN));
-        final int exponent = rounded.precision() - rounded.scale() - 1;
-        final String digits = rounded.unscaledValue().toString();
-        if (exponent > 15 || exponent < -3) {
-            // Scientific: d.dddD±ee
-            final String fraction = digits.substring(1).replaceFirst("0+$", "");
-            final String mantissa = digits.charAt(0)
-                    + (fraction.isEmpty() ? "" : "." + fraction);
-            return sign + mantissa + "D" + (exponent >= 0 ? "+" : "-")
-                    + String.format("%02d", Math.abs(exponent));
-        }
-        if (exponent >= 0) {
-            final String intPart = exponent + 1 >= digits.length()
-                    ? digits + "0".repeat(exponent + 1 - digits.length())
-                    : digits.substring(0, exponent + 1);
-            final String fraction = exponent + 1 >= digits.length()
-                    ? "" : digits.substring(exponent + 1).replaceFirst("0+$", "");
-            return sign + intPart + (fraction.isEmpty() ? "" : "." + fraction);
-        }
-        return sign + "." + "0".repeat(-exponent - 1)
-                + digits.replaceFirst("0+$", "");
-    }
-
-    private static String formatNumber(final double value) {
-        // Numbers are formatted at the expression's type; the default
-        // SINGLE type means the value is narrowed to float32 for display.
-        final double narrowed = single(value);
-        if (narrowed == Math.floor(narrowed) && !Double.isInfinite(narrowed) && Math.abs(narrowed) < 1e15) {
-            return Long.toString((long) narrowed);
-        }
-        return formatSingle7(narrowed);
-    }
-
-    /**
-     * Formats a SINGLE in the classic style: 7 significant digits of the
-     * value's exact float32 decimal expansion (rounded half-even at the 8th
-     * digit), trailing zeros trimmed, no leading zero on fractions, E±xx
-     * scientific notation outside the fixed range. PRINT VAL("12345.67")
-     * therefore shows 12345.67, not the raw 12345.669921875 float.
-     */
-    private static String formatSingle7(final double value) {
-        if (value == 0) {
-            return "0";
-        }
-        final String sign = value < 0 ? "-" : "";
-        final java.math.BigDecimal bd = new java.math.BigDecimal(Math.abs(value));
-        final java.math.BigDecimal rounded = bd.round(
-                new java.math.MathContext(7, java.math.RoundingMode.HALF_EVEN));
-        final int exponent = rounded.precision() - rounded.scale() - 1;
-        final String digits = rounded.unscaledValue().toString();
-        if (exponent > 6 || exponent < -3) {
-            final String fraction = digits.substring(1).replaceFirst("0+$", "");
-            final String mantissa = digits.charAt(0)
-                    + (fraction.isEmpty() ? "" : "." + fraction);
-            return sign + mantissa + "E" + (exponent >= 0 ? "+" : "-")
-                    + String.format("%02d", Math.abs(exponent));
-        }
-        if (exponent >= 0) {
-            final String intPart = exponent + 1 >= digits.length()
-                    ? digits + "0".repeat(exponent + 1 - digits.length())
-                    : digits.substring(0, exponent + 1);
-            final String fraction = exponent + 1 >= digits.length()
-                    ? "" : digits.substring(exponent + 1).replaceFirst("0+$", "");
-            return sign + intPart + (fraction.isEmpty() ? "" : "." + fraction);
-        }
-        return sign + "." + "0".repeat(-exponent - 1)
-                + digits.replaceFirst("0+$", "");
-    }
-
     private double throwDivisionByZero(final int line) {
         throw error("Division by zero", line);
     }
diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/PrintFormat.java b/src/main/java/eu/svjatoslav/crtbasic/interp/PrintFormat.java
new file mode 100644 (file)
index 0000000..4a69532
--- /dev/null
@@ -0,0 +1,253 @@
+package eu.svjatoslav.crtbasic.interp;
+
+import java.util.function.Function;
+import java.util.function.IntFunction;
+import java.util.function.ToDoubleFunction;
+
+/**
+ * Classic PRINT formatting: the number style (leading space for
+ * non-negative, trailing space; ~7 significant digits for SINGLE, 16
+ * for DOUBLE, D±xx/E±xx scientific notation), PRINT USING field
+ * formatting, and the 14-column print-zone arithmetic shared by the
+ * console and file print paths.
+ */
+public final class PrintFormat {
+
+    private PrintFormat() {
+        // Utility class.
+    }
+
+    /** PRINT formatting: leading space if non-negative, trailing space. */
+    public static String number(final double value) {
+        return number(value, false);
+    }
+
+    /** PRINT formatting for a numeric expression of known static type. */
+    public static String number(final double value, final boolean isDouble) {
+        final String s = isDouble ? formatDouble16(value) : formatNumber(value);
+        return (value >= 0 ? " " + s : s) + " ";
+    }
+
+    /**
+     * Formats a DOUBLE expression in the classic style: 16 significant
+     * digits of the value's exact decimal expansion, rounded half-even at
+     * the 17th digit, trailing zeros trimmed; scientific notation (D±xx)
+     * outside the fixed range.
+     *
+     * <p>Known gap: some classic implementations of this dialect misprint
+     * certain values by 1-2 ulp in a bit-pattern-dependent way (STR$(592#)
+     * gives "591.9999999999999", STR$(500#) "499.9999999999999", but 321#
+     * and 12714# print exactly there). The exact cause resists modeling —
+     * every constant-error hypothesis fails on same-decade pairs
+     * (300 vs 321, 321 vs 592). This formatter prints the mathematically
+     * correct 16 digits instead.
+     */
+    private static String formatDouble16(final double value) {
+        if (value == 0) {
+            return "0";
+        }
+        final String sign = value < 0 ? "-" : "";
+        final java.math.BigDecimal bd = new java.math.BigDecimal(Math.abs(value));
+        final java.math.BigDecimal rounded = bd.round(
+                new java.math.MathContext(16, java.math.RoundingMode.HALF_EVEN));
+        final int exponent = rounded.precision() - rounded.scale() - 1;
+        final String digits = rounded.unscaledValue().toString();
+        if (exponent > 15 || exponent < -3) {
+            // Scientific: d.dddD±ee
+            final String fraction = digits.substring(1).replaceFirst("0+$", "");
+            final String mantissa = digits.charAt(0)
+                    + (fraction.isEmpty() ? "" : "." + fraction);
+            return sign + mantissa + "D" + (exponent >= 0 ? "+" : "-")
+                    + String.format("%02d", Math.abs(exponent));
+        }
+        if (exponent >= 0) {
+            final String intPart = exponent + 1 >= digits.length()
+                    ? digits + "0".repeat(exponent + 1 - digits.length())
+                    : digits.substring(0, exponent + 1);
+            final String fraction = exponent + 1 >= digits.length()
+                    ? "" : digits.substring(exponent + 1).replaceFirst("0+$", "");
+            return sign + intPart + (fraction.isEmpty() ? "" : "." + fraction);
+        }
+        return sign + "." + "0".repeat(-exponent - 1)
+                + digits.replaceFirst("0+$", "");
+    }
+
+    private static String formatNumber(final double value) {
+        // Numbers are formatted at the expression's type; the default
+        // SINGLE type means the value is narrowed to float32 for display.
+        final double narrowed = (double) (float) value;
+        if (narrowed == Math.floor(narrowed) && !Double.isInfinite(narrowed)
+                && Math.abs(narrowed) < 1e15) {
+            return Long.toString((long) narrowed);
+        }
+        return formatSingle7(narrowed);
+    }
+
+    /**
+     * STR$ field content: the SINGLE-style format with no padding
+     * (leading/trailing spaces are the caller's business).
+     */
+    public static String singleNumber(final double value) {
+        return formatNumber(value);
+    }
+
+    /**
+     * Formats a SINGLE in the classic style: 7 significant digits of the
+     * value's exact float32 decimal expansion (rounded half-even at the 8th
+     * digit), trailing zeros trimmed, no leading zero on fractions, E±xx
+     * scientific notation outside the fixed range. PRINT VAL("12345.67")
+     * therefore shows 12345.67, not the raw 12345.669921875 float.
+     */
+    private static String formatSingle7(final double value) {
+        if (value == 0) {
+            return "0";
+        }
+        final String sign = value < 0 ? "-" : "";
+        final java.math.BigDecimal bd = new java.math.BigDecimal(Math.abs(value));
+        final java.math.BigDecimal rounded = bd.round(
+                new java.math.MathContext(7, java.math.RoundingMode.HALF_EVEN));
+        final int exponent = rounded.precision() - rounded.scale() - 1;
+        final String digits = rounded.unscaledValue().toString();
+        if (exponent > 6 || exponent < -3) {
+            final String fraction = digits.substring(1).replaceFirst("0+$", "");
+            final String mantissa = digits.charAt(0)
+                    + (fraction.isEmpty() ? "" : "." + fraction);
+            return sign + mantissa + "E" + (exponent >= 0 ? "+" : "-")
+                    + String.format("%02d", Math.abs(exponent));
+        }
+        if (exponent >= 0) {
+            final String intPart = exponent + 1 >= digits.length()
+                    ? digits + "0".repeat(exponent + 1 - digits.length())
+                    : digits.substring(0, exponent + 1);
+            final String fraction = exponent + 1 >= digits.length()
+                    ? "" : digits.substring(exponent + 1).replaceFirst("0+$", "");
+            return sign + intPart + (fraction.isEmpty() ? "" : "." + fraction);
+        }
+        return sign + "." + "0".repeat(-exponent - 1)
+                + digits.replaceFirst("0+$", "");
+    }
+
+    /**
+     * PRINT USING: walks the format left to right; literal characters print
+     * as-is ('_' escapes the next char), each format field consumes one
+     * item in order. String fields: '!' first char, '&amp;' whole string,
+     * '\...\' fixed width. Numeric fields: runs of '#', '.', '$', '*',
+     * '+', '-', ',' — right-justified, decimals rounded half-even, '$$'
+     * floats the dollar sign, '*' fills with asterisks, overflow prints
+     * '%' plus the number. When items outlast the format, QB restarts the
+     * format after a newline; the current examples never do.
+     */
+    public static String usingFormat(final String format, final int itemCount,
+                                     final IntFunction<Object> itemValue,
+                                     final Function<Object, String> asString,
+                                     final ToDoubleFunction<Object> asNumber) {
+        final StringBuilder out = new StringBuilder();
+        int itemIndex = 0;
+        int pos = 0;
+        // Literals print as walked; a FIELD consumes the next item. The
+        // walk stops at the first field with no item left — trailing
+        // literals after the last consumed item still print.
+        while (pos < format.length()) {
+            final char c = format.charAt(pos);
+            if (c == '_' && pos + 1 < format.length()) {
+                out.append(format.charAt(pos + 1));
+                pos += 2;
+                continue;
+            }
+            final boolean isStringField = c == '!' || c == '&' || c == '\\';
+            final boolean isNumericField = "#.*$+-,".indexOf(c) >= 0;
+            if ((isStringField || isNumericField) && itemIndex >= itemCount) {
+                break;
+            }
+            if (c == '!' || c == '&') {
+                final String text = asString.apply(itemValue.apply(itemIndex));
+                out.append(c == '!' ? (text.isEmpty() ? " " : text.charAt(0)) : text);
+                itemIndex++;
+                pos++;
+                continue;
+            }
+            if (c == '\\') {
+                final int close = format.indexOf('\\', pos + 1);
+                final int width = close < 0 ? 2 : close - pos + 1;
+                final String text = asString.apply(itemValue.apply(itemIndex));
+                out.append(text.length() >= width ? text.substring(0, width)
+                        : text + " ".repeat(width - text.length()));
+                itemIndex++;
+                pos = close < 0 ? format.length() : close + 1;
+                continue;
+            }
+            if ("#.*$+-,".indexOf(c) >= 0) {
+                final int start = pos;
+                while (pos < format.length() && "#.,$*^".indexOf(format.charAt(pos)) >= 0) {
+                    pos++;
+                }
+                if (pos < format.length() && "+-".indexOf(format.charAt(pos)) >= 0) {
+                    pos++;
+                }
+                out.append(numericField(format.substring(start, pos),
+                        asNumber.applyAsDouble(itemValue.apply(itemIndex))));
+                itemIndex++;
+                continue;
+            }
+            out.append(c);
+            pos++;
+        }
+        return out.toString();
+    }
+
+    /** Formats one number into one PRINT USING numeric field. */
+    static String numericField(final String spec, final double value) {
+        final boolean starFill = spec.indexOf('*') >= 0;
+        final boolean dollar = spec.indexOf('$') >= 0;
+        final boolean dollarFloat = spec.startsWith("$$") || spec.contains("**$");
+        final boolean trailingMinus = spec.endsWith("-");
+        final int dot = spec.indexOf('.');
+        int slots = 0;
+        int decimals = 0;
+        for (int i = 0; i < spec.length(); i++) {
+            final char c = spec.charAt(i);
+            if (c == '#') {
+                if (dot >= 0 && i > dot) {
+                    decimals++;
+                } else {
+                    slots++;
+                }
+            }
+        }
+        if (dollarFloat) {
+            slots++; // the floating '$' claims one digit slot
+        }
+        final java.math.BigDecimal rounded = new java.math.BigDecimal(Math.abs(value))
+                .setScale(decimals, java.math.RoundingMode.HALF_EVEN);
+        final String plain = rounded.stripTrailingZeros().toPlainString();
+        final String intDigits = plain.contains(".")
+                ? plain.substring(0, plain.indexOf('.')) : plain;
+        String fracDigits = decimals == 0 ? ""
+                : String.format(java.util.Locale.ROOT, "%." + decimals + "f", rounded)
+                .split("\\.")[1];
+        if (intDigits.length() > slots) {
+            // Overflow: '%' plus the unfielded number.
+            return "%" + formatNumber(value);
+        }
+        // The field's total width is the format spec's character count;
+        // a negative sign lives inside the field (no extra slot).
+        final String sign = value < 0 ? "-" : "";
+        final String digits = (dot >= 0 ? intDigits + "." + fracDigits : intDigits);
+        final String core = sign + (dollar ? "$" : "") + digits;
+        final int pad = Math.max(0, spec.length() - core.length());
+        final String padded = (starFill ? "*" : " ").repeat(pad) + core;
+        return trailingMinus && value < 0
+                ? (starFill ? "*" : " ").repeat(pad) + digits + "-"
+                : padded;
+    }
+
+    /**
+     * ',' print-zone arithmetic: the next 14-column zone after
+     * {@code column}, or -1 when it would pass {@code width} (the console
+     * wraps at its column count, file output at QBasic's default 255).
+     */
+    public static int nextZone(final int column, final int width) {
+        final int target = ((column - 1) / 14 + 1) * 14 + 1;
+        return target > width ? -1 : target;
+    }
+}
index 32f87cb..b5308f2 100644 (file)
@@ -1,6 +1,7 @@
 package eu.svjatoslav.crtbasic.io;
 
 import eu.svjatoslav.crtbasic.Cp437;
+import eu.svjatoslav.crtbasic.interp.PrintFormat;
 
 import java.util.HashMap;
 import java.util.List;
@@ -359,8 +360,8 @@ public final class IoSubsystem {
                         channel.writer.write(item.text());
                         channel.column += item.text().length();
                         if (item.separatorAfter() == ',') {
-                            final int target = ((channel.column - 1) / 14 + 1) * 14 + 1;
-                            if (target > 255) {
+                            final int target = PrintFormat.nextZone(channel.column, 255);
+                            if (target < 0) {
                                 channel.writer.write("\r\n");
                                 channel.column = 1;
                             } else {