From f5af200b65d57258e40380330ba536cea75073af 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 9071782816..3f25d36b85 100644 --- a/exist-core/pom.xml +++ b/exist-core/pom.xml @@ -765,6 +765,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 @@ -1342,6 +1343,7 @@ src/test/xquery/pi.xqm src/test/xquery/tail-recursion.xml 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/xquery3/parse-xml.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 aae8fc65bf..660c22cc6b 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 aaca539763..f358b9a0f2 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 3bb0216fdc130627d59a935e1288cb0ae43a61e1 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 3f25d36b85..8f753b1b69 100644 --- a/exist-core/pom.xml +++ b/exist-core/pom.xml @@ -1073,6 +1073,7 @@ src/main/java/org/exist/xquery/CombiningExpression.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 @@ -1256,6 +1257,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/main/java/org/exist/xquery/pragmas/Optimize.java src/test/java/org/exist/xquery/update/AbstractUpdateTest.java @@ -1738,6 +1740,7 @@ src/main/java/org/exist/xquery/CombiningExpression.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 @@ -1947,6 +1950,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/Optimize.java src/main/java/org/exist/xquery/pragmas/TimePragma.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 79db89c9c9..b4a6b2f417 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 * @@ -143,6 +167,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 @@ -244,7 +270,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) @@ -283,10 +309,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 @@ -318,6 +378,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 3841e0337f..b9ab022c9b 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 @@ -497,6 +497,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 0c66592456..3857cf0985 100644 --- a/exist-core/src/main/java/org/exist/xquery/XQueryContext.java +++ b/exist-core/src/main/java/org/exist/xquery/XQueryContext.java @@ -444,7 +444,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 e3a3e49cbb3581c9ea0c7928be9ca49726fa1f3a 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 660c22cc6b..20d8db93a4 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 67b0fa3d594c94003dbbb13a997055e7ffa1aa44 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 b9ab022c9b..a146b7a740 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 @@ -542,6 +542,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 d42d2b1d768881b709e54b2899e0e6836adfdbec 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 a146b7a740..b3b7986885 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); @@ -504,6 +505,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())) { @@ -530,17 +551,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 c2f841e07817d626ab1402f65ac4e75944b06ca7 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 b3b7986885..0b0d36dd3f 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 @@ -546,7 +546,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 f7d0f2b2b98660c114664eed5bd650b6b90323f4 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 0b0d36dd3f..84768536ed 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 @@ -553,7 +553,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 995881f6056a960db96174a3b139a763ce222cbd 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 20d8db93a4..77c17a6061 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 61e32fcae18340de8f1a23aca7a8daea95d9f2df 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 77c17a6061..eae629ad21 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 1d0ac7329a93d328d2211c28f6d2556f8e0103f8 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 eae629ad21..ab799e40bb 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 d1d8839c66836e0a9e2b37a9c8a0428f70df049d 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 ab799e40bb..7c0e5eb4b6 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 12b1135ee90b389abe85d9ba161d1df9519389f8 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 7c0e5eb4b6..0eb8a0fccb 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++; }