grobid-client-python 0.1.2__tar.gz → 0.1.4__tar.gz

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.
Files changed (66) hide show
  1. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/.github/workflows/ci-build.yml +5 -1
  2. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/PKG-INFO +28 -1
  3. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/Readme.md +27 -0
  4. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client/format/TEI2LossyJSON.py +80 -26
  5. grobid_client_python-0.1.4/grobid_client/format/TEI2LossyJSON_cli.py +131 -0
  6. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client/format/TEI2Markdown.py +82 -27
  7. grobid_client_python-0.1.4/grobid_client/format/TEI2Markdown_cli.py +130 -0
  8. grobid_client_python-0.1.4/grobid_client/format/__main__.py +48 -0
  9. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client/grobid_client.py +21 -1
  10. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client_python.egg-info/PKG-INFO +28 -1
  11. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client_python.egg-info/SOURCES.txt +9 -1
  12. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/pyproject.toml +1 -1
  13. grobid_client_python-0.1.4/resources/test_pdf/mjb3wlzxcb2mc-migowebupload-1766042162782.grobid.tei.xml +1053 -0
  14. grobid_client_python-0.1.4/tests/resources/article_withdrawn.grobid.tei.xml +51 -0
  15. grobid_client_python-0.1.4/tests/resources/repeated_text/ijms-24-05988.grobid.tei.xml +1298 -0
  16. grobid_client_python-0.1.4/tests/resources/repeated_text/ijms-24-05988.json +1459 -0
  17. grobid_client_python-0.1.4/tests/resources/repeated_text/ijms-24-05988.md +264 -0
  18. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/test_conversions.py +301 -0
  19. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/.github/workflows/ci-release.yml +0 -0
  20. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/.gitignore +0 -0
  21. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/LICENSE +0 -0
  22. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/MANIFEST.in +0 -0
  23. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/config.json +0 -0
  24. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/example.py +0 -0
  25. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client/__init__.py +0 -0
  26. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client/client.py +0 -0
  27. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client/format/__init__.py +0 -0
  28. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client/format/validate_json_refs.py +0 -0
  29. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client_python.egg-info/dependency_links.txt +0 -0
  30. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client_python.egg-info/entry_points.txt +0 -0
  31. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client_python.egg-info/requires.txt +0 -0
  32. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/grobid_client_python.egg-info/top_level.txt +0 -0
  33. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/pytest.ini +0 -0
  34. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/requirements.txt +0 -0
  35. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/resources/20180928112135.png +0 -0
  36. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/resources/20180928112138.csv +0 -0
  37. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/resources/test_pdf/0046d83a-edd6-4631-b57c-755cdcce8b7f.pdf +0 -0
  38. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/resources/test_pdf/0046d83a-edd6-4631-b57c-755cdcce8b7f.tei.xml +0 -0
  39. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/resources/test_txt/reference-strings-1.txt +0 -0
  40. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/resources/test_txt/reference-strings-2.txt +0 -0
  41. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/resources/test_txt/reference-strings-3.txt +0 -0
  42. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/resources/test_txt/reference-strings-4.txt +0 -0
  43. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/resources/test_txt/reference-strings-5.txt +0 -0
  44. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/resources/test_txt/reference-strings-6.txt +0 -0
  45. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/resources/test_txt/reference-strings-7.txt +0 -0
  46. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/setup.cfg +0 -0
  47. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/__init__.py +0 -0
  48. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/conftest.py +0 -0
  49. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/0046d83a-edd6-4631-b57c-755cdcce8b7f.tei.xml +0 -0
  50. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/__init__.py +0 -0
  51. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/refs_offsets/10.1038_s41477-023-01501-1.grobid.tei.xml +0 -0
  52. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/refs_offsets/10.1038_s41477-023-01501-1.json +0 -0
  53. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/refs_offsets/10.1038_s41586-023-05895-y.grobid.tei.xml +0 -0
  54. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/refs_offsets/10.1038_s41586-023-05895-y.json +0 -0
  55. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/refs_offsets/10.1038_s41598-023-32039-z.grobid.tei.xml +0 -0
  56. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/refs_offsets/10.1038_s41598-023-32039-z.json +0 -0
  57. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/refs_offsets/10.1371_journal.pone.0218311.grobid.tei.xml +0 -0
  58. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/refs_offsets/10.1371_journal.pone.0218311.json +0 -0
  59. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/refs_offsets/10.7554_elife.78558.grobid.tei.xml +0 -0
  60. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/refs_offsets/10.7554_elife.78558.json +0 -0
  61. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/resources/refs_offsets/2021.naacl-main.224.grobid.tei.xml +0 -0
  62. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/run_tests.py +0 -0
  63. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/test_client.py +0 -0
  64. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/test_config.json +0 -0
  65. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/test_grobid_client.py +0 -0
  66. {grobid_client_python-0.1.2 → grobid_client_python-0.1.4}/tests/test_integration.py +0 -0
