Skip to content
Open
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
33 changes: 32 additions & 1 deletion src/google/adk/tools/load_artifacts_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,31 @@ def _maybe_base64_to_bytes(data: str) -> bytes | None:
return None


def _decode_xml_reference(match: re.Match[str]) -> str:
"""Decodes one predefined XML entity or numeric character reference."""
reference = match.group(1)
if not reference.startswith('#'):
return {'amp': '&', 'lt': '<', 'gt': '>', 'quot': '"', 'apos': "'"}[
reference
]
try:
codepoint = (
int(reference[2:], 16)
if reference.startswith('#x')
else int(reference[1:])
)
# Preserve invalid XML character references instead of aborting extraction.
if codepoint in (9, 10, 13) or (
0x20 <= codepoint <= 0xD7FF
or 0xE000 <= codepoint <= 0xFFFD
or 0x10000 <= codepoint <= 0x10FFFF
):
return chr(codepoint)
except ValueError:
pass
return match.group(0)


def _try_extract_docx_text(data: bytes) -> str | None:
"""Extracts raw text from a DOCX binary."""
# We use regex instead of standard XML parser to avoid XML bomb vulnerabilities,
Expand Down Expand Up @@ -139,7 +164,13 @@ def _try_extract_docx_text(data: bytes) -> str | None:
for p in re.split(rf'<{p_tag}(?:[^>]*)>', xml_content):
texts = re.findall(rf'<{t_tag}(?:[^>]*)>([^<]*)</{t_tag}>', p)
if texts:
paragraphs.append(''.join(texts))
paragraphs.append(
re.sub(
r'&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);',
_decode_xml_reference,
''.join(texts),
)
)

return '\n'.join(paragraphs)
except (zipfile.BadZipFile, KeyError, struct.error) as e:
Expand Down
23 changes: 18 additions & 5 deletions tests/unittests/tools/test_load_artifacts_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,20 @@ async def test_load_artifacts_converts_csv_octet_stream_to_text():


@pytest.mark.asyncio
async def test_load_artifacts_converts_docx_to_text():
@pytest.mark.parametrize(
('xml_text', 'expected_text'),
[
('Hello DOCX', 'Hello DOCX'),
('Research &amp; Development', 'Research & Development'),
('x &lt; 5 &amp;&amp; y &gt; 1', 'x < 5 && y > 1'),
('&quot;Hello&quot; &apos;world&apos;', '"Hello" \'world\''),
('&#20013;&#x6587; &#x1F600;', '中文 😀'),
('Literal &amp;lt; and &amp;#65;', 'Literal &lt; and &#65;'),
('&#x80;', '\x80'),
('&unknown; &#0; &#x110000;', '&unknown; &#0; &#x110000;'),
],
)
async def test_load_artifacts_converts_docx_to_text(xml_text, expected_text):
"""DOCX binary payloads are extracted to raw text."""
artifact_name = 'document.docx'

Expand All @@ -180,9 +193,9 @@ async def test_load_artifacts_converts_docx_to_text():
with zipfile.ZipFile(docx_bytes_io, 'w') as zf:
zf.writestr(
'word/document.xml',
b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<w:document'
b' xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:t>Hello'
b' DOCX</w:t></w:p></w:body></w:document>',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<w:document'
' xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
f'<w:body><w:p><w:t>{xml_text}</w:t></w:p></w:body></w:document>',
)

docx_bytes = docx_bytes_io.getvalue()
Expand Down Expand Up @@ -216,7 +229,7 @@ async def test_load_artifacts_converts_docx_to_text():

artifact_part = llm_request.contents[-1].parts[1]
assert artifact_part.inline_data is None
assert artifact_part.text == 'Hello DOCX'
assert artifact_part.text == expected_text


@pytest.mark.asyncio
Expand Down