From b3a4090105b0a1e3e7b999b1f8c8266c3f5af9f3 Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Wed, 10 Dec 2025 19:52:09 +0100 Subject: [PATCH 01/12] [bugfix] Fix double negative character issue in negative exponents when using fn:format-number Closes https://github.com/eXist-db/exist/issues/5943 --- exist-core/pom.xml | 2 + .../xquery/functions/fn/FnFormatNumbers.java | 3 -- .../test/xquery/numbers/format-numbers.xql | 37 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/exist-core/pom.xml b/exist-core/pom.xml index 377bea02d7..25f68b79fc 100644 --- a/exist-core/pom.xml +++ b/exist-core/pom.xml @@ -791,6 +791,7 @@ src/test/resources/standalone-webapp/WEB-INF/web.xml src/test/xquery/tail-recursion.xml src/test/xquery/maps/maps.xqm + src/test/xquery/numbers/format-numbers.xql src/test/xquery/util/util.xml src/test/xquery/xquery3/parse-xml.xqm src/test/xquery/xquery3/serialize.xql @@ -1374,6 +1375,7 @@ src/test/xquery/tail-recursion.xml src/test/xquery/type-promotion.xqm src/test/xquery/maps/maps.xqm + src/test/xquery/numbers/format-numbers.xql src/test/xquery/securitymanager/acl.xqm src/test/xquery/util/util.xml src/test/xquery/xqsuite/xqsuite-assertions-dynamic.xqm diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java index 6f3a08bcb0..79d9cf0d17 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java @@ -713,9 +713,6 @@ private String format(final NumericValue number, final DecimalFormat decimalForm final int minimumExponentSize = subPicture.getMinimumExponentSize(); if (minimumExponentSize > 0) { formatted.append(decimalFormat.exponentSeparator); - if (exp < 0) { - formatted.append(decimalFormat.minusSign); - } final CodePointString expStr = new CodePointString(String.valueOf(exp)); diff --git a/exist-core/src/test/xquery/numbers/format-numbers.xql b/exist-core/src/test/xquery/numbers/format-numbers.xql index 68a83792d3..59b0a08d3d 100644 --- a/exist-core/src/test/xquery/numbers/format-numbers.xql +++ b/exist-core/src/test/xquery/numbers/format-numbers.xql @@ -1,4 +1,28 @@ (: + : Elemental + : Copyright (C) 2024, Evolved Binary Ltd + : + : admin@evolvedbinary.com + : https://www.evolvedbinary.com | https://www.elemental.xyz + : + : This library is free software; you can redistribute it and/or + : modify it under the terms of the GNU Lesser General Public + : License as published by the Free Software Foundation; version 2.1. + : + : This library is distributed in the hope that it will be useful, + : but WITHOUT ANY WARRANTY; without even the implied warranty of + : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + : Lesser General Public License for more details. + : + : You should have received a copy of the GNU Lesser General Public + : License along with this library; if not, write to the Free Software + : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + : + : NOTE: Parts of this file contain code from 'The eXist-db Authors'. + : The original license header is included below. + : + : ===================================================================== + : : eXist-db Open Source Native XML Database : Copyright (C) 2001 The eXist-db Authors : @@ -303,4 +327,17 @@ declare %test:assertEquals("0.0") function fd:decimal-zeros($picture as xs:string) { format-number(0, $picture) +}; + +declare + %test:args("1.234567E10") + %test:assertEquals("1.235e10") + %test:args("1.234567E-10") + %test:assertEquals("1.235e-10") + %test:args("0.00000000123456") + %test:assertEquals("1.235e-9") + %test:args("1.234567e-10") + %test:assertEquals("1.235e-10") +function fd:negative-exponent($value as xs:numeric) { + format-number($value, "0.000e0") }; \ No newline at end of file From 791497acd9bb173382b43081dee4965750270989 Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Wed, 10 Dec 2025 20:01:28 +0100 Subject: [PATCH 02/12] [feature] Implement 'declare default decimal-format' and 'declare decimal-format' prolog options in XQuery --- exist-core/pom.xml | 4 + .../antlr/org/exist/xquery/parser/XQuery.g | 66 ++++++++++++++- .../org/exist/xquery/parser/XQueryTree.g | 50 ++++++++++++ .../java/org/exist/xquery/DecimalFormat.java | 81 +++++++++++++++++++ .../java/org/exist/xquery/XQueryContext.java | 2 +- 5 files changed, 200 insertions(+), 3 deletions(-) diff --git a/exist-core/pom.xml b/exist-core/pom.xml index 25f68b79fc..f38e489555 100644 --- a/exist-core/pom.xml +++ b/exist-core/pom.xml @@ -1103,6 +1103,7 @@ src/test/java/org/exist/xquery/CleanupTest.java src/test/java/org/exist/xquery/ConstructedNodesRecoveryTest.java src/main/java/org/exist/xquery/Context.java + src/main/java/org/exist/xquery/DecimalFormat.java src/main/java/org/exist/xquery/DeferredFunctionCall.java src/main/java/org/exist/xquery/DynamicCardinalityCheck.java src/main/java/org/exist/xquery/DynamicTypeCheck.java @@ -1285,6 +1286,7 @@ src/main/java/org/exist/xquery/functions/xmldb/XMLDBStore.java src/main/java/org/exist/xquery/functions/xmldb/XMLDBXUpdate.java src/test/java/org/exist/xquery/functions/xquery3/TryCatchTest.java + src/main/antlr/org/exist/xquery/parser/XQuery.g src/main/antlr/org/exist/xquery/parser/XQueryTree.g src/test/java/org/exist/xquery/update/AbstractUpdateTest.java src/test/java/org/exist/xquery/update/IndexIntegrationTest.java @@ -1783,6 +1785,7 @@ src/test/java/org/exist/xquery/CleanupTest.java src/test/java/org/exist/xquery/ConstructedNodesRecoveryTest.java src/main/java/org/exist/xquery/Context.java + src/main/java/org/exist/xquery/DecimalFormat.java src/main/java/org/exist/xquery/DeferredFunctionCall.java src/main/java/org/exist/xquery/DynamicCardinalityCheck.java src/main/java/org/exist/xquery/DynamicTypeCheck.java @@ -1991,6 +1994,7 @@ src/main/java/org/exist/xquery/functions/xmldb/XMLDBXUpdate.java src/test/java/org/exist/xquery/functions/xquery3/SerializeTest.java src/test/java/org/exist/xquery/functions/xquery3/TryCatchTest.java + src/main/antlr/org/exist/xquery/parser/XQuery.g src/main/antlr/org/exist/xquery/parser/XQueryTree.g src/main/java/org/exist/xquery/pragmas/TimePragma.java src/test/java/org/exist/xquery/update/AbstractUpdateTest.java diff --git a/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g b/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g index bc59b416c8..d9c7a228fb 100644 --- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g +++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g @@ -1,4 +1,28 @@ /* + * Elemental + * Copyright (C) 2024, Evolved Binary Ltd + * + * admin@evolvedbinary.com + * https://www.evolvedbinary.com | https://www.elemental.xyz + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * NOTE: Parts of this file contain code from 'The eXist-db Authors'. + * The original license header is included below. + * + * ===================================================================== + * * eXist-db Open Source Native XML Database * Copyright (C) 2001 The eXist-db Authors * @@ -150,6 +174,8 @@ imaginaryTokenDefinitions NAMESPACE_DECL DEF_NAMESPACE_DECL DEF_COLLATION_DECL + DECIMAL_FORMAT_DECL + DEFAULT_DECIMAL_FORMAT DEF_FUNCTION_NS_DECL CONTEXT_ITEM_DECL ANNOT_DECL @@ -256,7 +282,7 @@ prolog throws XPathException ( importDecl | - ( "declare" ( "default" | "boundary-space" | "ordering" | "construction" | "base-uri" | "copy-namespaces" | "namespace" ) ) => + ( "declare" ( "default" | "boundary-space" | "ordering" | "construction" | "base-uri" | "copy-namespaces" | "namespace" | "decimal-format" ) ) => s:setter { if(!inSetters) @@ -295,10 +321,44 @@ versionDecl throws XPathException { #versionDecl = #(#[VERSION_DECL, v.getText()], enc); } ; +dfPropertyName +: + "decimal-separator" + | "grouping-separator" + | "infinity" + | "minus-sign" + | "NaN" + | "percent" + | "per-mille" + | "zero-digit" + | "digit" + | "pattern-separator" + | "exponent-separator" + ; + +decimalFormatDecl +{ String dfName = null; } +: + "declare"! + ( + "default" "decimal-format" + ( dfPropertyName EQ! STRING_LITERAL )* + { + ## = #( #[DECIMAL_FORMAT_DECL, "DECIMAL_FORMAT_DECL"], #[DEFAULT_DECIMAL_FORMAT, "DEFAULT_DECIMAL_FORMAT"], ## ); + } + | + "decimal-format" eqName + ( dfPropertyName EQ! STRING_LITERAL )* + { + ## = #( #[DECIMAL_FORMAT_DECL, "DECIMAL_FORMAT_DECL"], ## ); + } + ) +; + setter : ( - ( "declare" "default" ) => + ( "declare" "default" ( "collation" | "element" | "function" | "order" ) ) => "declare"! "default"! ( "collation"! defc:STRING_LITERAL @@ -330,6 +390,8 @@ setter | ( "declare" "namespace" ) => namespaceDecl + | ( "declare" ( "default" )? "decimal-format" ) => + decimalFormatDecl ) ; diff --git a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g index b7c04757f4..976dccde9c 100644 --- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g +++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g @@ -511,6 +511,56 @@ throws PermissionDeniedException, EXistException, XPathException } ) | + #( + DECIMAL_FORMAT_DECL + { + final XQueryAST root = (XQueryAST) _t; // points to DECIMAL_FORMAT_DECL + // first sibling is either DEFAULT_DECIMAL_FORMAT (default) or EQNAME (named) + final XQueryAST dfName = (XQueryAST) root.getNextSibling(); + + // position current at the first property name for the decimal format + XQueryAST current = (XQueryAST) dfName.getNextSibling(); + if ("default".equals(dfName.getText())) { + current = (XQueryAST) current.getNextSibling(); + } + + final Map dfProperties = new HashMap<>(); + + while (current != null) { + final XQueryAST pname = current; + final XQueryAST pval = (XQueryAST) current.getNextSibling(); + + if (pval == null) { + break; + } + + final String pn = pname.getText(); + String pv = pval.getText(); + if (pv.length() >= 2 && (pv.startsWith("\"") || pv.startsWith("'"))) { + pv = pv.substring(1, pv.length() - 1); + } + dfProperties.put(pn, pv); + + current = (XQueryAST) pval.getNextSibling(); + } + + final QName qnDfName; + if ("default".equals(dfName.getText())) { + qnDfName = XQueryContext.UNNAMED_DECIMAL_FORMAT; + } else { + try { + qnDfName = QName.parse(staticContext, dfName.getText(), null); + } catch (final IllegalQNameException iqe) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + dfName.getText()); + } + } + + final DecimalFormat df = DecimalFormat.fromProperties(dfProperties); + staticContext.setStaticDecimalFormat(qnDfName, df); + context.setStaticDecimalFormat(qnDfName, df); + } + ) + | #( qname:GLOBAL_VAR { diff --git a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java index 46a54962ad..d0418e37f2 100644 --- a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java +++ b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java @@ -1,4 +1,28 @@ /* + * Elemental + * Copyright (C) 2024, Evolved Binary Ltd + * + * admin@evolvedbinary.com + * https://www.evolvedbinary.com | https://www.elemental.xyz + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * NOTE: Parts of this file contain code from 'The eXist-db Authors'. + * The original license header is included below. + * + * ===================================================================== + * * eXist-db Open Source Native XML Database * Copyright (C) 2001 The eXist-db Authors * @@ -21,6 +45,8 @@ */ package org.exist.xquery; +import java.util.Map; + /** * Data class for a Decimal Format. * @@ -79,4 +105,59 @@ public DecimalFormat(final int decimalSeparator, final int exponentSeparator, fi this.NaN = NaN; this.minusSign = minusSign; } + + public static DecimalFormat fromProperties(final Map properties) { + int decimalSeparator = UNNAMED.decimalSeparator; + int exponentSeparator = UNNAMED.exponentSeparator; + int groupingSeparator = UNNAMED.groupingSeparator; + int percent = UNNAMED.percent; + int perMille = UNNAMED.perMille; + int zeroDigit = UNNAMED.zeroDigit; + int digit = UNNAMED.digit; + int patternSeparator = UNNAMED.patternSeparator; + String infinity = UNNAMED.infinity; + String NaN = UNNAMED.NaN; + int minusSign = UNNAMED.minusSign; + + for (final Map.Entry property : properties.entrySet()) { + final String value = property.getValue(); + switch (property.getKey()) { + case "decimal-separator": + decimalSeparator = value.charAt(0); + break; + case "exponent-separator": + exponentSeparator = value.charAt(0); + break; + case "grouping-separator": + groupingSeparator = value.charAt(0); + break; + case "percent": + percent = value.charAt(0); + break; + case "per-mille": + perMille = value.charAt(0); + break; + case "zero-digit": + zeroDigit = value.charAt(0); + break; + case "digit": + digit = value.charAt(0); + break; + case "pattern-separator": + patternSeparator = value.charAt(0); + break; + case "infinity": + infinity = value; + break; + case "NaN": + NaN = value; + break; + case "minus-sign": + minusSign = value.charAt(0); + break; + } + } + + return new DecimalFormat(decimalSeparator, exponentSeparator, groupingSeparator, percent, perMille, zeroDigit, digit, patternSeparator, infinity, NaN, minusSign); + } } diff --git a/exist-core/src/main/java/org/exist/xquery/XQueryContext.java b/exist-core/src/main/java/org/exist/xquery/XQueryContext.java index be3f62d21f..1eb9ff65b6 100644 --- a/exist-core/src/main/java/org/exist/xquery/XQueryContext.java +++ b/exist-core/src/main/java/org/exist/xquery/XQueryContext.java @@ -443,7 +443,7 @@ public class XQueryContext implements BinaryValueManager, Context { */ @Nullable private HttpContext httpContext = null; - private static final QName UNNAMED_DECIMAL_FORMAT = new QName("__UNNAMED__", Namespaces.XPATH_FUNCTIONS_NS); + public static final QName UNNAMED_DECIMAL_FORMAT = new QName("__UNNAMED__", Namespaces.XPATH_FUNCTIONS_NS); private final Map staticDecimalFormats = hashMap(Tuple(UNNAMED_DECIMAL_FORMAT, DecimalFormat.UNNAMED)); From dad31ab054609e450cbffae47d401bdb6134b135 Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Wed, 10 Dec 2025 21:13:10 +0100 Subject: [PATCH 03/12] [bugfix] Picture string provided to fn:format-number cannot contain an exponent and also a percent or per-mille character --- .../org/exist/xquery/functions/fn/FnFormatNumbers.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java index 79d9cf0d17..8d9a2d9d71 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java @@ -484,6 +484,14 @@ private Tuple2> analyzePictureString(final Deci } else { /* passive character */ analyzePassiveChar(decimalFormat, c, capturePrefix, subPicture); + + if (subPicture.hasPercent()) { + throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain a percent character as it already has an exponent separator sign."); + } + + if (subPicture.hasPerMille()) { + throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain a per-mille character as it already has an exponent separator sign."); + } } break; // end of EXPONENT_PART From 5d71b2f6bff5cbeb4c74344d9d6e2ff05102155d Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Wed, 10 Dec 2025 21:13:51 +0100 Subject: [PATCH 04/12] [bugfix] Significant characters within a picture string for a Decimal Format must be distinct --- .../org/exist/xquery/parser/XQueryTree.g | 4 +++ .../java/org/exist/xquery/DecimalFormat.java | 30 +++++++++++++++++++ .../java/org/exist/xquery/ErrorCodes.java | 1 + 3 files changed, 35 insertions(+) diff --git a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g index 976dccde9c..95ae125742 100644 --- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g +++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g @@ -556,6 +556,10 @@ throws PermissionDeniedException, EXistException, XPathException } final DecimalFormat df = DecimalFormat.fromProperties(dfProperties); + if (!df.checkDistinctCharacters()) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0098.getErrorCode(), "Characters within the picture string of the decimal format: " + dfName.getText() + " are not distinct."); + } + staticContext.setStaticDecimalFormat(qnDfName, df); context.setStaticDecimalFormat(qnDfName, df); } diff --git a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java index d0418e37f2..a736b3603d 100644 --- a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java +++ b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java @@ -106,6 +106,36 @@ public DecimalFormat(final int decimalSeparator, final int exponentSeparator, fi this.minusSign = minusSign; } + /** + * Checks that the characters used in a picture string have distinct values. + * + * @return true if all the characters are distinct, false otherwise. + */ + public boolean checkDistinctCharacters() { + final int[] characters = new int[] { + decimalSeparator, + exponentSeparator, + groupingSeparator, + percent, + perMille, + zeroDigit, + digit, + patternSeparator + }; + + for (int i = 0; i < characters.length; i++) { + final int c = characters[i]; + for (int j = i + 1; j < characters.length; j++) { + final int o = characters[j]; + if (c == o) { + return false; + } + } + } + + return true; + } + public static DecimalFormat fromProperties(final Map properties) { int decimalSeparator = UNNAMED.decimalSeparator; int exponentSeparator = UNNAMED.exponentSeparator; diff --git a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java index bacca169ef..604eeaaaec 100644 --- a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java +++ b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java @@ -220,6 +220,7 @@ public enum W3CErrorCode implements IErrorCode { XQDY0092 ("An implementation MAY raise a dynamic error if a constructed attribute named xml:space has a value other than preserve or default."), XQST0093 ("It is a static error to import a module M1 if there exists a sequence of modules M1 ... Mi ... M1 such that each module directly depends on the next module in the sequence (informally, if M1 depends on itself through some chain of module dependencies.)"), XQST0094 ("The name of each grouping variable must be equal (by the eq operator on expanded QNames) to the name of a variable in the input tuple stream."), + XQST0098 ("It is a static error if, for any named or unnamed decimal format, the properties representing characters used in a picture string do not each have distinct values. The following properties represent characters used in a picture string: decimal-separator, exponent-separator, grouping-separator, percent, per-mille, the family of ten decimal digits starting with zero-digit, digit, and pattern-separator."), XQDY0101 ("An error is raised if a computed namespace constructor attempts to do any of the following: Bind the prefix xml to some namespace URI other than http://www.w3.org/XML/1998/namespace. Bind a prefix other than xml to the namespace URI http://www.w3.org/XML/1998/namespace. Bind the prefix xmlns to any namespace URI. Bind a prefix to the namespace URI http://www.w3.org/2000/xmlns/. Bind any prefix (including the empty prefix) to a zero-length namespace URI."), XQDY0102 ("If the name of an element in an element constructor is in no namespace, creating a default namespace for that element using a computed namespace constructor is an error."), XQST0103 ("All variables in a window clause must have distinct names."), From 32d68ddbc67fc5935d1ff4a134675e3ed8ec819c Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Wed, 10 Dec 2025 21:19:54 +0100 Subject: [PATCH 05/12] [bugfix] Query prolog must not allow two decimal format declarations with the same name --- .../org/exist/xquery/parser/XQueryTree.g | 32 ++++++++++++------- .../java/org/exist/xquery/ErrorCodes.java | 1 + 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g index 95ae125742..0bfcf303ff 100644 --- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g +++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g @@ -106,6 +106,7 @@ options { protected Set importedModules = new HashSet<>(); protected Set importedModuleFunctions = null; protected Set importedModuleVariables = null; + private boolean hasDefaultDecimalFormat = false; public XQueryTreeParser(XQueryContext context) { this(context, null); @@ -518,6 +519,26 @@ throws PermissionDeniedException, EXistException, XPathException // first sibling is either DEFAULT_DECIMAL_FORMAT (default) or EQNAME (named) final XQueryAST dfName = (XQueryAST) root.getNextSibling(); + final QName qnDfName; + if ("default".equals(dfName.getText())) { + qnDfName = XQueryContext.UNNAMED_DECIMAL_FORMAT; + if (hasDefaultDecimalFormat) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0111.getErrorCode(), "Query prolog cannot contain two default decimal format declarations."); + } else { + hasDefaultDecimalFormat = true; + } + } else { + try { + qnDfName = QName.parse(staticContext, dfName.getText(), null); + } catch (final IllegalQNameException iqe) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + dfName.getText()); + } + + if (staticContext.getStaticDecimalFormat(qnDfName) != null) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0111.getErrorCode(), "Query prolog cannot contain two decimal format declarations with the same name: " + dfName.getText()); + } + } + // position current at the first property name for the decimal format XQueryAST current = (XQueryAST) dfName.getNextSibling(); if ("default".equals(dfName.getText())) { @@ -544,17 +565,6 @@ throws PermissionDeniedException, EXistException, XPathException current = (XQueryAST) pval.getNextSibling(); } - final QName qnDfName; - if ("default".equals(dfName.getText())) { - qnDfName = XQueryContext.UNNAMED_DECIMAL_FORMAT; - } else { - try { - qnDfName = QName.parse(staticContext, dfName.getText(), null); - } catch (final IllegalQNameException iqe) { - throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + dfName.getText()); - } - } - final DecimalFormat df = DecimalFormat.fromProperties(dfProperties); if (!df.checkDistinctCharacters()) { throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0098.getErrorCode(), "Characters within the picture string of the decimal format: " + dfName.getText() + " are not distinct."); diff --git a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java index 604eeaaaec..30fa76f13d 100644 --- a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java +++ b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java @@ -224,6 +224,7 @@ public enum W3CErrorCode implements IErrorCode { XQDY0101 ("An error is raised if a computed namespace constructor attempts to do any of the following: Bind the prefix xml to some namespace URI other than http://www.w3.org/XML/1998/namespace. Bind a prefix other than xml to the namespace URI http://www.w3.org/XML/1998/namespace. Bind the prefix xmlns to any namespace URI. Bind a prefix to the namespace URI http://www.w3.org/2000/xmlns/. Bind any prefix (including the empty prefix) to a zero-length namespace URI."), XQDY0102 ("If the name of an element in an element constructor is in no namespace, creating a default namespace for that element using a computed namespace constructor is an error."), XQST0103 ("All variables in a window clause must have distinct names."), + XQST0111 ("It is a static error for a query prolog to contain two decimal formats with the same name, or to contain two default decimal formats."), XQDY0137 ("No two keys in a map may have the same key value"), XQDY0138 ("Position n does not exist in this array"), XUDY0023 ("It is a dynamic error if an insert, replace, or rename expression affects an element node by introducing a new namespace binding that conflicts with one of its existing namespace bindings."), From 4607c068d38c7b7bbecbb618fcf3ab48579d1ec1 Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Wed, 10 Dec 2025 21:23:07 +0100 Subject: [PATCH 06/12] [bugfix] A decimal format must only define the same property once --- .../src/main/antlr/org/exist/xquery/parser/XQueryTree.g | 4 +++- exist-core/src/main/java/org/exist/xquery/ErrorCodes.java | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g index 0bfcf303ff..62294eabff 100644 --- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g +++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g @@ -560,7 +560,9 @@ throws PermissionDeniedException, EXistException, XPathException if (pv.length() >= 2 && (pv.startsWith("\"") || pv.startsWith("'"))) { pv = pv.substring(1, pv.length() - 1); } - dfProperties.put(pn, pv); + if (dfProperties.put(pn, pv) != null) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0114.getErrorCode(), "Decimal format: " + dfName.getText() + " defines the property: " + pn + " more than once."); + } current = (XQueryAST) pval.getNextSibling(); } diff --git a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java index 30fa76f13d..7d2c9622f3 100644 --- a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java +++ b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java @@ -225,6 +225,7 @@ public enum W3CErrorCode implements IErrorCode { XQDY0102 ("If the name of an element in an element constructor is in no namespace, creating a default namespace for that element using a computed namespace constructor is an error."), XQST0103 ("All variables in a window clause must have distinct names."), XQST0111 ("It is a static error for a query prolog to contain two decimal formats with the same name, or to contain two default decimal formats."), + XQST0114 ("It is a static error for a decimal format declaration to define the same property more than once."), XQDY0137 ("No two keys in a map may have the same key value"), XQDY0138 ("Position n does not exist in this array"), XUDY0023 ("It is a dynamic error if an insert, replace, or rename expression affects an element node by introducing a new namespace binding that conflicts with one of its existing namespace bindings."), From b8c3bb8d3ffae31aae3007ae48e291754b974c71 Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Wed, 10 Dec 2025 23:24:11 +0100 Subject: [PATCH 07/12] [bugfix] Make sure the format of the value of a decimal-format property is correct --- .../org/exist/xquery/parser/XQueryTree.g | 7 ++- .../java/org/exist/xquery/DecimalFormat.java | 48 ++++++++++++++++++- .../java/org/exist/xquery/ErrorCodes.java | 1 + 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g index 62294eabff..14a547257c 100644 --- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g +++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g @@ -567,7 +567,12 @@ throws PermissionDeniedException, EXistException, XPathException current = (XQueryAST) pval.getNextSibling(); } - final DecimalFormat df = DecimalFormat.fromProperties(dfProperties); + final DecimalFormat df; + try { + df = DecimalFormat.fromProperties(dfProperties); + } catch (final IllegalArgumentException ex) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0097.getErrorCode(), ex.getMessage() + " within the picture string of the decimal format: " + dfName.getText() + "."); + } if (!df.checkDistinctCharacters()) { throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0098.getErrorCode(), "Characters within the picture string of the decimal format: " + dfName.getText() + " are not distinct."); } diff --git a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java index a736b3603d..b9cd96c72a 100644 --- a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java +++ b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java @@ -136,7 +136,16 @@ public boolean checkDistinctCharacters() { return true; } - public static DecimalFormat fromProperties(final Map properties) { + /** + * Constructs a Decimal Format from a map of decimal format properties. + * + * @param properties the properties for the decimal format. + * + * @return the Decimal Format. + * + * @throws IllegalArgumentException if any of the properties are invalid. + */ + public static DecimalFormat fromProperties(final Map properties) throws IllegalArgumentException { int decimalSeparator = UNNAMED.decimalSeparator; int exponentSeparator = UNNAMED.exponentSeparator; int groupingSeparator = UNNAMED.groupingSeparator; @@ -153,36 +162,73 @@ public static DecimalFormat fromProperties(final Map properties) final String value = property.getValue(); switch (property.getKey()) { case "decimal-separator": + if (value.length() != 1) { + throw new IllegalArgumentException("decimal-separator must be a single character"); + } decimalSeparator = value.charAt(0); break; + case "exponent-separator": + if (value.length() != 1) { + throw new IllegalArgumentException("exponent-separator must be a single character"); + } exponentSeparator = value.charAt(0); break; + case "grouping-separator": + if (value.length() != 1) { + throw new IllegalArgumentException("groupung-separator must be a single character"); + } groupingSeparator = value.charAt(0); break; + case "percent": + if (value.length() != 1) { + throw new IllegalArgumentException("percent must be a single character"); + } percent = value.charAt(0); break; + case "per-mille": + if (value.length() != 1) { + throw new IllegalArgumentException("per-mille must be a single character"); + } perMille = value.charAt(0); break; + case "zero-digit": + if (value.length() != 1) { + throw new IllegalArgumentException("zero-digit must be a single character"); + } zeroDigit = value.charAt(0); break; + case "digit": + if (value.length() != 1) { + throw new IllegalArgumentException("digit must be a single character"); + } digit = value.charAt(0); break; + case "pattern-separator": + if (value.length() != 1) { + throw new IllegalArgumentException("pattern-separator must be a single character"); + } patternSeparator = value.charAt(0); break; + case "infinity": infinity = value; break; + case "NaN": NaN = value; break; + case "minus-sign": + if (value.length() != 1) { + throw new IllegalArgumentException("minus-sign must be a single character"); + } minusSign = value.charAt(0); break; } diff --git a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java index 7d2c9622f3..6183cc35b2 100644 --- a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java +++ b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java @@ -220,6 +220,7 @@ public enum W3CErrorCode implements IErrorCode { XQDY0092 ("An implementation MAY raise a dynamic error if a constructed attribute named xml:space has a value other than preserve or default."), XQST0093 ("It is a static error to import a module M1 if there exists a sequence of modules M1 ... Mi ... M1 such that each module directly depends on the next module in the sequence (informally, if M1 depends on itself through some chain of module dependencies.)"), XQST0094 ("The name of each grouping variable must be equal (by the eq operator on expanded QNames) to the name of a variable in the input tuple stream."), + XQST0097 ("It is a static error for a decimal-format to specify a value that is not valid for a given property, as described in statically known decimal formats"), XQST0098 ("It is a static error if, for any named or unnamed decimal format, the properties representing characters used in a picture string do not each have distinct values. The following properties represent characters used in a picture string: decimal-separator, exponent-separator, grouping-separator, percent, per-mille, the family of ten decimal digits starting with zero-digit, digit, and pattern-separator."), XQDY0101 ("An error is raised if a computed namespace constructor attempts to do any of the following: Bind the prefix xml to some namespace URI other than http://www.w3.org/XML/1998/namespace. Bind a prefix other than xml to the namespace URI http://www.w3.org/XML/1998/namespace. Bind the prefix xmlns to any namespace URI. Bind a prefix to the namespace URI http://www.w3.org/2000/xmlns/. Bind any prefix (including the empty prefix) to a zero-length namespace URI."), XQDY0102 ("If the name of an element in an element constructor is in no namespace, creating a default namespace for that element using a computed namespace constructor is an error."), From 15bd753e04bb406428ad86b6ca8a7b5c46d28e6e Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Wed, 10 Dec 2025 23:37:07 +0100 Subject: [PATCH 08/12] [bugfix] Ensure that negative exponents are correctly padded in fn:format-number --- .../org/exist/xquery/functions/fn/FnFormatNumbers.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java index 8d9a2d9d71..d55a5b8cdd 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java @@ -722,6 +722,11 @@ private String format(final NumericValue number, final DecimalFormat decimalForm if (minimumExponentSize > 0) { formatted.append(decimalFormat.exponentSeparator); + final boolean negativeExp = exp < 0; + if (negativeExp) { + // negative exponent, make positive + exp *= -1; + } final CodePointString expStr = new CodePointString(String.valueOf(exp)); final int expPadLen = subPicture.getMinimumExponentSize() - expStr.length(); @@ -729,6 +734,10 @@ private String format(final NumericValue number, final DecimalFormat decimalForm expStr.leftPad(decimalFormat.zeroDigit, expPadLen); } + if (negativeExp) { + // restore the minus sign for the negative exponent in the output + formatted.append('-'); + } formatted.append(expStr); } From 0408cc23ba6697686c0c07a921b3b323c826025f Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Wed, 10 Dec 2025 23:47:43 +0100 Subject: [PATCH 09/12] [bugfix] Do not identify non-exponent characters as exponent character in the exponent part of the picture string given to fn:format-number --- .../xquery/functions/fn/FnFormatNumbers.java | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java index d55a5b8cdd..cd86f624dd 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java @@ -401,7 +401,7 @@ private Tuple2> analyzePictureString(final Deci subPicture.clearSuffix(); subPicture.incrementMaximumFractionalPartSize(); - } else if (c == decimalFormat.patternSeparator) { + } else if (c == decimalFormat.patternSeparator) { capturePrefix = false; subPicture.clearSuffix(); @@ -442,10 +442,9 @@ private Tuple2> analyzePictureString(final Deci break; // end of FRACTIONAL_PART - case EXPONENT_PART: + if (c == decimalFormat.decimalSeparator - || c == decimalFormat.exponentSeparator || c == decimalFormat.groupingSeparator || c == decimalFormat.digit) { capturePrefix = false; @@ -453,6 +452,45 @@ private Tuple2> analyzePictureString(final Deci throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture cannot have any active characters following the exponent-separator-sign"); + } else if (c == decimalFormat.exponentSeparator) { + + /* + A character that matches the exponent-separator property is treated as an + exponent-separator-sign if it is both preceded and followed within the + sub-picture by an active character. + */ + + // we need to peek at the next char to determine if it is active + final boolean nextIsActive; + if (idx + 1 < pictureString.length()) { + nextIsActive = isActiveChar(decimalFormat, pictureString.codePointAt(idx + 1)); + } else { + nextIsActive = false; + } + + if (isActiveChar(decimalFormat, prevChar) && nextIsActive) { + // this is an exponent-separator-sign... but we already have one + capturePrefix = false; + subPicture.clearSuffix(); + + throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture cannot have any active characters following the exponent-separator-sign"); + + } else { + // just another passive character + + /* passive character */ + analyzePassiveChar(decimalFormat, c, capturePrefix, subPicture); + + if (subPicture.hasPercent()) { + throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain a percent character as it already has an exponent separator sign."); + } + + if (subPicture.hasPerMille()) { + throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain a per-mille character as it already has an exponent separator sign."); + } + } + + } else if (c == decimalFormat.patternSeparator) { capturePrefix = false; subPicture.clearSuffix(); From 8f93c8d12a8abc8f27dc50b0f7fe077b4d2eb463 Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Wed, 10 Dec 2025 23:53:24 +0100 Subject: [PATCH 10/12] [bugfix] Increase decimal precision from 64 bits to 128 bits in fn:format-number --- .../java/org/exist/xquery/functions/fn/FnFormatNumbers.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java index cd86f624dd..e913ffff35 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java @@ -647,7 +647,7 @@ private String format(final NumericValue number, final DecimalFormat decimalForm } } - adjustedNumber = new DecimalValue(this, adjustedNumber.convertTo(Type.DECIMAL).toJavaObject(BigDecimal.class).multiply(BigDecimal.ONE, MathContext.DECIMAL64)).round(new IntegerValue(this, subPicture.getMaximumFractionalPartSize())).abs(); + adjustedNumber = new DecimalValue(this, adjustedNumber.convertTo(Type.DECIMAL).toJavaObject(BigDecimal.class).multiply(BigDecimal.ONE, MathContext.DECIMAL128)).round(new IntegerValue(this, subPicture.getMaximumFractionalPartSize())).abs(); /* we can now start formatting for display */ From 7bcff61b321f1e25549656083a66ef9ec6c0bb07 Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Thu, 11 Dec 2025 11:39:39 +0100 Subject: [PATCH 11/12] [bugfix] Only remove the decimal separator in fn:format-number if there is no decimal separator in the picture, or there are no fractional digits, and the decimal separator is the right-most character --- .../main/java/org/exist/util/CodePointString.java | 14 +++++++------- .../exist/xquery/functions/fn/FnFormatNumbers.java | 7 ++++++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/exist-core/src/main/java/org/exist/util/CodePointString.java b/exist-core/src/main/java/org/exist/util/CodePointString.java index d97b362d46..f2083d1c1b 100644 --- a/exist-core/src/main/java/org/exist/util/CodePointString.java +++ b/exist-core/src/main/java/org/exist/util/CodePointString.java @@ -353,14 +353,14 @@ public CodePointString insert(final int[] indexes, final int codePoint) { * @return this */ public CodePointString removeFirst(final int codePoint) { - int idx = -1; - for (int i = 0; i < codePoints.length; i++) { - if (codePoints[i] == codePoint) { - idx = i; - break; - } - } + final int idx = indexOf(codePoint); + return removeChar(idx); + } + /** + * Removes the codepoint at the specified index. + */ + public CodePointString removeChar(final int idx) { if (idx > -1) { final int[] newCodePoints = new int[codePoints.length - 1]; diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java index e913ffff35..e23ac6e552 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java @@ -752,7 +752,12 @@ private String format(final NumericValue number, final DecimalFormat decimalForm // Rule 12 - strip decimal separator if unneeded if (!subPicture.hasDecimalSeparator() || fractLen == 0) { - formatted.removeFirst(decimalFormat.decimalSeparator); + // decimal separator must be the rightmost character in the string + final int rightMostIndex = formatted.length() - 1; + final int rightMost = formatted.codePointAt(rightMostIndex); + if (rightMost == decimalFormat.decimalSeparator) { + formatted.removeChar(rightMostIndex); + } } // Rule 13 - add exponent if exists From 0daf9df274a761ae1c1cd05df8d4a92459f199f1 Mon Sep 17 00:00:00 2001 From: Adam Retter Date: Sat, 13 Dec 2025 20:17:59 +0100 Subject: [PATCH 12/12] [bugfix] Correct the determination of regular vs non-regular integer-part-grouping-positions. All W3C QT3 format-number tests are now passing except: 1. `numberformat121` and `numberformat122` which raise a java.lang.StackOverflowError due to a lack of tail-call-optimisation 2. `numberformat127` and `numberformat128` which appear to be incorrect, see: https://github.com/w3c/qt3tests/issues/73 --- .../xquery/functions/fn/FnFormatNumbers.java | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java index e23ac6e552..b272a524b9 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java @@ -331,6 +331,10 @@ private Tuple2> analyzePictureString(final Deci analyzePassiveChar(decimalFormat, c, capturePrefix, subPicture); } + if (state == AnalyzeState.INTEGER_PART) { + subPicture.incrementIntegerPartExtent(); + } + break; // end of INTEGER_PART @@ -796,6 +800,8 @@ private String format(final NumericValue number, final DecimalFormat decimalForm * See https://www.w3.org/TR/xpath-functions-31/#analyzing-picture-string */ private static class SubPicture { + private int integerPartStartIdx = 0; + private int integerPartLength = 0; private int[] integerPartGroupingPositions; private int minimumIntegerPartSize; private int scalingFactor; @@ -815,6 +821,8 @@ private static class SubPicture { public SubPicture copy() { final SubPicture copy = new SubPicture(); + copy.integerPartStartIdx = integerPartStartIdx; + copy.integerPartLength = integerPartLength; copy.integerPartGroupingPositions = integerPartGroupingPositions == null ? null : Arrays.copyOf(integerPartGroupingPositions, integerPartGroupingPositions.length); copy.minimumIntegerPartSize = minimumIntegerPartSize; copy.scalingFactor = scalingFactor; @@ -865,7 +873,7 @@ public void incrementIntegerPartGroupingPosition() { * @return the value of G if regular, or -1 if irregular */ public int integerPartGroupingPositionsAreRegular() { - // There is an least one grouping-separator in the integer part of the sub-picture. + // There is at least one grouping-separator in the integer part of the sub-picture. if (integerPartGroupingPositions.length > 0) { // There is a positive integer G (the grouping size) such that the position of every grouping-separator @@ -891,23 +899,23 @@ public int integerPartGroupingPositionsAreRegular() { return -1; } - // Every position in the integer part of the sub-picture that is a positive integer multiple of G is - // occupied by a grouping-separator. - final int largestGroupPosition = integerPartGroupingPositions[integerPartGroupingPositions.length - 1]; - int m = 2; - for (int p = g; p <= largestGroupPosition; p = g * m++) { + // Check that every position in the integer part of the sub-picture that is a positive integer multiple + // of G is occupied by a grouping-separator. + // We can test this by determining if the leftmost group (the group to the left of the leftmost + // separator) is not larger than G. - boolean isGroupSeparator = false; - for (final int integerPartGroupingPosition : integerPartGroupingPositions) { - if (integerPartGroupingPosition == p) { - isGroupSeparator = true; - break; - } - } + // Calculate total active characters: integerPartLength includes all characters (digits + separators), + // so we subtract the number of separators to get just the active characters. + final int totalActiveCharacters = integerPartLength - integerPartGroupingPositions.length; - if (!isGroupSeparator) { - return -1; - } + // The leftmost separator is always at index 0, and its numberOfCharacters tells us how many active + // characters are to the right of it. Therefore, the leftmost group size is the remaining characters. + final int leftmostGroupSize = totalActiveCharacters - integerPartGroupingPositions[0]; + + // If the leftmost group is larger than G, it means that there should have been another separator + // within it (at position G from the right of the leftmost group), but there isn't... so it's irregular! + if (leftmostGroupSize > g) { + return -1; } return g; @@ -916,6 +924,10 @@ public int integerPartGroupingPositionsAreRegular() { return -1; } + public void incrementIntegerPartExtent() { + integerPartLength++; + } + public void incrementMinimumIntegerPartSize() { minimumIntegerPartSize++; }