@@ -22,7 +22,11 @@ jobs:
22
22
  python-version: ${{ matrix.python-version }}
23
23
  cache: 'pip'
24
24
  - name: Cleanup more disk space
25
- run: sudo rm -rf /usr/share/dotnet && sudo rm -rf /opt/ghc && sudo rm -rf "/usr/local/share/boost" && sudo rm -rf "$AGENT_TOOLSDIRECTORY"
25
+ run: |
26
+ sudo rm -rf /usr/share/dotnet
27
+ sudo rm -rf /opt/ghc
28
+ sudo rm -rf "/usr/local/share/boost"
29
+ sudo rm -rf "$AGENT_TOOLSDIRECTORY"
26
30
  - name: Install dependencies
27
31
  run: |
28
32
  python -m pip install --upgrade pip
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grobid-client-python
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Simple python client for GROBID REST services
5
5
  Author-email: Patrice Lopez <patrice.lopez@science-miner.com>
6
6
  Maintainer-email: Patrice Lopez <patrice.lopez@science-miner.com>, Luca Foppiano <lucanoro@duck.com>
@@ -443,6 +443,7 @@ client.process(
443
443
  markdown_output=True
444
444
  )
445
445
 
446
+ ```python
446
447
  # Process citation lists
447
448
  client.process(
448
449
  service="processCitationList",
@@ -451,6 +452,32 @@ client.process(
451
452
  )
452
453
  ```
453
454
 
455
+ ### Standalone Conversion Tools
456
+
457
+ The library includes standalone scripts to convert TEI XML files to other formats without using the main client or server.
458
+
459
+ #### TEI to JSON Converter
460
+
461
+ Converts TEI XML files to the structured JSON format (similar to `--json` option).
462
+
463
+ ```bash
464
+ # Convert a single file
465
+ python -m grobid_client.format.TEI2LossyJSON_cli --input path/to/file.tei.xml --output path/to/output.json
466
+
467
+ # Convert with verbose logging
468
+ python -m grobid_client.format.TEI2LossyJSON_cli --input path/to/file.tei.xml --verbose
469
+ ```
470
+
471
+ #### TEI to Markdown Converter
472
+
473
+ Converts TEI XML files to Markdown format (similar to `--markdown` option).
474
+
475
+ ```bash
476
+ # Convert a single file
477
+ python -m grobid_client.format.TEI2Markdown_cli --input path/to/file.tei.xml --output path/to/output.md
478
+ ```
479
+
480
+
454
481
  ## ⚙️ Configuration
455
482
 
456
483
  Configuration can be provided via a JSON file. When using the CLI, the `--server` argument overrides the config file
@@ -224,6 +224,7 @@ client.process(
224
224
  markdown_output=True
225
225
  )
226
226
 
227
+ ```python
227
228
  # Process citation lists
228
229
  client.process(
229
230
  service="processCitationList",
@@ -232,6 +233,32 @@ client.process(
232
233
  )
233
234
  ```
234
235
 
