diff --git a/pixi.toml b/pixi.toml
index e90e1ca..ba9d3d6 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -11,4 +11,4 @@ test = "mojo run -I src test/test_captions.mojo"
demo = "mojo run -I src examples/clip_transcript.mojo test/data/sample.vtt 0 20000"
[dependencies]
-mojo = ">=1.0.0b3,<2"
+mojo = ">=1.0.0b3.dev0,<2"
diff --git a/src/captions/captions.mojo b/src/captions/captions.mojo
index c05fdcc..55abb01 100644
--- a/src/captions/captions.mojo
+++ b/src/captions/captions.mojo
@@ -176,6 +176,24 @@ def _parse_timing(line: String, mut start_ms: Int, mut end_ms: Int) raises:
end_ms = _parse_ts(right)
+def _is_timing_line(line: String) -> Bool:
+ """Whether `line` is a genuine `timestamp --> timestamp` timing line.
+
+ Cue text may legitimately contain `-->` (e.g. "map --> filter", or a
+ Unicode-arrow gloss), so a bare `-->` substring is not enough to mark
+ a line as a cue boundary; both sides must parse as timestamps.
+ """
+ if line.find("-->") == -1:
+ return False
+ var start_ms = 0
+ var end_ms = 0
+ try:
+ _parse_timing(line, start_ms, end_ms)
+ except:
+ return False
+ return True
+
+
def _strip_voice_tags(text: String, mut speaker: String) -> String:
"""Remove `` / `` markup; record the first voice's name."""
if text.find(" List[Cue]:
while seg_start < end:
var t = -1
for j in range(seg_start, end):
- if lines[j].find("-->") != -1:
+ if _is_timing_line(lines[j]):
t = j
break
if t == -1:
@@ -299,14 +317,24 @@ def _parse_block(lines: List[String], start: Int, end: Int) raises -> List[Cue]:
index = index * 10 + Int(b) - ord("0")
var start_ms = 0
var end_ms = 0
- _parse_timing(lines[t], start_ms, end_ms)
+ # Isolate each segment's parse: a malformed timing line skips just
+ # this segment, never discarding cues already gathered from the
+ # block. `_is_timing_line` already validated `lines[t]`, so this
+ # is defense-in-depth against divergence between the two.
+ try:
+ _parse_timing(lines[t], start_ms, end_ms)
+ except:
+ seg_start = t + 1
+ continue
# A block can hold a second (or third...) cue glued on with no
# blank-line separator. If a later "text" line is itself a
# timing line, that's where this cue's text ends and the next
# cue begins — including its optional index line just before it.
+ # A bare `-->` inside cue text is not a boundary; only a line that
+ # parses as `timestamp --> timestamp` is.
var text_end = end
for j in range(t + 1, end):
- if lines[j].find("-->") != -1:
+ if _is_timing_line(lines[j]):
if j > t + 1 and _is_all_digits(lines[j - 1]):
text_end = j - 1
else:
diff --git a/test/test_captions.mojo b/test/test_captions.mojo
index 520ba8b..b3d9fe9 100644
--- a/test/test_captions.mojo
+++ b/test/test_captions.mojo
@@ -334,6 +334,51 @@ def test_srt_glued_cues_without_blank_line() raises:
assert_equal(caps.cues[1].text, "Second cue text.")
+def test_arrow_in_cue_text_not_a_boundary() raises:
+ """A `-->` inside cue text is prose, not a timing line: the cue must
+ survive whole rather than being split (or discarded) as a boundary."""
+ var caps = parse_captions(
+ String("1\n00:00:01,000 --> 00:00:02,000\nUse map --> filter here.\n")
+ )
+ assert_equal(len(caps.cues), 1)
+ assert_equal(caps.cues[0].text, "Use map --> filter here.")
+ # And it must round-trip through both serializers unchanged.
+ var via_srt = parse_captions(to_srt(caps))
+ assert_equal(len(via_srt.cues), 1)
+ assert_equal(via_srt.cues[0].text, "Use map --> filter here.")
+ var via_vtt = parse_captions(to_vtt(caps))
+ assert_equal(len(via_vtt.cues), 1)
+ assert_equal(via_vtt.cues[0].text, "Use map --> filter here.")
+
+
+def test_arrow_in_multiline_text_preserved() raises:
+ """A `-->` on a later text line must not truncate the cue or spawn a
+ bogus second cue; the full multi-line text is kept."""
+ var caps = parse_captions(
+ String(
+ "1\n00:00:01,000 --> 00:00:02,000\n"
+ "first line\nx --> y transform\nthird line\n"
+ )
+ )
+ assert_equal(len(caps.cues), 1)
+ assert_equal(caps.cues[0].text, "first line\nx --> y transform\nthird line")
+
+
+def test_arrow_text_still_splits_glued_real_cue() raises:
+ """Even when a cue's text holds a `-->`, a genuinely glued second cue
+ (a real timing line, no blank separator) is still split out."""
+ var caps = parse_captions(
+ String(
+ "1\n00:00:01,000 --> 00:00:02,000\nUse map --> filter here.\n"
+ "2\n00:00:03,000 --> 00:00:04,000\nSecond cue text.\n"
+ )
+ )
+ assert_equal(len(caps.cues), 2)
+ assert_equal(caps.cues[0].text, "Use map --> filter here.")
+ assert_equal(caps.cues[1].index, 2)
+ assert_equal(caps.cues[1].text, "Second cue text.")
+
+
def test_srt_explicit_zero_index_roundtrip() raises:
"""An explicit cue number of 0 is a real index, not the "absent"
sentinel, and must survive a to_srt round trip unchanged."""