From 40dd03f1e0dc3d98110e4ebee778a12f2bd55d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20C=C3=A9r=C3=A8s?= Date: Fri, 2 Oct 2026 11:53:22 +0200 Subject: [PATCH] fix(rdfa): align list context lifecycle with RDFa Core 1.1 specification - Fix step 8 to compare new subject against parent object instead of parent subject - Isolate child list mappings for relation descendants in step 13 - Emit list triples once descendants contribute in step 14 - Prevent empty prefix declarations from overriding default vocabulary - Add regression tests covering list scoping and reproducer scenarios --- .../data/impl/io/parser/rdfa/RDFaParser.java | 104 ++++++++++------- .../rdfa/model/RDFaProcessingContext.java | 10 ++ .../impl/io/parser/rdfa/RDFaParserTest.java | 110 ++++++++++++++++++ 3 files changed, 179 insertions(+), 45 deletions(-) diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/RDFaParser.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/RDFaParser.java index dd02d636d..db5648063 100644 --- a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/RDFaParser.java +++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/RDFaParser.java @@ -183,38 +183,40 @@ private void startProcessElement(String qName, Attributes attrs) { // 1 First, the local values are initialized RDFaProcessingContext processingContext; if (!this.processingContexts.isEmpty()) { // Not a root element - processingContext = new RDFaProcessingContext(currentProcessingContext().getEvaluationContext()); - processingContext.setNamespaceDeclarations(currentProcessingContext().getNamespaceDeclarations()); - processingContext.setRootElement(false); + RDFaProcessingContext parent = currentProcessingContext(); + RDFaEvaluationContext childContext = new RDFaEvaluationContext(parent.getEvaluationContext()); // 13. Next, all elements that are children of the current element are processed using the rules described here, using a new evaluation context, initialized as follows: // If the skip element flag is 'true' then the new evaluation context is a copy of the current context that was passed in to this level of processing, with the language and list of IRI mappings values replaced with the local values; if (this.currentProcessingContext().isSkipElement()) { - processingContext.setEvaluationContext(new RDFaEvaluationContext(currentProcessingContext().getEvaluationContext())); - processingContext.getEvaluationContext().setLanguage(this.currentProcessingContext().getCurrentLanguage()); + childContext.setLanguage(parent.getCurrentLanguage()); // Otherwise, the values are: } else { Resource oldParentSubject = currentProcessingContext().getEvaluationContext().getParentSubjectResource(); // the base is set to the base value of the current evaluation context; - processingContext.setEvaluationContext(new RDFaEvaluationContext(currentProcessingContext().getEvaluationContext())); // the parent subject is set to the value of new subject, if non-null, or the value of the parent subject of the current evaluation context; - processingContext.getEvaluationContext().setParentSubjectResource(this.currentProcessingContext().getNewSubject()); + childContext.setParentSubjectResource(parent.getNewSubject() != null + ? parent.getNewSubject() : oldParentSubject); // the parent object is set to value of current object resource, if non-null, or the value of new subject, if non-null, or the value of the parent subject of the current evaluation context; if (this.currentProcessingContext().getCurrentObjectResource() != null) { - processingContext.getEvaluationContext().setParentObjectResource(this.currentProcessingContext().getCurrentObjectResource()); + childContext.setParentObjectResource(parent.getCurrentObjectResource()); } else if (this.currentProcessingContext().getNewSubject() != null) { - processingContext.getEvaluationContext().setParentObjectResource(this.currentProcessingContext().getNewSubject()); + childContext.setParentObjectResource(parent.getNewSubject()); } else { - processingContext.getEvaluationContext().setParentObjectResource(oldParentSubject); + childContext.setParentObjectResource(oldParentSubject); } // the list of incomplete triples is set to the local list of incomplete triples; - processingContext.getEvaluationContext().setIncompleteStatements(this.currentProcessingContext().getIncompleteStatements()); + childContext.setIncompleteStatements(parent.getIncompleteStatements()); // the list mapping is set to the local list mapping; - processingContext.getEvaluationContext().setListMappings(this.currentProcessingContext().getListMappings()); + childContext.setListMappings(parent.getChildListMappings()); // language is set to the value of current language. - processingContext.getEvaluationContext().setLanguage(this.currentProcessingContext().getCurrentLanguage()); + childContext.setLanguage(parent.getCurrentLanguage()); // the default vocabulary is set to the value of the local default vocabulary. - processingContext.getEvaluationContext().setDefaultVocabulary(this.currentProcessingContext().getDefaultVocabulary()); + childContext.setDefaultVocabulary(parent.getDefaultVocabulary()); } + // Initialize local references only after the incoming context is complete. + processingContext = new RDFaProcessingContext(childContext); + processingContext.setNamespaceDeclarations(parent.getNamespaceDeclarations()); + processingContext.setRootElement(false); } else { // This is the start of the document RDFaEvaluationContext startingContext = getNewContext(getValueFactory().createIRI(this.baseIri)); @@ -472,12 +474,12 @@ && isAttributePresent(RDFaAttributes.TYPEOF)) { } } - // 8. If in any of the previous steps a new subject was set to a non-null value different from the parent subject; - Resource parentSubj = currentProcessingContext().getEvaluationContext().getParentSubjectResource(); - if (this.currentProcessingContext().getNewSubject() != null && !this.currentProcessingContext().getNewSubject().equals(parentSubj)) { + // 8. A subject different from the incoming parent object starts a local list scope. + Resource parentObject = currentProcessingContext().getEvaluationContext().getParentObjectResource(); + if (this.currentProcessingContext().getNewSubject() != null && !this.currentProcessingContext().getNewSubject().equals(parentObject)) { Map> freshListMappings = new HashMap<>(); this.currentProcessingContext().setListMappings(freshListMappings); - this.currentProcessingContext().getEvaluationContext().setListMappings(freshListMappings); + // Preserve the incoming map for incomplete relations and step 14 ownership. } // 9. If in any of the previous steps a current object resource was set to a non-null value, it is now used to generate triples and add entries to the local list mapping: @@ -563,6 +565,14 @@ && isAttributePresent(RDFaAttributes.REL)) { } } } + + // 13. Descendants of a relation describe its object. Share one scope + // between those descendants, distinct from this element's subject lists. + RDFaProcessingContext context = currentProcessingContext(); + if (!context.isSkipElement() && context.getCurrentObjectResource() != null + && !context.getCurrentObjectResource().equals(context.getNewSubject())) { + context.setChildListMappings(new HashMap<>()); + } } /* @@ -691,37 +701,41 @@ private void endProcessElement(String qName) { } // 14. Finally, if there is one or more mapping in the local list mapping, list triples are generated as follows: - Resource parentSubj = currentProcessingContext().getEvaluationContext().getParentSubjectResource(); - boolean isListOwner = this.currentProcessingContext().isRootElement() - || parentSubj == null - || (this.currentProcessingContext().getNewSubject() != null && !this.currentProcessingContext().getNewSubject().equals(parentSubj)); - - if (isListOwner) { - for (Map.Entry> listMapping : this.currentProcessingContext().getListMappings().entrySet()) { - IRI propertyIRI = listMapping.getKey(); - List propertyList = listMapping.getValue(); - - if (propertyList.isEmpty()) { - getModel().add(this.currentProcessingContext().getNewSubject(), propertyIRI, RDF.nil.getIRI()); - } else { - List bnodes = new ArrayList<>(); - for (int i = 0; i < propertyList.size(); i++) { - bnodes.add(getValueFactory().createBNode()); - } - for (int i = 0; i < propertyList.size(); i++) { - BNode elementNode = bnodes.get(i); - Resource nextElementNode = (i < propertyList.size() - 1) ? bnodes.get(i + 1) : RDF.nil.getIRI(); - getModel().add(elementNode, RDF.first.getIRI(), propertyList.get(i)); - getModel().add(elementNode, RDF.rest.getIRI(), nextElementNode); - } - getModel().add(this.currentProcessingContext().getNewSubject(), propertyIRI, bnodes.getFirst()); - } - } + RDFaProcessingContext context = currentProcessingContext(); + if (context.getListMappings() != context.getEvaluationContext().getListMappings()) { + emitListMappings(context.getNewSubject(), context.getListMappings()); + } + if (context.getChildListMappings() != context.getListMappings()) { + emitListMappings(context.getCurrentObjectResource(), context.getChildListMappings()); } this.processingContexts.pop(); } + /** Emit each owned list scope once, after all descendants have contributed. */ + private void emitListMappings(Resource subject, Map> mappings) { + for (Map.Entry> listMapping : mappings.entrySet()) { + IRI propertyIRI = listMapping.getKey(); + List propertyList = listMapping.getValue(); + + if (propertyList.isEmpty()) { + getModel().add(subject, propertyIRI, RDF.nil.getIRI()); + } else { + List bnodes = new ArrayList<>(); + for (int i = 0; i < propertyList.size(); i++) { + bnodes.add(getValueFactory().createBNode()); + } + for (int i = 0; i < propertyList.size(); i++) { + BNode elementNode = bnodes.get(i); + Resource nextElementNode = (i < propertyList.size() - 1) ? bnodes.get(i + 1) : RDF.nil.getIRI(); + getModel().add(elementNode, RDF.first.getIRI(), propertyList.get(i)); + getModel().add(elementNode, RDF.rest.getIRI(), nextElementNode); + } + getModel().add(subject, propertyIRI, bnodes.getFirst()); + } + } + } + /** * Internal SAX handler that delegates to the parser's methods */ @@ -828,7 +842,7 @@ private void parseSinglePrefixDeclaration(String prefixRaw, String namespaceStri throw new ParsingException("Expecting namespace prefix declaration to end with \":\", got " + prefixRaw + " in declaration " + declaration); } String prefix = prefixRaw.replaceAll(":$", "").trim(); - if ((prefix.isEmpty() && !isHtmlDocument()) || prefix.startsWith("_")) { + if (prefix.isEmpty() || prefix.startsWith("_")) { return; } if (namespaceString == null || namespaceString.trim().isEmpty()) { diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/model/RDFaProcessingContext.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/model/RDFaProcessingContext.java index fa141d971..f86f48cb7 100644 --- a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/model/RDFaProcessingContext.java +++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/model/RDFaProcessingContext.java @@ -25,6 +25,8 @@ public class RDFaProcessingContext { private Resource typedResource = null; private Set incompleteStatements = null; private Map> listMappings = new HashMap<>(); + /** Separate list scope when descendants describe the current object. */ + private Map> childListMappings; private String currentLanguage = null; private Value currentPropertyValue = null; private String defaultVocabulary = null; @@ -188,6 +190,14 @@ public void setListMappings(Map> listMappings) { this.listMappings = listMappings; } + public Map> getChildListMappings() { + return childListMappings != null ? childListMappings : listMappings; + } + + public void setChildListMappings(Map> childListMappings) { + this.childListMappings = childListMappings; + } + /** * * @param key The IRI of the list diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/RDFaParserTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/RDFaParserTest.java index 25dec5548..a056e1012 100644 --- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/RDFaParserTest.java +++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/rdfa/RDFaParserTest.java @@ -57,6 +57,116 @@ void getRDFFormat() { assertEquals(RDFFormat.RDFA, parser.getRDFFormat()); } + @Test + void descendantsShareOneListOnTheParentObject() { + assertRdfaLists(""" +
+ A + B +
+ """, "ex:subject ex:link ex:object . ex:object ex:items (\"A\" \"B\") ."); + } + + @Test + void listSubjectAndRelationObjectKeepSeparateCollections() { + assertRdfaLists(""" +
+ S1 +
+ O1 + O2 +
+ S2 +
+ """, """ + ex:subject ex:link ex:object ; ex:items ("S1" "S2") . + ex:object ex:items ("O1" "O2") . + """); + } + + @Test + void transparentWrappersAndRepeatedSubjectPreserveListOrder() { + assertRdfaLists(""" +
+
A
+
B
+ A +
+ """, "ex:subject ex:link ex:object . ex:object ex:items (\"A\" \"B\" \"A\") ."); + } + + @Test + void nestedSubjectDoesNotConsumeTheInheritedList() { + assertRdfaLists(""" +
+ A +
B
+ C + D +
+ """, """ + ex:subject ex:items ("A" "C") ; ex:otherItems ("D") . + ex:other ex:items ("B") . + """); + } + + @Test + void incompleteListRelationCollectsDescendantsAndKeepsEmptyLists() { + assertRdfaLists(""" +
+
+ +
+
+ +
+ """, "ex:subject ex:items (ex:first ex:second) ; ex:empty rdf:nil ."); + } + + @Test + void listRelationAndObjectPropertiesHaveDifferentOwners() { + assertRdfaLists(""" +
+ A + B +
+ """, "ex:subject ex:items (ex:object) . ex:object ex:items (\"A\" \"B\") ."); + } + + private void assertRdfaLists(String body, String expectedTurtle) { + String document = "" + + body + ""; + Model actual = createTestModel(); + RDFParser parser = parserFactory.createRDFParser(RDFFormat.RDFA, actual, valueFactory); + parser.parse(new ByteArrayInputStream(document.getBytes(java.nio.charset.StandardCharsets.UTF_8)), + "http://example.org/document"); + Model expected = createTestModel(); + RDFParser turtle = parserFactory.createRDFParser(RDFFormat.TURTLE, expected, valueFactory); + turtle.parse(new ByteArrayInputStream((defaultTurtlePrefixes + expectedTurtle) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)), "http://example.org/document"); + assertModelsIsomorphic(expected, actual); + } + + @Test + void emptyPrefixDeclarationCannotOverrideTheDefaultRdfaPrefix() { + String xhtml = """ + + + Ivan Herman + + + """; + Model model = createTestModel(); + RDFParser parser = parserFactory.createRDFParser(RDFFormat.RDFA, model, valueFactory); + parser.parse(new ByteArrayInputStream(xhtml.getBytes(java.nio.charset.StandardCharsets.UTF_8)), + "http://example.org/document"); + IRI subject = valueFactory.createIRI("http://example.org/person"); + Literal name = valueFactory.createLiteral("Ivan Herman"); + assertEquals(1, model.size()); + assertTrue(model.contains(subject, valueFactory.createIRI("http://www.w3.org/1999/xhtml/vocab#name"), name)); + assertFalse(model.contains(subject, valueFactory.createIRI("http://xmlns.com/foaf/0.1/name"), name)); + } + @Test void parseCurrentSubjectCreatorHead() { String currentSubjectXHTML = """