Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions dapytains/tei/citeStructure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cb n="2"/> node;
for "1.2.2" in a pb/cb/lb tree, the <pb n="1"/> and that page's second <cb/>.

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)

Expand Down
64 changes: 58 additions & 6 deletions dapytains/tei/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. <cb/>) into the reconstructed tree.

A milestone-nested reference resolves to its deepest node only (e.g. the <lb/>), while
the milestones carrying the passage's citation identity (the <cb/> 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 <cb/> 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 []

Expand All @@ -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

Expand All @@ -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. <cb/>) anchoring the start reference's ancestor
units, to be copied where they precede the start node (see _copy_milestone_anchors)
:return: Newly incremented tree

"""
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -675,6 +719,13 @@ def get_passage(
start_sibling = None
end_sibling = None

# Milestone-nested start (e.g. a line under a <cb/>): 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))
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions tests/tei/cb_lb_milestones_split_ab.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<TEI xmlns="http://www.tei-c.org/ns/1.0">
<teiHeader>
<fileDesc>
<titleStmt>
<title>Sample Latin Inscription, one ab per column</title>
</titleStmt>
<publicationStmt>
<p>Unit test example</p>
</publicationStmt>
<sourceDesc>
<p>Fictitious inscription for testing.</p>
</sourceDesc>
</fileDesc>

<encodingDesc>
<refsDecl>
<citeStructure unit="column" match="//body//cb" use="@n">
<citeStructure unit="line" match=".//lb" use="@n" delim="." />
</citeStructure>
</refsDecl>
</encodingDesc>
</teiHeader>

<text>
<body>
<div type="edition">
<ab>

<cb xml:id="c1" n="1"/>
<lb xml:id="c1l1" n="1"/>IMP CAESARI
<lb xml:id="c1l2" n="2"/>DIVI F AVGVSTO
<lb xml:id="c1l3" n="3"/>PONTIFICI MAXIMO
<lb xml:id="c1l4" n="4"/>TRIB POTESTATE X
</ab>
<ab>
<cb xml:id="c2" n="2"/>
<lb xml:id="c2l1" n="1"/>COS XIII P P
<lb xml:id="c2l2" n="2"/>SENATVS POPVLVSQVE
<seg>
<lb xml:id="c2l3" n="3"/>ROMANVS
<lb xml:id="c2l4" n="4"/>D D
</seg>

</ab>
</div>
</body>
</text>
</TEI>
77 changes: 73 additions & 4 deletions tests/test_tei.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cb/> milestone anchoring its column
assert tostring(doc.get_passage("1.1"), encoding=str) == (
'<TEI xmlns="http://www.tei-c.org/ns/1.0"><text>\n'
' <body>\n'
' <div type="edition">\n'
' <ab>\n\n'
' <cb xml:id="c1" n="1"/>\n'
' <lb xml:id="c1l1" n="1"/>IMP CAESARI\n'
' </ab>\n'
' </div>\n'
Expand All @@ -297,6 +299,7 @@ def test_milestone_cb_lb():
' <body>\n'
' <div type="edition">\n'
' <ab>\n\n'
' <cb xml:id="c2" n="2"/>\n'
' <lb xml:id="c2l1" n="1"/>COS XIII P P\n'
' </ab>\n'
' </div>\n'
Expand All @@ -311,6 +314,7 @@ def test_milestone_cb_lb():
' <body>\n'
' <div type="edition">\n'
' <ab>\n\n'
' <cb xml:id="c1" n="1"/>\n'
' <lb xml:id="c1l4" n="4"/>TRIB POTESTATE X\n\n'
' </ab>\n'
' </div>\n'
Expand All @@ -319,12 +323,13 @@ def test_milestone_cb_lb():
'</TEI>'
)

# A range crossing the column boundary should include the <cb/> milestone itself
# A range crossing the column boundary should include both <cb/> milestones
assert tostring(doc.get_passage("1.4", "2.1"), encoding=str) == (
'<TEI xmlns="http://www.tei-c.org/ns/1.0"><text>\n'
' <body>\n'
' <div type="edition">\n'
' <ab>\n\n'
' <cb xml:id="c1" n="1"/>\n'
' <lb xml:id="c1l4" n="4"/>TRIB POTESTATE X\n\n'
' <cb xml:id="c2" n="2"/>\n'
' <lb xml:id="c2l1" n="1"/>COS XIII P P\n'
Expand All @@ -336,6 +341,61 @@ def test_milestone_cb_lb():
)


def test_milestone_cb_lb_split_ab():
"""Milestone columns living in separate <ab> containers, with lines wrapped in a <seg>:
a range must carry each side's <cb/> 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 <cb/> anchor must still be copied (lines 1-2
# must not); on the end side, the <seg> wrapper is kept and cut after line 2.3.
assert tostring(doc.get_passage("1.3", "2.3"), encoding=str) == (
'<TEI xmlns="http://www.tei-c.org/ns/1.0"><text>\n'
' <body>\n'
' <div type="edition">\n'
' <ab>\n\n'
' <cb xml:id="c1" n="1"/>\n'
' <lb xml:id="c1l3" n="3"/>PONTIFICI MAXIMO\n'
' <lb xml:id="c1l4" n="4"/>TRIB POTESTATE X\n'
' </ab>\n'
' <ab>\n'
' <cb xml:id="c2" n="2"/>\n'
' <lb xml:id="c2l1" n="1"/>COS XIII P P\n'
' <lb xml:id="c2l2" n="2"/>SENATVS POPVLVSQVE\n'
' <seg>\n'
' <lb xml:id="c2l3" n="3"/>ROMANVS\n'
' </seg>\n\n'
' </ab>\n'
' </div>\n'
' </body>\n'
' </text>\n'
'</TEI>'
)

