diff --git a/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/CommonCssConstants.java b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/CommonCssConstants.java index dfa958baa5..5bba79c185 100644 --- a/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/CommonCssConstants.java +++ b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/CommonCssConstants.java @@ -1804,6 +1804,16 @@ public class CommonCssConstants { */ public static final String HAS = "has"; + /** + * The Constant IS. + */ + public static final String IS = "is"; + + /** + * The Constant WHERE. + */ + public static final String WHERE = "where"; + /** * The Constant DISABLED. */ diff --git a/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/parse/CssSelectorParser.java b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/parse/CssSelectorParser.java index 08d26007c8..dce7a853b4 100644 --- a/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/parse/CssSelectorParser.java +++ b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/parse/CssSelectorParser.java @@ -103,7 +103,7 @@ public static List parseSelectorItems(String selector) { * @return a list of {@link ICssSelectorItem} objects representing the parsed components * of the CSS selector */ - static List parseSelectorItems(String selector, boolean allowRelativeSelectorAtStart) { + public static List parseSelectorItems(String selector, boolean allowRelativeSelectorAtStart) { List selectorItems = new ArrayList<>(); State state = new NoneState(); for (int i = 0; i < selector.length(); ++i) { @@ -140,7 +140,7 @@ static List parseSelectorItems(String selector, boolean allowR * @param input the string to split * @return a list of string parts split by top-level commas */ - static List splitByTopLevelComma(String input) { + public static List splitByTopLevelComma(String input) { return CssUtils.splitString(input, ',', new EscapeGroup('(', ')'), new EscapeGroup('"'), @@ -503,6 +503,9 @@ private static abstract class FunctionState implements State { protected char closure; protected boolean inString = false; protected boolean isReadyForSwitch = false; + // Tracks nested parentheses for pseudo-class functions like :is(...), :not(...), :where(...), :has(...) + // Only used when closure == ')'. + protected int parenthesesDepth = 0; /** * Constructs a new FunctionState object with the specified closure character. @@ -533,7 +536,27 @@ public void addChar(char c, boolean isEscaped) { if ((c == '"' || c == '\'') && !isEscaped) { inString = !inString; } - if (c == closure && !isEscaped && !inString) { + + if (isEscaped || inString) { + return; + } + + if (closure == ')') { + if (c == '(') { + parenthesesDepth++; + } else if (c == ')') { + // Close one nesting level; function ends when we close the initial '('. + if (parenthesesDepth > 0) { + parenthesesDepth--; + } + if (parenthesesDepth == 0) { + isReadyForSwitch = true; + } + } + return; + } + + if (c == closure) { isReadyForSwitch = true; } } diff --git a/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassForgivingSelectorListSelectorItem.java b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassForgivingSelectorListSelectorItem.java new file mode 100644 index 0000000000..362dcdf9f0 --- /dev/null +++ b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassForgivingSelectorListSelectorItem.java @@ -0,0 +1,116 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2026 Apryse Group NV + Authors: Apryse Software. + + This program is offered under a commercial and under the AGPL license. + For commercial licensing, contact us at https://itextpdf.com/sales. For AGPL licensing, see below. + + AGPL licensing: + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + */ + +package com.itextpdf.styledxmlparser.css.selector.item; + +import com.itextpdf.styledxmlparser.css.parse.CssSelectorParser; +import com.itextpdf.styledxmlparser.css.selector.CssSelector; +import com.itextpdf.styledxmlparser.css.selector.ICssSelector; +import com.itextpdf.styledxmlparser.node.ICustomElementNode; +import com.itextpdf.styledxmlparser.node.IDocumentNode; +import com.itextpdf.styledxmlparser.node.IElementNode; +import com.itextpdf.styledxmlparser.node.INode; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Base class for pseudo-classes that accept a forgiving selector list (e.g. :is(), :where()). + */ +abstract class CssPseudoClassForgivingSelectorListSelectorItem extends CssPseudoClassSelectorItem { + protected final List selectorList; + + protected CssPseudoClassForgivingSelectorListSelectorItem(String pseudoClass, List selectorList, + String argumentsString) { + super(pseudoClass, argumentsString); + this.selectorList = selectorList; + } + + @Override + public boolean matches(INode node) { + if (!(node instanceof IElementNode) || node instanceof ICustomElementNode || node instanceof IDocumentNode) { + return false; + } + for (ICssSelector sel : selectorList) { + if (sel != null && sel.matches(node)) { + return true; + } + } + return false; + } + + /** + * Parses a forgiving selector list for :is() / :where(). + *

+ * Per Selectors Level 4, :is() and :where() accept a forgiving selector list: + * invalid selectors are ignored rather than invalidating the whole pseudo-class. + * + * @param arguments selector list as written inside parentheses + * @return list of valid selectors (possibly empty), or null if arguments are syntactically missing + */ + static List parseForgivingSelectorListWithoutPseudoElements(String arguments) { + if (arguments == null || arguments.trim().isEmpty()) { + // :is() / :where() with empty arguments is invalid. + return null; + } + + List parts = CssSelectorParser.splitByTopLevelComma(arguments); + if (parts.isEmpty()) { + return null; + } + + List selectors = new ArrayList<>(); + for (String rawPart : parts) { + String part = rawPart == null ? "" : rawPart.trim(); + if (part.isEmpty()) { + // Empty entries like :is(.a,,.b) are invalid selectors in the list; ignore (forgiving). + continue; + } + + try { + CssSelector sel = new CssSelector(CssSelectorParser.parseSelectorItems(part, false)); + if (!containsPseudoElement(Collections.singletonList(sel))) { + selectors.add(sel); + } + } catch (IllegalArgumentException ex) { + // Invalid/unsupported selector in the list; ignore (forgiving). + } + } + + return selectors; + } + + static boolean containsPseudoElement(List selectors) { + for (ICssSelector sel : selectors) { + if (sel instanceof CssSelector) { + for (ICssSelectorItem item : ((CssSelector) sel).getSelectorItems()) { + if (item instanceof CssPseudoElementSelectorItem) { + return true; + } + } + } + } + return false; + } +} \ No newline at end of file diff --git a/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassIsSelectorItem.java b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassIsSelectorItem.java new file mode 100644 index 0000000000..6c401f9fa1 --- /dev/null +++ b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassIsSelectorItem.java @@ -0,0 +1,33 @@ +package com.itextpdf.styledxmlparser.css.selector.item; + +import com.itextpdf.styledxmlparser.css.CommonCssConstants; +import com.itextpdf.styledxmlparser.css.selector.ICssSelector; + +import java.util.List; + +class CssPseudoClassIsSelectorItem extends CssPseudoClassForgivingSelectorListSelectorItem { + + CssPseudoClassIsSelectorItem(List selectorList, String argumentsString) { + super(CommonCssConstants.IS, selectorList, argumentsString); + } + + @Override + public int getSpecificity() { + int max = 0; + for (ICssSelector sel : selectorList) { + if (sel != null) { + max = Math.max(max, sel.calculateSpecificity()); + } + } + return max; + } + + public static CssPseudoClassIsSelectorItem createIsSelectorItem(String arguments) { + List selectors = parseForgivingSelectorListWithoutPseudoElements(arguments); + if (selectors == null) { + return null; + } + return new CssPseudoClassIsSelectorItem(selectors, arguments); + } +} + diff --git a/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassNotSelectorItem.java b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassNotSelectorItem.java index d85027cfc1..64d85c7736 100644 --- a/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassNotSelectorItem.java +++ b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassNotSelectorItem.java @@ -41,6 +41,13 @@ class CssPseudoClassNotSelectorItem extends CssPseudoClassSelectorItem { this.argumentsSelector = argumentsSelector; } + @Override + public int getSpecificity() { + // Per Selectors Level 4: :not() specificity is replaced by the specificity + // of the most specific complex selector in its selector list argument. + return argumentsSelector != null ? argumentsSelector.calculateSpecificity() : 0; + } + public List getArgumentsSelector() { return CssSelectorParser.parseSelectorItems(arguments); } diff --git a/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassSelectorItem.java b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassSelectorItem.java index d75009350d..c15ce27bb5 100644 --- a/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassSelectorItem.java +++ b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassSelectorItem.java @@ -119,6 +119,10 @@ public static CssPseudoClassSelectorItem create(String pseudoClass, String argum return createHasSelectorItem(arguments); case CommonCssConstants.NOT: return createNotSelectorItem(arguments); + case CommonCssConstants.IS: + return CssPseudoClassIsSelectorItem.createIsSelectorItem(arguments); + case CommonCssConstants.WHERE: + return CssPseudoClassWhereSelectorItem.createWhereSelectorItem(arguments); case CommonCssConstants.ROOT: return CssPseudoClassRootSelectorItem.getInstance(); case CommonCssConstants.LINK: diff --git a/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassWhereSelectorItem.java b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassWhereSelectorItem.java new file mode 100644 index 0000000000..ce2d8e8393 --- /dev/null +++ b/styled-xml-parser/src/main/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassWhereSelectorItem.java @@ -0,0 +1,28 @@ +package com.itextpdf.styledxmlparser.css.selector.item; + +import com.itextpdf.styledxmlparser.css.CommonCssConstants; +import com.itextpdf.styledxmlparser.css.selector.ICssSelector; + +import java.util.List; + +class CssPseudoClassWhereSelectorItem extends CssPseudoClassForgivingSelectorListSelectorItem { + + CssPseudoClassWhereSelectorItem(List selectorList, String argumentsString) { + super(CommonCssConstants.WHERE, selectorList, argumentsString); + } + + @Override + public int getSpecificity() { + // Per Selectors Level 4: :where() always contributes 0 specificity. + return 0; + } + + public static CssPseudoClassWhereSelectorItem createWhereSelectorItem(String arguments) { + List selectors = parseForgivingSelectorListWithoutPseudoElements(arguments); + if (selectors == null) { + return null; + } + return new CssPseudoClassWhereSelectorItem(selectors, arguments); + } +} + diff --git a/styled-xml-parser/src/test/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassesTest.java b/styled-xml-parser/src/test/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassesTest.java new file mode 100644 index 0000000000..98aaa81be2 --- /dev/null +++ b/styled-xml-parser/src/test/java/com/itextpdf/styledxmlparser/css/selector/item/CssPseudoClassesTest.java @@ -0,0 +1,458 @@ +package com.itextpdf.styledxmlparser.css.selector.item; + +import com.itextpdf.styledxmlparser.IXmlParser; +import com.itextpdf.styledxmlparser.css.selector.CssSelector; +import com.itextpdf.styledxmlparser.node.IDocumentNode; +import com.itextpdf.styledxmlparser.node.IElementNode; +import com.itextpdf.styledxmlparser.node.INode; +import com.itextpdf.styledxmlparser.node.impl.jsoup.JsoupHtmlParser; +import com.itextpdf.test.ExtendedITextTest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("UnitTest") +public class CssPseudoClassesTest extends ExtendedITextTest { + + @Test + public void isMatchesAnySelectorInListTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse("

"); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + CssSelector s = new CssSelector(":is(.c, p)"); + + Assertions.assertTrue(s.matches(a)); + Assertions.assertTrue(s.matches(b)); + } + + @Test + public void whereMatchesAnySelectorInListTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse("

"); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + CssSelector s = new CssSelector(":where(.c, p)"); + + Assertions.assertTrue(s.matches(a)); + Assertions.assertTrue(s.matches(b)); + } + + @Test + public void isSupportsAttributeSelectorWithCommaInQuotedValueTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "" + + "" + + "
" + ); + + IElementNode s1 = findElementById(doc, "s1"); + IElementNode s2 = findElementById(doc, "s2"); + + CssSelector selector = new CssSelector(":is([title='a,b'], #doesNotExist)"); + + Assertions.assertTrue(selector.matches(s1)); + Assertions.assertFalse(selector.matches(s2)); + } + + @Test + public void isSupportsNestedFunctionalPseudoClassesTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "
" + + "

" + + "

" + + "
" + ); + + IElementNode host = findElementById(doc, "host"); + IElementNode p1 = findElementById(doc, "p1"); + IElementNode p2 = findElementById(doc, "p2"); + + Assertions.assertNotNull(host); + Assertions.assertNotNull(p1); + Assertions.assertNotNull(p2); + + CssSelector selector = new CssSelector(":is(:not(.x), p.x)"); + + // host has no .x => :not(.x) matches + Assertions.assertTrue(selector.matches(host)); + // p1 has .x => :not(.x) doesn't match, but p.x does + Assertions.assertTrue(selector.matches(p1)); + // p2 has no .x => :not(.x) matches + Assertions.assertTrue(selector.matches(p2)); + } + + + @Test + public void whereParsesWithExtraWhitespaceAndMatchesCorrectlyTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + ); + + IElementNode host = findElementById(doc, "host"); + IElementNode p1 = findElementById(doc, "p1"); + + CssSelector selector = new CssSelector(":where( div.c , #p1 )"); + + Assertions.assertTrue(selector.matches(host)); + Assertions.assertTrue(selector.matches(p1)); + } + + @Test + public void isCanBeUsedInsideHasArgumentsTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + " " + + "
" + + "
" + + " " + + "
" + ); + + IElementNode host = findElementById(doc, "host"); + IElementNode other = findElementById(doc, "other"); + + CssSelector selector = new CssSelector("div:has(:is(span[title='a,b'], p))"); + + Assertions.assertTrue(selector.matches(host)); + Assertions.assertFalse(selector.matches(other)); + } + + @Test + public void whereCanBeUsedInsideHasArgumentsTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + + "
" + + "
" + + " " + + "
" + ); + + IElementNode host = findElementById(doc, "host"); + IElementNode other = findElementById(doc, "other"); + + CssSelector selector = new CssSelector("div:has(:where(p.x, span#s2))"); + + Assertions.assertTrue(selector.matches(host)); + Assertions.assertTrue(selector.matches(other)); + } + + @Test + public void isCanNestWhereTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + + "" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + IElementNode c = findElementById(doc, "c"); + + CssSelector selector = new CssSelector(":is(:where(.c, p), #doesNotExist)"); + + Assertions.assertTrue(selector.matches(a)); + Assertions.assertTrue(selector.matches(b)); + Assertions.assertFalse(selector.matches(c)); + } + + @Test + public void whereCanNestIsTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + + "" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + IElementNode c = findElementById(doc, "c"); + + CssSelector selector = new CssSelector(":where(:is(.c, p), #doesNotExist)"); + + Assertions.assertTrue(selector.matches(a)); + Assertions.assertTrue(selector.matches(b)); + Assertions.assertFalse(selector.matches(c)); + } + + @Test + public void isCanNestWhereAndIsMultipleLevelsTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + + "

" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + IElementNode c = findElementById(doc, "c"); + + CssSelector selector = new CssSelector(":is(:where(div.c, :is(p.x, #nope)), #alsoNope)"); + + Assertions.assertTrue(selector.matches(a)); + Assertions.assertTrue(selector.matches(b)); + Assertions.assertFalse(selector.matches(c)); + } + + @Test + public void isIgnoresRelativeSelectorInArgumentsAndStillMatchesValidOnesTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + // Per Selectors Level 4 forgiving selector list rules, invalid entries are ignored. + CssSelector selector = new CssSelector(":is(> p, .c)"); + + Assertions.assertTrue(selector.matches(a)); + Assertions.assertFalse(selector.matches(b)); + } + + @Test + public void whereIgnoresRelativeSelectorInArgumentsAndStillMatchesValidOnesTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + CssSelector selector = new CssSelector(":where(+ p, .c)"); + + Assertions.assertTrue(selector.matches(a)); + Assertions.assertFalse(selector.matches(b)); + } + + @Test + public void isIgnoresUnsupportedPseudoClassInArgumentsTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + // Unknown pseudo-class should not invalidate the whole :is(), it should be ignored. + CssSelector selector = new CssSelector(":is(:unknownPseudo(.x), .c)"); + + Assertions.assertTrue(selector.matches(a)); + Assertions.assertFalse(selector.matches(b)); + } + + @Test + public void whereIgnoresUnsupportedPseudoClassInArgumentsTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + CssSelector selector = new CssSelector(":where(:unknownPseudo(.x), .c)"); + + Assertions.assertTrue(selector.matches(a)); + Assertions.assertFalse(selector.matches(b)); + } + + @Test + public void isIgnoresSelectorWithPseudoElementInArgumentsTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + // Selectors containing pseudo-elements are not allowed in :is(...), but in a forgiving list + // they should be ignored rather than invalidating :is(...). + CssSelector selector = new CssSelector(":is(div::before, .c)"); + + Assertions.assertTrue(selector.matches(a)); + Assertions.assertFalse(selector.matches(b)); + } + + @Test + public void whereIgnoresSelectorWithPseudoElementInArgumentsTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + CssSelector selector = new CssSelector(":where(p::after, .c)"); + + Assertions.assertTrue(selector.matches(a)); + Assertions.assertFalse(selector.matches(b)); + } + + @Test + public void isAllInvalidSelectorsInArgumentsMatchesNothingTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + // All entries invalid => selector-list is effectively empty => matches nothing. + CssSelector selector = new CssSelector(":is(> p, ::before, :unknownPseudo(.x))"); + + Assertions.assertFalse(selector.matches(a)); + Assertions.assertFalse(selector.matches(b)); + } + + @Test + public void whereAllInvalidSelectorsInArgumentsMatchesNothingTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + CssSelector selector = new CssSelector(":where(+ p, ::after, :unknownPseudo(.x))"); + + Assertions.assertFalse(selector.matches(a)); + Assertions.assertFalse(selector.matches(b)); + } + + @Test + public void isEmptyArgumentsIsInvalidTest() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new CssSelector(":is()")); + Assertions.assertThrows(IllegalArgumentException.class, () -> new CssSelector(":is( )")); + } + + @Test + public void whereEmptyArgumentsIsInvalidTest() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new CssSelector(":where()")); + Assertions.assertThrows(IllegalArgumentException.class, () -> new CssSelector(":where(\n\t )")); + } + + @Test + public void isSpecificityUsesMaxOfArgumentsTest() { + // :is(#id, .class) should have ID specificity (highest among arguments) + CssSelector isWithId = new CssSelector(":is(#myId, .myClass)"); + // ID specificity = 1 << 20 = 1048576 + Assertions.assertEquals(1 << 20, isWithId.calculateSpecificity()); + + // :is(.a, .b) should have class specificity + CssSelector isWithClasses = new CssSelector(":is(.a, .b)"); + // Class specificity = 1 << 10 = 1024 + Assertions.assertEquals(1 << 10, isWithClasses.calculateSpecificity()); + + // :is(div, p) should have element specificity + CssSelector isWithElements = new CssSelector(":is(div, p)"); + // Element specificity = 1 + Assertions.assertEquals(1, isWithElements.calculateSpecificity()); + } + + @Test + public void whereSpecificityIsAlwaysZeroTest() { + // :where() always contributes 0 specificity regardless of arguments + CssSelector whereWithId = new CssSelector(":where(#myId, .myClass)"); + Assertions.assertEquals(0, whereWithId.calculateSpecificity()); + + CssSelector whereWithClasses = new CssSelector(":where(.a, .b)"); + Assertions.assertEquals(0, whereWithClasses.calculateSpecificity()); + } + + @Test + public void combinedSelectorSpecificityTest() { + // div:is(.a, .b) should have element (1) + class (1024) = 1025 + CssSelector combined = new CssSelector("div:is(.a, .b)"); + Assertions.assertEquals(1 + (1 << 10), combined.calculateSpecificity()); + + // div:where(.a, #id) should have only element (1) since :where() = 0 + CssSelector combinedWhere = new CssSelector("div:where(.a, #id)"); + Assertions.assertEquals(1, combinedWhere.calculateSpecificity()); + } + + + @Test + public void isExtraCommaCreatesEmptyEntryWhichIsIgnoredTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + CssSelector selector = new CssSelector(":is(.c,, #doesNotExist)"); + + Assertions.assertTrue(selector.matches(a)); + Assertions.assertFalse(selector.matches(b)); + } + + @Test + public void whereExtraCommaCreatesEmptyEntryWhichIsIgnoredTest() { + IXmlParser parser = new JsoupHtmlParser(); + IDocumentNode doc = parser.parse( + "
" + + "

" + ); + + IElementNode a = findElementById(doc, "a"); + IElementNode b = findElementById(doc, "b"); + + CssSelector selector = new CssSelector(":where(.c,, #doesNotExist)"); + + Assertions.assertTrue(selector.matches(a)); + Assertions.assertFalse(selector.matches(b)); + } + + private static IElementNode findElementById(INode root, String id) { + if (root instanceof IElementNode) { + IElementNode el = (IElementNode) root; + String attr = el.getAttribute("id"); + if (id.equals(attr)) { + return el; + } + } + + for (INode child : root.childNodes()) { + IElementNode found = findElementById(child, id); + if (found != null) { + return found; + } + } + return null; + } +} + diff --git a/styled-xml-parser/src/test/java/com/itextpdf/styledxmlparser/css/selector/item/SpecificityCalculationTest.java b/styled-xml-parser/src/test/java/com/itextpdf/styledxmlparser/css/selector/item/SpecificityCalculationTest.java index c9c93dc657..4992430ddf 100644 --- a/styled-xml-parser/src/test/java/com/itextpdf/styledxmlparser/css/selector/item/SpecificityCalculationTest.java +++ b/styled-xml-parser/src/test/java/com/itextpdf/styledxmlparser/css/selector/item/SpecificityCalculationTest.java @@ -133,12 +133,12 @@ public void test19() { @Test public void test20() { - Assertions.assertEquals(CssSpecificityConstants.CLASS_SPECIFICITY, getSpecificity(":not(p)")); + Assertions.assertEquals(CssSpecificityConstants.ELEMENT_SPECIFICITY, getSpecificity(":not(p)")); } @Test public void test21() { - Assertions.assertEquals(CssSpecificityConstants.CLASS_SPECIFICITY, getSpecificity(":not(#id)")); + Assertions.assertEquals(CssSpecificityConstants.ID_SPECIFICITY, getSpecificity(":not(#id)")); } @Test @@ -164,6 +164,18 @@ public void test25() { getSpecificity(".class_name:nth-last-of-type(2n - 3)")); } + @Test + public void isSpecificityIsMaxOfSelectorListTest() { + Assertions.assertEquals(getSpecificity("#id"), getSpecificity(":is(#id, p, .c)")); + Assertions.assertEquals(getSpecificity(".c"), getSpecificity(":is(.c, p)")); + } + + @Test + public void whereSpecificityIsAlwaysZeroTest() { + Assertions.assertEquals(0, getSpecificity(":where(#id, p, .c)")); + Assertions.assertEquals(1, getSpecificity("div:where(#id, .c, p)")); + } + @Test public void pageTest01() { Assertions.assertEquals(CssSpecificityConstants.ID_SPECIFICITY, getPageSelectorSpecificity("customPageName"));