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 a Speaker: 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 _TAG regex 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.

Part of: SRT, SCC & WebVTT Parsing Workflows — the broadcast caption parsing reference.