From 875f4c0c484f5f679d83e665cb65817c3eb6ded9 Mon Sep 17 00:00:00 2001 From: Sabith Saheb Date: Sun, 18 Oct 2026 15:41:03 +0530 Subject: [PATCH] Count code points, not UTF-16 units, in XPath string functions XPath 1.0 defines substring(), string-length() and translate() over characters, but CoreFunction indexed and counted UTF-16 code units. A supplementary character in the input was counted twice by string-length(), could be split by substring() into a lone surrogate, and was matched or emitted half at a time by translate(). Count and offset by code point instead. Strings without supplementary characters behave as before. --- .../jxpath/ri/compiler/CoreFunction.java | 38 +++++++++++-------- .../jxpath/ri/compiler/CoreFunctionTest.java | 15 ++++++++ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/apache/commons/jxpath/ri/compiler/CoreFunction.java b/src/main/java/org/apache/commons/jxpath/ri/compiler/CoreFunction.java index 2c6548991..49f56fda4 100644 --- a/src/main/java/org/apache/commons/jxpath/ri/compiler/CoreFunction.java +++ b/src/main/java/org/apache/commons/jxpath/ri/compiler/CoreFunction.java @@ -650,7 +650,7 @@ protected Object functionStringLength(final EvalContext context) { assertArgCount(1); s = InfoSetUtil.stringValue(getArg1().computeValue(context)); } - return Double.valueOf(s.length()); + return Double.valueOf(s.codePointCount(0, s.length())); } /** @@ -665,19 +665,20 @@ protected Object functionSubstring(final EvalContext context) { assertArgRange(minArgs, maxArgs); final int ac = getArgumentCount(); final String s1 = InfoSetUtil.stringValue(getArg1().computeValue(context)); + final int s1Length = s1.codePointCount(0, s1.length()); double from = InfoSetUtil.doubleValue(getArg2().computeValue(context)); if (Double.isNaN(from)) { return ""; } from = Math.round(from); - if (from > s1.length() + 1) { + if (from > s1Length + 1) { return ""; } if (ac == 2) { if (from < 1) { from = 1; } - return s1.substring((int) from - 1); + return s1.substring(s1.offsetByCodePoints(0, (int) from - 1)); } double length = InfoSetUtil.doubleValue(getArg3().computeValue(context)); length = Math.round(length); @@ -688,16 +689,16 @@ protected Object functionSubstring(final EvalContext context) { if (to < 1) { return ""; } - if (to > s1.length() + 1) { + if (to > s1Length + 1) { if (from < 1) { from = 1; } - return s1.substring((int) from - 1); + return s1.substring(s1.offsetByCodePoints(0, (int) from - 1)); } if (from < 1) { from = 1; } - return s1.substring((int) from - 1, (int) (to - 1)); + return s1.substring(s1.offsetByCodePoints(0, (int) from - 1), s1.offsetByCodePoints(0, (int) (to - 1))); } /** @@ -770,20 +771,27 @@ protected Object functionTranslate(final EvalContext context) { final String s1 = InfoSetUtil.stringValue(getArg1().computeValue(context)); final String s2 = InfoSetUtil.stringValue(getArg2().computeValue(context)); final String s3 = InfoSetUtil.stringValue(getArg3().computeValue(context)); - final char[] chars = s1.toCharArray(); - int out = 0; - for (int in = 0; in < chars.length; in++) { - final char c = chars[in]; - final int inx = s2.indexOf(c); + final int[] codePoints = s1.codePoints().toArray(); + final int[] fromCodePoints = s2.codePoints().toArray(); + final int[] toCodePoints = s3.codePoints().toArray(); + final StringBuilder buffer = new StringBuilder(s1.length()); + for (final int c : codePoints) { + int inx = -1; + for (int i = 0; i < fromCodePoints.length; i++) { + if (fromCodePoints[i] == c) { + inx = i; + break; + } + } if (inx != -1) { - if (inx < s3.length()) { - chars[out++] = s3.charAt(inx); + if (inx < toCodePoints.length) { + buffer.appendCodePoint(toCodePoints[inx]); } } else { - chars[out++] = c; + buffer.appendCodePoint(c); } } - return new String(chars, 0, out); + return buffer.toString(); } /** diff --git a/src/test/java/org/apache/commons/jxpath/ri/compiler/CoreFunctionTest.java b/src/test/java/org/apache/commons/jxpath/ri/compiler/CoreFunctionTest.java index db68bde51..5cf435e2c 100644 --- a/src/test/java/org/apache/commons/jxpath/ri/compiler/CoreFunctionTest.java +++ b/src/test/java/org/apache/commons/jxpath/ri/compiler/CoreFunctionTest.java @@ -132,6 +132,21 @@ void testNumberConversionIsXPathConformant() { assertXPathValue(context, "'1e3' = 1000", Boolean.FALSE); } + @Test + void testStringFunctionsCountCodePoints() { + // U+1D11E MUSICAL SYMBOL G CLEF is one XPath character but two UTF-16 chars. + context.getVariables().declareVariable("clef", "𝄞abc"); + assertXPathValue(context, "string-length($clef)", Double.valueOf(4)); + assertXPathValue(context, "substring($clef, 2)", "abc"); + assertXPathValue(context, "substring($clef, 1, 1)", "𝄞"); + assertXPathValue(context, "substring($clef, 2, 2)", "ab"); + assertXPathValue(context, "substring($clef, 0, 2)", "𝄞"); + assertXPathValue(context, "substring($clef, 5)", ""); + assertXPathValue(context, "translate($clef, 'a', '-')", "𝄞-bc"); + assertXPathValue(context, "translate($clef, substring($clef, 1, 1), 'x')", "xabc"); + assertXPathValue(context, "translate('abc', 'b', $clef)", "a𝄞c"); + } + @Test void testExtendedKeyFunction() { context.setKeyManager(new ExtendedKeyManager() {