Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions packtools/sps/formats/pdf/pipeline/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,15 +365,14 @@ def extract_body_data(xml_tree, table_layout_overrides=None):
if sec['title'] is not None:
sec['title'] = ''.join(sec['title'].itertext()).strip()

# Collect textual paragraphs but exclude figure/table elements
# Collect textual paragraphs but exclude figure/table elements. Uses
# get_text_from_node (tail-preserving) rather than a bare
# `.xpath('.//text()...')` + `' '.join(...)`, which inserted an
# artificial space between every text-node fragment regardless of
# whether the source had one there (e.g. "(<xref>...</xref>)" came
# out as "( ... )", and "<xref/>; <xref/>" as "... ; ...").
for para in document_section.findall('p'):
try:
# Get text nodes that are not inside fig or table-wrap
texts = para.xpath('.//text()[not(ancestor::fig) and not(ancestor::table-wrap)]')
para_text = ' '.join(' '.join(texts).split()).strip()
except Exception:
# Fallback to generic text extraction
para_text = xml_utils.get_text_from_node(para)
para_text = xml_utils.get_text_from_node(para, skip_tags={'fig', 'table-wrap'}).strip()
if para_text:
sec['paragraphs'].append(para_text)

Expand Down
64 changes: 43 additions & 21 deletions packtools/sps/formats/pdf/utils/xml_utils.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,40 @@
def get_text_from_node(node):
import re


def get_text_from_node(node, skip_tags=None):
"""
Extracts text from an XML node, including its children.
Extracts text from an XML node, including its children, preserving the
adjacency of the source (no space is inserted between fragments unless
one was already there as literal text or a tail).

Args:
node (ElementTree): The XML node to extract text from.
skip_tags (set, optional): Child tag names to drop entirely from the
output; only their `.tail` (the text that follows them in the
source) is kept. Used to flatten a paragraph to readable text
while excluding embedded elements such as <fig>/<table-wrap>.

Returns:
str: The text extracted from the given node.
"""
skip_tags = skip_tags or set()
texts_els = []

if node.text:
texts_els.append(node.text)

