grobid-client-python 0.1.4__py3-none-any.whl → 0.2.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/client.py +44 -21
- grobid_client/format/TEI2LossyJSON.py +129 -90
- grobid_client/format/TEI2LossyJSON_cli.py +4 -2
- grobid_client/format/TEI2Markdown.py +19 -17
- grobid_client/format/TEI2Markdown_cli.py +4 -2
- grobid_client/format/__main__.py +3 -1
- grobid_client/format/validate_json_refs.py +3 -2
- grobid_client/grobid_client.py +1419 -214
- grobid_client/py.typed +0 -0
- {grobid_client_python-0.1.4.dist-info → grobid_client_python-0.2.0.dist-info}/METADATA +165 -22
- grobid_client_python-0.2.0.dist-info/RECORD +16 -0
- {grobid_client_python-0.1.4.dist-info → grobid_client_python-0.2.0.dist-info}/WHEEL +1 -1
- grobid_client_python-0.1.4.dist-info/RECORD +0 -15
- {grobid_client_python-0.1.4.dist-info → grobid_client_python-0.2.0.dist-info}/entry_points.txt +0 -0
- {grobid_client_python-0.1.4.dist-info → grobid_client_python-0.2.0.dist-info}/top_level.txt +0 -0
grobid_client/client.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
""" Generic API Client """
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
2
4
|
from copy import deepcopy
|
|
3
5
|
import json
|
|
6
|
+
from typing import Any, Optional, Tuple
|
|
7
|
+
|
|
4
8
|
import requests
|
|
5
9
|
|
|
6
10
|
try:
|
|
@@ -16,12 +20,17 @@ class ApiClient(object):
|
|
|
16
20
|
service methods, i.e. ``get``, ``post``, ``put`` and ``delete``.
|
|
17
21
|
"""
|
|
18
22
|
|
|
19
|
-
accept_type = "application/xml"
|
|
20
|
-
api_base = None
|
|
23
|
+
accept_type: str = "application/xml"
|
|
24
|
+
api_base: Optional[str] = None
|
|
21
25
|
|
|
22
26
|
def __init__(
|
|
23
|
-
self,
|
|
24
|
-
|
|
27
|
+
self,
|
|
28
|
+
base_url: str,
|
|
29
|
+
username: Optional[str] = None,
|
|
30
|
+
api_key: Optional[str] = None,
|
|
31
|
+
status_endpoint: Optional[str] = None,
|
|
32
|
+
timeout: int = 60,
|
|
33
|
+
) -> None:
|
|
25
34
|
"""Initialise client.
|
|
26
35
|
|
|
27
36
|
Args:
|
|
@@ -37,7 +46,7 @@ class ApiClient(object):
|
|
|
37
46
|
self.timeout = timeout
|
|
38
47
|
|
|
39
48
|
@staticmethod
|
|
40
|
-
def encode(request, data):
|
|
49
|
+
def encode(request: Any, data: Optional[dict]) -> Any:
|
|
41
50
|
"""Add request content data to request body, set Content-type header.
|
|
42
51
|
|
|
43
52
|
Should be overridden by subclasses if not using JSON encoding.
|
|
@@ -58,7 +67,7 @@ class ApiClient(object):
|
|
|
58
67
|
return request
|
|
59
68
|
|
|
60
69
|
@staticmethod
|
|
61
|
-
def decode(response):
|
|
70
|
+
def decode(response: Any) -> Any:
|
|
62
71
|
"""Decode the returned data in the response.
|
|
63
72
|
|
|
64
73
|
Should be overridden by subclasses if something else than JSON is
|
|
@@ -73,9 +82,9 @@ class ApiClient(object):
|
|
|
73
82
|
try:
|
|
74
83
|
return response.json()
|
|
75
84
|
except ValueError as e:
|
|
76
|
-
return e.message
|
|
85
|
+
return e.message # type: ignore[attr-defined] # pre-existing (Python 2 style)
|
|
77
86
|
|
|
78
|
-
def get_credentials(self):
|
|
87
|
+
def get_credentials(self) -> dict:
|
|
79
88
|
"""Returns parameters to be added to authenticate the request.
|
|
80
89
|
|
|
81
90
|
This lives on its own to make it easier to re-implement it if needed.
|
|
@@ -87,14 +96,14 @@ class ApiClient(object):
|
|
|
87
96
|
|
|
88
97
|
def call_api(
|
|
89
98
|
self,
|
|
90
|
-
method,
|
|
91
|
-
url,
|
|
92
|
-
headers=None,
|
|
93
|
-
params=None,
|
|
94
|
-
data=None,
|
|
95
|
-
files=None,
|
|
96
|
-
timeout=None,
|
|
97
|
-
):
|
|
99
|
+
method: str,
|
|
100
|
+
url: str,
|
|
101
|
+
headers: Optional[dict] = None,
|
|
102
|
+
params: Optional[dict] = None,
|
|
103
|
+
data: Optional[dict] = None,
|
|
104
|
+
files: Optional[dict] = None,
|
|
105
|
+
timeout: Optional[int] = None,
|
|
106
|
+
) -> Tuple[requests.Response, int]:
|
|
98
107
|
"""Call API.
|
|
99
108
|
|
|
100
109
|
This returns object containing data, with error details if applicable.
|
|
@@ -130,7 +139,7 @@ class ApiClient(object):
|
|
|
130
139
|
|
|
131
140
|
return r, r.status_code
|
|
132
141
|
|
|
133
|
-
def get(self, url, params=None, **kwargs):
|
|
142
|
+
def get(self, url: str, params: Optional[dict] = None, **kwargs: Any) -> Tuple[requests.Response, int]:
|
|
134
143
|
"""Call the API with a GET request.
|
|
135
144
|
|
|
136
145
|
Args:
|
|
@@ -142,7 +151,7 @@ class ApiClient(object):
|
|
|
142
151
|
"""
|
|
143
152
|
return self.call_api("GET", url, params=params, **kwargs)
|
|
144
153
|
|
|
145
|
-
def delete(self, url, params=None, **kwargs):
|
|
154
|
+
def delete(self, url: str, params: Optional[dict] = None, **kwargs: Any) -> Tuple[requests.Response, int]:
|
|
146
155
|
"""Call the API with a DELETE request.
|
|
147
156
|
|
|
148
157
|
Args:
|
|
@@ -154,7 +163,14 @@ class ApiClient(object):
|
|
|
154
163
|
"""
|
|
155
164
|
return self.call_api("DELETE", url, params=params, **kwargs)
|
|
156
165
|
|
|
157
|
-
def put(
|
|
166
|
+
def put(
|
|
167
|
+
self,
|
|
168
|
+
url: str,
|
|
169
|
+
params: Optional[dict] = None,
|
|
170
|
+
data: Optional[dict] = None,
|
|
171
|
+
files: Optional[dict] = None,
|
|
172
|
+
**kwargs: Any,
|
|
173
|
+
) -> Tuple[requests.Response, int]:
|
|
158
174
|
"""Call the API with a PUT request.
|
|
159
175
|
|
|
160
176
|
Args:
|
|
@@ -170,7 +186,14 @@ class ApiClient(object):
|
|
|
170
186
|
"PUT", url, params=params, data=data, files=files, **kwargs
|
|
171
187
|
)
|
|
172
188
|
|
|
173
|
-
def post(
|
|
189
|
+
def post(
|
|
190
|
+
self,
|
|
191
|
+
url: str,
|
|
192
|
+
params: Optional[dict] = None,
|
|
193
|
+
data: Optional[dict] = None,
|
|
194
|
+
files: Optional[dict] = None,
|
|
195
|
+
**kwargs: Any,
|
|
196
|
+
) -> Tuple[requests.Response, int]:
|
|
174
197
|
"""Call the API with a POST request.
|
|
175
198
|
|
|
176
199
|
Args:
|
|
@@ -186,7 +209,7 @@ class ApiClient(object):
|
|
|
186
209
|
method="POST", url=url, params=params, data=data, files=files, **kwargs
|
|
187
210
|
)
|
|
188
211
|
|
|
189
|
-
def service_status(self, **kwargs):
|
|
212
|
+
def service_status(self, **kwargs: Any) -> Tuple[requests.Response, int]:
|
|
190
213
|
"""Call the API to get the status of the service.
|
|
191
214
|
|
|
192
215
|
Returns:
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
Original version: https://github.com/howisonlab/softcite-dataset/blob/master/code/corpus/TEI2LossyJSON.py
|
|
6
6
|
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
7
9
|
import logging
|
|
8
10
|
import os
|
|
9
11
|
import uuid
|
|
@@ -12,13 +14,13 @@ from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
|
12
14
|
import html
|
|
13
15
|
import re
|
|
14
16
|
from pathlib import Path
|
|
15
|
-
from typing import Dict, Union, BinaryIO, Iterator
|
|
17
|
+
from typing import Any, Dict, Union, BinaryIO, Iterator
|
|
16
18
|
|
|
17
19
|
import dateparser
|
|
18
20
|
from bs4 import BeautifulSoup, Tag
|
|
19
21
|
|
|
20
22
|
# Configure module-level logger
|
|
21
|
-
logger = logging.getLogger(__name__)
|
|
23
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
22
24
|
logger.propagate = False # Prevent propagation to avoid duplicate logs
|
|
23
25
|
|
|
24
26
|
# Only configure basic logging if nothing is set up yet
|
|
@@ -35,10 +37,10 @@ class TEI2LossyJSONConverter:
|
|
|
35
37
|
The class also provides utilities to process a directory of TEI files in parallel and in batches.
|
|
36
38
|
"""
|
|
37
39
|
|
|
38
|
-
def __init__(self, validate_refs: bool = True):
|
|
39
|
-
self.validate_refs = validate_refs
|
|
40
|
+
def __init__(self, validate_refs: bool = True) -> None:
|
|
41
|
+
self.validate_refs: bool = validate_refs
|
|
40
42
|
|
|
41
|
-
def convert_tei_file(self, tei_file: Union[Path, BinaryIO], stream: bool = False):
|
|
43
|
+
def convert_tei_file(self, tei_file: Union[str, Path, BinaryIO], stream: bool = False) -> Any:
|
|
42
44
|
"""Backward-compatible function. If stream=True returns a generator that yields passages (dicts).
|
|
43
45
|
If stream=False returns the full document dict (same shape as original function).
|
|
44
46
|
"""
|
|
@@ -226,7 +228,7 @@ class TEI2LossyJSONConverter:
|
|
|
226
228
|
|
|
227
229
|
return document
|
|
228
230
|
|
|
229
|
-
def _extract_comprehensive_reference_data(self, bibl_struct: Tag, index: int) ->
|
|
231
|
+
def _extract_comprehensive_reference_data(self, bibl_struct: Tag, index: int) -> dict[str, Any] | None:
|
|
230
232
|
"""
|
|
231
233
|
Extract detailed bibliographic information from TEI biblStruct elements.
|
|
232
234
|
Implements comprehensive parsing for all standard TEI bibliographic components.
|
|
@@ -241,11 +243,11 @@ class TEI2LossyJSONConverter:
|
|
|
241
243
|
citation_data['target'] = xml_id
|
|
242
244
|
|
|
243
245
|
# Initialize containers for different types of content
|
|
244
|
-
contributor_list = []
|
|
245
|
-
publication_metadata = {}
|
|
246
|
-
identifier_collection = {}
|
|
247
|
-
supplementary_info = []
|
|
248
|
-
link_references = []
|
|
246
|
+
contributor_list: list[dict[str, Any]] = []
|
|
247
|
+
publication_metadata: dict[str, Any] = {}
|
|
248
|
+
identifier_collection: dict[str, Any] = {}
|
|
249
|
+
supplementary_info: list[str] = []
|
|
250
|
+
link_references: list[str] = []
|
|
249
251
|
|
|
250
252
|
# 1. Process analytic level information (article/conference paper content)
|
|
251
253
|
analytic_section = bibl_struct.find("analytic")
|
|
@@ -376,9 +378,9 @@ class TEI2LossyJSONConverter:
|
|
|
376
378
|
|
|
377
379
|
return None
|
|
378
380
|
|
|
379
|
-
def _extract_contributor_details(self, contributor_element: Tag) ->
|
|
381
|
+
def _extract_contributor_details(self, contributor_element: Tag) -> dict[str, Any] | None:
|
|
380
382
|
"""Extract detailed information about authors, editors, and other contributors."""
|
|
381
|
-
contributor_info = {}
|
|
383
|
+
contributor_info: dict[str, Any] = {}
|
|
382
384
|
|
|
383
385
|
# Extract name components
|
|
384
386
|
surname_element = contributor_element.find("surname")
|
|
@@ -413,7 +415,7 @@ class TEI2LossyJSONConverter:
|
|
|
413
415
|
|
|
414
416
|
return contributor_info if contributor_info.get('name') else None
|
|
415
417
|
|
|
416
|
-
def _process_identifier_element(self, identifier_element: Tag, identifier_collection:
|
|
418
|
+
def _process_identifier_element(self, identifier_element: Tag, identifier_collection: dict[str, Any], level: str) -> None:
|
|
417
419
|
"""Process identifier elements (DOI, ISBN, ISSN, etc.) and organize by type and level."""
|
|
418
420
|
identifier_text = self._clean_text(identifier_element.get_text())
|
|
419
421
|
identifier_type = identifier_element.get("type", "").lower()
|
|
@@ -430,13 +432,13 @@ class TEI2LossyJSONConverter:
|
|
|
430
432
|
else:
|
|
431
433
|
identifier_collection[level_key]['unknown'] = identifier_text
|
|
432
434
|
|
|
433
|
-
def _process_pointer_element(self, pointer_element: Tag, link_references: list):
|
|
435
|
+
def _process_pointer_element(self, pointer_element: Tag, link_references: list[str]) -> None:
|
|
434
436
|
"""Process pointer elements that contain external links."""
|
|
435
437
|
pointer_target = pointer_element.get("target", "").strip()
|
|
436
438
|
if pointer_target:
|
|
437
439
|
link_references.append(pointer_target)
|
|
438
440
|
|
|
439
|
-
def _process_imprint_details(self, imprint_element: Tag, publication_metadata:
|
|
441
|
+
def _process_imprint_details(self, imprint_element: Tag, publication_metadata: dict[str, Any]) -> None:
|
|
440
442
|
"""Extract and process imprint information including publisher, dates, and page ranges."""
|
|
441
443
|
|
|
442
444
|
# Extract publisher information
|
|
@@ -494,9 +496,9 @@ class TEI2LossyJSONConverter:
|
|
|
494
496
|
elif scope_unit == "chapter":
|
|
495
497
|
publication_metadata['chapter'] = scope_text
|
|
496
498
|
|
|
497
|
-
def _compile_citation_data(self, citation_data:
|
|
498
|
-
publication_metadata:
|
|
499
|
-
supplementary_info: list, links: list):
|
|
499
|
+
def _compile_citation_data(self, citation_data: dict[str, Any], contributors: list[dict[str, Any]],
|
|
500
|
+
publication_metadata: dict[str, Any], identifiers: dict[str, Any],
|
|
501
|
+
supplementary_info: list[str], links: list[str]) -> None:
|
|
500
502
|
"""Compile all extracted information into the final citation structure."""
|
|
501
503
|
# Process contributors
|
|
502
504
|
if contributors:
|
|
@@ -546,7 +548,7 @@ class TEI2LossyJSONConverter:
|
|
|
546
548
|
else:
|
|
547
549
|
citation_data['urls'] = links
|
|
548
550
|
|
|
549
|
-
def _validate_citation_content(self, citation_data:
|
|
551
|
+
def _validate_citation_content(self, citation_data: dict[str, Any]) -> bool:
|
|
550
552
|
"""Validate that the citation contains meaningful information."""
|
|
551
553
|
# Check for essential bibliographic elements
|
|
552
554
|
essential_elements = ['title', 'authors', 'journal', 'doi', 'isbn', 'issn', 'pmc', 'pmid']
|
|
@@ -559,13 +561,13 @@ class TEI2LossyJSONConverter:
|
|
|
559
561
|
|
|
560
562
|
return has_essential or has_fallback
|
|
561
563
|
|
|
562
|
-
def _extract_person_data(self, person_element: Tag) ->
|
|
564
|
+
def _extract_person_data(self, person_element: Tag) -> dict[str, Any] | None:
|
|
563
565
|
"""
|
|
564
566
|
Extract person data (author/editor) from TEI persName or author elements.
|
|
565
567
|
Handles various name formats and affiliations.
|
|
566
568
|
"""
|
|
567
569
|
|
|
568
|
-
person_data = {}
|
|
570
|
+
person_data: dict[str, Any] = {}
|
|
569
571
|
|
|
570
572
|
# Try different name extraction methods
|
|
571
573
|
forename = person_element.find("forename")
|
|
@@ -691,7 +693,7 @@ class TEI2LossyJSONConverter:
|
|
|
691
693
|
head_paragraph = None
|
|
692
694
|
|
|
693
695
|
|
|
694
|
-
def _process_div_with_nested_content(self, div: Tag, passage_level: str, head_paragraph: str = None) -> Iterator[Dict[str, Union[str, Dict[str, str]]]]:
|
|
696
|
+
def _process_div_with_nested_content(self, div: Tag, passage_level: str, head_paragraph: str | None = None) -> Iterator[Dict[str, Union[str, Dict[str, str]]]]:
|
|
695
697
|
"""
|
|
696
698
|
Process a div and its nested content, handling various back section types.
|
|
697
699
|
Supports nested divs for complex back sections like annex with multiple subsections.
|
|
@@ -813,7 +815,7 @@ class TEI2LossyJSONConverter:
|
|
|
813
815
|
if current_head_paragraph is not None:
|
|
814
816
|
head_paragraph = current_head_paragraph
|
|
815
817
|
|
|
816
|
-
def process_directory(self, directory: Union[str, Path], pattern: str = "*.tei.xml", parallel: bool = True, workers: int = None) -> Iterator[Dict]:
|
|
818
|
+
def process_directory(self, directory: Union[str, Path], pattern: str = "*.tei.xml", parallel: bool = True, workers: int | None = None) -> Iterator[Dict]:
|
|
817
819
|
"""Process a directory of TEI files and yield converted documents.
|
|
818
820
|
When parallel=True this uses ProcessPoolExecutor to parallelize file-level conversion.
|
|
819
821
|
Each yielded item is a dict with keys: 'path' and 'document' (document may be None on parse error).
|
|
@@ -839,7 +841,7 @@ class TEI2LossyJSONConverter:
|
|
|
839
841
|
yield {"path": f, "document": doc}
|
|
840
842
|
|
|
841
843
|
|
|
842
|
-
def _convert_file_worker(path: str):
|
|
844
|
+
def _convert_file_worker(path: str) -> Any:
|
|
843
845
|
"""Worker used by ProcessPoolExecutor. Imports inside function to avoid pickling issues."""
|
|
844
846
|
from bs4 import BeautifulSoup
|
|
845
847
|
# Reuse existing top-level helpers from this module by importing here
|
|
@@ -850,7 +852,7 @@ def _convert_file_worker(path: str):
|
|
|
850
852
|
return converter.convert_tei_file(path, stream=False)
|
|
851
853
|
|
|
852
854
|
|
|
853
|
-
def box_to_dict(coord_list):
|
|
855
|
+
def box_to_dict(coord_list: list[str]) -> dict[str, float]:
|
|
854
856
|
"""Convert coordinate list to dictionary format."""
|
|
855
857
|
if len(coord_list) >= 4:
|
|
856
858
|
return {
|
|
@@ -862,105 +864,141 @@ def box_to_dict(coord_list):
|
|
|
862
864
|
return {}
|
|
863
865
|
|
|
864
866
|
|
|
865
|
-
def get_random_id(prefix=""):
|
|
867
|
+
def get_random_id(prefix: str = "") -> str:
|
|
866
868
|
"""Generate a random ID with optional prefix."""
|
|
867
869
|
return f"{prefix}{uuid.uuid4().hex[:8]}"
|
|
868
870
|
|
|
869
871
|
|
|
870
872
|
def get_refs_with_offsets(element):
|
|
871
|
-
"""Extract references with their text offsets from an element.
|
|
873
|
+
"""Extract references with their text offsets from an element.
|
|
874
|
+
|
|
875
|
+
Uses a deterministic character-level index map from raw to cleaned text,
|
|
876
|
+
so offsets are always exact — no fuzzy search needed.
|
|
877
|
+
"""
|
|
872
878
|
refs = []
|
|
873
879
|
|
|
874
|
-
|
|
875
|
-
def _clean_text(text: str) -> str:
|
|
880
|
+
def _clean_text(text):
|
|
876
881
|
if not text:
|
|
877
882
|
return ""
|
|
878
883
|
text = re.sub(r'\s+', ' ', text.strip())
|
|
879
|
-
|
|
884
|
+
# DO NOT call html.unescape(text). BeautifulSoup get_text() already unescapes
|
|
885
|
+
# standard XML entities. Calling html.unescape causes aggressive conversion of
|
|
886
|
+
# malformed text (e.g., "<niss" without semicolon -> "<niss"), which shrinks
|
|
887
|
+
# the string and silently breaks all subsequent offsets.
|
|
880
888
|
return text
|
|
881
889
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
890
|
+
def _build_clean_map(raw_text):
|
|
891
|
+
"""Build cleaned text and a mapping array: raw_map[raw_idx] -> cleaned_idx.
|
|
892
|
+
|
|
893
|
+
Mimics the exact same cleaning as _clean_text:
|
|
894
|
+
1. strip leading/trailing whitespace
|
|
895
|
+
2. collapse internal whitespace runs to single space
|
|
896
|
+
(html.unescape is applied afterwards to the final string)
|
|
887
897
|
"""
|
|
898
|
+
n = len(raw_text)
|
|
899
|
+
raw_map = [0] * (n + 1)
|
|
900
|
+
|
|
901
|
+
# Find boundaries for strip()
|
|
902
|
+
left = 0
|
|
903
|
+
while left < n and raw_text[left].isspace():
|
|
904
|
+
left += 1
|
|
905
|
+
right = n - 1
|
|
906
|
+
while right >= 0 and raw_text[right].isspace():
|
|
907
|
+
right -= 1
|
|
908
|
+
|
|
909
|
+
if left > right:
|
|
910
|
+
return "", raw_map
|
|
911
|
+
|
|
912
|
+
out_chars = []
|
|
913
|
+
ci = 0 # current index in cleaned text
|
|
914
|
+
in_ws = False
|
|
915
|
+
|
|
916
|
+
for i in range(n):
|
|
917
|
+
if i < left or i > right:
|
|
918
|
+
raw_map[i] = ci
|
|
919
|
+
continue
|
|
920
|
+
ch = raw_text[i]
|
|
921
|
+
if ch.isspace():
|
|
922
|
+
if not in_ws:
|
|
923
|
+
out_chars.append(' ')
|
|
924
|
+
raw_map[i] = ci
|
|
925
|
+
ci += 1
|
|
926
|
+
in_ws = True
|
|
927
|
+
else:
|
|
928
|
+
raw_map[i] = ci
|
|
929
|
+
else:
|
|
930
|
+
out_chars.append(ch)
|
|
931
|
+
raw_map[i] = ci
|
|
932
|
+
ci += 1
|
|
933
|
+
in_ws = False
|
|
934
|
+
|
|
935
|
+
raw_map[n] = ci
|
|
936
|
+
return "".join(out_chars), raw_map
|
|
937
|
+
|
|
938
|
+
def traverse(node, pos=0):
|
|
939
|
+
"""Walk the DOM tree in document order, collecting raw text positions for refs."""
|
|
888
940
|
if hasattr(node, 'name') and node.name:
|
|
889
|
-
# This is an element node
|
|
890
941
|
if node.name == "ref" and node.get("type") == "bibr":
|
|
891
|
-
|
|
892
|
-
ref_text
|
|
893
|
-
if ref_text: # Only record non-empty references
|
|
942
|
+
ref_text = node.get_text()
|
|
943
|
+
if ref_text.strip():
|
|
894
944
|
refs.append({
|
|
895
945
|
"type": node.get("type", ""),
|
|
896
946
|
"target": node.get("target", ""),
|
|
897
|
-
"
|
|
898
|
-
"
|
|
899
|
-
"
|
|
947
|
+
"raw_text": ref_text,
|
|
948
|
+
"raw_start": pos,
|
|
949
|
+
"raw_end": pos + len(ref_text),
|
|
900
950
|
})
|
|
901
|
-
|
|
902
|
-
return ref_text, current_pos + len(ref_text)
|
|
951
|
+
return ref_text, pos + len(ref_text)
|
|
903
952
|
else:
|
|
904
|
-
|
|
905
|
-
text_parts = []
|
|
906
|
-
pos = current_pos
|
|
953
|
+
parts = []
|
|
907
954
|
for child in node.children:
|
|
908
|
-
child_text,
|
|
955
|
+
child_text, pos = traverse(child, pos)
|
|
909
956
|
if child_text is not None:
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
return "".join(text_parts), pos
|
|
957
|
+
parts.append(child_text)
|
|
958
|
+
return "".join(parts), pos
|
|
913
959
|
else:
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
# For text nodes, we need to be more careful about whitespace
|
|
918
|
-
# Only apply the full cleaning at the end for the complete text
|
|
919
|
-
return text_content, current_pos + len(text_content)
|
|
960
|
+
s = str(node)
|
|
961
|
+
return s, pos + len(s)
|
|
920
962
|
|
|
921
|
-
#
|
|
922
|
-
raw_text, _ =
|
|
963
|
+
# 1. Collect raw text and raw ref positions
|
|
964
|
+
raw_text, _ = traverse(element, 0)
|
|
923
965
|
|
|
924
|
-
#
|
|
925
|
-
|
|
966
|
+
# 2. Build the index map
|
|
967
|
+
cleaned, raw_map = _build_clean_map(raw_text)
|
|
926
968
|
|
|
927
|
-
#
|
|
969
|
+
# 3. Map each ref's raw offsets to cleaned offsets (deterministic, no search)
|
|
928
970
|
final_refs = []
|
|
929
971
|
for ref in refs:
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
#
|
|
934
|
-
#
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
"text": ref_text,
|
|
949
|
-
"offset_start": final_start,
|
|
950
|
-
"offset_end": final_end
|
|
951
|
-
})
|
|
972
|
+
mapped_start = raw_map[ref["raw_start"]]
|
|
973
|
+
mapped_end = raw_map[ref["raw_end"]]
|
|
974
|
+
|
|
975
|
+
# Extract the text directly from the cleaned string using the precise offsets.
|
|
976
|
+
# This absolutely guarantees that struct['text'][start:end] == ref['text'].
|
|
977
|
+
exact_text = cleaned[mapped_start:mapped_end]
|
|
978
|
+
|
|
979
|
+
# Sometimes a ref is entirely whitespace and gets collapsed to empty string
|
|
980
|
+
if not exact_text.strip():
|
|
981
|
+
continue
|
|
982
|
+
|
|
983
|
+
final_refs.append({
|
|
984
|
+
"type": ref["type"],
|
|
985
|
+
"target": ref["target"],
|
|
986
|
+
"text": exact_text,
|
|
987
|
+
"offset_start": mapped_start,
|
|
988
|
+
"offset_end": mapped_end,
|
|
989
|
+
})
|
|
952
990
|
|
|
953
991
|
return final_refs
|
|
954
992
|
|
|
955
993
|
|
|
956
|
-
def get_formatted_passage(head_paragraph, head_section, paragraph_id, element):
|
|
994
|
+
def get_formatted_passage(head_paragraph: str | None, head_section: str | None, paragraph_id: str, element: Tag) -> dict[str, Any]:
|
|
957
995
|
"""Format a passage (paragraph or sentence) with metadata and references."""
|
|
958
996
|
# Import the clean_text method
|
|
959
997
|
def _clean_text_local(text: str) -> str:
|
|
960
998
|
if not text:
|
|
961
999
|
return ""
|
|
962
1000
|
text = re.sub(r'\s+', ' ', text.strip())
|
|
963
|
-
|
|
1001
|
+
# DO NOT call html.unescape(text) here either, to maintain parity and prevent offset corruption.
|
|
964
1002
|
return text
|
|
965
1003
|
|
|
966
1004
|
text = _clean_text_local(element.get_text())
|
|
@@ -984,7 +1022,7 @@ def get_formatted_passage(head_paragraph, head_section, paragraph_id, element):
|
|
|
984
1022
|
return passage
|
|
985
1023
|
|
|
986
1024
|
|
|
987
|
-
def xml_table_to_markdown(table_element):
|
|
1025
|
+
def xml_table_to_markdown(table_element: Tag | None) -> str | None:
|
|
988
1026
|
"""Convert XML table to markdown format."""
|
|
989
1027
|
if not table_element:
|
|
990
1028
|
return None
|
|
@@ -1004,7 +1042,7 @@ def xml_table_to_markdown(table_element):
|
|
|
1004
1042
|
return "\n".join(markdown_lines) if markdown_lines else None
|
|
1005
1043
|
|
|
1006
1044
|
|
|
1007
|
-
def xml_table_to_json(table_element):
|
|
1045
|
+
def xml_table_to_json(table_element: Tag | None) -> dict[str, Any] | None:
|
|
1008
1046
|
"""Convert XML table to JSON format."""
|
|
1009
1047
|
if not table_element:
|
|
1010
1048
|
return None
|
|
@@ -1055,6 +1093,7 @@ def xml_table_to_json(table_element):
|
|
|
1055
1093
|
|
|
1056
1094
|
|
|
1057
1095
|
# Backwards compatible top-level function that uses the class
|
|
1058
|
-
def convert_tei_file(tei_file: Union[Path, BinaryIO], stream: bool = False):
|
|
1096
|
+
def convert_tei_file(tei_file: Union[str, Path, BinaryIO], stream: bool = False) -> Any:
|
|
1059
1097
|
converter = TEI2LossyJSONConverter()
|
|
1060
1098
|
return converter.convert_tei_file(tei_file, stream=stream)
|
|
1099
|
+
|
|
@@ -5,6 +5,8 @@ Standalone CLI for TEI2LossyJSON converter.
|
|
|
5
5
|
This script provides a command-line interface for converting TEI XML files to JSON format
|
|
6
6
|
using the TEI2LossyJSONConverter.
|
|
7
7
|
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
8
10
|
import argparse
|
|
9
11
|
import json
|
|
10
12
|
import logging
|
|
@@ -14,7 +16,7 @@ from pathlib import Path
|
|
|
14
16
|
from .TEI2LossyJSON import TEI2LossyJSONConverter
|
|
15
17
|
|
|
16
18
|
|
|
17
|
-
def setup_logging(verbose: bool = False):
|
|
19
|
+
def setup_logging(verbose: bool = False) -> None:
|
|
18
20
|
"""Setup logging configuration."""
|
|
19
21
|
level = logging.INFO if verbose else logging.WARNING
|
|
20
22
|
logging.basicConfig(
|
|
@@ -54,7 +56,7 @@ def convert_single_file(input_file: Path, output_file: Path, verbose: bool = Fal
|
|
|
54
56
|
return False
|
|
55
57
|
|
|
56
58
|
|
|
57
|
-
def main():
|
|
59
|
+
def main() -> None:
|
|
58
60
|
"""Main CLI entry point."""
|
|
59
61
|
parser = argparse.ArgumentParser(
|
|
60
62
|
description="Convert TEI XML files to JSON format using TEI2LossyJSON converter",
|