236
+ ### Standalone Conversion Tools
237
+
238
+ The library includes standalone scripts to convert TEI XML files to other formats without using the main client or server.
239
+
240
+ #### TEI to JSON Converter
241
+
242
+ Converts TEI XML files to the structured JSON format (similar to `--json` option).
243
+
244
+ ```bash
245
+ # Convert a single file
246
+ python -m grobid_client.format.TEI2LossyJSON_cli --input path/to/file.tei.xml --output path/to/output.json
247
+
248
+ # Convert with verbose logging
249
+ python -m grobid_client.format.TEI2LossyJSON_cli --input path/to/file.tei.xml --verbose
250
+ ```
251
+
252
+ #### TEI to Markdown Converter
253
+
254
+ Converts TEI XML files to Markdown format (similar to `--markdown` option).
255
+
256
+ ```bash
257
+ # Convert a single file
258
+ python -m grobid_client.format.TEI2Markdown_cli --input path/to/file.tei.xml --output path/to/output.md
259
+ ```
260
+
261
+
235
262
  ## ⚙️ Configuration
236
263
 
237
264
  Configuration can be provided via a JSON file. When using the CLI, the `--server` argument overrides the config file
@@ -9,6 +9,8 @@ import os
9
9
  import uuid
10
10
  from collections import OrderedDict
11
11
  from concurrent.futures import ProcessPoolExecutor, as_completed
12
+ import html
13
+ import re
12
14
  from pathlib import Path
13
15
  from typing import Dict, Union, BinaryIO, Iterator
14
16
 
@@ -41,8 +43,15 @@ class TEI2LossyJSONConverter:
41
43
  If stream=False returns the full document dict (same shape as original function).
42
44
  """
43
45
  # Load with BeautifulSoup but avoid building huge structures when streaming
44
- with open(tei_file, 'r') as f:
45
- content = f.read()
46
+ if hasattr(tei_file, 'read'):
47
+ # File-like object (BinaryIO/StringIO)
48
+ content = tei_file.read()
49
+ if isinstance(content, bytes):
50
+ content = content.decode('utf-8')
51
+ else:
52
+ # Path-like object
53
+ with open(tei_file, 'r', encoding='utf-8') as f:
54
+ content = f.read()
46
55
  soup = BeautifulSoup(content, 'xml')
47
56
 
48
57
  if soup.TEI is None:
@@ -222,7 +231,6 @@ class TEI2LossyJSONConverter:
222
231
  Extract detailed bibliographic information from TEI biblStruct elements.
223
232
  Implements comprehensive parsing for all standard TEI bibliographic components.
224
233
  """
225
- import re
226
234
 
227
235
  citation_data = OrderedDict()
228
236
  citation_data['id'] = f"b{index}"
@@ -430,7 +438,6 @@ class TEI2LossyJSONConverter:
430
438
 
431
439
  def _process_imprint_details(self, imprint_element: Tag, publication_metadata: Dict):
432
440
  """Extract and process imprint information including publisher, dates, and page ranges."""
433
- import re
434
441
 
435
442
  # Extract publisher information
436
443
  publisher_elements = imprint_element.find_all("publisher")
@@ -557,7 +564,6 @@ class TEI2LossyJSONConverter:
557
564
  Extract person data (author/editor) from TEI persName or author elements.
558
565
  Handles various name formats and affiliations.