for child in node:
if child.tag == 'xref':

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sugiro simplificar get_text_from_node e aplicar uma normalização de espaçamento de pontuação:

  1. A remoção dos branches elif child.tag == 'xref': e elif child.tag in ('italic', 'bold'): evita a perda de tags como <sup> (estilo Vancouver), <sub>, <sc>, além de evitar a perda de subchild.tail em marcações aninhadas.
  2. A inclusão de _normalize_punctuation_spacing garante que artefatos de espaço presentes no XML de origem (como ( <xref> em a2.xml e a16.xml) sejam corrigidos para o padrão tipográfico esperado no PDF ((Citation) em vez de ( Citation)).
def get_text_from_node(node, skip_tags=None):
    skip_tags = skip_tags or set()
    texts_els = []

    if node.text:
        texts_els.append(node.text)

    for child in node:
        if child.tag in skip_tags:
            pass
        else:
            texts_els.append(get_text_from_node(child, skip_tags=skip_tags))

        if child.tail:
            texts_els.append(child.tail)

    text = ''.join(texts_els)
    text = _remove_double_spaces(text)
    text = _normalize_punctuation_spacing(text)
    return text


def _normalize_punctuation_spacing(text):
    text = re.sub(r'\(\s+', '(', text)
    text = re.sub(r'\s+\)', ')', text)
    text = re.sub(r'\[\s+', '[', text)
    text = re.sub(r'\s+\]', ']', text)
    text = re.sub(r'\s+;', ';', text)
    text = re.sub(r'\s+,', ',', text)
    return text

xref_text = child.text if child.text else ''
for subchild in child:
if subchild.tag in ('italic', 'bold'):
xref_text += (subchild.text if subchild.text else '')
if subchild.tail:
xref_text += (subchild.tail if subchild.tail else '')
texts_els.append(xref_text)
elif child.tag in ('italic', 'bold'):
if child.text:
texts_els.append(child.text)
for subchild in child:
texts_els.append(get_text_from_node(subchild))
if child.tag in skip_tags:
pass
else:
texts_els.append(get_text_from_node(child))
texts_els.append(get_text_from_node(child, skip_tags=skip_tags))

if child.tail:
texts_els.append(child.tail)

text = ''.join(texts_els)
text = _remove_double_spaces(text)
text = _normalize_punctuation_spacing(text)
return text

def get_node_level(element, root):
Expand Down Expand Up @@ -113,14 +113,36 @@ def _add_period(text):

def _remove_double_spaces(text):
"""
Removes double spaces from the given text.
Collapses any run of whitespace (including tabs and newlines left over
from pretty-printed XML, e.g. the indentation tail of a skipped
<fig>/<table-wrap>) into a single space.

Args:
text (str): The text to normalize.

Returns:
str: The text with whitespace runs collapsed to single spaces.
"""
return re.sub(r'\s+', ' ', text)

def _normalize_punctuation_spacing(text):
"""
Removes whitespace that ends up glued to the inside of parentheses and
brackets, or before a comma/semicolon, when the source XML has a space
directly before/after an inline element such as <xref> (e.g. "( <xref>
Fig. 1</xref> )") — a common defect that survives adjacency-preserving
extraction because the space is literal text, not an artifact of it.

Args:
text (str): The text to remove double spaces from.
text (str): The text to normalize.

Returns:
str: The text with double spaces removed.
str: The text with punctuation spacing normalized.
"""
while ' ' in text:
text = text.replace(' ', ' ')
text = re.sub(r'\(\s+', '(', text)
text = re.sub(r'\s+\)', ')', text)
text = re.sub(r'\[\s+', '[', text)
text = re.sub(r'\s+\]', ']', text)
text = re.sub(r'\s+;', ';', text)
text = re.sub(r'\s+,', ',', text)
return text
36 changes: 36 additions & 0 deletions tests/sps/formats/pdf/pipeline/test_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,42 @@ def test_extract_body_data_with_table_references(self):
result = xml_pipe.extract_body_data(xml)
self.assertEqual(result, expected)

def test_paragraph_citations_have_no_stray_space_around_parentheses(self):
# Regression: a naive `.xpath('.//text()...')` + `' '.join(...)`
# inserted a space between every text-node fragment regardless of
# adjacency in the source, turning "(<xref>...</xref>; <xref>...
# </xref>)" into "( ... ; ... )".
xml = etree.fromstring(
'<article><sec><title>Introduction</title>'
'<p>Pressure is increasing '
'(<xref ref-type="bibr" rid="B1">Lang and Barling, 2012</xref>'
'; <xref ref-type="bibr" rid="B2">Ripple et al., 2019</xref>) '
'worldwide.</p>'
'</sec></article>'
)
result = xml_pipe.extract_body_data(xml)
self.assertEqual(
result[0]['paragraphs'],
['Pressure is increasing (Lang and Barling, 2012; Ripple et al., 2019) worldwide.'],
)

def test_embedded_fig_tail_whitespace_is_collapsed_not_left_raw(self):
# A skipped <fig>'s tail can carry the source's pretty-printing
# indentation (a newline + spaces); it must collapse to one space
# rather than leak into the rendered paragraph.
xml = etree.fromstring(
'<article><sec><title>Results</title>'
'<p>See the figure below\n'
'<fig id="f1"><label>Figure 1</label></fig>\n '
'for details.</p>'
'</sec></article>'
)
result = xml_pipe.extract_body_data(xml)
self.assertEqual(
result[0]['paragraphs'],
['See the figure below for details.'],
)


class TestExtractCategory(unittest.TestCase):

Expand Down
58 changes: 58 additions & 0 deletions tests/sps/formats/pdf/utils/test_xml_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,64 @@ def test_get_text_from_node_multiple_xref_italic(self):
result = xml_utils.get_text_from_node(xmltree)
self.assertEqual(expected, result)

def test_get_text_from_node_preserves_parenthesis_adjacency(self):
# No space should be inserted between "(" and the xref text, or
# between the xref text and ")", when none exists in the source.
xmltree = etree.fromstring(
'<p>seen (<xref ref-type="bibr">Author, 2020</xref>; '
'<xref ref-type="bibr">Other, 2021</xref>) here</p>'
)
expected = 'seen (Author, 2020; Other, 2021) here'
result = xml_utils.get_text_from_node(xmltree)
self.assertEqual(expected, result)

def test_get_text_from_node_skip_tags_drops_content_but_keeps_tail(self):
xmltree = etree.fromstring(
'<p>Before <fig id="f1"><label>Figure 1</label></fig> after</p>'
)
result = xml_utils.get_text_from_node(xmltree, skip_tags={'fig'})
self.assertEqual('Before after', result)

def test_get_text_from_node_skip_tags_collapses_tail_whitespace(self):
xmltree = etree.fromstring(
'<p>Before\n<table-wrap id="t1"><label>Table 1</label></table-wrap>\n after</p>'
)
result = xml_utils.get_text_from_node(xmltree, skip_tags={'table-wrap'})
self.assertEqual('Before after', result)

def test_get_text_from_node_without_skip_tags_keeps_fig_content(self):
xmltree = etree.fromstring(
'<p>Before <fig id="f1"><label>Figure 1</label></fig> after</p>'
)
result = xml_utils.get_text_from_node(xmltree)
self.assertEqual('Before Figure 1 after', result)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sugiro adicionar testes cobrindo:

  1. <sup> dentro de <xref>
  2. Marcações aninhadas com tail
  3. Normalização de espaços ao redor de parênteses, colchetes, ponto-e-vírgula e vírgula
    def test_get_text_from_node_with_sup_inside_xref(self):
        xmltree = etree.fromstring(
            '<p>Author <xref ref-type="bibr"><sup>1,2</sup></xref> stated</p>'
        )
        self.assertEqual('Author 1,2 stated', xml_utils.get_text_from_node(xmltree))

    def test_get_text_from_node_nested_formatting_with_tail(self):
        xmltree = etree.fromstring(
            '<p>Start <bold>bold <italic>and italic</italic> still bold</bold> end</p>'
        )
        self.assertEqual(
            'Start bold and italic still bold end',
            xml_utils.get_text_from_node(xmltree),
        )

    def test_get_text_from_node_normalizes_spaces_around_parentheses_and_punctuation(self):
        xmltree = etree.fromstring(
            '<p>Studies ( <xref ref-type="bibr">Author, 2020</xref> ; '
            '<xref ref-type="bibr">Other, 2021</xref> ) and [ <xref ref-type="bibr">1</xref> ] '
            'with comma ( <xref ref-type="bibr">Foo, 2019</xref> , more).</p>'
        )
        self.assertEqual(
            'Studies (Author, 2020; Other, 2021) and [1] with comma (Foo, 2019, more).',
            xml_utils.get_text_from_node(xmltree),
        )

def test_get_text_from_node_with_sup_inside_xref(self):
xmltree = etree.fromstring(
'<p>Author <xref ref-type="bibr"><sup>1,2</sup></xref> stated</p>'
)
self.assertEqual('Author 1,2 stated', xml_utils.get_text_from_node(xmltree))

def test_get_text_from_node_nested_formatting_with_tail(self):
xmltree = etree.fromstring(
'<p>Start <bold>bold <italic>and italic</italic> still bold</bold> end</p>'
)
self.assertEqual(
'Start bold and italic still bold end',
xml_utils.get_text_from_node(xmltree),
)

def test_get_text_from_node_normalizes_spaces_around_parentheses_and_punctuation(self):
xmltree = etree.fromstring(
'<p>Studies ( <xref ref-type="bibr">Author, 2020</xref> ; '
'<xref ref-type="bibr">Other, 2021</xref> ) and [ <xref ref-type="bibr">1</xref> ] '
'with comma ( <xref ref-type="bibr">Foo, 2019</xref> , more).</p>'
)
self.assertEqual(
'Studies (Author, 2020; Other, 2021) and [1] with comma (Foo, 2019, more).',
xml_utils.get_text_from_node(xmltree),
)


class TestGetTextFromMixedCitationNode(unittest.TestCase):

Expand Down