diff --git a/dapytains/tei/citeStructure.py b/dapytains/tei/citeStructure.py index 2ce98aa..c00aae4 100644 --- a/dapytains/tei/citeStructure.py +++ b/dapytains/tei/citeStructure.py @@ -285,6 +285,23 @@ def resolve_node(self, reference: str) -> Optional[saxonlib.PyXdmNode]: """ Resolve `reference` to its concrete node. """ return self._resolve_groups(self._parse_reference(reference)) + def milestone_anchors(self, reference: str) -> List[saxonlib.PyXdmNode]: + """ Concrete nodes of every milestone-mode ancestor unit in `reference`'s chain, in + chain (= document) order: e.g. for line "2.3" in a cb/lb tree, the node; + for "1.2.2" in a pb/cb/lb tree, the and that page's second . + + These milestones give the passage its citation identity but never appear on the + resolved node's own ancestor path — milestone "children" are siblings in the document, + not descendants — so passage extraction must copy them explicitly. """ + groups = self._parse_reference(reference) + anchors = [] + for position, (key, _) in enumerate(groups[:-1]): + if self.structure_by_key[key].milestone: + node = self._resolve_groups(groups[:position + 1]) + if node is not None: + anchors.append(node) + return anchors + def generate_xpath(self, reference): groups = self._parse_reference(reference) diff --git a/dapytains/tei/document.py b/dapytains/tei/document.py index 026e814..db24b09 100644 --- a/dapytains/tei/document.py +++ b/dapytains/tei/document.py @@ -346,6 +346,37 @@ def _treat_siblings( ) +def _copy_milestone_anchors( + xpath_proc: PyXPathProcessor, + result_start: saxonlib.PyXdmNode, + anchors: List[saxonlib.PyXdmNode], + new_tree: Element, + processor: saxonlib.PySaxonProcessor +) -> None: + """ Copy citation-anchoring milestone nodes (e.g. ) into the reconstructed tree. + + A milestone-nested reference resolves to its deepest node only (e.g. the ), while + the milestones carrying the passage's citation identity (the of "this column") sit + before it in the document as siblings, not ancestors. At each reconstruction level, copy + the anchors that are direct children of the current root and precede the start node; + anchors sitting deeper along the path are copied by the deeper recursion levels. An + anchor sealed inside an element that is not on the walked path (e.g. a closing + inside a previous wrapper) is not recovered. + + :param xpath_proc: XPath processor whose context is the current root + :param result_start: Node the walk resolved at this level (start side) + :param anchors: Milestone nodes of the start reference's ancestor units + :param new_tree: Reconstructed copy of the current root + """ + xpath_proc.declare_variable("__anchor") + xpath_proc.declare_variable("__anchor_start") + xpath_proc.set_parameter("__anchor_start", result_start) + for anchor in anchors: + xpath_proc.set_parameter("__anchor", anchor) + if xpath_proc.effective_boolean_value("exists(./node()[. is $__anchor][. << $__anchor_start])"): + copy_node(anchor, include_children=True, parent=new_tree, processor=processor) + + def xpath_eval(proc: PyXPathProcessor, xpath) -> List: return proc.evaluate(xpath) or [] @@ -367,7 +398,8 @@ def reconstruct_doc( end_xpath: Optional[List[str]] = None, start_siblings: Optional[Union[str, int]] = None, end_siblings: Optional[Union[str, int]] = None, - copy_until: bool = False + copy_until: bool = False, + start_anchors: Optional[List[saxonlib.PyXdmNode]] = None ) -> Element: """ Loop over passages to construct and increment new tree given a parent and XPaths @@ -379,6 +411,8 @@ def reconstruct_doc( :type end_xpath: [str] :param start_siblings: If siblings of starts need to be captured, provide the XPATH here. If == COPY_UNTIL_END, copy until ends :param end_siblings: If siblings of end need to be captured, provide XPath here. If == COPY_UNTIL_END, copy until ends + :param start_anchors: Milestone nodes (e.g. ) anchoring the start reference's ancestor + units, to be copied where they precede the start node (see _copy_milestone_anchors) :return: Newly incremented tree """ @@ -424,6 +458,9 @@ def reconstruct_doc( ): copy_node(sibling, include_children=True, parent=new_tree, processor=processor) + if start_anchors and new_tree is not None and result_start is not None: + _copy_milestone_anchors(xpath_proc, result_start, start_anchors, new_tree, processor=processor) + # We get the children if the XPath stops here # We copy the node we found copied_node = copy_node( @@ -446,7 +483,8 @@ def reconstruct_doc( start_xpath=queue_start, end_xpath=queue_end, start_siblings=start_siblings, - end_siblings=end_siblings, processor=processor + end_siblings=end_siblings, processor=processor, + start_anchors=start_anchors ) if start_siblings is not None: _treat_siblings(context_node=result_start, xpath=start_siblings, last_node=copied_node, @@ -479,7 +517,8 @@ def reconstruct_doc( end_xpath=queue_end, start_siblings=start_siblings, end_siblings=end_siblings, - processor=processor + processor=processor, + start_anchors=start_anchors ) return new_tree @@ -506,7 +545,8 @@ def reconstruct_doc( end_xpath=queue_end, start_siblings=start_siblings, end_siblings=end_siblings, - processor=processor + processor=processor, + start_anchors=start_anchors ) return new_tree @@ -517,6 +557,9 @@ def reconstruct_doc( if end_is_traversing: queue_end = end_xpath + if start_anchors and new_tree is not None and result_start is not None: + _copy_milestone_anchors(xpath_proc, result_start, start_anchors, new_tree, processor=processor) + # We start by copying start. parent_start = copy_node( result_start, @@ -537,7 +580,8 @@ def reconstruct_doc( start_xpath=queue_start, end_xpath=queue_start, start_siblings=start_siblings, - processor=processor + processor=processor, + start_anchors=start_anchors ) # We look for siblings between start and end matches. When both ends are already @@ -675,6 +719,13 @@ def get_passage( start_sibling = None end_sibling = None + # Milestone-nested start (e.g. a line under a ): the milestones that anchor its + # ancestor units are siblings in the document, not ancestors, so the walk would skip + # them. Resolve them now so reconstruct_doc can copy them where they belong. + start_anchors = None + if self.citeStructure[tree].is_milestone_nested(start): + start_anchors = self.citeStructure[tree].milestone_anchors(start) + if end: end_xpath = self.citeStructure[tree].generate_xpath(end) end_xpath_norm = normalize_xpath(xpath_split(end_xpath)) @@ -715,7 +766,8 @@ def get_passage( end_xpath=end_xpath_norm, start_siblings=start_sibling, end_siblings=end_sibling, - processor=self.xml_processor + processor=self.xml_processor, + start_anchors=start_anchors ) objectify.deannotate(root, cleanup_namespaces=True) diff --git a/setup.py b/setup.py index 6bdc4ec..10feb92 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ def read_requirements(): setup( name="dapytains", - version="1.0.0rc1", + version="1.0.0rc2", author="Thibault Clérice", author_email="thibault.clerice@inria.fr", description="A brief description of dapytains", diff --git a/tests/tei/cb_lb_milestones_split_ab.xml b/tests/tei/cb_lb_milestones_split_ab.xml new file mode 100644 index 0000000..9598256 --- /dev/null +++ b/tests/tei/cb_lb_milestones_split_ab.xml @@ -0,0 +1,48 @@ + + + + + Sample Latin Inscription, one ab per column + + +

