Stripping CSS from WebVTT for Broadcast
WebVTT carries presentation that a legacy CEA-608/708 decoder physically cannot render: STYLE blocks with CSS, REGION definitions for positioned overlays, and inline cue tags like <c.loud>, <b>, <v Speaker>, and karaoke timestamps <00:00:01.000>. Push a styled .vtt straight to an SDI or transport-stream captioner and the decoder either ignores the markup and prints the raw tag text into the caption, or chokes on positioning it has no model for — because a 608 caption is a plain-text grid of at most 32 columns and 4 rows, with no CSS, no classes, and no percentage coordinates. The conversion this page performs is a reduction: drop STYLE and REGION blocks entirely, strip every inline tag while preserving the one piece of semantic information worth keeping (the speaker name from <v>), collapse each cue to plain text wrapped onto the 32-column grid, and flag any percentage positioning that the grid cannot honour so it is not silently lost. This is the broadcast-safe reduction step that follows WebVTT cue extraction and validation.
import re
from typing import Iterator
CEA608_COLUMNS = 32 # CEA-608 fixed 32-column safe-area grid
CEA608_MAX_ROWS = 4 # 608 pop-on/roll-up buffer holds at most 4 rows
# Inline WebVTT cue components (W3C WebVTT §6.3) legacy 608/708 cannot render.
_VOICE = re.compile(r"<v(?:\.[^\s>]+)*\s+([^>]+)>") # <v Speaker> / <v.loud Speaker>
_TIMESTAMP = re.compile(r"<\d{2}:\d{2}:\d{2}\.\d{3}>") # karaoke cue timestamps
_TAG = re.compile(r"</?[a-zA-Z][^>]*>") # <c.loud>, <b>, </i>, <lang en>, </v>
_POS_PERCENT = re.compile(r"\b(?:position|line|size|align):\S*?\d+%") # percentage positioning
def _split_blocks(vtt: str) -> Iterator[str]:
# WebVTT blocks are separated by one or more blank lines (W3C WebVTT §4).
for block in re.split(r"\n[ \t]*\n", vtt.replace("\r\n", "\n").strip()):
if block.strip():
yield block.strip()
def _wrap_to_grid(text: str, width: int) -> list:
"""Greedy word-wrap onto the fixed 608 column width; hard-split any single
token longer than the grid so no row exceeds `width` characters."""
rows, line = [], ""
for word in text.split():
if line and len(line) + 1 + len(word) > width:
rows.append(line)
line = ""
while len(word) > width: # token longer than the whole row
rows.append(word[:width])
word = word[width:]
line = f"{line} {word}".strip()
if line:
rows.append(line)
return rows
def strip_for_broadcast(vtt: str) -> dict:
"""Reduce a WebVTT file to 608-safe cue text. Drops STYLE/REGION blocks,
strips inline tags, maps <v> to a speaker label, and flags positioning a
32-column grid cannot honour."""
cues, flags = [], []
for block in _split_blocks(vtt):
head = block.split("\n", 1)[0].strip()
# W3C WebVTT §4.1 — STYLE (CSS) and REGION blocks have no 608 equivalent.
if head == "WEBVTT" or head.startswith(("STYLE", "REGION", "NOTE")):
if head.startswith(("STYLE", "REGION")):
flags.append(f"dropped {head.split()[0]} block")
continue
lines = block.split("\n")
timing_idx = next((i for i, ln in enumerate(lines) if "-->" in ln), None)
if timing_idx is None: # not a cue (stray id, comment)
continue
timing = lines[timing_idx]
if _POS_PERCENT.search(timing):
flags.append(f"percentage positioning dropped @ {timing.split('-->')[0].strip()}")
payload = "\n".join(lines[timing_idx + 1:])
speaker = _VOICE.search(payload) # capture speaker before stripping
payload = _VOICE.sub("", payload)
payload = _TIMESTAMP.sub("", payload)
payload = _TAG.sub("", payload) # remaining <c>, <b>, <i>, <lang>, </...>
text = re.sub(r"\s+", " ", payload).strip()
if speaker:
text = f"{speaker.group(1).strip()}: {text}"
rows = _wrap_to_grid(text, CEA608_COLUMNS)
if len(rows) > CEA608_MAX_ROWS:
flags.append(f"cue over {CEA608_MAX_ROWS} rows ({len(rows)}) — truncated")
rows = rows[:CEA608_MAX_ROWS]
if rows:
cues.append({"start": timing.split("-->")[0].strip(), "rows": rows})
return {"cues": cues, "flags": flags}
Code walkthrough
_split_blocks normalizes line endings and splits on blank lines, which is exactly how the WebVTT grammar delimits its blocks. Each block’s first line is its type discriminator: the WEBVTT signature line, a STYLE/REGION/NOTE header, or a cue (id line optional, then a timing line containing -->). The stripper drops the three non-cue block types outright — STYLE and REGION are the CSS and layout definitions with no CEA-608 analogue, and each dropped one is recorded in flags so the conversion is auditable rather than silent.
The cue path finds the timing line by scanning for --> rather than assuming a fixed line offset, because the cue identifier line is optional in WebVTT. Before any stripping, _VOICE captures the speaker name from a <v Speaker> tag, because that is the one piece of inline markup carrying broadcast-relevant meaning: 608 has no voice tag, but a Speaker: prefix preserves who is talking. Then the tags come off in order — voice spans, karaoke timestamps, and finally the general _TAG pattern that removes <c.classname>, <b>, <i>, <lang>, and every stray closing tag — leaving plain text that \s+ collapse tidies into a single spaced line.
_wrap_to_grid enforces the physical constraint that defines a 608 caption. It greedily packs words up to the 32-column width, and it hard-splits any single token longer than the whole row so a URL or a run-on never produces a row wider than the grid can display. The caller then enforces the 4-row ceiling: a cue that wraps to more than four rows is truncated to four and flagged, because a 608 buffer cannot hold more and the alternative — dropping the overflow silently — hides a real captioning error. Percentage positioning is caught on the timing line by _POS_PERCENT and flagged rather than mapped, because there is no lossless translation from a position:60% coordinate to a discrete row/column cell; that mapping is a deliberate editorial decision, not an automatic one.
Threshold reference table
| Constraint | Value | Source / basis |
|---|---|---|
| Grid width | 32 columns | CEA-608 safe-area grid |
| Maximum rows per cue | 4 rows | CEA-608 caption buffer |
| Dropped blocks | STYLE, REGION, NOTE |
W3C WebVTT §4.1 — no 608 equivalent |
| Stripped inline tags | <c>, <b>, <i>, <u>, <lang>, <ruby> |
W3C WebVTT §6.3 cue components |
| Preserved as label | <v Speaker> → Speaker: |
Speaker identity retained |
| Stripped timestamps | <HH:MM:SS.mmm> |
W3C WebVTT karaoke timing |
| Flagged, not mapped | position/line/size/align % |
No lossless grid coordinate |
| Over-long token | hard-split at 32 | Prevent row overflow |
Edge cases & known gotchas
- Voice spans become speaker labels, not dropped text.
<v Roger>Hello</v>must retain “Roger” — stripping the tag without capturing the name loses who spoke, which matters for multi-speaker broadcast captions. The code lifts the first<v>name to aSpeaker:prefix; a cue with two different speakers needs a manual split, since 608 shows one speaker context per caption. - Ruby and
<lang>annotations have no 608 target. East-Asian ruby text and<lang>-tagged spans carry linguistic structure a 608 grid cannot express. The general tag strip removes them, keeping the base text; if the ruby reading rather than the base glyph is what should air, that is an editorial decision the stripper deliberately does not make. - Nested tags must be removed depth-first in effect. WebVTT allows
<c.loud><b>text</b></c>. The_TAGregex removes each tag independently, so nesting order does not matter — every open and close tag is deleted — but a malformed unclosed tag can leave a<in the text, so validate output for stray angle brackets before muxing. - Percentage positioning is flagged, never guessed. A
position:80% line:90%cue could be a lower-third or a top-of-frame note; mapping it to a specific row without the editorial intent risks placing a caption over a lower-third graphic. The stripper flags it for a human to assign a row, consistent with how mapping WebVTT cues to broadcast timelines handles positional intent. - A cue that is pure styling collapses to empty. A cue whose entire payload was tags (a styled blank or a spacer) reduces to an empty string and is skipped, so no empty caption is emitted downstream.
Integration hook
This reduction runs after cue extraction and before muxing: it consumes the validated cue stream from WebVTT cue extraction and validation and emits plain 608-grid rows plus a flag list. The flags are the handoff — percentage positioning and over-row cues are exactly the cases a human or the timeline-mapping stage must resolve before the text reaches an insertion stage like SDI VANC caption insertion, where the row/column grid this step produces is what gets packed into VANC packets. Nothing here re-parses WebVTT structure; it assumes the upstream validator has already confirmed well-formed cues and timing.
Related
- WebVTT cue extraction and validation — the parent stage that produces the validated cues this step reduces.
- Mapping WebVTT cues to broadcast timelines — how positional intent flagged here is resolved onto a broadcast timeline.
- SDI VANC caption insertion — the muxing stage that packs the 608 grid rows this step emits into VANC.
Part of: SRT, SCC & WebVTT Parsing Workflows — the broadcast caption parsing reference.