Skip to content

Commit 0af3fbd

Browse files
committed
finalize output - remove duplicated paragraphs
1 parent 658ae5e commit 0af3fbd

3 files changed

Lines changed: 185 additions & 28 deletions

File tree

Readme.md

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ A simple, efficient Python client for [GROBID](https://github.com/kermitt2/grobi
3030
- **Command Line & Library**: Use as a standalone CLI tool or import into your Python projects
3131
- **Coordinate Extraction**: Optional PDF coordinate extraction for precise element positioning
3232
- **Sentence Segmentation**: Layout-aware sentence segmentation capabilities
33+
- **JSON Output**: Convert TEI XML output to structured JSON format with CORD-19-like structure
3334

3435
## 📋 Prerequisites
3536

@@ -40,6 +41,7 @@ A simple, efficient Python client for [GROBID](https://github.com/kermitt2/grobi
4041
- Default server: `http://localhost:8070`
4142
- Online demo: https://lfoppiano-grobid.hf.space (usage limits apply), more details [here](https://grobid.readthedocs.io/en/latest/getting_started/#using-grobid-from-the-cloud).
4243

44+
4345
> [!IMPORTANT]
4446
> GROBID supports Windows only through Docker containers. See the [Docker documentation](https://grobid.readthedocs.io/en/latest/Grobid-docker/) for details.
4547
@@ -131,6 +133,7 @@ grobid_client [OPTIONS] SERVICE
131133
| `--teiCoordinates` | Add PDF coordinates to XML |
132134
| `--segmentSentences` | Segment sentences with coordinates |
133135
| `--flavor` | Processing flavor for fulltext extraction |
136+
| `--json` | Convert TEI output to JSON format |
134137

135138
#### Examples
136139

@@ -141,11 +144,14 @@ grobid_client --input ~/documents --output ~/results processFulltextDocument
141144
# High concurrency with coordinates
142145
grobid_client --input ~/pdfs --output ~/tei --n 20 --teiCoordinates processFulltextDocument
143146

147+
# Process with JSON output
148+
grobid_client --input ~/pdfs --output ~/results --json processFulltextDocument
149+
144150
# Process citations with custom server
145151
grobid_client --server https://grobid.example.com --input ~/citations.txt processCitationList
146152

147-
# Force reprocessing with sentence segmentation
148-
grobid_client --input ~/docs --force --segmentSentences processFulltextDocument
153+
# Force reprocessing with sentence segmentation and JSON output
154+
grobid_client --input ~/docs --force --segmentSentences --json processFulltextDocument
149155
```
150156

151157
### Python Library
@@ -188,6 +194,14 @@ client.process(
188194
segmentSentences=True
189195
)
190196

197+
# Process with JSON output
198+
client.process(
199+
service="processFulltextDocument",
200+
input_path="/path/to/pdfs",
201+
output_path="/path/to/output",
202+
json_output=True
203+
)
204+
191205
# Process citation lists
192206
client.process(
193207
service="processCitationList",
@@ -234,6 +248,87 @@ Extracts complete document structure including headers, body text, figures, tabl
234248
grobid_client --input pdfs/ --output results/ processFulltextDocument
235249
```
236250

251+
### JSON Output Format
252+
253+
When using the `--json` flag, the client converts TEI XML output to a structured JSON format similar to CORD-19. This provides:
254+
255+
- **Structured Bibliography**: Title, authors, DOI, publication date, journal information
256+
- **Body Text**: Paragraphs and sentences with metadata and reference annotations
257+
- **Figures and Tables**: Structured JSON format for tables with headers, rows, and metadata
258+
- **Reference Information**: In-text citations with offsets and targets
259+
260+
#### JSON Structure
261+
262+
```json
263+
{
264+
"level": "paragraph",
265+
"biblio": {
266+
"title": "Document Title",
267+
"authors": ["Author 1", "Author 2"],
268+
"doi": "10.1000/example",
269+
"publication_date": "2023-01-01",
270+
"journal": "Journal Name",
271+
"abstract": [...]
272+
},
273+
"body_text": [
274+
{
275+
"id": "p_12345",
276+
"text": "Paragraph text with citations [1].",
277+
"head_section": "Introduction",
278+
"refs": [
279+
{
280+
"type": "bibr",
281+
"target": "b1",
282+
"text": "[1]",
283+
"offset_start": 25,
284+
"offset_end": 28
285+
}
286+
]
287+
}
288+
],
289+
"figures_and_tables": [
290+
{
291+
"id": "table_1",
292+
"type": "table",
293+
"label": "Table 1",
294+
"head": "Sample Data",
295+
"content": {
296+
"headers": ["Header 1", "Header 2"],
297+
"rows": [["Value 1", "Value 2"]],
298+
"metadata": {
299+
"row_count": 1,
300+
"column_count": 2,
301+
"has_headers": true
302+
}
303+
}
304+
}
305+
]
306+
}
307+
```
308+
309+
#### Usage Examples
310+
311+
```bash
312+
# Generate both TEI and JSON outputs
313+
grobid_client --input pdfs/ --output results/ --json processFulltextDocument
314+
315+
# JSON output with coordinates and sentence segmentation
316+
grobid_client --input pdfs/ --output results/ --json --teiCoordinates --segmentSentences processFulltextDocument
317+
```
318+
319+
```python
320+
# Python library usage
321+
client.process(
322+
service="processFulltextDocument",
323+
input_path="/path/to/pdfs",
324+
output_path="/path/to/output",
325+
json_output=True
326+
)
327+
```
328+
329+
> [!NOTE]
330+
> When using `--json`, the `--force` flag only checks for existing TEI files. If a TEI file is rewritten (due to `--force`), the corresponding JSON file is automatically rewritten as well.
331+
237332
### Header Document Processing
238333
Extracts only document metadata (title, authors, abstract, etc.).
239334

grobid_client/format/TEI2LossyJSON.py

Lines changed: 87 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def convert_tei_file(self, tei_file: Union[Path, BinaryIO], stream: bool = False
181181
head = item.head
182182
label = item.label
183183
if item.has_attr("type") and item.attrs["type"] == "table":
184-
content = xml_table_to_markdown(item.table) if item.table else None
184+
json_content = xml_table_to_json(item.table) if item.table else None
185185
note = item.note
186186
figures_and_tables.append(
187187
{
@@ -190,7 +190,7 @@ def convert_tei_file(self, tei_file: Union[Path, BinaryIO], stream: bool = False
190190
"head": head.text if head else "",
191191
"type": "table",
192192
"desc": desc.text if desc else "",
193-
"content": content,
193+
"content": json_content,
194194
"note": note.text if note else "",
195195
"coords": [
196196
box_to_dict(coord.split(","))
@@ -228,35 +228,46 @@ def _iter_passages_from_soup(self, soup: BeautifulSoup, passage_level: str) -> I
228228

229229
def _iter_passages_from_soup_for_text(self, text_node: Tag, passage_level: str) -> Iterator[Dict[str, Union[str, Dict[str, str]]]]:
230230
head_paragraph = None
231-
div_nodes = text_node.find_all("div")
232-
for id_div, div in enumerate(div_nodes):
233-
head = div.find("head")
234-
p_nodes = div.find_all("p")
235-
head_section = None
236-
237-
if head:
238-
if len(p_nodes) == 0:
239-
head_paragraph = head.text
231+
232+
# Process body and back sections, but only their direct child divs
233+
for section in text_node.find_all(['body', 'back']):
234+
# Only get direct child divs of this section
235+
div_nodes = [child for child in section.children if hasattr(child, 'name') and child.name == "div"]
236+
237+
for id_div, div in enumerate(div_nodes):
238+
head = div.find("head")
239+
p_nodes = div.find_all("p")
240+
head_section = None
241+
242+
if head:
243+
if len(p_nodes) == 0:
244+
head_paragraph = head.text
245+
else:
246+
head_section = head.text
240247
else:
241-
head_section = head.text
242-
243-
for id_p, p in enumerate(p_nodes):
244-
paragraph_id = get_random_id(prefix="p_")
245-
if passage_level == "sentence":
246-
for id_s, sentence in enumerate(p.find_all("s")):
247-
struct = get_formatted_passage(head_paragraph, head_section, paragraph_id, sentence)
248+
# If no head element, try to use the type attribute as head_section
249+
div_type = div.get("type")
250+
if div_type and len(p_nodes) > 0:
251+
head_section = div_type.title() # Capitalize first letter
252+
253+
for id_p, p in enumerate(p_nodes):
254+
paragraph_id = get_random_id(prefix="p_")
255+
256+
if passage_level == "sentence":
257+
for id_s, sentence in enumerate(p.find_all("s")):
258+
struct = get_formatted_passage(head_paragraph, head_section, paragraph_id, sentence)
259+
if self.validate_refs:
260+
for ref in struct['refs']:
261+
assert ref['offset_start'] < ref['offset_end']
262+
assert struct['text'][ref['offset_start']:ref['offset_end']] == ref['text']
263+
yield struct
264+
else:
265+
struct = get_formatted_passage(head_paragraph, head_section, paragraph_id, p)
248266
if self.validate_refs:
249267
for ref in struct['refs']:
250268
assert ref['offset_start'] < ref['offset_end']
251269
assert struct['text'][ref['offset_start']:ref['offset_end']] == ref['text']
252270
yield struct
253-
else:
254-
struct = get_formatted_passage(head_paragraph, head_section, paragraph_id, p)
255-
if self.validate_refs:
256-
for ref in struct['refs']:
257-
assert ref['offset_start'] < ref['offset_end']
258-
assert struct['text'][ref['offset_start']:ref['offset_end']] == ref['text']
259-
yield struct
260271

261272
def process_directory(self, directory: Union[str, Path], pattern: str = "*.tei.xml", parallel: bool = True, workers: int = None) -> Iterator[Dict]:
262273
"""Process a directory of TEI files and yield converted documents.
@@ -289,7 +300,7 @@ def _convert_file_worker(path: str):
289300
from bs4 import BeautifulSoup
290301
import dateparser
291302
# Reuse existing top-level helpers from this module by importing here
292-
from grobid_client.format.TEI2LossyJSON import box_to_dict, get_random_id, get_formatted_passage, get_refs_with_offsets, xml_table_to_markdown
303+
from grobid_client.format.TEI2LossyJSON import box_to_dict, get_random_id, get_formatted_passage, get_refs_with_offsets, xml_table_to_markdown, xml_table_to_json
293304
content = open(path, 'r').read()
294305
soup = BeautifulSoup(content, 'xml')
295306
converter = TEI2LossyJSONConverter()
@@ -377,6 +388,56 @@ def xml_table_to_markdown(table_element):
377388
return "\n".join(markdown_lines) if markdown_lines else None
378389

379390

391+
def xml_table_to_json(table_element):
392+
"""Convert XML table to JSON format."""
393+
if not table_element:
394+
return None
395+
396+
table_data = {
397+
"headers": [],
398+
"rows": [],
399+
"metadata": {}
400+
}
401+
402+
# Check if table has a header row (thead)
403+
thead = table_element.find("thead")
404+
if thead:
405+
header_row = thead.find("row")
406+
if header_row:
407+
for cell in header_row.find_all("cell"):
408+
cell_text = cell.get_text().strip()
409+
table_data["headers"].append(cell_text)
410+
411+
# Process table body rows
412+
tbody = table_element.find("tbody")
413+
if tbody:
414+
rows = tbody.find_all("row")
415+
else:
416+
# If no tbody, get all rows
417+
rows = table_element.find_all("row")
418+
# Skip first row if we already processed it as header
419+
if thead and rows:
420+
rows = rows[1:]
421+
422+
for row in rows:
423+
row_data = []
424+
for cell in row.find_all("cell"):
425+
cell_text = cell.get_text().strip()
426+
row_data.append(cell_text)
427+
428+
if row_data:
429+
table_data["rows"].append(row_data)
430+
431+
# Add metadata
432+
table_data["metadata"] = {
433+
"row_count": len(table_data["rows"]),
434+
"column_count": len(table_data["headers"]) if table_data["headers"] else (len(table_data["rows"][0]) if table_data["rows"] else 0),
435+
"has_headers": len(table_data["headers"]) > 0
436+
}
437+
438+
return table_data if table_data["rows"] else None
439+
440+
380441
# Backwards compatible top-level function that uses the class
381442
def convert_tei_file(tei_file: Union[Path, BinaryIO], stream: bool = False):
382443
converter = TEI2LossyJSONConverter()

grobid_client/grobid_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ def process_batch(
506506

507507
if json_data:
508508
json_filename = filename.replace('.grobid.tei.xml', '.json')
509+
# Always write JSON file when TEI is written (respects --force behavior)
509510
with open(json_filename, 'w', encoding='utf8') as json_file:
510511
json.dump(json_data, json_file, indent=2, ensure_ascii=False)
511512
self.logger.debug(f"Successfully wrote JSON file: {json_filename}")

0 commit comments

Comments
 (0)