Coverage for rdgai/tei.py: 100.00%

113 statements  

« prev     ^ index     » next       coverage.py v7.11.1, created at 2026-07-03 02:54 +0000

1import re 

2from pathlib import Path 

3from lxml import etree as ET 

4from lxml.etree import _ElementTree as ElementTree 

5from lxml.etree import _Element as Element 

6 

7from .languages import convert_language_code 

8 

9 

10def get_language_code(doc:ElementTree|Element) -> str: 

11 """ Reads the element <text> and returns the value of the xml:lang attribute.""" 

12 text = find_element(doc, ".//text") 

13 if text is None: 

14 return "" 

15 

16 return text.attrib.get("{http://www.w3.org/XML/1998/namespace}lang", "") 

17 

18 

19def get_language(doc:ElementTree|Element) -> str: 

20 code = get_language_code(doc) 

21 return convert_language_code(code) 

22 

23 

24def make_nc_name(string): 

25 invalid_chars = "!\"#$%&'()*+/:;<=>?@[\\]^,{|}~` " 

26 result = string.translate(str.maketrans(invalid_chars, '_' * len(invalid_chars))) 

27 # if result[0].isdigit or result[0] in [".", "-"]: 

28 # result = "id-" + result 

29 

30 return result 

31 

32 

33def extract_text(node:Element, include_tail:bool=True, sep:str=" ") -> str: 

34 if node is None: 

35 return "" 

36 

37 if isinstance(node.tag, str): 

38 tag = re.sub(r"{.*}", "", node.tag) 

39 else: 

40 return "" 

41 

42 if tag in ["pc", "witDetail", "note"]: 

43 return "" 

44 if tag == "app": 

45 lemma = find_element(node, ".//lem") 

46 if lemma is None: 

47 lemma = find_element(node, ".//rdg") 

48 return extract_text(lemma) or "" 

49 if tag == "ref": 

50 root = node.getroottree().getroot() 

51 target_id = node.attrib['target'].lstrip("#") 

52 ns = {"tei": "http://www.tei-c.org/ns/1.0"} 

53 target = root.xpath(f"//*[@xml:id='{target_id}']", namespaces=ns) 

54 

55 if target: 

56 return extract_text(target[0]) 

57 

58 if tag == "w": 

59 sep = "" 

60 

61 text = node.text or "" 

62 for child in node: 

63 text += sep + extract_text(child, sep=sep) 

64 

65 if include_tail and node.tail: 

66 text += sep + node.tail 

67 

68 return text.strip() 

69 

70 

71def read_tei(path:Path) -> ElementTree: 

72 parser = ET.XMLParser(remove_blank_text=True) 

73 with open(path, 'r') as f: 

74 return ET.parse(f, parser) 

75 

76 

77def find_element(doc:ElementTree|Element, xpath:str) -> Element|None: 

78 assert doc is not None, f"Document is None in find_element({doc}, {xpath})" 

79 if isinstance(doc, ElementTree): 

80 doc = doc.getroot() 

81 namespaces = doc.nsmap | {"xml": "http://www.w3.org/XML/1998/namespace"} 

82 element = doc.find(xpath, namespaces=namespaces) 

83 if element is None: 

84 try: 

85 element = doc.find(xpath) 

86 except SyntaxError: 

87 return None 

88 return element 

89 

90 

91def find_elements(doc:ElementTree|Element|None, xpath:str) -> list[Element]: 

92 if doc is None: 

93 return [] 

94 if isinstance(doc, ElementTree): 

95 doc = doc.getroot() 

96 namespaces = doc.nsmap | {"xml": "http://www.w3.org/XML/1998/namespace"} 

97 results = doc.findall(xpath, namespaces=namespaces) 

98 results += doc.findall(xpath) 

99 return results 

100 

101 

102def find_parent(element:Element, tag:str) -> Element|None: 

103 """ 

104 Finds the nearest ancestor of the given element with the specified tag. 

105 

106 Args: 

107 element (Element): The starting XML element from which to search upward. 

108 tag (str): The tag name of the ancestor element to find. 

109 

110 Returns: 

111 Optional[Element]: The nearest ancestor element with the specified tag, or None if no such element is found. 

112 

113 Example: 

114 >>> from xml.etree.ElementTree import Element 

115 >>> root = Element('root') 

116 >>> ab = Element('ab') 

117 >>> section = Element('section') 

118 >>> target = Element('target') 

119 >>> root.append(ab) 

120 >>> ab.append(section) 

121 >>> section.append(target) 

122 >>> result = find_parent(target, 'ab') 

123 >>> assert result == ab 

124 

125 This will find the <ab> ancestor of the <target> element. 

126 """ 

127 while element is not None: 

128 element_tag = re.sub(r"{.*}", "", element.tag) 

129 if element_tag == tag: 

130 return element 

131 element = element.getparent() 

132 return None 

133 

134 

135def write_tei(doc:ElementTree, path:Path|str) -> None: 

136 Path(path).parent.mkdir(parents=True, exist_ok=True) 

137 doc.write(str(path), encoding="utf-8", xml_declaration=True, pretty_print=True) 

138 

139 

140def get_reading_identifier(reading:Element, check:bool=False, create_if_necessary:bool=True) -> str: 

141 identifier = reading.attrib.get("{http://www.w3.org/XML/1998/namespace}id", "") 

142 if not identifier: 

143 identifier = reading.attrib.get("n", "") 

144 

145 if not identifier and create_if_necessary: 

146 app = reading.getparent() 

147 identifier = 1 

148 while find_element(app, f".//rdg[@n='{identifier}']") is not None: 

149 identifier += 1 

150 identifier = str(identifier) 

151 reading.attrib["n"] = identifier 

152 

153 if check: 

154 assert identifier, f"Reading {reading} must have a name attribute 'xml:id' or 'n'." 

155 

156 return identifier 

157 

158 

159def extract_text_siblings(element:Element, until_tag:str, preceding:bool=False, truncate:int|None=None) -> str: 

160 """ 

161 Extracts text from sibling elements of the given element until a sibling with the specified tag is encountered. 

162  

163 Args: 

164 element (Element): The starting XML element from which to extract text from its siblings. 

165 until_tag (str): The tag name of the sibling element that will stop the extraction process. 

166 preceding (bool): If True, extracts text from preceding siblings; if False, extracts from following siblings. 

167 truncate (int|None): If specified, limits the number of words extracted to this value. 

168  

169 Returns: 

170 str: A string containing the concatenated text from the sibling elements, up to the specified limit if provided. 

171 """ 

172 words = [] 

173 for sibling in element.itersiblings(preceding=preceding): 

174 tag = sibling.tag 

175 if isinstance(tag, str) and tag.rsplit("}", 1)[-1] == until_tag: 

176 break 

177 

178 text = extract_text(sibling) 

179 if text: 

180 my_words = text.split() 

181 if preceding: 

182 # Reverse the order of words for preceding context because we are iterating backwards through the siblings. 

183 my_words.reverse() 

184 

185 words.extend(my_words) 

186 if truncate and len(words) >= truncate: 

187 words = words[:truncate] 

188 break 

189 

190 if preceding: 

191 words.reverse() 

192 

193 return " ".join(words)