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.
@@ -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)