PR #23747 opened by Hugh McMaster (hmc) URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/23747 Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/23747.patch
DVB teletext subtitles use color to differentiate between speakers. When extracting these subtitles to SubRip format, FFmpeg (via libzvbi) strips out all color information, resulting in plain-text output and white text on screen. Media players including VLC, mpv and Kodi will render SubRip subtitles in color if HTML <font color="..."> tags are present. While FFmpeg supports colored subtitles via the Advanced SubStation Alpha format, support for this format is not universal and can add unnecessary overhead when rendering. # Summary of changes This patch introduces a new -txt_format option named text_color, which extracts DVB teletext subtitles to SubRip format with color information retained. <!-- If this PR requires new FATE test samples, attach them to the PR and list their target paths below (relative to the fate-suite root). Attached filenames must match the sample's filename: ```fate-samples # e.g. vorbis/new-sample.ogg ``` --> >From f4535fdd2b75f9d1b95a60a1b64d4821892d667d Mon Sep 17 00:00:00 2001 From: Hugh McMaster <[email protected]> Date: Thu, 9 Jul 2026 22:29:30 +1000 Subject: [PATCH] libavcodec/libzvbi-teletextdec.c: Support colored subtitles in SubRip output DVB teletext subtitles use color to differentiate between speakers. When extracting these subtitles to SubRip format, FFmpeg (via libzvbi) strips out all color information, resulting in plain-text output and white text on screen. Media players including VLC, mpv and Kodi will render SubRip subtitles in color if HTML <font color="..."> tags are present. While FFmpeg supports colored subtitles via the Advanced SubStation Alpha format, support for this format is not universal and can add unnecessary overhead when rendering. This patch introduces a new -txt_format option named text_color, which extracts DVB teletext subtitles to SubRip format with color information retained. Signed-off-by: Hugh McMaster <[email protected]> --- libavcodec/libzvbi-teletextdec.c | 147 +++++++++++++++++++++++++++++-- 1 file changed, 138 insertions(+), 9 deletions(-) diff --git a/libavcodec/libzvbi-teletextdec.c b/libavcodec/libzvbi-teletextdec.c index 06fd54cbb7..92b38f5705 100644 --- a/libavcodec/libzvbi-teletextdec.c +++ b/libavcodec/libzvbi-teletextdec.c @@ -18,6 +18,8 @@ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ +#include <stdbool.h> + #include "avcodec.h" #include "libavcodec/ass.h" #include "codec_internal.h" @@ -59,7 +61,7 @@ typedef struct TeletextContext int default_region; int x_offset; int y_offset; - int format_id; /* 0 = bitmap, 1 = text/ass, 2 = ass */ + int format_id; /* 0 = bitmap, 1 = text/ass, 2 = text_color/ass, 3 = ass */ int chop_top; int sub_duration; /* in msec */ int transparent_bg; @@ -231,6 +233,131 @@ static int gen_sub_text(TeletextContext *ctx, AVSubtitleRect *sub_rect, vbi_page return 0; } +static inline bool is_txt_space(vbi_char c, bool include_ascii_space) +{ + if (include_ascii_space && c.unicode == 0x0020) + return true; + + return (c.unicode < 0x0020 || c.unicode == 0x00a0 || + c.unicode >= 0xE000 && c.unicode <= 0xF8FF || + c.size > VBI_DOUBLE_SIZE || c.opacity == VBI_TRANSPARENT_SPACE); +} + +/* Generate text subtitles with HTML <font color="..."> tags */ +static int gen_sub_text_color(TeletextContext *ctx, AVSubtitleRect *sub_rect, vbi_page *page, int chop_top) +{ + AVBPrint buf; + int start_row = chop_top; + int prev_printed_row_idx = -1; + bool content_added = false; + + av_bprint_init(&buf, 0, AV_BPRINT_SIZE_UNLIMITED); + + for (int y = start_row; y < page->rows; y++) { + vbi_char *row = page->text + y * page->columns; + int start_col = 0; + int end_col = page->columns - 1; + int active_color = -1; + bool tag_is_open = false; + + if (ctx->chop_spaces) { + while (start_col < page->columns && is_txt_space(row[start_col], true)) + start_col++; + + while (end_col >= start_col && is_txt_space(row[end_col], true)) + end_col--; + + if (start_col > end_col) + continue; + } + + /* Check for duplicate row text */ + if (prev_printed_row_idx != -1) { + vbi_char *prev_row = page->text + prev_printed_row_idx * page->columns; + bool is_duplicate = true; + + for (int i = start_col; i <= end_col; i++) { + if (row[i].unicode != prev_row[i].unicode) { + is_duplicate = false; + break; + } + } + + if (is_duplicate) + continue; + } + + if (content_added) + av_bprintf(&buf, "\n"); + + content_added = true; + prev_printed_row_idx = y; + + for (int x = start_col; x <= end_col; x++) { + vbi_char c = row[x]; + + int r = VBI_R(page->color_map[c.foreground]); + int g = VBI_G(page->color_map[c.foreground]); + int b = VBI_B(page->color_map[c.foreground]); + int current_rgb = (r << 16) | (g << 8) | b; + + if (current_rgb == 0xFFFFFF) + current_rgb = -1; + + if (current_rgb != active_color) { + if (tag_is_open) { + av_bprintf(&buf, "</font>"); + tag_is_open = false; + } + + if (current_rgb != -1) { + av_bprintf(&buf, "<font color=\"#%02X%02X%02X\">", r, g, b); + tag_is_open = true; + } + active_color = current_rgb; + } + + if (is_txt_space(c, true)) { + av_bprintf(&buf, " "); + } else if (c.unicode < 0x80) { + av_bprintf(&buf, "%c", (char)c.unicode); + } else { + uint8_t byte; + char tmp[5]; + unsigned int num_bytes = 0; + + PUT_UTF8(c.unicode, byte, tmp[num_bytes++] = byte;); + if (num_bytes) + av_bprint_append_data(&buf, tmp, num_bytes); + } + } + + if (tag_is_open) + av_bprintf(&buf, "</font>"); + } + + if (!av_bprint_is_complete(&buf)) { + av_bprint_finalize(&buf, NULL); + return AVERROR(ENOMEM); + } + + if (buf.len) { + sub_rect->type = SUBTITLE_ASS; + sub_rect->ass = create_ass_text(ctx, buf.str); + + if (!sub_rect->ass) { + av_bprint_finalize(&buf, NULL); + return AVERROR(ENOMEM); + } + av_log(ctx, AV_LOG_DEBUG, "subtext:%s:txetbus\n", sub_rect->ass); + } else { + sub_rect->type = SUBTITLE_NONE; + } + av_bprint_finalize(&buf, NULL); + + return 0; +} + static void bprint_color(const char *type, AVBPrint *buf, vbi_page *page, unsigned ci) { int r = VBI_R(page->color_map[ci]); @@ -239,9 +366,6 @@ static void bprint_color(const char *type, AVBPrint *buf, vbi_page *page, unsign av_bprintf(buf, "{\\%s&H%02X%02X%02X&}", type, b, g, r); } -#define IS_TXT_SPACE(ch) ((ch).unicode < 0x0020 || (ch).unicode >= 0xe000 || (ch).unicode == 0x00a0 ||\ - (ch).size > VBI_DOUBLE_SIZE || (ch).opacity == VBI_TRANSPARENT_SPACE) - static void get_trim_info(vbi_page *page, vbi_char *row, int *leading, int *trailing, int *olen) { int i, len = 0; @@ -250,7 +374,7 @@ static void get_trim_info(vbi_page *page, vbi_char *row, int *leading, int *trai *leading = 0; for (i = 0; i < page->columns; i++) { - uint16_t out = IS_TXT_SPACE(row[i]) ? 32 : row[i].unicode; + uint16_t out = is_txt_space(row[i], false) ? 32 : row[i].unicode; if (out == 32 && !char_seen) (*leading)++; @@ -268,7 +392,7 @@ static void decode_string(vbi_page *page, vbi_char *row, AVBPrint *buf, int i; for (i = start; i < end; i++) { - uint16_t out = IS_TXT_SPACE(row[i]) ? 32 : row[i].unicode; + uint16_t out = is_txt_space(row[i], false) ? 32 : row[i].unicode; if (*cur_color != row[i].foreground) { bprint_color("c", buf, page, row[i].foreground); @@ -556,6 +680,9 @@ static void handler(vbi_event *ev, void *user_data) res = gen_sub_text(ctx, cur_page->sub_rect, &page, chop_top); break; case 2: + res = gen_sub_text_color(ctx, cur_page->sub_rect, &page, chop_top); + break; + case 3: res = gen_sub_ass(ctx, cur_page->sub_rect, &page, chop_top); break; default: @@ -755,8 +882,9 @@ static int teletext_init_decoder(AVCodecContext *avctx) case 0: return 0; case 1: - return ff_ass_subtitle_header_default(avctx); case 2: + return ff_ass_subtitle_header_default(avctx); + case 3: return my_ass_subtitle_header(avctx); } return AVERROR_BUG; @@ -793,10 +921,11 @@ static const AVOption options[] = { {"txt_page", "page numbers to decode, subtitle for subtitles, * for all", OFFSET(pgno), AV_OPT_TYPE_STRING, {.str = "*"}, 0, 0, SD}, {"txt_default_region", "default G0 character set used for decoding", OFFSET(default_region), AV_OPT_TYPE_INT, {.i64 = -1}, -1, 87, SD}, {"txt_chop_top", "discards the top teletext line", OFFSET(chop_top), AV_OPT_TYPE_INT, {.i64 = 1}, 0, 1, SD}, - {"txt_format", "format of the subtitles (bitmap or text or ass)", OFFSET(format_id), AV_OPT_TYPE_INT, {.i64 = 0}, 0, 2, SD, .unit = "txt_format"}, + {"txt_format", "format of the subtitles (bitmap, text, text_color, or ass)", OFFSET(format_id), AV_OPT_TYPE_INT, {.i64 = 0}, 0, 3, SD, .unit = "txt_format"}, {"bitmap", NULL, 0, AV_OPT_TYPE_CONST, {.i64 = 0}, 0, 0, SD, .unit = "txt_format"}, {"text", NULL, 0, AV_OPT_TYPE_CONST, {.i64 = 1}, 0, 0, SD, .unit = "txt_format"}, - {"ass", NULL, 0, AV_OPT_TYPE_CONST, {.i64 = 2}, 0, 0, SD, .unit = "txt_format"}, + {"text_color", NULL, 0, AV_OPT_TYPE_CONST, {.i64 = 2}, 0, 0, SD, .unit = "txt_format"}, + {"ass", NULL, 0, AV_OPT_TYPE_CONST, {.i64 = 3}, 0, 0, SD, .unit = "txt_format"}, {"txt_left", "x offset of generated bitmaps", OFFSET(x_offset), AV_OPT_TYPE_INT, {.i64 = 0}, 0, 65535, SD}, {"txt_top", "y offset of generated bitmaps", OFFSET(y_offset), AV_OPT_TYPE_INT, {.i64 = 0}, 0, 65535, SD}, {"txt_chop_spaces", "chops leading and trailing spaces from text", OFFSET(chop_spaces), AV_OPT_TYPE_INT, {.i64 = 1}, 0, 1, SD}, -- 2.52.0 _______________________________________________ ffmpeg-devel mailing list -- [email protected] To unsubscribe send an email to [email protected]
