diff --git a/src/google/adk/tools/load_artifacts_tool.py b/src/google/adk/tools/load_artifacts_tool.py
index b90219ad57..d84e06f607 100644
--- a/src/google/adk/tools/load_artifacts_tool.py
+++ b/src/google/adk/tools/load_artifacts_tool.py
@@ -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,
@@ -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:
diff --git a/tests/unittests/tools/test_load_artifacts_tool.py b/tests/unittests/tools/test_load_artifacts_tool.py
index d0f6e34ad3..a7e8907d47 100644
--- a/tests/unittests/tools/test_load_artifacts_tool.py
+++ b/tests/unittests/tools/test_load_artifacts_tool.py
@@ -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 & Development', 'Research & Development'),
+ ('x < 5 && y > 1', 'x < 5 && y > 1'),
+ ('"Hello" 'world'', '"Hello" \'world\''),
+ ('中文 😀', 'δΈζ π'),
+ ('Literal < and A', 'Literal < and A'),
+ ('', '\x80'),
+ ('&unknown; ', '&unknown; '),
+ ],
+)
+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'
@@ -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'\nHello'
- b' DOCX',
+ '\n'
+ f'{xml_text}',
)
docx_bytes = docx_bytes_io.getvalue()
@@ -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