diff --git a/dapytains/tei/citeStructure.py b/dapytains/tei/citeStructure.py index c00aae4..abe4514 100644 --- a/dapytains/tei/citeStructure.py +++ b/dapytains/tei/citeStructure.py @@ -96,6 +96,12 @@ def get_children_cite_structures(elem: saxonlib.PyXdmNode, processor: saxonlib.P return [] +def _relative(prefix: str, xpath: str) -> str: + """ Join a "./" prefix with a child citeStructure match. A match may be written "//lb" as + well as ".//lb": the naive join gives the invalid ".///lb". """ + return f"{prefix}{xpath}".replace(".///", ".//") + + class CiteStructureParser: """ @@ -277,7 +283,7 @@ def _resolve_groups(self, groups: List[tuple]) -> Optional[saxonlib.PyXdmNode]: concrete_node = (self._milestone_window(concrete_node, boundary, formatted) or [None])[0] else: local_proc = get_xpath_proc(concrete_node, processor=self.processor) - concrete_node = local_proc.evaluate_single(f"./{formatted}") + concrete_node = local_proc.evaluate_single(_relative("./", formatted)) prev_structure = structure return concrete_node @@ -339,6 +345,53 @@ def get_next_milestone_boundary(self, reference: str) -> Optional[saxonlib.PyXdm start_node = self._resolve_groups(groups[:-1]) return self._milestone_boundary(start_node, parent_structure.match) + def _first_node_after(self, node: saxonlib.PyXdmNode) -> Optional[saxonlib.PyXdmNode]: + """ First node in document order after `node`'s whole subtree, i.e. the point where + `node` ends. """ + xpath_proc = get_xpath_proc(self.doc_root, processor=self.processor) + xpath_proc.declare_variable("__node") + xpath_proc.set_parameter("__node", node) + return xpath_proc.evaluate_single("($__node/following::node())[1]") + + def _earliest(self, *nodes: Optional[saxonlib.PyXdmNode]) -> Optional[saxonlib.PyXdmNode]: + """ The first of `nodes` in document order, ignoring the ones that are None. """ + earliest = None + xpath_proc = get_xpath_proc(self.doc_root, processor=self.processor) + xpath_proc.declare_variable("__a") + xpath_proc.declare_variable("__b") + for node in nodes: + if node is None: + continue + if earliest is None: + earliest = node + continue + xpath_proc.set_parameter("__a", node) + xpath_proc.set_parameter("__b", earliest) + if xpath_proc.effective_boolean_value("$__a << $__b"): + earliest = node + return earliest + + def get_next_unit_boundary(self, reference: str) -> Optional[saxonlib.PyXdmNode]: + """ Return the node that bounds `reference`'s deepest unit when that unit has no next + sibling inside its own parent: the next unit of the same kind in document order (e.g. + the first section of chapter 8 for the last section of chapter 7), clamped to the end + of `reference`'s own parent unit, so a passage never reaches into the following parent + (chapter 7's last section stops at the end of chapter 7, it does not pick up what + chapter 8 puts before its own first section). Without this bound, the passage would + bleed to the end of the document. Returns None when nothing follows `reference`. """ + groups = self._parse_reference(reference) + key, _ = groups[-1] + start_node = self._resolve_groups(groups) + if start_node is None: + return None + boundary = self._milestone_boundary(start_node, self.structure_by_key[key].match) + if len(groups) < 2: + return boundary + parent_node = self._resolve_groups(groups[:-1]) + if parent_node is None: + return boundary + return self._earliest(boundary, self._first_node_after(parent_node)) + def _dispatch( self, child_xpath: str, @@ -387,9 +440,16 @@ def find_refs( else: xpath_prefix = "./" if unit else "" # .evaluate returns None instead of an empty list... - values = xpath_proc.evaluate(f"{xpath_prefix}{structure.xpath}") or [] - - for value in values: + values = xpath_proc.evaluate(_relative(xpath_prefix, structure.xpath)) or [] + + for idx, value in enumerate(values, start=1): + if not value.string_value: + raise ValueError( + f"Empty citation value for unit '{structure.citeType}' " + f"(match #{idx} of `{structure.xpath}`" + + (f" inside `{unit.ref}`" if unit else "") + + "): the cited node has no value, check the source document." + ) child = CitableUnit( citeType=structure.citeType, ref=f"{prefix}{value.string_value}", @@ -442,7 +502,7 @@ def find_refs_from_branches( if milestone_match is not None: results = self._milestone_window(root, boundary, s.xpath) else: - results = xpath_proc.evaluate(f"{xpath_prefix}{s.xpath}") + results = xpath_proc.evaluate(_relative(xpath_prefix, s.xpath)) if results is not None: unsorted.extend( [ diff --git a/dapytains/tei/document.py b/dapytains/tei/document.py index db24b09..03ea948 100644 --- a/dapytains/tei/document.py +++ b/dapytains/tei/document.py @@ -137,7 +137,9 @@ def _get_sibling_xpath(node_xpath: str, prefix: str = ".", ancestor: str = "") - if node_xpath == "node()": return "./following-sibling::node()" - return (f"let $end := following-sibling::node()[{prefix}/descendant-or-self::{node_xpath}] " + # head(): several siblings can match the boundary xpath (e.g. every later chapter contains + # a section n="2"), and only the nearest one bounds the passage. + return (f"let $end := head(following-sibling::node()[{prefix}/descendant-or-self::{node_xpath}]) " f"return (./following-sibling::node() [. << $end])") @@ -185,6 +187,34 @@ def _prune(node: saxonlib.PyXdmNode, milestone: str, processor: saxonlib.PySaxon return x +def _prune_at_node( + node: saxonlib.PyXdmNode, + boundary: saxonlib.PyXdmNode, + processor: saxonlib.PySaxonProcessor +) -> str: + """ Same as _prune, but cuts at a concrete boundary node rather than at the first node + matching a tag/attribute based xpath fragment: the same fragment (e.g. a section n="2") + usually occurs in many places in a document, while the node does not. """ + xq = processor.new_xquery_processor() + xq.set_context(xdm_item=node) + xq.set_parameter("boundary", boundary) + query = """declare namespace output = 'http://www.w3.org/2010/xslt-xquery-serialization'; +declare default element namespace 'http://www.tei-c.org/ns/1.0'; +declare option output:omit-xml-declaration 'yes'; +declare variable $boundary external; +declare function local:prune($node, $b) { + if ($node instance of element()) then + if (empty($node/descendant-or-self::node() intersect $b)) then $node + else element {name($node)} { + $node/@*, + for $child in $node/node()[. << $b] return local:prune($child, $b) + } + else $node +}; +local:prune(., $boundary)""" + return xq.run_query_to_string(query_text=query) + + def copy_node( node: saxonlib.PyXdmNode, processor: saxonlib.PySaxonProcessor, @@ -203,7 +233,9 @@ def copy_node( if include_children: # We simply go from the element as a string to an element as XML. # We need to workaround false indentation through this xQuery - if isinstance(remove_milestone, str): + if isinstance(remove_milestone, saxonlib.PyXdmNode): + element = _prune_at_node(node, remove_milestone, processor=processor) + elif isinstance(remove_milestone, str): element = _prune(node, remove_milestone, processor=processor) else: xq = processor.new_xquery_processor() @@ -267,14 +299,20 @@ def normalize_xpath(xpath: List[str]) -> List[str]: new_xpath.append("/"+xpath[x]) elif len(xpath[x]) > 0: new_xpath.append(xpath[x]) - return new_xpath + # A "." step (from a child citeStructure matching ".//something") selects the node we are + # already on: keeping it would copy that node twice in the reconstructed tree. + return [step for step in new_xpath if step.strip("/") != "."] def reverse_ancestor(xpaths: List[str]) -> str: + strip = re.compile(r"^([./]+)") + # A step such as "." (produced by a citeStructure whose child match starts with ".//") + # carries no ancestor constraint: stripping it leaves nothing, and keeping it would + # generate the invalid "[ancestor::]". + xpaths = [stripped for stripped in (strip.sub("", xpath) for xpath in xpaths) if stripped] if not xpaths: return "" - strip = re.compile(r"^([./]+)") - here = f"[ancestor::{strip.sub('', xpaths[0])}{reverse_ancestor(xpaths[1:]) if len(xpaths) > 1 else ''}]" + here = f"[ancestor::{xpaths[0]}{reverse_ancestor(xpaths[1:]) if len(xpaths) > 1 else ''}]" return here @@ -299,13 +337,40 @@ def _treat_siblings( if isinstance(xpath, saxonlib.PyXdmNode): xproc.declare_variable("__boundary") xproc.set_parameter("__boundary", xpath) - next_nodes = xpath_eval(xproc, "./following-sibling::node()[. << $__boundary]") + # "<<" compares start positions, so a sibling that *contains* the boundary also + # matches: it is excluded here and copied (pruned) below instead. + next_nodes = xpath_eval( + xproc, + "./following-sibling::node()[. << $__boundary]" + "[empty(descendant::node() intersect $__boundary)]" + ) for node in next_nodes: if node.node_kind_str == "text": if not last_node.tail: last_node.tail = unescape(_get_text(node, ".", processor=processor)) else: last_node = copy_node(node, include_children=True, parent=last_node.getparent(), processor=processor) + + # The boundary can sit inside a following sibling (e.g. the next section starts in the + # middle of the next
): that sibling is not "<< boundary", so it is copied here,
+ # pruned at the boundary.
+ sibling_with_data = xproc.evaluate_single(
+ "./following-sibling::node()[descendant::node() intersect $__boundary]"
+ )
+ if sibling_with_data is not None:
+ pruned = copy_node(
+ sibling_with_data,
+ include_children=True,
+ parent=last_node.getparent(),
+ remove_milestone=xpath,
+ processor=processor
+ )
+ # Nothing of that sibling belongs to the passage (the boundary is its very first
+ # content): drop the empty shell rather than emitting it.
+ if pruned is not None and not "".join(pruned.itertext()):
+ parent = pruned.getparent()
+ if parent is not None:
+ parent.remove(pruned)
return last_node
loc_xpath = "node()" if xpath == COPY_UNTIL_END else xpath
@@ -733,13 +798,21 @@ def get_passage(
next_ref = self.get_next(tree, end)
if next_ref:
next_ref = next_ref.ref
- if self.citeStructure[tree].is_milestone_nested(next_ref):
- end_sibling = self.citeStructure[tree].resolve_node(next_ref)
- else:
+ # A concrete node is always preferable to a tag/attribute based xpath
+ # fragment: the fragment (e.g. `milestone[@unit='section'][@n='2']`) also
+ # matches every other chapter's section 2, which would push the boundary
+ # far beyond the end of the passage.
+ end_sibling = self.citeStructure[tree].resolve_node(next_ref)
+ if end_sibling is None:
next_ref_xpath = normalize_xpath(xpath_split(self.citeStructure[tree].generate_xpath(next_ref)))[-1]
end_sibling = next_ref_xpath.strip("/")
elif (milestone_boundary := self.citeStructure[tree].get_next_milestone_boundary(end)) is not None:
end_sibling = milestone_boundary
+ elif (unit_boundary := self.citeStructure[tree].get_next_unit_boundary(end)) is not None:
+ # No next sibling inside the parent unit (e.g. last section of a chapter):
+ # the passage still stops at the next unit of the same kind, not at the
+ # end of the document.
+ end_sibling = unit_boundary
else:
end_sibling = COPY_UNTIL_END
else:
@@ -748,13 +821,21 @@ def get_passage(
next_ref = self.get_next(tree, start)
if next_ref:
next_ref = next_ref.ref
- if self.citeStructure[tree].is_milestone_nested(next_ref):
- start_sibling = self.citeStructure[tree].resolve_node(next_ref)
- else:
+ # A concrete node is always preferable to a tag/attribute based xpath
+ # fragment: the fragment (e.g. `milestone[@unit='section'][@n='2']`) also
+ # matches every other chapter's section 2, which would push the boundary
+ # far beyond the end of the passage.
+ start_sibling = self.citeStructure[tree].resolve_node(next_ref)
+ if start_sibling is None:
next_ref_xpath = normalize_xpath(xpath_split(self.citeStructure[tree].generate_xpath(next_ref)))[-1]
start_sibling = next_ref_xpath.strip("/")
elif (milestone_boundary := self.citeStructure[tree].get_next_milestone_boundary(start)) is not None:
start_sibling = milestone_boundary
+ elif (unit_boundary := self.citeStructure[tree].get_next_unit_boundary(start)) is not None:
+ # No next sibling inside the parent unit (e.g. last section of a chapter):
+ # the passage still stops at the next unit of the same kind, not at the
+ # end of the document.
+ start_sibling = unit_boundary
else:
start_sibling = COPY_UNTIL_END
diff --git a/tests/tei/div_section_milestones.xml b/tests/tei/div_section_milestones.xml
new file mode 100644
index 0000000..bfad0f8
--- /dev/null
+++ b/tests/tei/div_section_milestones.xml
@@ -0,0 +1,17 @@
+ Before. Before. Before.