grobid-client-python 0.0.18__py3-none-any.whl → 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- grobid_client/format/TEI2LossyJSON.py +1006 -0
- grobid_client/format/TEI2Markdown.py +707 -0
- grobid_client/format/__init__.py +0 -0
- grobid_client/format/validate_json_refs.py +378 -0
- grobid_client/grobid_client.py +171 -42
- {grobid_client_python-0.0.18.dist-info → grobid_client_python-0.1.0.dist-info}/METADATA +318 -20
- grobid_client_python-0.1.0.dist-info/RECORD +12 -0
- grobid_client_python-0.0.18.dist-info/RECORD +0 -8
- {grobid_client_python-0.0.18.dist-info → grobid_client_python-0.1.0.dist-info}/WHEEL +0 -0
- {grobid_client_python-0.0.18.dist-info → grobid_client_python-0.1.0.dist-info}/entry_points.txt +0 -0
- {grobid_client_python-0.0.18.dist-info → grobid_client_python-0.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convert the rich, unambiguous, standard, generic, extendable TEI XML format of GROBID and Pub2TEI into
|
|
3
|
+
something similar to CORD-19 degraded JSON format (let's call it a working format)
|
|
4
|
+
|
|
5
|
+
Original version: https://github.com/howisonlab/softcite-dataset/blob/master/code/corpus/TEI2LossyJSON.py
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import uuid
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, Union, BinaryIO, Iterator
|
|
14
|
+
|
|
15
|
+
import dateparser
|
|
16
|
+
from bs4 import BeautifulSoup, Tag
|
|
17
|
+
|
|
18
|
+
# Configure module-level logger
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
logger.propagate = False # Prevent propagation to avoid duplicate logs
|
|
21
|
+
|
|
22
|
+
# Only configure basic logging if nothing is set up yet
|
|
23
|
+
if not logger.handlers and not logging.getLogger().handlers:
|
|
24
|
+
# Basic configuration if not already configured by the application
|
|
25
|
+
logging.basicConfig(level=logging.INFO)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TEI2LossyJSONConverter:
|
|
29
|
+
"""Converter that can operate in two modes:
|
|
30
|
+
- non-streaming (backwards-compatible): returns a full document dict for a single file
|
|
31
|
+
- streaming: yields passages one by one to keep memory usage low when processing many files
|
|
32
|
+
|
|
33
|
+
The class also provides utilities to process a directory of TEI files in parallel and in batches.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, validate_refs: bool = True):
|
|
37
|
+
self.validate_refs = validate_refs
|
|
38
|
+
|
|
39
|
+
def convert_tei_file(self, tei_file: Union[Path, BinaryIO], stream: bool = False):
|
|
40
|
+
"""Backward-compatible function. If stream=True returns a generator that yields passages (dicts).
|
|
41
|
+
If stream=False returns the full document dict (same shape as original function).
|
|
42
|
+
"""
|
|
43
|
+
# Load with BeautifulSoup but avoid building huge structures when streaming
|
|
44
|
+
with open(tei_file, 'r') as f:
|
|
45
|
+
content = f.read()
|
|
46
|
+
soup = BeautifulSoup(content, 'xml')
|
|
47
|
+
|
|
48
|
+
if soup.TEI is None:
|
|
49
|
+
logger.warning("%s: The TEI file is not well-formed or empty. Skipping the file.", tei_file)
|
|
50
|
+
return None if not stream else iter(())
|
|
51
|
+
|
|
52
|
+
# Determine passage level early
|
|
53
|
+
passage_level = "sentence" if len(soup.find_all("s")) > len(soup.find_all("p")) else "paragraph"
|
|
54
|
+
|
|
55
|
+
if stream:
|
|
56
|
+
# Use generator that yields passages as they are formatted
|
|
57
|
+
return self._iter_passages_from_soup(soup, passage_level)
|
|
58
|
+
else:
|
|
59
|
+
# Build the full document (backward compatible)
|
|
60
|
+
document = OrderedDict()
|
|
61
|
+
document['level'] = passage_level
|
|
62
|
+
|
|
63
|
+
biblio_structure = OrderedDict()
|
|
64
|
+
document['biblio'] = biblio_structure
|
|
65
|
+
|
|
66
|
+
text_structure = []
|
|
67
|
+
document['body_text'] = text_structure
|
|
68
|
+
figures_and_tables = []
|
|
69
|
+
document['figures_and_tables'] = figures_and_tables
|
|
70
|
+
references_structure = []
|
|
71
|
+
document['references'] = references_structure
|
|
72
|
+
|
|
73
|
+
# Populate header and body using the same traversal used by the generator
|
|
74
|
+
for child in soup.TEI.children:
|
|
75
|
+
if child.name == 'teiHeader':
|
|
76
|
+
# Header parsing mirrors original behavior
|
|
77
|
+
title_node = child.find("title", attrs={"type": "main", "level": "a"})
|
|
78
|
+
biblio_structure["title"] = title_node.text if title_node else ""
|
|
79
|
+
biblio_structure["authors"] = list(
|
|
80
|
+
filter(
|
|
81
|
+
lambda x: x.strip() != "",
|
|
82
|
+
[
|
|
83
|
+
" ".join(
|
|
84
|
+
[
|
|
85
|
+
author.find('forename').text if author.find('forename') is not None else "",
|
|
86
|
+
author.find('surname').text if author.find('surname') is not None else ""
|
|
87
|
+
]
|
|
88
|
+
) for author in child.find_all("author")
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
doi_node = child.find("idno", type="DOI")
|
|
94
|
+
if doi_node:
|
|
95
|
+
biblio_structure['doi'] = doi_node.text
|
|
96
|
+
|
|
97
|
+
md5_node = child.find("idno", type="MD5")
|
|
98
|
+
if md5_node:
|
|
99
|
+
biblio_structure['hash'] = md5_node.text
|
|
100
|
+
|
|
101
|
+
pmc_idno = child.find("idno", type="PMC")
|
|
102
|
+
if pmc_idno:
|
|
103
|
+
biblio_structure['pmc'] = pmc_idno.text
|
|
104
|
+
|
|
105
|
+
pub_date = child.find("date", attrs={"type": "published"})
|
|
106
|
+
if pub_date:
|
|
107
|
+
iso_date = pub_date.attrs.get("when")
|
|
108
|
+
if iso_date:
|
|
109
|
+
biblio_structure["publication_date"] = iso_date
|
|
110
|
+
try:
|
|
111
|
+
year = dateparser.parse(iso_date).year
|
|
112
|
+
biblio_structure["publication_year"] = year
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
publisherStmt = child.find("publicationStmt")
|
|
117
|
+
publisher_node = publisherStmt.find("publisher") if publisherStmt else None
|
|
118
|
+
if publisher_node:
|
|
119
|
+
biblio_structure["publisher"] = publisher_node.text
|
|
120
|
+
|
|
121
|
+
journal_node = child.find("title", attrs={"type": "main", "level": "j"})
|
|
122
|
+
if journal_node:
|
|
123
|
+
biblio_structure["journal"] = journal_node.text
|
|
124
|
+
|
|
125
|
+
journal_abbr_node = child.find("title", attrs={"type": "abbr", "level": "j"})
|
|
126
|
+
if journal_abbr_node:
|
|
127
|
+
biblio_structure["journal_abbr"] = journal_abbr_node.text
|
|
128
|
+
|
|
129
|
+
abstract_node = child.find("abstract")
|
|
130
|
+
if abstract_node:
|
|
131
|
+
abstract_paragraph_nodes = abstract_node.find_all("p")
|
|
132
|
+
if passage_level == "sentence":
|
|
133
|
+
biblio_structure["abstract"] = [
|
|
134
|
+
[
|
|
135
|
+
{
|
|
136
|
+
"id": sentence.get("xml:id") if sentence.has_attr("xml:id") else id,
|
|
137
|
+
"text": sentence.text,
|
|
138
|
+
"coords": [
|
|
139
|
+
box_to_dict(coord.split(","))
|
|
140
|
+
for coord in sentence['coords'].split(";")
|
|
141
|
+
] if sentence.has_attr("coords") else [],
|
|
142
|
+
"refs": get_refs_with_offsets(sentence)
|
|
143
|
+
}
|
|
144
|
+
for id, sentence in enumerate(paragraph.find_all("s"))
|
|
145
|
+
]
|
|
146
|
+
for paragraph in abstract_paragraph_nodes
|
|
147
|
+
]
|
|
148
|
+
else:
|
|
149
|
+
biblio_structure["abstract"] = [
|
|
150
|
+
{
|
|
151
|
+
"id": id,
|
|
152
|
+
"text": paragraph.text,
|
|
153
|
+
"coords": [
|
|
154
|
+
box_to_dict(coord.split(","))
|
|
155
|
+
for coord in paragraph['coords'].split(";")
|
|
156
|
+
] if paragraph.has_attr("coords") else [],
|
|
157
|
+
"refs": get_refs_with_offsets(paragraph)
|
|
158
|
+
}
|
|
159
|
+
for id, paragraph in enumerate(abstract_paragraph_nodes)
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
elif child.name == 'text':
|
|
163
|
+
# Collect body_text using the generator to avoid duplicating logic
|
|
164
|
+
for passage in self._iter_passages_from_soup_for_text(child, passage_level):
|
|
165
|
+
text_structure.append(passage)
|
|
166
|
+
|
|
167
|
+
# Collect figures and tables (kept in memory as they should be relatively small)
|
|
168
|
+
figures_and_tables_xml = child.find_all("figure")
|
|
169
|
+
for item in figures_and_tables_xml:
|
|
170
|
+
item_id = item.attrs.get("xml:id") if item.has_attr("xml:id") else get_random_id()
|
|
171
|
+
desc = item.figDesc
|
|
172
|
+
head = item.head
|
|
173
|
+
label = item.label
|
|
174
|
+
if item.has_attr("type") and item.attrs["type"] == "table":
|
|
175
|
+
json_content = xml_table_to_json(item.table) if item.table else None
|
|
176
|
+
note = item.note
|
|
177
|
+
figures_and_tables.append(
|
|
178
|
+
{
|
|
179
|
+
"id": item_id,
|
|
180
|
+
"label": label.text if label else "",
|
|
181
|
+
"head": head.text if head else "",
|
|
182
|
+
"type": "table",
|
|
183
|
+
"desc": desc.text if desc else "",
|
|
184
|
+
"content": json_content,
|
|
185
|
+
"note": note.text if note else "",
|
|
186
|
+
"coords": [
|
|
187
|
+
box_to_dict(coord.split(","))
|
|
188
|
+
for coord in item['coords'].split(";")
|
|
189
|
+
] if item.has_attr("coords") else []
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
graphic_coords = item.graphic.attrs['coords'] if item.graphic and item.graphic.has_attr(
|
|
194
|
+
"coords") else None
|
|
195
|
+
figures_and_tables.append(
|
|
196
|
+
{
|
|
197
|
+
"id": item_id,
|
|
198
|
+
"label": label.text if label else "",
|
|
199
|
+
"head": head.text if head else "",
|
|
200
|
+
"type": "figure",
|
|
201
|
+
"desc": desc.text if desc else "",
|
|
202
|
+
"note": item.note.text if item.note else "",
|
|
203
|
+
"coords": [
|
|
204
|
+
box_to_dict(coord.split(","))
|
|
205
|
+
for coord in graphic_coords.split(";")
|
|
206
|
+
] if graphic_coords else []
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Extract references from listBibl with comprehensive processing
|
|
211
|
+
list_bibl = soup.find("listBibl")
|
|
212
|
+
if list_bibl:
|
|
213
|
+
for i, bibl_struct in enumerate(list_bibl.find_all("biblStruct"), 1):
|
|
214
|
+
ref_data = self._extract_comprehensive_reference_data(bibl_struct, i)
|
|
215
|
+
if ref_data:
|
|
216
|
+
references_structure.append(ref_data)
|
|
217
|
+
|
|
218
|
+
return document
|
|
219
|
+
|
|
220
|
+
def _extract_comprehensive_reference_data(self, bibl_struct: Tag, index: int) -> Dict:
|
|
221
|
+
"""
|
|
222
|
+
Extract detailed bibliographic information from TEI biblStruct elements.
|
|
223
|
+
Implements comprehensive parsing for all standard TEI bibliographic components.
|
|
224
|
+
"""
|
|
225
|
+
import re
|
|
226
|
+
|
|
227
|
+
citation_data = OrderedDict()
|
|
228
|
+
citation_data['id'] = f"b{index}"
|
|
229
|
+
|
|
230
|
+
# Extract reference identifier if present
|
|
231
|
+
xml_id = bibl_struct.get('{http://www.w3.org/XML/1998/namespace}id') or bibl_struct.get('xml:id')
|
|
232
|
+
if xml_id:
|
|
233
|
+
citation_data['target'] = xml_id
|
|
234
|
+
|
|
235
|
+
# Initialize containers for different types of content
|
|
236
|
+
contributor_list = []
|
|
237
|
+
publication_metadata = {}
|
|
238
|
+
identifier_collection = {}
|
|
239
|
+
supplementary_info = []
|
|
240
|
+
link_references = []
|
|
241
|
+
|
|
242
|
+
# 1. Process analytic level information (article/conference paper content)
|
|
243
|
+
analytic_section = bibl_struct.find("analytic")
|
|
244
|
+
if analytic_section:
|
|
245
|
+
# Extract title information from analytic level
|
|
246
|
+
analytic_titles = analytic_section.find_all("title")
|
|
247
|
+
for title_element in analytic_titles:
|
|
248
|
+
title_level = title_element.get("level", "")
|
|
249
|
+
title_content = self._clean_text(title_element.get_text())
|
|
250
|
+
if title_content:
|
|
251
|
+
if title_level == "a":
|
|
252
|
+
citation_data['title'] = title_content
|
|
253
|
+
elif title_level == "j":
|
|
254
|
+
publication_metadata['journal'] = title_content
|
|
255
|
+
|
|
256
|
+
# Extract author information from analytic level
|
|
257
|
+
analytic_authors = analytic_section.find_all("author")
|
|
258
|
+
for author_element in analytic_authors:
|
|
259
|
+
author_info = self._extract_contributor_details(author_element)
|
|
260
|
+
if author_info:
|
|
261
|
+
contributor_list.append(author_info)
|
|
262
|
+
|
|
263
|
+
# Handle reference elements within analytic section
|
|
264
|
+
analytic_ref = analytic_section.find("ref")
|
|
265
|
+
if analytic_ref:
|
|
266
|
+
ref_content = self._clean_text(analytic_ref.get_text())
|
|
267
|
+
if ref_content:
|
|
268
|
+
citation_data['reference_text'] = ref_content
|
|
269
|
+
if analytic_ref.get('target'):
|
|
270
|
+
citation_data['reference_uri'] = analytic_ref.get('target')
|
|
271
|
+
|
|
272
|
+
# Process identifier elements in analytic section
|
|
273
|
+
analytic_identifiers = analytic_section.find_all("idno")
|
|
274
|
+
for identifier_element in analytic_identifiers:
|
|
275
|
+
self._process_identifier_element(identifier_element, identifier_collection, 'analytic')
|
|
276
|
+
|
|
277
|
+
# Process pointer elements in analytic section
|
|
278
|
+
analytic_pointers = analytic_section.find_all("ptr")
|
|
279
|
+
for pointer_element in analytic_pointers:
|
|
280
|
+
self._process_pointer_element(pointer_element, link_references)
|
|
281
|
+
|
|
282
|
+
# 2. Process monograph level information (book/journal publication details)
|
|
283
|
+
monograph_section = bibl_struct.find("monogr")
|
|
284
|
+
if monograph_section:
|
|
285
|
+
# Extract title information from monograph level
|
|
286
|
+
monograph_titles = monograph_section.find_all("title")
|
|
287
|
+
for title_element in monograph_titles:
|
|
288
|
+
title_level = title_element.get("level", "")
|
|
289
|
+
title_content = self._clean_text(title_element.get_text())
|
|
290
|
+
if title_content:
|
|
291
|
+
if title_level == "m" and not citation_data.get('title'):
|
|
292
|
+
citation_data['title'] = title_content # Book title
|
|
293
|
+
elif title_level == "j" and not publication_metadata.get('journal'):
|
|
294
|
+
publication_metadata['journal'] = title_content
|
|
295
|
+
elif title_level == "s":
|
|
296
|
+
publication_metadata['series'] = title_content
|
|
297
|
+
|
|
298
|
+
# Extract contributors from monograph level (authors/editors)
|
|
299
|
+
monograph_contributors = monograph_section.find_all(["author", "editor"])
|
|
300
|
+
for contributor_element in monograph_contributors:
|
|
301
|
+
contributor_info = self._extract_contributor_details(contributor_element)
|
|
302
|
+
if contributor_info:
|
|
303
|
+
if contributor_element.name == "editor":
|
|
304
|
+
contributor_info['role'] = 'editor'
|
|
305
|
+
contributor_list.append(contributor_info)
|
|
306
|
+
|
|
307
|
+
# Extract imprint information (publication details)
|
|
308
|
+
imprint_section = monograph_section.find("imprint")
|
|
309
|
+
if imprint_section:
|
|
310
|
+
self._process_imprint_details(imprint_section, publication_metadata)
|
|
311
|
+
|
|
312
|
+
# Process identifier elements in monograph section
|
|
313
|
+
monograph_identifiers = monograph_section.find_all("idno")
|
|
314
|
+
for identifier_element in monograph_identifiers:
|
|
315
|
+
self._process_identifier_element(identifier_element, identifier_collection, 'monograph')
|
|
316
|
+
|
|
317
|
+
# Process pointer elements in monograph section
|
|
318
|
+
monograph_pointers = monograph_section.find_all("ptr")
|
|
319
|
+
for pointer_element in monograph_pointers:
|
|
320
|
+
self._process_pointer_element(pointer_element, link_references)
|
|
321
|
+
|
|
322
|
+
# 3. Process series level information
|
|
323
|
+
series_section = bibl_struct.find("series")
|
|
324
|
+
if series_section:
|
|
325
|
+
series_titles = series_section.find_all("title")
|
|
326
|
+
for title_element in series_titles:
|
|
327
|
+
title_content = self._clean_text(title_element.get_text())
|
|
328
|
+
if title_content and not publication_metadata.get('series'):
|
|
329
|
+
publication_metadata['series'] = title_content
|
|
330
|
+
|
|
331
|
+
series_contributors = series_section.find_all(["author", "editor"])
|
|
332
|
+
for contributor_element in series_contributors:
|
|
333
|
+
contributor_info = self._extract_contributor_details(contributor_element)
|
|
334
|
+
if contributor_info:
|
|
335
|
+
contributor_info['role'] = contributor_element.name
|
|
336
|
+
contributor_list.append(contributor_info)
|
|
337
|
+
|
|
338
|
+
# 4. Process top-level identifiers within biblStruct
|
|
339
|
+
top_level_identifiers = bibl_struct.find_all("idno")
|
|
340
|
+
for identifier_element in top_level_identifiers:
|
|
341
|
+
self._process_identifier_element(identifier_element, identifier_collection, 'biblstruct')
|
|
342
|
+
|
|
343
|
+
# 5. Process notes and supplementary information
|
|
344
|
+
note_elements = bibl_struct.find_all("note")
|
|
345
|
+
for note_element in note_elements:
|
|
346
|
+
note_content = self._clean_text(note_element.get_text())
|
|
347
|
+
note_type = note_element.get("type", "")
|
|
348
|
+
if note_content:
|
|
349
|
+
if note_type == "raw_reference":
|
|
350
|
+
citation_data['raw_reference'] = note_content
|
|
351
|
+
elif note_type:
|
|
352
|
+
citation_data[f'note_{note_type}'] = note_content
|
|
353
|
+
else:
|
|
354
|
+
supplementary_info.append(note_content)
|
|
355
|
+
|
|
356
|
+
# 6. Process pointer elements at biblStruct level
|
|
357
|
+
biblstruct_pointers = bibl_struct.find_all("ptr")
|
|
358
|
+
for pointer_element in biblstruct_pointers:
|
|
359
|
+
self._process_pointer_element(pointer_element, link_references)
|
|
360
|
+
|
|
361
|
+
# 7. Compile extracted information into final citation structure
|
|
362
|
+
self._compile_citation_data(citation_data, contributor_list, publication_metadata,
|
|
363
|
+
identifier_collection, supplementary_info, link_references)
|
|
364
|
+
|
|
365
|
+
# Ensure we have meaningful content before returning
|
|
366
|
+
if self._validate_citation_content(citation_data):
|
|
367
|
+
return citation_data
|
|
368
|
+
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
def _extract_contributor_details(self, contributor_element: Tag) -> Dict:
|
|
372
|
+
"""Extract detailed information about authors, editors, and other contributors."""
|
|
373
|
+
contributor_info = {}
|
|
374
|
+
|
|
375
|
+
# Extract name components
|
|
376
|
+
surname_element = contributor_element.find("surname")
|
|
377
|
+
forename_element = contributor_element.find("forename")
|
|
378
|
+
|
|
379
|
+
if surname_element and forename_element:
|
|
380
|
+
surname_text = self._clean_text(surname_element.get_text())
|
|
381
|
+
forename_text = self._clean_text(forename_element.get_text())
|
|
382
|
+
contributor_info['name'] = f"{forename_text} {surname_text}"
|
|
383
|
+
contributor_info['surname'] = surname_text
|
|
384
|
+
contributor_info['forename'] = forename_text
|
|
385
|
+
elif surname_element:
|
|
386
|
+
surname_text = self._clean_text(surname_element.get_text())
|
|
387
|
+
contributor_info['name'] = surname_text
|
|
388
|
+
contributor_info['surname'] = surname_text
|
|
389
|
+
elif forename_element:
|
|
390
|
+
forename_text = self._clean_text(forename_element.get_text())
|
|
391
|
+
contributor_info['name'] = forename_text
|
|
392
|
+
contributor_info['forename'] = forename_text
|
|
393
|
+
else:
|
|
394
|
+
# Fallback to full text content
|
|
395
|
+
full_name = self._clean_text(contributor_element.get_text())
|
|
396
|
+
if full_name:
|
|
397
|
+
contributor_info['name'] = full_name
|
|
398
|
+
|
|
399
|
+
# Extract affiliation information
|
|
400
|
+
affiliation_element = contributor_element.find("affiliation")
|
|
401
|
+
if affiliation_element:
|
|
402
|
+
affiliation_text = self._clean_text(affiliation_element.get_text())
|
|
403
|
+
if affiliation_text:
|
|
404
|
+
contributor_info['affiliation'] = affiliation_text
|
|
405
|
+
|
|
406
|
+
return contributor_info if contributor_info.get('name') else None
|
|
407
|
+
|
|
408
|
+
def _process_identifier_element(self, identifier_element: Tag, identifier_collection: Dict, level: str):
|
|
409
|
+
"""Process identifier elements (DOI, ISBN, ISSN, etc.) and organize by type and level."""
|
|
410
|
+
identifier_text = self._clean_text(identifier_element.get_text())
|
|
411
|
+
identifier_type = identifier_element.get("type", "").lower()
|
|
412
|
+
|
|
413
|
+
if identifier_text:
|
|
414
|
+
# Create level-specific container if it doesn't exist
|
|
415
|
+
level_key = f"{level}_identifiers"
|
|
416
|
+
if level_key not in identifier_collection:
|
|
417
|
+
identifier_collection[level_key] = {}
|
|
418
|
+
|
|
419
|
+
# Store identifier by type
|
|
420
|
+
if identifier_type:
|
|
421
|
+
identifier_collection[level_key][identifier_type] = identifier_text
|
|
422
|
+
else:
|
|
423
|
+
identifier_collection[level_key]['unknown'] = identifier_text
|
|
424
|
+
|
|
425
|
+
def _process_pointer_element(self, pointer_element: Tag, link_references: list):
|
|
426
|
+
"""Process pointer elements that contain external links."""
|
|
427
|
+
pointer_target = pointer_element.get("target", "").strip()
|
|
428
|
+
if pointer_target:
|
|
429
|
+
link_references.append(pointer_target)
|
|
430
|
+
|
|
431
|
+
def _process_imprint_details(self, imprint_element: Tag, publication_metadata: Dict):
|
|
432
|
+
"""Extract and process imprint information including publisher, dates, and page ranges."""
|
|
433
|
+
import re
|
|
434
|
+
|
|
435
|
+
# Extract publisher information
|
|
436
|
+
publisher_elements = imprint_element.find_all("publisher")
|
|
437
|
+
for publisher_element in publisher_elements:
|
|
438
|
+
publisher_name = self._clean_text(publisher_element.get_text())
|
|
439
|
+
if publisher_name:
|
|
440
|
+
publication_metadata['publisher'] = publisher_name
|
|
441
|
+
publisher_location = publisher_element.get("from")
|
|
442
|
+
if publisher_location:
|
|
443
|
+
publication_metadata['publisher_location'] = publisher_location
|
|
444
|
+
|
|
445
|
+
# Extract date information
|
|
446
|
+
date_elements = imprint_element.find_all("date")
|
|
447
|
+
for date_element in date_elements:
|
|
448
|
+
date_type = date_element.get("type", "")
|
|
449
|
+
date_content = self._clean_text(date_element.get_text())
|
|
450
|
+
date_when = date_element.get("when")
|
|
451
|
+
|
|
452
|
+
if date_when:
|
|
453
|
+
publication_metadata['publication_date'] = date_when
|
|
454
|
+
# Extract year from ISO date
|
|
455
|
+
year_match = re.search(r'\b(19|20)\d{2}\b', date_when)
|
|
456
|
+
if year_match:
|
|
457
|
+
publication_metadata['year'] = int(year_match.group())
|
|
458
|
+
elif date_content:
|
|
459
|
+
if date_type:
|
|
460
|
+
publication_metadata[f'date_{date_type}'] = date_content
|
|
461
|
+
else:
|
|
462
|
+
publication_metadata['publication_date_text'] = date_content
|
|
463
|
+
# Try to extract year from text
|
|
464
|
+
year_match = re.search(r'\b(19|20)\d{2}\b', date_content)
|
|
465
|
+
if year_match:
|
|
466
|
+
publication_metadata['year'] = int(year_match.group())
|
|
467
|
+
|
|
468
|
+
# Extract bibliographic scope information (pages, volume, issue)
|
|
469
|
+
scope_elements = imprint_element.find_all("biblScope")
|
|
470
|
+
for scope_element in scope_elements:
|
|
471
|
+
scope_unit = scope_element.get("unit", "")
|
|
472
|
+
scope_text = self._clean_text(scope_element.get_text())
|
|
473
|
+
scope_from = scope_element.get("from")
|
|
474
|
+
scope_to = scope_element.get("to")
|
|
475
|
+
|
|
476
|
+
if scope_unit == "page":
|
|
477
|
+
if scope_from:
|
|
478
|
+
publication_metadata['page_start'] = scope_from
|
|
479
|
+
if scope_to:
|
|
480
|
+
publication_metadata['page_end'] = scope_to
|
|
481
|
+
if scope_text and not scope_from and not scope_to:
|
|
482
|
+
publication_metadata['pages'] = scope_text
|
|
483
|
+
elif scope_unit in ["volume", "vol"]:
|
|
484
|
+
publication_metadata['volume'] = scope_text
|
|
485
|
+
elif scope_unit in ["issue", "num"]:
|
|
486
|
+
publication_metadata['issue'] = scope_text
|
|
487
|
+
elif scope_unit == "chapter":
|
|
488
|
+
publication_metadata['chapter'] = scope_text
|
|
489
|
+
|
|
490
|
+
def _compile_citation_data(self, citation_data: Dict, contributors: list,
|
|
491
|
+
publication_metadata: Dict, identifiers: Dict,
|
|
492
|
+
supplementary_info: list, links: list):
|
|
493
|
+
"""Compile all extracted information into the final citation structure."""
|
|
494
|
+
# Process contributors
|
|
495
|
+
if contributors:
|
|
496
|
+
authors = [c for c in contributors if c.get('role') != 'editor']
|
|
497
|
+
editors = [c for c in contributors if c.get('role') == 'editor']
|
|
498
|
+
|
|
499
|
+
if authors:
|
|
500
|
+
if len(authors) == 1:
|
|
501
|
+
citation_data['authors'] = authors[0]['name']
|
|
502
|
+
else:
|
|
503
|
+
citation_data['authors'] = [author['name'] for author in authors]
|
|
504
|
+
|
|
505
|
+
if editors:
|
|
506
|
+
if len(editors) == 1:
|
|
507
|
+
citation_data['editors'] = editors[0]['name']
|
|
508
|
+
else:
|
|
509
|
+
citation_data['editors'] = [editor['name'] for editor in editors]
|
|
510
|
+
|
|
511
|
+
# Merge publication metadata
|
|
512
|
+
for key, value in publication_metadata.items():
|
|
513
|
+
if value:
|
|
514
|
+
citation_data[key] = value
|
|
515
|
+
|
|
516
|
+
# Merge identifier information
|
|
517
|
+
for level, level_identifiers in identifiers.items():
|
|
518
|
+
for id_type, id_value in level_identifiers.items():
|
|
519
|
+
# Prioritize common identifier types at top level
|
|
520
|
+
if id_type in ['doi', 'isbn', 'issn', 'pmc', 'pmid', 'arxiv']:
|
|
521
|
+
citation_data[id_type] = id_value
|
|
522
|
+
else:
|
|
523
|
+
# Store other identifiers in nested structure
|
|
524
|
+
if 'identifiers' not in citation_data:
|
|
525
|
+
citation_data['identifiers'] = {}
|
|
526
|
+
citation_data['identifiers'][f"{level}_{id_type}"] = id_value
|
|
527
|
+
|
|
528
|
+
# Add supplementary information
|
|
529
|
+
if supplementary_info:
|
|
530
|
+
if len(supplementary_info) == 1:
|
|
531
|
+
citation_data['notes'] = supplementary_info[0]
|
|
532
|
+
else:
|
|
533
|
+
citation_data['notes'] = supplementary_info
|
|
534
|
+
|
|
535
|
+
# Add link references
|
|
536
|
+
if links:
|
|
537
|
+
if len(links) == 1:
|
|
538
|
+
citation_data['url'] = links[0]
|
|
539
|
+
else:
|
|
540
|
+
citation_data['urls'] = links
|
|
541
|
+
|
|
542
|
+
def _validate_citation_content(self, citation_data: Dict) -> bool:
|
|
543
|
+
"""Validate that the citation contains meaningful information."""
|
|
544
|
+
# Check for essential bibliographic elements
|
|
545
|
+
essential_elements = ['title', 'authors', 'journal', 'doi', 'isbn', 'issn', 'pmc', 'pmid']
|
|
546
|
+
|
|
547
|
+
# Check if any essential element is present
|
|
548
|
+
has_essential = any(citation_data.get(element) for element in essential_elements)
|
|
549
|
+
|
|
550
|
+
# Check for fallback elements
|
|
551
|
+
has_fallback = any(citation_data.get(element) for element in ['raw_reference', 'reference_text'])
|
|
552
|
+
|
|
553
|
+
return has_essential or has_fallback
|
|
554
|
+
|
|
555
|
+
def _extract_person_data(self, person_element: Tag) -> Dict:
|
|
556
|
+
"""
|
|
557
|
+
Extract person data (author/editor) from TEI persName or author elements.
|
|
558
|
+
Handles various name formats and affiliations.
|
|
559
|
+
"""
|
|
560
|
+
import re
|
|
561
|
+
|
|
562
|
+
person_data = {}
|
|
563
|
+
|
|
564
|
+
# Try different name extraction methods
|
|
565
|
+
forename = person_element.find("forename")
|
|
566
|
+
surname = person_element.find("surname")
|
|
567
|
+
|
|
568
|
+
if forename and surname:
|
|
569
|
+
# Standard format: forename + surname
|
|
570
|
+
forename_text = self._clean_text(forename.get_text())
|
|
571
|
+
surname_text = self._clean_text(surname.get_text())
|
|
572
|
+
person_data['name'] = f"{forename_text} {surname_text}"
|
|
573
|
+
person_data['forename'] = forename_text
|
|
574
|
+
person_data['surname'] = surname_text
|
|
575
|
+
elif surname:
|
|
576
|
+
# Surname only
|
|
577
|
+
surname_text = self._clean_text(surname.get_text())
|
|
578
|
+
person_data['name'] = surname_text
|
|
579
|
+
person_data['surname'] = surname_text
|
|
580
|
+
elif forename:
|
|
581
|
+
# Forename only
|
|
582
|
+
forename_text = self._clean_text(forename.get_text())
|
|
583
|
+
person_data['name'] = forename_text
|
|
584
|
+
person_data['forename'] = forename_text
|
|
585
|
+
else:
|
|
586
|
+
# Try to get name from full text content
|
|
587
|
+
full_name = self._clean_text(person_element.get_text())
|
|
588
|
+
if full_name:
|
|
589
|
+
person_data['name'] = full_name
|
|
590
|
+
# Try to parse into components
|
|
591
|
+
name_parts = full_name.split()
|
|
592
|
+
if len(name_parts) >= 2:
|
|
593
|
+
person_data['surname'] = name_parts[-1]
|
|
594
|
+
person_data['forename'] = " ".join(name_parts[:-1])
|
|
595
|
+
|
|
596
|
+
# Extract affiliation if present
|
|
597
|
+
affiliation = person_element.find("affiliation")
|
|
598
|
+
if affiliation:
|
|
599
|
+
aff_text = self._clean_text(affiliation.get_text())
|
|
600
|
+
if aff_text:
|
|
601
|
+
person_data['affiliation'] = aff_text
|
|
602
|
+
|
|
603
|
+
# Try to extract institution and location
|
|
604
|
+
# Look for common patterns like "Institution, City, Country"
|
|
605
|
+
parts = [part.strip() for part in aff_text.split(',') if part.strip()]
|
|
606
|
+
if len(parts) >= 1:
|
|
607
|
+
person_data['institution'] = parts[0]
|
|
608
|
+
if len(parts) >= 2:
|
|
609
|
+
person_data['location'] = ", ".join(parts[1:])
|
|
610
|
+
|
|
611
|
+
return person_data if person_data.get('name') else None
|
|
612
|
+
|
|
613
|
+
def _clean_text(self, text: str) -> str:
|
|
614
|
+
"""
|
|
615
|
+
Clean and normalize text content to handle encoding issues and extra whitespace.
|
|
616
|
+
"""
|
|
617
|
+
if not text:
|
|
618
|
+
return ""
|
|
619
|
+
|
|
620
|
+
# Handle common encoding issues
|
|
621
|
+
if isinstance(text, bytes):
|
|
622
|
+
try:
|
|
623
|
+
text = text.decode('utf-8')
|
|
624
|
+
except UnicodeDecodeError:
|
|
625
|
+
try:
|
|
626
|
+
text = text.decode('latin-1')
|
|
627
|
+
except UnicodeDecodeError:
|
|
628
|
+
text = text.decode('utf-8', errors='ignore')
|
|
629
|
+
|
|
630
|
+
# Normalize whitespace and strip
|
|
631
|
+
import re
|
|
632
|
+
text = re.sub(r'\s+', ' ', text.strip())
|
|
633
|
+
|
|
634
|
+
# Remove any potential XML/HTML entities
|
|
635
|
+
import html
|
|
636
|
+
text = html.unescape(text)
|
|
637
|
+
|
|
638
|
+
return text
|
|
639
|
+
|
|
640
|
+
def _iter_passages_from_soup(self, soup: BeautifulSoup, passage_level: str) -> Iterator[Dict[str, Union[str, Dict[str, str]]]]:
|
|
641
|
+
"""Yield formatted passages discovered in the TEI soup. This yields the same structures
|
|
642
|
+
as get_formatted_passage but one at a time to keep memory usage low."""
|
|
643
|
+
for child in soup.TEI.children:
|
|
644
|
+
if child.name == 'text':
|
|
645
|
+
for passage in self._iter_passages_from_soup_for_text(child, passage_level):
|
|
646
|
+
yield passage
|
|
647
|
+
|
|
648
|
+
def _iter_passages_from_soup_for_text(self, text_node: Tag, passage_level: str) -> Iterator[Dict[str, Union[str, Dict[str, str]]]]:
|
|
649
|
+
head_paragraph = None
|
|
650
|
+
|
|
651
|
+
# Process body and back sections
|
|
652
|
+
for section in text_node.find_all(['body', 'back']):
|
|
653
|
+
# Only get direct child divs of this section (handle namespace variants)
|
|
654
|
+
div_nodes = []
|
|
655
|
+
for child in section.children:
|
|
656
|
+
if hasattr(child, 'name') and child.name:
|
|
657
|
+
# Handle both namespaced and non-namespaced divs
|
|
658
|
+
if child.name == "div" or child.name.endswith(":div"):
|
|
659
|
+
div_nodes.append(child)
|
|
660
|
+
|
|
661
|
+
for id_div, div in enumerate(div_nodes):
|
|
662
|
+
# Skip references div as it's handled separately
|
|
663
|
+
if div.get("type") == "references":
|
|
664
|
+
continue
|
|
665
|
+
|
|
666
|
+
div_type = div.get("type")
|
|
667
|
+
|
|
668
|
+
# Process this div and potentially nested divs
|
|
669
|
+
for passage in self._process_div_with_nested_content(div, passage_level, head_paragraph):
|
|
670
|
+
yield passage
|
|
671
|
+
|
|
672
|
+
def _process_div_with_nested_content(self, div: Tag, passage_level: str, head_paragraph: str = None) -> Iterator[Dict[str, Union[str, Dict[str, str]]]]:
|
|
673
|
+
"""
|
|
674
|
+
Process a div and its nested content, handling various back section types.
|
|
675
|
+
Supports nested divs for complex back sections like annex with multiple subsections.
|
|
676
|
+
"""
|
|
677
|
+
head = div.find("head")
|
|
678
|
+
p_nodes = div.find_all("p")
|
|
679
|
+
head_section = None
|
|
680
|
+
current_head_paragraph = None
|
|
681
|
+
|
|
682
|
+
# Check if this div has nested divs first (handle namespace variants)
|
|
683
|
+
nested_divs = []
|
|
684
|
+
for child in div.children:
|
|
685
|
+
if hasattr(child, 'name') and child.name:
|
|
686
|
+
# Handle both namespaced and non-namespaced divs
|
|
687
|
+
if child.name == "div" or child.name.endswith(":div"):
|
|
688
|
+
nested_divs.append(child)
|
|
689
|
+
|
|
690
|
+
# Count only direct child paragraphs, not those in nested divs
|
|
691
|
+
direct_p_nodes = [child for child in div.children if hasattr(child, 'name') and child.name == "p"]
|
|
692
|
+
|
|
693
|
+
if len(nested_divs) > 0 and len(direct_p_nodes) == 0:
|
|
694
|
+
# This is a container div - process each nested div independently
|
|
695
|
+
for nested_div in nested_divs:
|
|
696
|
+
# Skip references divs
|
|
697
|
+
if nested_div.get("type") == "references":
|
|
698
|
+
continue
|
|
699
|
+
# Pass None as head_paragraph to ensure nested divs use their own headers
|
|
700
|
+
for passage in self._process_div_with_nested_content(nested_div, passage_level, None):
|
|
701
|
+
yield passage
|
|
702
|
+
return # Don't process this div further
|
|
703
|
+
|
|
704
|
+
# Determine the section header and content type for divs with content
|
|
705
|
+
if head:
|
|
706
|
+
if len(direct_p_nodes) == 0:
|
|
707
|
+
# This div has only a head, no paragraphs (standalone head)
|
|
708
|
+
current_head_paragraph = self._clean_text(head.get_text())
|
|
709
|
+
else:
|
|
710
|
+
# This div has both head and paragraphs - head is the section header
|
|
711
|
+
head_section = self._clean_text(head.get_text())
|
|
712
|
+
else:
|
|
713
|
+
# If no head element, try to use the type attribute as head_section
|
|
714
|
+
div_type = div.get("type")
|
|
715
|
+
if div_type:
|
|
716
|
+
# Handle specific div types with appropriate section names
|
|
717
|
+
if div_type == "acknowledgement":
|
|
718
|
+
head_section = "Acknowledgements"
|
|
719
|
+
elif div_type == "conflict":
|
|
720
|
+
head_section = "Conflicts of Interest"
|
|
721
|
+
elif div_type == "contribution":
|
|
722
|
+
head_section = "Author Contributions"
|
|
723
|
+
elif div_type == "availability":
|
|
724
|
+
# Only set as default if this div has its own content
|
|
725
|
+
if len(direct_p_nodes) > 0:
|
|
726
|
+
head_section = "Data Availability"
|
|
727
|
+
elif div_type == "annex":
|
|
728
|
+
head_section = "Annex"
|
|
729
|
+
else:
|
|
730
|
+
# Generic handling - capitalize and format
|
|
731
|
+
head_section = div_type.replace("_", " ").title()
|
|
732
|
+
|
|
733
|
+
# Process paragraphs in this div
|
|
734
|
+
if len(direct_p_nodes) > 0:
|
|
735
|
+
for id_p, p in enumerate(direct_p_nodes):
|
|
736
|
+
paragraph_id = get_random_id(prefix="p_")
|
|
737
|
+
|
|
738
|
+
if passage_level == "sentence":
|
|
739
|
+
for id_s, sentence in enumerate(p.find_all("s")):
|
|
740
|
+
struct = get_formatted_passage(current_head_paragraph or head_paragraph, head_section, paragraph_id, sentence)
|
|
741
|
+
if self.validate_refs:
|
|
742
|
+
for ref in struct['refs']:
|
|
743
|
+
assert "Wrong offsets", ref['offset_start'] < ref['offset_end']
|
|
744
|
+
assert "Cannot apply offsets", struct['text'][ref['offset_start']:ref['offset_end']] == ref['text']
|
|
745
|
+
yield struct
|
|
746
|
+
else:
|
|
747
|
+
struct = get_formatted_passage(current_head_paragraph or head_paragraph, head_section, paragraph_id, p)
|
|
748
|
+
if self.validate_refs:
|
|
749
|
+
for ref in struct['refs']:
|
|
750
|
+
assert "Wrong offsets", ref['offset_start'] < ref['offset_end']
|
|
751
|
+
assert "Cannot apply offsets", struct['text'][ref['offset_start']:ref['offset_end']] == ref['text']
|
|
752
|
+
yield struct
|
|
753
|
+
|
|
754
|
+
# Update head_paragraph for potential next div
|
|
755
|
+
if current_head_paragraph is not None:
|
|
756
|
+
head_paragraph = current_head_paragraph
|
|
757
|
+
|
|
758
|
+
def process_directory(self, directory: Union[str, Path], pattern: str = "*.tei.xml", parallel: bool = True, workers: int = None) -> Iterator[Dict]:
|
|
759
|
+
"""Process a directory of TEI files and yield converted documents.
|
|
760
|
+
When parallel=True this uses ProcessPoolExecutor to parallelize file-level conversion.
|
|
761
|
+
Each yielded item is a dict with keys: 'path' and 'document' (document may be None on parse error).
|
|
762
|
+
"""
|
|
763
|
+
directory = Path(directory)
|
|
764
|
+
files = list(directory.rglob(pattern))
|
|
765
|
+
if not parallel or len(files) <= 1:
|
|
766
|
+
for f in files:
|
|
767
|
+
yield {"path": f, "document": self.convert_tei_file(f, stream=False)}
|
|
768
|
+
return
|
|
769
|
+
|
|
770
|
+
# Use processes for CPU-bound parsing when many files are available
|
|
771
|
+
workers = workers or min(32, (os.cpu_count() or 1))
|
|
772
|
+
with ProcessPoolExecutor(max_workers=workers) as ex:
|
|
773
|
+
futures = {ex.submit(_convert_file_worker, str(f)): f for f in files}
|
|
774
|
+
for fut in as_completed(futures):
|
|
775
|
+
f = futures[fut]
|
|
776
|
+
try:
|
|
777
|
+
doc = fut.result()
|
|
778
|
+
except Exception:
|
|
779
|
+
logger.exception("Error processing %s", f)
|
|
780
|
+
doc = None
|
|
781
|
+
yield {"path": f, "document": doc}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _convert_file_worker(path: str):
|
|
785
|
+
"""Worker used by ProcessPoolExecutor. Imports inside function to avoid pickling issues."""
|
|
786
|
+
from bs4 import BeautifulSoup
|
|
787
|
+
# Reuse existing top-level helpers from this module by importing here
|
|
788
|
+
with open(path, 'r') as f:
|
|
789
|
+
content = f.read()
|
|
790
|
+
soup = BeautifulSoup(content, 'xml')
|
|
791
|
+
converter = TEI2LossyJSONConverter()
|
|
792
|
+
return converter.convert_tei_file(path, stream=False)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def box_to_dict(coord_list):
|
|
796
|
+
"""Convert coordinate list to dictionary format."""
|
|
797
|
+
if len(coord_list) >= 4:
|
|
798
|
+
return {
|
|
799
|
+
"x": float(coord_list[0]),
|
|
800
|
+
"y": float(coord_list[1]),
|
|
801
|
+
"width": float(coord_list[2]),
|
|
802
|
+
"height": float(coord_list[3])
|
|
803
|
+
}
|
|
804
|
+
return {}
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def get_random_id(prefix=""):
|
|
808
|
+
"""Generate a random ID with optional prefix."""
|
|
809
|
+
return f"{prefix}{uuid.uuid4().hex[:8]}"
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def get_refs_with_offsets(element):
|
|
813
|
+
"""Extract references with their text offsets from an element."""
|
|
814
|
+
refs = []
|
|
815
|
+
|
|
816
|
+
# Apply the same text cleaning as get_formatted_passage
|
|
817
|
+
def _clean_text(text: str) -> str:
|
|
818
|
+
if not text:
|
|
819
|
+
return ""
|
|
820
|
+
import re
|
|
821
|
+
import html
|
|
822
|
+
text = re.sub(r'\s+', ' ', text.strip())
|
|
823
|
+
text = html.unescape(text)
|
|
824
|
+
return text
|
|
825
|
+
|
|
826
|
+
# Now extract references with offsets based on the cleaned text
|
|
827
|
+
def traverse_and_collect(node, current_pos=0):
|
|
828
|
+
"""
|
|
829
|
+
Recursively traverse the DOM tree, building cleaned text content and tracking exact positions.
|
|
830
|
+
Returns tuple: (text_content, next_position)
|
|
831
|
+
"""
|
|
832
|
+
if hasattr(node, 'name') and node.name:
|
|
833
|
+
# This is an element node
|
|
834
|
+
if node.name == "ref" and node.get("type") == "bibr":
|
|
835
|
+
# Found a reference - get its cleaned text and record its exact position
|
|
836
|
+
ref_text = _clean_text(node.get_text())
|
|
837
|
+
if ref_text: # Only record non-empty references
|
|
838
|
+
refs.append({
|
|
839
|
+
"type": node.get("type", ""),
|
|
840
|
+
"target": node.get("target", ""),
|
|
841
|
+
"text": ref_text,
|
|
842
|
+
"offset_start": current_pos,
|
|
843
|
+
"offset_end": current_pos + len(ref_text)
|
|
844
|
+
})
|
|
845
|
+
# Return the cleaned reference text and advance position
|
|
846
|
+
return ref_text, current_pos + len(ref_text)
|
|
847
|
+
else:
|
|
848
|
+
# Process children in document order and accumulate their cleaned text
|
|
849
|
+
text_parts = []
|
|
850
|
+
pos = current_pos
|
|
851
|
+
for child in node.children:
|
|
852
|
+
child_text, new_pos = traverse_and_collect(child, pos)
|
|
853
|
+
if child_text is not None:
|
|
854
|
+
text_parts.append(child_text)
|
|
855
|
+
pos = new_pos
|
|
856
|
+
return "".join(text_parts), pos
|
|
857
|
+
else:
|
|
858
|
+
# This is a text node (NavigableString) - be more careful with cleaning
|
|
859
|
+
text_content = str(node)
|
|
860
|
+
|
|
861
|
+
# For text nodes, we need to be more careful about whitespace
|
|
862
|
+
# Only apply the full cleaning at the end for the complete text
|
|
863
|
+
return text_content, current_pos + len(text_content)
|
|
864
|
+
|
|
865
|
+
# Build raw text with accurate positions first
|
|
866
|
+
raw_text, _ = traverse_and_collect(element, 0)
|
|
867
|
+
|
|
868
|
+
# Now apply the same cleaning as get_formatted_passage to the complete text
|
|
869
|
+
final_text = _clean_text(raw_text)
|
|
870
|
+
|
|
871
|
+
# Adjust all reference offsets to match the cleaned text
|
|
872
|
+
final_refs = []
|
|
873
|
+
for ref in refs:
|
|
874
|
+
# Find the reference text in the cleaned text to get correct offsets
|
|
875
|
+
ref_text = ref['text']
|
|
876
|
+
|
|
877
|
+
# The reference text was also cleaned, so we need to find it in the final cleaned text
|
|
878
|
+
# We can search around the original position to find the correct occurrence
|
|
879
|
+
search_start = max(0, ref['offset_start'] - 10) # Look a bit before the original position
|
|
880
|
+
search_end = min(len(final_text), ref['offset_start'] + 10) # Look a bit after
|
|
881
|
+
search_area = final_text[search_start:search_end]
|
|
882
|
+
|
|
883
|
+
# Find the reference in the search area
|
|
884
|
+
relative_pos = search_area.find(ref_text)
|
|
885
|
+
if relative_pos != -1:
|
|
886
|
+
final_start = search_start + relative_pos
|
|
887
|
+
final_end = final_start + len(ref_text)
|
|
888
|
+
|
|
889
|
+
final_refs.append({
|
|
890
|
+
"type": ref["type"],
|
|
891
|
+
"target": ref["target"],
|
|
892
|
+
"text": ref_text,
|
|
893
|
+
"offset_start": final_start,
|
|
894
|
+
"offset_end": final_end
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
return final_refs
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def get_formatted_passage(head_paragraph, head_section, paragraph_id, element):
|
|
901
|
+
"""Format a passage (paragraph or sentence) with metadata and references."""
|
|
902
|
+
# Import the clean_text method
|
|
903
|
+
def _clean_text_local(text: str) -> str:
|
|
904
|
+
if not text:
|
|
905
|
+
return ""
|
|
906
|
+
import re
|
|
907
|
+
import html
|
|
908
|
+
text = re.sub(r'\s+', ' ', text.strip())
|
|
909
|
+
text = html.unescape(text)
|
|
910
|
+
return text
|
|
911
|
+
|
|
912
|
+
text = _clean_text_local(element.get_text())
|
|
913
|
+
refs = get_refs_with_offsets(element)
|
|
914
|
+
|
|
915
|
+
passage = {
|
|
916
|
+
"id": paragraph_id,
|
|
917
|
+
"text": text,
|
|
918
|
+
"coords": [
|
|
919
|
+
box_to_dict(coord.split(","))
|
|
920
|
+
for coord in element.get("coords", "").split(";")
|
|
921
|
+
] if element.has_attr("coords") else [],
|
|
922
|
+
"refs": refs
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if head_paragraph:
|
|
926
|
+
passage["head_paragraph"] = head_paragraph
|
|
927
|
+
if head_section:
|
|
928
|
+
passage["head_section"] = head_section
|
|
929
|
+
|
|
930
|
+
return passage
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def xml_table_to_markdown(table_element):
|
|
934
|
+
"""Convert XML table to markdown format."""
|
|
935
|
+
if not table_element:
|
|
936
|
+
return None
|
|
937
|
+
|
|
938
|
+
markdown_lines = []
|
|
939
|
+
|
|
940
|
+
# Process table rows
|
|
941
|
+
for row in table_element.find_all("row"):
|
|
942
|
+
cells = []
|
|
943
|
+
for cell in row.find_all("cell"):
|
|
944
|
+
cell_text = cell.get_text().strip()
|
|
945
|
+
cells.append(cell_text)
|
|
946
|
+
|
|
947
|
+
if cells:
|
|
948
|
+
markdown_lines.append("| " + " | ".join(cells) + " |")
|
|
949
|
+
|
|
950
|
+
return "\n".join(markdown_lines) if markdown_lines else None
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def xml_table_to_json(table_element):
|
|
954
|
+
"""Convert XML table to JSON format."""
|
|
955
|
+
if not table_element:
|
|
956
|
+
return None
|
|
957
|
+
|
|
958
|
+
table_data = {
|
|
959
|
+
"headers": [],
|
|
960
|
+
"rows": [],
|
|
961
|
+
"metadata": {}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
# Check if table has a header row (thead)
|
|
965
|
+
thead = table_element.find("thead")
|
|
966
|
+
if thead:
|
|
967
|
+
header_row = thead.find("row")
|
|
968
|
+
if header_row:
|
|
969
|
+
for cell in header_row.find_all("cell"):
|
|
970
|
+
cell_text = cell.get_text().strip()
|
|
971
|
+
table_data["headers"].append(cell_text)
|
|
972
|
+
|
|
973
|
+
# Process table body rows
|
|
974
|
+
tbody = table_element.find("tbody")
|
|
975
|
+
if tbody:
|
|
976
|
+
rows = tbody.find_all("row")
|
|
977
|
+
else:
|
|
978
|
+
# If no tbody, get all rows
|
|
979
|
+
rows = table_element.find_all("row")
|
|
980
|
+
# Skip first row if we already processed it as header
|
|
981
|
+
if thead and rows:
|
|
982
|
+
rows = rows[1:]
|
|
983
|
+
|
|
984
|
+
for row in rows:
|
|
985
|
+
row_data = []
|
|
986
|
+
for cell in row.find_all("cell"):
|
|
987
|
+
cell_text = cell.get_text().strip()
|
|
988
|
+
row_data.append(cell_text)
|
|
989
|
+
|
|
990
|
+
if row_data:
|
|
991
|
+
table_data["rows"].append(row_data)
|
|
992
|
+
|
|
993
|
+
# Add metadata
|
|
994
|
+
table_data["metadata"] = {
|
|
995
|
+
"row_count": len(table_data["rows"]),
|
|
996
|
+
"column_count": len(table_data["headers"]) if table_data["headers"] else (len(table_data["rows"][0]) if table_data["rows"] else 0),
|
|
997
|
+
"has_headers": len(table_data["headers"]) > 0
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
return table_data if table_data["rows"] else None
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
# Backwards compatible top-level function that uses the class
|
|
1004
|
+
def convert_tei_file(tei_file: Union[Path, BinaryIO], stream: bool = False):
|
|
1005
|
+
converter = TEI2LossyJSONConverter()
|
|
1006
|
+
return converter.convert_tei_file(tei_file, stream=stream)
|