559
566
  """
560
- import re
561
567
 
562
568
  person_data = {}
563
569
 
@@ -628,11 +634,9 @@ class TEI2LossyJSONConverter:
628
634
  text = text.decode('utf-8', errors='ignore')
629
635
 
630
636
  # Normalize whitespace and strip
631
- import re
632
637
  text = re.sub(r'\s+', ' ', text.strip())
633
638
 
634
639
  # Remove any potential XML/HTML entities
635
- import html
636
640
  text = html.unescape(text)
637
641
 
638
642
  return text
@@ -665,14 +669,33 @@ class TEI2LossyJSONConverter:
665
669
 
666
670
  div_type = div.get("type")
667
671
 
672
+ # Check if this is a header-only div (no content, no nested divs)
673
+ # If so, capture its header as context for subsequent divs
674
+ head = div.find("head")
675
+ direct_p_nodes = [c for c in div.children if hasattr(c, 'name') and c.name == "p"]
676
+ direct_formula_nodes = [c for c in div.children if hasattr(c, 'name') and c.name == "formula"]
677
+ nested_divs = [c for c in div.children if hasattr(c, 'name') and (c.name == "div" or (c.name and c.name.endswith(":div")))]
678
+ has_direct_content = len(direct_p_nodes) > 0 or len(direct_formula_nodes) > 0
679
+
680
+ if head and not has_direct_content and len(nested_divs) == 0:
681
+ # This is a header-only div with no nested content
682
+ # Capture the header for the next div
683
+ head_paragraph = self._clean_text(head.get_text())
684
+ continue # Skip to next div, the header will be used by subsequent sibling
685
+
668
686
  # Process this div and potentially nested divs
669
687
  for passage in self._process_div_with_nested_content(div, passage_level, head_paragraph):
670
688
  yield passage
689
+
690
+ # Reset head_paragraph after it's been used by a content-bearing div
691
+ head_paragraph = None
692
+
671
693
 
672
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]]]]:
673
695
  """
674
696
  Process a div and its nested content, handling various back section types.
675
697
  Supports nested divs for complex back sections like annex with multiple subsections.
