diff --git a/src/activity_parser/parse_tcx_gpx.py b/src/activity_parser/parse_tcx_gpx.py
index fee9049..5168e34 100644
--- a/src/activity_parser/parse_tcx_gpx.py
+++ b/src/activity_parser/parse_tcx_gpx.py
@@ -92,7 +92,7 @@ def walk_fields(element: etree._Element) -> Iterator[tuple[FieldPath, str]]:
"""Yields (path, value) for every attribute and leaf element under ``element``.
``path`` matches the keys of the field tables in ``xml_fields``. XML comments are
- skipped.
+ skipped. ``path`` is never empty.
"""
yield from _walk(element, ())
@@ -116,7 +116,7 @@ def _walk(element: etree._Element, path: FieldPath) -> Iterator[tuple[FieldPath,
yield path + ((namespace, "@" + localname),), cast(str, value)
text = element.text
- if text is not None and not text.isspace():
+ if path and text is not None and not text.isspace():
yield path, text
# "*" matches only true elements, so comments are skipped without special-casing.
for child in element.iterchildren("*"):
diff --git a/tests/files/gpx/direct_text.gpx b/tests/files/gpx/direct_text.gpx
new file mode 100644
index 0000000..7484338
--- /dev/null
+++ b/tests/files/gpx/direct_text.gpx
@@ -0,0 +1,14 @@
+
+
+
+
+
+ stray-trkpt-text
+ 10.0
+
+
+
+
+
diff --git a/tests/files/tcx/direct_text.tcx b/tests/files/tcx/direct_text.tcx
new file mode 100644
index 0000000..0e292b6
--- /dev/null
+++ b/tests/files/tcx/direct_text.tcx
@@ -0,0 +1,21 @@
+
+
+
+
+
+ 2026-01-05T08:00:00Z
+ stray-lap-text
+ 1.0
+ 5.0
+
+
+
+
+
diff --git a/tests/test_parse_tcx_gpx.py b/tests/test_parse_tcx_gpx.py
index cea4787..853da25 100644
--- a/tests/test_parse_tcx_gpx.py
+++ b/tests/test_parse_tcx_gpx.py
@@ -31,6 +31,8 @@
CADENCE_COLLISION_TCX = TCX_FILES / "cadence_collision.tcx"
UNKNOWN_COLLISION_TCX = TCX_FILES / "unknown_collision.tcx"
MIXED_CONTENT_TCX = TCX_FILES / "mixed_content.tcx"
+DIRECT_TEXT_TCX = TCX_FILES / "direct_text.tcx"
+DIRECT_TEXT_GPX = GPX_FILES / "direct_text.gpx"
VENDOR_TYPE_ATTRIBUTE_TCX = TCX_FILES / "vendor_type_attribute.tcx"
DUPLICATE_CADENCE_TCX = TCX_FILES / "duplicate_cadence.tcx"
LAPS_ONLY_TCX = TCX_FILES / "laps_only.tcx"
@@ -389,6 +391,15 @@ def test_gpx_unknown_extension_kept_as_namespaced_string():
assert column not in normalized.columns
+def test_gpx_direct_text_on_trkpt_is_skipped():
+ # Text directly inside trkpt itself used to raise IndexError.
+ records, _, _ = parse_gpx(DIRECT_TEXT_GPX)
+ assert records["latitude"].tolist() == pytest.approx([37.0000])
+ assert records["longitude"].tolist() == pytest.approx([-122.0000])
+ assert records["altitude"].tolist() == [10.0]
+ assert not records.isin(["stray-trkpt-text"]).any().any()
+
+
def test_gpx_1_0_base_fields():
# GPX 1.0 exposes course/speed directly; GPS-fix diagnostics aren't fitness data.
records, _, _ = ActivityParser().parse(GPX10)
@@ -493,6 +504,16 @@ def test_tcx_mixed_content_keeps_both_text_and_child():
assert records[f"{{{ns}}}Nested"].tolist() == ["should-not-be-lost"]
+def test_tcx_direct_text_on_lap_and_trackpoint_is_skipped():
+ # Text directly inside Lap/Trackpoint themselves used to raise IndexError.
+ records, laps, _ = parse_tcx(DIRECT_TEXT_TCX)
+ assert records["altitude"].tolist() == [10.0]
+ assert laps["total_elapsed_time"].tolist() == [1.0]
+ assert laps["total_distance"].tolist() == pytest.approx([0.005])
+ assert not records.isin(["stray-trackpoint-text"]).any().any()
+ assert not laps.isin(["stray-lap-text"]).any().any()
+
+
def test_tcx_type_attribute_only_skipped_for_xsi_namespace():
# Only genuine xsi:type is metadata; a same-named attribute elsewhere is real data.
records, _, _ = parse_tcx(VENDOR_TYPE_ATTRIBUTE_TCX)