fix(webvtt): decode cue text escapes and allow them in annotations - #776
kakiuwang-ui wants to merge 1 commit into
Conversation
The six escapes the validators already recognise were only ever checked, never decoded, so a cue reading "Pride & Prejudice" kept the escape verbatim all the way into the converted document. Worse, the cue span start tag pattern excluded "&" from the annotation, so a speaker name holding an escape matched no tag at all. The payload then fell through to a text span, was rejected there for containing "<", and the whole cue was dropped with only a RuntimeWarning — silent data loss for a construct the annotation validator explicitly permits. Escapes are now resolved when a span is built and written back by __str__, so the model holds the characters a reader should see while export still produces valid WebVTT. Only "&" and the character that would terminate the construct are re-escaped; the other three are optional, so a file that never used them round-trips unchanged. Signed-off-by: kakiuwang-ui <kakiuwang@gmail.com>
|
✅ DCO Check Passed Thanks @kakiuwang-ui, all your commits are properly signed off. 🎉 |
Merge Protections🔴 1 of 2 protections blocking · waiting on 👀 reviews
🔴 Require two reviewer for test updatesWaiting for
This rule is failing.When test data is updated, we require two reviewers
Show 1 satisfied protection🟢 Enforce conventional commitMake sure that we follow https://www.conventionalcommits.org/en/v1.0.0/
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
ceberam
left a comment
There was a problem hiding this comment.
Thanks @kakiuwang-ui for catching this bug and proposing a fix. I have just added a minor suggestion to strengthen the test.
| for escape, char in ( | ||
| ("&", "&"), | ||
| ("<", "<"), | ||
| (">", ">"), | ||
| ("‎", "\u200e"), | ||
| ("‏", "\u200f"), | ||
| (" ", "\u00a0"), | ||
| ): |
There was a problem hiding this comment.
I would use here _ENTITY_CHARS to cover exactly the escapes that the WebVTT module defines. If _ENTITY_CHARS gains a seventh entry in a future commit, the test automatically covers it; if a mapping is silently changed, the test catches it instead of staying green on stale values.
A WebVTT file whose speaker name contains an escape loses the whole cue:
WebVTTFile.parsereturns no cue blocks for that, and the only trace is aRuntimeWarning. Downstream in docling the line simply is not in the converted document.The cause is the start tag pattern, which excludes
&from the annotation:r"(?:[ \t](?P<annotation>[^\n\r&>]*))?>"<v Ben & Jerry>matches no tag, so the payload falls through to a text span, which rejects it for containing<, andparsedrops the block. That contradictsis_valid_annotationa few lines up, which explicitly permits&,<,>,‎,‏and in an annotation — a validator that could never receive the input it was written for.The second, quieter half: those six escapes were only ever validated, never decoded.
Pride & Prejudicekept the escape verbatim into the document, and per WebVTT 1 §6.3 the escape denotes the character.Implementation
_ENTITY_CHARSmaps each escape to the character it denotes, and_VALID_ENTITIESis derived from it so the whitelist and the decode table cannot drift apart._unescaperesolves escapes when a span is built;__str__writes them back.Why decode at construction rather than at the call site.
str()on the model is the export path, so decoding without a matching escape on the way out would emit invalid WebVTT. Doing both keeps the model holding the characters a reader should see while the file still round-trips. This follows #744, which already established thatparsenormalises cue text rather than preserving the source byte for byte.Which characters are written back. Only
&, and the one that would terminate the construct —<in cue text,>in an annotation. The other three are optional escapes, so the literal character is kept and a file that never used them round-trips unchanged. A source that wrote>in cue text comes back as a bare>, which is valid and how the spec allows it to be written.Behaviour change worth a look
WebVTTCueTextSpan(text="Pride & Prejudice").textused to be the escaped string and is now"Pride & Prejudice". One existing assertion pinned the old value:Reading it in context — it sits right after the
&foo;case that must raise — it looks like it was written to say "a valid entity is accepted", with equality as the convenient way to say that, rather than as a statement that escapes must stay raw. I have updated it to assert the decoded text plusstr(span) == valid_text, so both directions are pinned. Happy to be told otherwise if keepingtextas source-form was deliberate.Test
test_webvtt_cue_escapes_in_annotation— the cue above parses to one block, the annotation isBen & Jerry, the text isWe charge 5 < 10 & win, and the block formats back to the original line.test_vtt_cue_commponentsgains a loop over all six escapes and a check that>comes back as a bare>.Each fix was checked by breaking it: putting
&back in the annotation pattern fails 1, dropping_unescapefails 2, dropping_escapefails 2.tests/test_webvtt.pygoes 6 → 7 passed;test_serialization.py+test_docling_doc.py+test_webvtt.pygo 186 → 187 with no regressions, which covers the byte-exact round-trip assertion intest_webvtt_fileand the.gt.vttcomparisons. No fixture contains an escape today, so none of those baselines move.Notes for review
transforms/serializer/webvtt.pybuilds cue text from aDoclingDocumentwithout escaping, so a document whose text holds&or<serialises to WebVTT that this parser would then reject. Different path, and it wants its own decision about where escaping belongs. Happy to send it separately.&, unchanged. Only the pattern was widened, so such input now reaches the validator that was always meant to judge it instead of silently killing the cue.WebVTTCueBlockdocstring.