# Single ref inside the <seg>: the <cb/> 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) == (
'<TEI xmlns="http://www.tei-c.org/ns/1.0"><text>\n'
' <body>\n'
' <div type="edition">\n'
' <ab>\n'
' <cb xml:id="c2" n="2"/>\n'
' <seg>\n'
' <lb xml:id="c2l3" n="3"/>ROMANVS\n'
' </seg>\n\n'
' </ab>\n'
' </div>\n'
' </body>\n'
' </text>\n'
'</TEI>'
)


def test_milestone_pb_cb_lb():
"""Test a 3-level manuscript milestone hierarchy: page (<pb/>) > column (<cb/>) > line (<lb/>)"""
doc = Document(f"{local_dir}/pb_cb_lb_milestones.xml")
Expand All @@ -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 <pb/> and <cb/> milestones.
assert tostring(doc.get_passage("1.1.1"), encoding=str) == (
'<TEI xmlns="http://www.tei-c.org/ns/1.0"><text>\n'
' <body>\n'
' <div type="edition">\n'
' <ab>\n\n'
' <pb xml:id="p1" n="1"/>\n'
' <cb xml:id="p1c1" n="1"/>\n'
' <lb xml:id="p1c1l1" n="1"/>alpha\n'
' </ab>\n'
' </div>\n'
Expand All @@ -364,6 +426,8 @@ def test_milestone_pb_cb_lb():
' <body>\n'
' <div type="edition">\n'
' <ab>\n\n'
' <pb xml:id="p2" n="2"/>\n'
' <cb xml:id="p2c2" n="2"/>\n'
' <lb xml:id="p2c2l1" n="1"/>eta\n'
' </ab>\n'
' </div>\n'
Expand All @@ -379,6 +443,8 @@ def test_milestone_pb_cb_lb():
' <body>\n'
' <div type="edition">\n'
' <ab>\n\n'
' <pb xml:id="p1" n="1"/>\n'
' <cb xml:id="p1c2" n="2"/>\n'
' <lb xml:id="p1c2l2" n="2"/>delta\n\n'
' <pb xml:id="p2" n="2"/>\n'
' </ab>\n'
Expand All @@ -388,12 +454,15 @@ def test_milestone_pb_cb_lb():
'</TEI>'
)

# A range crossing the page boundary should include both the <pb/> and <cb/> milestones
# A range crossing the page boundary should include the <pb/> and <cb/> milestones of
# both sides
assert tostring(doc.get_passage("1.2.2", "2.1.1"), encoding=str) == (
'<TEI xmlns="http://www.tei-c.org/ns/1.0"><text>\n'
' <body>\n'
' <div type="edition">\n'
' <ab>\n\n'
' <pb xml:id="p1" n="1"/>\n'
' <cb xml:id="p1c2" n="2"/>\n'
' <lb xml:id="p1c2l2" n="2"/>delta\n\n'
' <pb xml:id="p2" n="2"/>\n'
' <cb xml:id="p2c1" n="1"/>\n'
Expand Down
Loading