698
+ Also handles formula elements that are direct children of divs.
676
699
  """
677
700
  head = div.find("head")
678
701
  p_nodes = div.find_all("p")
@@ -687,10 +710,12 @@ class TEI2LossyJSONConverter:
687
710
  if child.name == "div" or child.name.endswith(":div"):
688
711
  nested_divs.append(child)
689
712
 
690
- # Count only direct child paragraphs, not those in nested divs
713
+ # Count only direct child paragraphs and formulas, not those in nested divs
691
714
  direct_p_nodes = [child for child in div.children if hasattr(child, 'name') and child.name == "p"]
715
+ direct_formula_nodes = [child for child in div.children if hasattr(child, 'name') and child.name == "formula"]
716
+ has_direct_content = len(direct_p_nodes) > 0 or len(direct_formula_nodes) > 0
692
717
 
693
- if len(nested_divs) > 0 and len(direct_p_nodes) == 0:
718
+ if len(nested_divs) > 0 and not has_direct_content:
694
719
  # This is a container div - process each nested div independently
695
720
  for nested_div in nested_divs:
696
721
  # Skip references divs
@@ -703,11 +728,11 @@ class TEI2LossyJSONConverter:
703
728
 
704
729
  # Determine the section header and content type for divs with content
705
730
  if head:
706
- if len(direct_p_nodes) == 0:
707
- # This div has only a head, no paragraphs (standalone head)
731
+ if not has_direct_content:
732
+ # This div has only a head, no paragraphs or formulas (standalone head)
708
733
  current_head_paragraph = self._clean_text(head.get_text())
709
734
  else:
710
- # This div has both head and paragraphs - head is the section header
735
+ # This div has both head and content - head is the section header
711
736
  head_section = self._clean_text(head.get_text())
712
737
  else:
713
738
  # If no head element, try to use the type attribute as head_section
@@ -722,7 +747,7 @@ class TEI2LossyJSONConverter:
722
747
  head_section = "Author Contributions"
723
748
  elif div_type == "availability":
724
749
  # Only set as default if this div has its own content
725
- if len(direct_p_nodes) > 0:
750
+ if has_direct_content:
726
751
  head_section = "Data Availability"
727
752
  elif div_type == "annex":
728
753
  head_section = "Annex"
@@ -730,27 +755,60 @@ class TEI2LossyJSONConverter:
730
755
  # Generic handling - capitalize and format
731
756
  head_section = div_type.replace("_", " ").title()
732
757
 
733
- # Process paragraphs in this div
734
- if len(direct_p_nodes) > 0:
735
- for id_p, p in enumerate(direct_p_nodes):
758
+ # Process direct children (paragraphs and formulas) in document order
759
+ for child in div.children:
760
+ if not hasattr(child, 'name') or not child.name:
761
+ continue
762
+
763
+ if child.name == "p":
736
764
  paragraph_id = get_random_id(prefix="p_")
737
765
 
738
766
  if passage_level == "sentence":
739
- for id_s, sentence in enumerate(p.find_all("s")):
767
+ for id_s, sentence in enumerate(child.find_all("s")):
740
768
  struct = get_formatted_passage(current_head_paragraph or head_paragraph, head_section, paragraph_id, sentence)
741
769
  if self.validate_refs:
742
770
  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']
771
+ assert ref['offset_start'] < ref['offset_end'], "Wrong offsets"
772
+ assert struct['text'][ref['offset_start']:ref['offset_end']] == ref['text'], "Cannot apply offsets"
745
773
  yield struct
746
774
  else:
747
- struct = get_formatted_passage(current_head_paragraph or head_paragraph, head_section, paragraph_id, p)
775
+ struct = get_formatted_passage(current_head_paragraph or head_paragraph, head_section, paragraph_id, child)
748
776
  if self.validate_refs:
749
777
  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']
778
+ assert ref['offset_start'] < ref['offset_end'], "Wrong offsets"
779
+ assert struct['text'][ref['offset_start']:ref['offset_end']] == ref['text'], "Cannot apply offsets"
752
780
  yield struct
753
781
 
782
+ elif child.name == "formula":
783
+ # Process formula elements as passages
784
+ formula_id = get_random_id(prefix="f_")
785
+ formula_text = self._clean_text(child.get_text())
786
+
787
+ if formula_text:
788
+ # Create a passage structure for the formula
789
+ formula_passage = {
790
+ "id": formula_id,
791
+ "text": formula_text,
792
+ "coords": [
793
+ box_to_dict(coord.split(","))
794
+ for coord in child.get("coords", "").split(";")
795
+ ] if child.has_attr("coords") else [],
796
+ "refs": [],
797
+ "type": "formula"
798
+ }
799
+
800
+ if current_head_paragraph or head_paragraph:
801
+ formula_passage["head_paragraph"] = current_head_paragraph or head_paragraph
802
+ if head_section:
803
+ formula_passage["head_section"] = head_section
804
+
805
+ # Extract formula label if present
806
+ label = child.find("label")
807
+ if label:
808
+ formula_passage["label"] = self._clean_text(label.get_text())
809
+
810
+ yield formula_passage
811
+
754
812
  # Update head_paragraph for potential next div
755
813
  if current_head_paragraph is not None:
756
814
  head_paragraph = current_head_paragraph
@@ -817,8 +875,6 @@ def get_refs_with_offsets(element):
817
875
  def _clean_text(text: str) -> str:
818
876
  if not text:
819
877
  return ""
820
- import re
821
- import html
822
878
  text = re.sub(r'\s+', ' ', text.strip())
823
879
  text = html.unescape(text)
824
880
  return text
@@ -903,8 +959,6 @@ def get_formatted_passage(head_paragraph, head_section, paragraph_id, element):
903
959
  def _clean_text_local(text: str) -> str:
904
960
  if not text:
905
961
  return ""
906
- import re
907
- import html
908
962
  text = re.sub(r'\s+', ' ', text.strip())
909
963
  text = html.unescape(text)
910
964
  return text
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Standalone CLI for TEI2LossyJSON converter.
4
+
5
+ This script provides a command-line interface for converting TEI XML files to JSON format
6
+ using the TEI2LossyJSONConverter.
7
+ """
8
+ import argparse
9
+ import json
10
+ import logging
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from .TEI2LossyJSON import TEI2LossyJSONConverter
15
+
16
+
17
+ def setup_logging(verbose: bool = False):
18
+ """Setup logging configuration."""
19
+ level = logging.INFO if verbose else logging.WARNING
20
+ logging.basicConfig(
21
+ level=level,
22
+ format='%(asctime)s - %(levelname)s - %(message)s',
23
+ datefmt='%Y-%m-%d %H:%M:%S'
24
+ )
25
+
26
+
27
+ def convert_single_file(input_file: Path, output_file: Path, verbose: bool = False) -> bool:
28
+ """Convert a single TEI file to JSON format."""
29
+ try:
30
+ if verbose:
31
+ logging.info(f"Converting {input_file} to {output_file}")
32
+
33
+ converter = TEI2LossyJSONConverter()
34
+ result = converter.convert_tei_file(input_file, stream=False)
35
+
36
+ if result is None:
37
+ logging.error(f"Failed to convert {input_file}: TEI file is not well-formed or empty")
38
+ return False
39
+
40
+ # Ensure output directory exists
41
+ output_file.parent.mkdir(parents=True, exist_ok=True)
42
+
43
+ # Write JSON output
44
+ with open(output_file, 'w', encoding='utf-8') as f:
45
+ json.dump(result, f, indent=2, ensure_ascii=False)
46
+
47
+ if verbose:
48
+ logging.info(f"Successfully converted {input_file} to {output_file}")
49
+
50
+ return True
51
+
52
+ except Exception as e:
53
+ logging.error(f"Error converting {input_file}: {str(e)}")
54
+ return False
55
+
56
+
57
+ def main():
58
+ """Main CLI entry point."""
59
+ parser = argparse.ArgumentParser(
60
+ description="Convert TEI XML files to JSON format using TEI2LossyJSON converter",
61
+ formatter_class=argparse.RawDescriptionHelpFormatter,
62
+ epilog="""
63
+ Examples:
64
+ # Convert a single TEI file
65
+ python -m grobid_client.format.TEI2LossyJSON --input input.tei.xml --output output.json
66
+
67
+ # Convert with verbose logging
68
+ python -m grobid_client.format.TEI2LossyJSON --input input.tei.xml --output output.json --verbose
69
+
70
+ # Convert and output to stdout
71
+ python -m grobid_client.format.TEI2LossyJSON --input input.tei.xml
72
+ """
73
+ )
74
+
75
+ parser.add_argument(
76
+ "--input", "-i",
77
+ type=Path,
78
+ required=True,
79
+ help="Input TEI XML file to convert"
80
+ )
81
+
82
+ parser.add_argument(
83
+ "--output", "-o",
84
+ type=Path,
85
+ help="Output JSON file (if not specified, prints to stdout)"
86
+ )
87
+
88
+ parser.add_argument(
89
+ "--verbose", "-v",
90
+ action="store_true",
91
+ help="Enable verbose logging"
92
+ )
93
+
94
+ args = parser.parse_args()
95
+
96
+ # Setup logging
97
+ setup_logging(args.verbose)
98
+
99
+ # Validate input file
100
+ if not args.input.exists():
101
+ logging.error(f"Input file does not exist: {args.input}")
102
+ sys.exit(1)
103
+
104
+ if not args.input.is_file():
105
+ logging.error(f"Input path is not a file: {args.input}")
106
+ sys.exit(1)
107
+
108
+ # Convert the file
109
+ if args.output:
110
+ success = convert_single_file(args.input, args.output, args.verbose)
111
+ sys.exit(0 if success else 1)
112
+ else:
113
+ # Output to stdout
114
+ try:
115
+ converter = TEI2LossyJSONConverter()
116
+ result = converter.convert_tei_file(args.input, stream=False)
117
+
118
+ if result is None:
119
+ logging.error(f"Failed to convert {args.input}: TEI file is not well-formed or empty")
120
+ sys.exit(1)
121
+
122
+ # Print JSON to stdout
123
+ print(json.dumps(result, indent=2, ensure_ascii=False))
124
+
125
+ except Exception as e:
126
+ logging.error(f"Error converting {args.input}: {str(e)}")
127
+ sys.exit(1)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
@@ -11,8 +11,7 @@ Markdown format with the following sections:
11
11
  - Annex