Unit test example

+
+ +

Fictitious inscription for testing.

+
+
+ + + + + + + + +
+ + + +
+ + + + IMP CAESARI + DIVI F AVGVSTO + PONTIFICI MAXIMO + TRIB POTESTATE X + + + + COS XIII P P + SENATVS POPVLVSQVE + + ROMANVS + D D + + + +
+ +
+
diff --git a/tests/test_tei.py b/tests/test_tei.py index 075eb75..61ce0d8 100644 --- a/tests/test_tei.py +++ b/tests/test_tei.py @@ -279,12 +279,14 @@ def test_milestone_cb_lb(): ("2", ["2.1", "2.2", "2.3", "2.4"]), ] - # Same @n value ("1") in both columns must resolve to different, disambiguated lines + # Same @n value ("1") in both columns must resolve to different, disambiguated lines, + # and each passage must carry the milestone anchoring its column assert tostring(doc.get_passage("1.1"), encoding=str) == ( '\n' ' \n' '
\n' ' \n\n' + ' \n' ' IMP CAESARI\n' ' \n' '
\n' @@ -297,6 +299,7 @@ def test_milestone_cb_lb(): ' \n' '
\n' ' \n\n' + ' \n' ' COS XIII P P\n' ' \n' '
\n' @@ -311,6 +314,7 @@ def test_milestone_cb_lb(): ' \n' '
\n' ' \n\n' + ' \n' ' TRIB POTESTATE X\n\n' ' \n' '
\n' @@ -319,12 +323,13 @@ def test_milestone_cb_lb(): '
' ) - # A range crossing the column boundary should include the milestone itself + # A range crossing the column boundary should include both milestones assert tostring(doc.get_passage("1.4", "2.1"), encoding=str) == ( '\n' ' \n' '
\n' ' \n\n' + ' \n' ' TRIB POTESTATE X\n\n' ' \n' ' COS XIII P P\n' @@ -336,6 +341,61 @@ def test_milestone_cb_lb(): ) +def test_milestone_cb_lb_split_ab(): + """Milestone columns living in separate containers, with lines wrapped in a : + a range must carry each side's anchor and truncate the wrapper at the end line.""" + doc = Document(f"{local_dir}/cb_lb_milestones_split_ab.xml") + + refs = doc.get_reffs() + assert [(r.ref, [c.ref for c in r.children]) for r in refs] == [ + ("1", ["1.1", "1.2", "1.3", "1.4"]), + ("2", ["2.1", "2.2", "2.3", "2.4"]), + ] + + # The range starts at line 3, but column 1's anchor must still be copied (lines 1-2 + # must not); on the end side, the wrapper is kept and cut after line 2.3. + assert tostring(doc.get_passage("1.3", "2.3"), encoding=str) == ( + '\n' + ' \n' + '
\n' + ' \n\n' + ' \n' + ' PONTIFICI MAXIMO\n' + ' TRIB POTESTATE X\n' + ' \n' + ' \n' + ' \n' + ' COS XIII P P\n' + ' SENATVS POPVLVSQVE\n' + ' \n' + ' ROMANVS\n' + ' \n\n' + ' \n' + '
\n' + ' \n' + '
\n' + '
' + ) + + # Single ref inside the : the anchor is a sibling of the wrapper, one level + # above the line, and must still be copied. + assert tostring(doc.get_passage("2.3"), encoding=str) == ( + '\n' + ' \n' + '
\n' + ' \n' + ' \n' + ' \n' + ' ROMANVS\n' + ' \n\n' + ' \n' + '
\n' + ' \n' + '
\n' + '
' + ) + + def test_milestone_pb_cb_lb(): """Test a 3-level manuscript milestone hierarchy: page () > column () > line ()""" doc = Document(f"{local_dir}/pb_cb_lb_milestones.xml") @@ -346,12 +406,14 @@ def test_milestone_pb_cb_lb(): ] # Same @n values ("1"/"2") repeat for column and line across every page; each must resolve - # to its own, disambiguated line. + # to its own, disambiguated line, anchored by its own and milestones. assert tostring(doc.get_passage("1.1.1"), encoding=str) == ( '\n' ' \n' '
\n' ' \n\n' + ' \n' + ' \n' ' alpha\n' ' \n' '
\n' @@ -364,6 +426,8 @@ def test_milestone_pb_cb_lb(): ' \n' '
\n' ' \n\n' + ' \n' + ' \n' ' eta\n' ' \n' '
\n' @@ -379,6 +443,8 @@ def test_milestone_pb_cb_lb(): ' \n' '
\n' ' \n\n' + ' \n' + ' \n' ' delta\n\n' ' \n' ' \n' @@ -388,12 +454,15 @@ def test_milestone_pb_cb_lb(): '' ) - # A range crossing the page boundary should include both the and milestones + # A range crossing the page boundary should include the and milestones of + # both sides assert tostring(doc.get_passage("1.2.2", "2.1.1"), encoding=str) == ( '\n' ' \n' '
\n' ' \n\n' + ' \n' + ' \n' ' delta\n\n' ' \n' ' \n'