12
12
  - References
13
13
  """
14
- import os
15
- import uuid
14
+ import re
16
15
  from pathlib import Path
17
16
  from typing import List, Dict, Union, Optional, BinaryIO
18
17
  from bs4 import BeautifulSoup, NavigableString, Tag
@@ -44,9 +43,12 @@ class TEI2MarkdownConverter:
44
43
  try:
45
44
  # Load with BeautifulSoup
46
45
  if isinstance(tei_file, (str, Path)):
47
- content = open(tei_file, 'r', encoding='utf-8').read()
46
+ with open(tei_file, 'r', encoding='utf-8') as f:
47
+ content = f.read()
48
48
  else:
49
49
  content = tei_file.read()
50
+ if isinstance(content, bytes):
51
+ content = content.decode('utf-8')
50
52
 
51
53
  soup = BeautifulSoup(content, 'xml')
52
54
 
@@ -77,7 +79,7 @@ class TEI2MarkdownConverter:
77
79
  # Extract publication date
78
80
  pub_date = self._extract_publication_date(soup)
79
81
  if pub_date:
80
- markdown_sections.append(f"Publishd on {pub_date}\n\n")
82
+ markdown_sections.append(f"Published on {pub_date}\n\n")
81
83
 
82
84
  # Extract abstract
83
85
  abstract = self._extract_abstract(soup)
@@ -211,14 +213,24 @@ class TEI2MarkdownConverter:
211
213
  head = div.find("head")
212
214
  if head:
213
215
  section_title = head.get_text().strip()
214
- fulltext_sections.append(f"### {section_title}\n")
215
-
216
- # Get paragraphs
217
- paragraphs = div.find_all("p")
218
- for p in paragraphs:
219
- paragraph_text = self._process_paragraph(p)
220
- if paragraph_text.strip():
221
- fulltext_sections.append(f"{paragraph_text}\n\n")
216
+ if section_title:
217
+ fulltext_sections.append(f"### {section_title}\n")
218
+
219
+ # Process direct children of the div in document order
220
+ # This captures paragraphs, formulas, and other elements as they appear
221
+ for child in div.children:
222
+ if not hasattr(child, 'name') or not child.name:
223
+ continue
224
+
225
+ if child.name == "p":
226
+ paragraph_text = self._process_paragraph(child)
227
+ if paragraph_text.strip():
228
+ fulltext_sections.append(f"{paragraph_text}\n\n")
229
+ elif child.name == "formula":
230
+ # Handle formula elements - extract text and optional label
231
+ formula_text = self._process_formula(child)
232
+ if formula_text.strip():
233
+ fulltext_sections.append(f"{formula_text}\n\n")
222
234
 
223
235
  return "".join(fulltext_sections)
224
236
 
@@ -270,16 +282,23 @@ class TEI2MarkdownConverter:
270
282
  if header_text not in annex_sections:
271
283
  annex_sections.append(header_text)
272
284
 
273
- # Process paragraphs that are direct children of this div (not in nested divs)
285
+ # Process direct children of this div in document order
286
+ # This captures paragraphs, formulas, and other elements as they appear
274
287
  for child in div.children:
275
- if hasattr(child, 'name') and child.name == "p":
288
+ if not hasattr(child, 'name') or not child.name:
289
+ continue
290
+
291
+ if child.name == "p":
276
292
  paragraph_text = self._process_paragraph(child)
277
293
  if paragraph_text.strip():
278
294
  annex_sections.append(f"{paragraph_text}\n\n")
279
-
280
- # Process nested div elements
281
- for child in div.children:
282
- if hasattr(child, 'name') and child.name == "div":
295
+ elif child.name == "formula":
296
+ # Handle formula elements
297
+ formula_text = self._process_formula(child)
298
+ if formula_text.strip():
299
+ annex_sections.append(f"{formula_text}\n\n")
300
+ elif child.name == "div":
301
+ # Process nested div elements
283
302
  self._process_div_and_nested_divs(child, annex_sections)
284
303
 
285
304
  def _extract_references(self, soup: BeautifulSoup) -> str:
@@ -336,6 +355,34 @@ class TEI2MarkdownConverter:
336
355
 
337
356
  return "".join(text_parts).strip()
338
357
 
358
+ def _process_formula(self, formula_element: Tag) -> str:
359
+ """Process a formula element and convert to markdown.
360
+
361
+ Formulas are rendered as italicized text with optional equation label.
362
+ """
363
+ # Get the main formula text (excluding the label)
364
+ formula_text_parts = []
365
+ label_text = ""
366
+
367
+ for child in formula_element.children:
368
+ if hasattr(child, 'name') and child.name == "label":
369
+ # Extract equation label (e.g., "(1)", "(2)")
370
+ label_text = child.get_text().strip()
371
+ elif isinstance(child, NavigableString):
372
+ formula_text_parts.append(str(child))
373
+ else:
374
+ # Other elements within formula - get their text
375
+ formula_text_parts.append(child.get_text())
376
+
377
+ formula_text = "".join(formula_text_parts).strip()
378
+
379
+ if formula_text:
380
+ # Format as: *formula text* (label) if label exists
381
+ if label_text:
382
+ return f"*{formula_text}* {label_text}"
383
+ return f"*{formula_text}*"
384
+ return ""
385
+
339
386
  def _table_to_markdown(self, table_element: Tag) -> str:
340
387
  """Convert a table element to simple markdown."""
341
388
  markdown_lines = []
@@ -511,17 +558,25 @@ class TEI2MarkdownConverter:
511
558
  unit = bibl_scope.get("unit", "").lower()
512
559
  text = bibl_scope.get_text().strip()
513
560
 
514
- if unit == "vol" and text:
561
+ if unit in ["vol", "volume"] and text:
515
562
  bib_data['volume'] = text
516
563
  elif unit == "issue" and text:
517
564
  bib_data['issue'] = text
518
565
  elif unit == "page" and text:
519
566
  # Handle page ranges
520
- if "from" in bibl_scope.attrs:
521
- bib_data['pages'] = f"{text}-"
522
- elif "to" in bibl_scope.attrs and bib_data.get('pages'):
523
- bib_data['pages'] += text
524
- else:
567
+ from_val = bibl_scope.get("from")
568
+ to_val = bibl_scope.get("to")
569
+ if from_val and to_val:
570
+ # Both from and to in same element
571
+ bib_data['pages'] = f"{from_val}-{to_val}"
572
+ elif from_val:
573
+ # Only from specified, may get combined with another element
574
+ bib_data['pages'] = f"{from_val}-"
575
+ elif to_val and bib_data.get('pages'):
576
+ # Only to specified, append to existing from
577
+ bib_data['pages'] = bib_data['pages'] + to_val
578
+ elif text and not bib_data.get('pages'):
579
+ # Plain text, no from/to attributes
525
580
  bib_data['pages'] = text
526
581
 
527
582
  def _extract_author_info(self, author: Tag) -> dict:
@@ -593,8 +648,6 @@ class TEI2MarkdownConverter:
593
648
 
594
649
  def _extract_year(self, date_text: str) -> str:
595
650
  """Extract year from date text, handling various formats."""
596
- import re
597
-
598
651
  # Look for 4-digit year patterns
599
652
  year_match = re.search(r'\b(19|20)\d{2}\b', date_text)
600
653
  if year_match:
@@ -629,6 +682,9 @@ class TEI2MarkdownConverter:
629
682
  """Build publication details string from extracted data."""
630
683
  details = []
631
684
 
685
+ if ref_data.get('year'):
686
+ details.append(f"({ref_data['year']})")
687
+
632
688
  if ref_data.get('volume'):
633
689
  details.append(ref_data['volume'])
634
690
 
@@ -684,7 +740,6 @@ class TEI2MarkdownConverter:
684
740
  raw_text = bibl_struct.get_text().strip()
685
741
 
686
742
  # Remove reference number if present
687
- import re
688
743
  raw_text = re.sub(r'^\[\d+\]\s*', '', raw_text)
689
744
 
690
745
  # Clean up excessive whitespace