feat: teletext formatting (#1384)

This PR adds parsing of teletext styling, and rendering of the styling
in output TTML and WebVTT subtitle tracks.

Beyond unit tests, I've used the sample
https://drive.google.com/file/d/19ZYsoeUfH85gEilQkaAdLbPhC4CxhDEh/view?usp=sharing
which has rather advanced subtitling with two separate rows at the same
time, where one is left aligned and another is right aligned. This
necessitates two parallel cues to be rendered. It also has some colored
text.

Solve #1335.

## parse teletext styling and formatting

Extend the teletext parser to parse the teletext styling and formatting.
This includes translating rows into regions, calculating alignment
from start and stop position of the text, and extracting text and
background colors.

The colors are limited to full lines.
Both lines and regions are propagated in the TextSample structures.
This is because the number of lines may differ from different sources.
For teletext, there are 24 rows, but they are essentially always
used with double height, so the number of output lines is 12
from 0 to 11.
There are also corresponding regions are denoted "ttx_R",
where R is an integer row number. A renderer can use either
the line number or the region ID to render the text.

## ttml generation for teletext to EBU-TT-D

Add support to render teletext input in EBU-TT-D (IMSC-1) format.
This includes appropriate regions ttx_0 to ttx_11 signalled
in the TextSamples, alignment and text and background colors.

The general TTML output has been changed to always include
metadata, layout, and styling nodes, even if they are empty.

EBU-TT-D is detected by the presence of "ttx_?" regions in the
samples. If detected, extra TTML elements will be added and
the EBU-TT-D linePadding used as well.

Appropriate styles for background and text colors are generated
depending on the color and backgroundColor attributes in the
text fragments.

## adapt WebVTT output to teletext TextSample.

Teletext input generates both a region with prefix ttx_
and a floating point line number (e.g. 9.5) in the
range 0 to 11.5 (due to input 0-23 as double lines).

The output is adopted to drop such regions
and convert the line number to an integer
since the standard only used floats for percent
values but not for plain line numbers.
This commit is contained in:
Torbjörn Einarson 2024-04-29 19:33:03 +02:00 committed by GitHub
parent 84009d82ef
commit 4b5e80d02c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 679 additions and 133 deletions

View File

@ -54,6 +54,7 @@ Sanil Raut <sr1990003@gmail.com>
Sergio Ammirata <sergio@ammirata.net> Sergio Ammirata <sergio@ammirata.net>
Thomas Inskip <tinskip@google.com> Thomas Inskip <tinskip@google.com>
Tim Lansen <tim.lansen@gmail.com> Tim Lansen <tim.lansen@gmail.com>
Torbjörn Einarsson <torbjorn.einarsson@eyevinn.se>
Vincent Nguyen <nvincen@amazon.com> Vincent Nguyen <nvincen@amazon.com>
Weiguo Shao <weiguo.shao@dolby.com> Weiguo Shao <weiguo.shao@dolby.com>

View File

@ -4,7 +4,7 @@
<Period id="0" start="PT0S"> <Period id="0" start="PT0S">
<AdaptationSet id="0" contentType="text" segmentAlignment="true"> <AdaptationSet id="0" contentType="text" segmentAlignment="true">
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/> <Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>
<Representation id="0" bandwidth="4120" codecs="stpp" mimeType="application/mp4"> <Representation id="0" bandwidth="4552" codecs="stpp" mimeType="application/mp4">
<SegmentTemplate timescale="1000" initialization="bear-english-text-init.mp4" media="bear-english-text-$Number$.m4s" startNumber="1"> <SegmentTemplate timescale="1000" initialization="bear-english-text-init.mp4" media="bear-english-text-$Number$.m4s" startNumber="1">
<SegmentTimeline> <SegmentTimeline>
<S t="0" d="1000" r="4"/> <S t="0" d="1000" r="4"/>

View File

@ -1,6 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xml:lang=""> <tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xml:lang="">
<head/> <head>
<metadata/>
<styling/>
<layout/>
</head>
<body> <body>
<div> <div>
<p xml:space="preserve" begin="00:00:00.000" end="00:00:00.800" tts:textAlign="center">Yup, that's a bear, eh.</p> <p xml:space="preserve" begin="00:00:00.000" end="00:00:00.800" tts:textAlign="center">Yup, that's a bear, eh.</p>

View File

@ -1,6 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xml:lang=""> <tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xml:lang="">
<head/> <head>
<metadata/>
<styling/>
<layout/>
</head>
<body> <body>
<div> <div>
<p xml:space="preserve" begin="00:00:01.000" end="00:00:04.700" tts:textAlign="center">He 's... um... doing bear-like stuff.</p> <p xml:space="preserve" begin="00:00:01.000" end="00:00:04.700" tts:textAlign="center">He 's... um... doing bear-like stuff.</p>

View File

@ -1,6 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xml:lang=""> <tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xml:lang="">
<head/> <head>
<metadata/>
<styling/>
<layout/>
</head>
<body> <body>
<div> <div>
<p xml:space="preserve" begin="00:00:01.000" end="00:00:04.700" tts:textAlign="center">He 's... um... doing bear-like stuff.</p> <p xml:space="preserve" begin="00:00:01.000" end="00:00:04.700" tts:textAlign="center">He 's... um... doing bear-like stuff.</p>

View File

@ -1,6 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xml:lang=""> <tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xml:lang="">
<head/> <head>
<metadata/>
<styling/>
<layout/>
</head>
<body> <body>
<div> <div>
<p xml:space="preserve" begin="00:00:01.000" end="00:00:04.700" tts:textAlign="center">He 's... um... doing bear-like stuff.</p> <p xml:space="preserve" begin="00:00:01.000" end="00:00:04.700" tts:textAlign="center">He 's... um... doing bear-like stuff.</p>

View File

@ -1,6 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xml:lang=""> <tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xml:lang="">
<head/> <head>
<metadata/>
<styling/>
<layout/>
</head>
<body> <body>
<div> <div>
<p xml:space="preserve" begin="00:00:01.000" end="00:00:04.700" tts:textAlign="center">He 's... um... doing bear-like stuff.</p> <p xml:space="preserve" begin="00:00:01.000" end="00:00:04.700" tts:textAlign="center">He 's... um... doing bear-like stuff.</p>

View File

@ -4,7 +4,7 @@
<Period id="0" start="PT0S"> <Period id="0" start="PT0S">
<AdaptationSet id="0" contentType="text" segmentAlignment="true"> <AdaptationSet id="0" contentType="text" segmentAlignment="true">
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/> <Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>
<Representation id="0" bandwidth="2616" mimeType="application/ttml+xml"> <Representation id="0" bandwidth="3048" mimeType="application/ttml+xml">
<SegmentTemplate timescale="1000" media="bear-english-text-$Number$.ttml" startNumber="1"> <SegmentTemplate timescale="1000" media="bear-english-text-$Number$.ttml" startNumber="1">
<SegmentTimeline> <SegmentTimeline>
<S t="0" d="1000" r="4"/> <S t="0" d="1000" r="4"/>

View File

@ -80,6 +80,11 @@ struct TextFragmentStyle {
std::optional<bool> underline; std::optional<bool> underline;
std::optional<bool> bold; std::optional<bool> bold;
std::optional<bool> italic; std::optional<bool> italic;
// The colors could be any string that can be interpreted as
// a color in TTML (or WebVTT). As a start, the 8 teletext colors are used,
// i.e. black, red, green, yellow, blue, magenta, cyan, and white
std::string color;
std::string backgroundColor;
}; };
/// Represents a recursive structure of styled blocks of text. Only one of /// Represents a recursive structure of styled blocks of text. Only one of

View File

@ -7,10 +7,10 @@
#include <packager/media/formats/mp2t/es_parser_teletext.h> #include <packager/media/formats/mp2t/es_parser_teletext.h>
#include <packager/media/base/bit_reader.h> #include <packager/media/base/bit_reader.h>
#include <packager/media/base/text_stream_info.h>
#include <packager/media/base/timestamp.h> #include <packager/media/base/timestamp.h>
#include <packager/media/formats/mp2t/es_parser_teletext_tables.h> #include <packager/media/formats/mp2t/es_parser_teletext_tables.h>
#include <packager/media/formats/mp2t/mp2t_common.h> #include <packager/media/formats/mp2t/mp2t_common.h>
#include <iostream>
namespace shaka { namespace shaka {
namespace media { namespace media {
@ -18,6 +18,8 @@ namespace mp2t {
namespace { namespace {
constexpr const char* kRegionTeletextPrefix = "ttx_";
const uint8_t EBU_TELETEXT_WITH_SUBTITLING = 0x03; const uint8_t EBU_TELETEXT_WITH_SUBTITLING = 0x03;
const int kPayloadSize = 40; const int kPayloadSize = 40;
const int kNumTriplets = 13; const int kNumTriplets = 13;
@ -94,14 +96,6 @@ bool ParseSubtitlingDescriptor(
return true; return true;
} }
std::string RemoveTrailingSpaces(const std::string& input) {
const auto index = input.find_last_not_of(' ');
if (index == std::string::npos) {
return "";
}
return input.substr(0, index + 1);
}
} // namespace } // namespace
EsParserTeletext::EsParserTeletext(const uint32_t pid, EsParserTeletext::EsParserTeletext(const uint32_t pid,
@ -169,7 +163,7 @@ bool EsParserTeletext::ParseInternal(const uint8_t* data,
const int64_t pts) { const int64_t pts) {
BitReader reader(data, size); BitReader reader(data, size);
RCHECK(reader.SkipBits(8)); RCHECK(reader.SkipBits(8));
std::vector<std::string> lines; std::vector<TextRow> rows;
while (reader.bits_available()) { while (reader.bits_available()) {
uint8_t data_unit_id; uint8_t data_unit_id;
@ -178,15 +172,17 @@ bool EsParserTeletext::ParseInternal(const uint8_t* data,
uint8_t data_unit_length; uint8_t data_unit_length;
RCHECK(reader.ReadBits(8, &data_unit_length)); RCHECK(reader.ReadBits(8, &data_unit_length));
if (data_unit_id != EBU_TELETEXT_WITH_SUBTITLING) {
RCHECK(reader.SkipBytes(data_unit_length));
continue;
}
if (data_unit_length != 44) { if (data_unit_length != 44) {
// Teletext data unit length is always 44 bytes
LOG(ERROR) << "Bad Teletext data length"; LOG(ERROR) << "Bad Teletext data length";
break; break;
} }
if (data_unit_id != EBU_TELETEXT_WITH_SUBTITLING) {
RCHECK(reader.SkipBytes(44));
continue;
}
RCHECK(reader.SkipBits(16)); RCHECK(reader.SkipBits(16));
@ -207,27 +203,26 @@ bool EsParserTeletext::ParseInternal(const uint8_t* data,
const uint8_t* data_block = reader.current_byte_ptr(); const uint8_t* data_block = reader.current_byte_ptr();
RCHECK(reader.SkipBytes(40)); RCHECK(reader.SkipBytes(40));
std::string display_text; TextRow row;
if (ParseDataBlock(pts, data_block, packet_nr, magazine, display_text)) { if (ParseDataBlock(pts, data_block, packet_nr, magazine, row)) {
lines.emplace_back(std::move(display_text)); rows.emplace_back(std::move(row));
} }
} }
if (lines.empty()) { if (rows.empty()) {
return true; return true;
} }
const uint16_t index = magazine_ * 100 + page_number_; const uint16_t index = magazine_ * 100 + page_number_;
auto page_state_itr = page_state_.find(index); auto page_state_itr = page_state_.find(index);
if (page_state_itr == page_state_.end()) { if (page_state_itr == page_state_.end()) {
page_state_.emplace(index, TextBlock{std::move(lines), {}, last_pts_}); page_state_.emplace(index, TextBlock{std::move(rows), {}, last_pts_});
} else { } else {
for (auto& line : lines) { for (auto& row : rows) {
auto& page_state_lines = page_state_itr->second.lines; auto& page_state_lines = page_state_itr->second.rows;
page_state_lines.emplace_back(std::move(line)); page_state_lines.emplace_back(std::move(row));
} }
lines.clear(); rows.clear();
} }
return true; return true;
@ -237,13 +232,17 @@ bool EsParserTeletext::ParseDataBlock(const int64_t pts,
const uint8_t* data_block, const uint8_t* data_block,
const uint8_t packet_nr, const uint8_t packet_nr,
const uint8_t magazine, const uint8_t magazine,
std::string& display_text) { TextRow& row) {
if (packet_nr == 0) { if (packet_nr == 0) {
last_pts_ = pts; last_pts_ = pts;
BitReader reader(data_block, 32); BitReader reader(data_block, 32);
const uint8_t page_number_units = ReadHamming(reader); const uint8_t page_number_units = ReadHamming(reader);
const uint8_t page_number_tens = ReadHamming(reader); const uint8_t page_number_tens = ReadHamming(reader);
if (page_number_units == 0xf || page_number_tens == 0xf) {
RCHECK(reader.SkipBits(40));
return false;
}
const uint8_t page_number = 10 * page_number_tens + page_number_units; const uint8_t page_number = 10 * page_number_tens + page_number_units;
const uint16_t index = magazine * 100 + page_number; const uint16_t index = magazine * 100 + page_number;
@ -251,9 +250,6 @@ bool EsParserTeletext::ParseDataBlock(const int64_t pts,
page_number_ = page_number; page_number_ = page_number;
magazine_ = magazine; magazine_ = magazine;
if (page_number == 0xFF) {
return false;
}
RCHECK(reader.SkipBits(40)); RCHECK(reader.SkipBits(40));
const uint8_t subcode_c11_c14 = ReadHamming(reader); const uint8_t subcode_c11_c14 = ReadHamming(reader);
@ -273,7 +269,7 @@ bool EsParserTeletext::ParseDataBlock(const int64_t pts,
return false; return false;
} }
display_text = BuildText(data_block, packet_nr); row = BuildRow(data_block, packet_nr);
return true; return true;
} }
@ -317,45 +313,81 @@ void EsParserTeletext::SendPending(const uint16_t index, const int64_t pts) {
auto page_state_itr = page_state_.find(index); auto page_state_itr = page_state_.find(index);
if (page_state_itr == page_state_.end() || if (page_state_itr == page_state_.end() ||
page_state_itr->second.lines.empty()) { page_state_itr->second.rows.empty()) {
return; return;
} }
const auto& pending_lines = page_state_itr->second.lines; const auto& pending_rows = page_state_itr->second.rows;
const auto pending_pts = page_state_itr->second.pts; const auto pending_pts = page_state_itr->second.pts;
TextFragmentStyle text_fragment_style;
TextSettings text_settings; TextSettings text_settings;
std::shared_ptr<TextSample> text_sample; std::shared_ptr<TextSample> text_sample;
if (pending_lines.size() == 1) {
TextFragment text_fragment(text_fragment_style, pending_lines[0].c_str());
text_sample = std::make_shared<TextSample>("", pending_pts, pts,
text_settings, text_fragment);
} else {
std::vector<TextFragment> sub_fragments; std::vector<TextFragment> sub_fragments;
for (const auto& line : pending_lines) {
sub_fragments.emplace_back(text_fragment_style, line.c_str()); if (pending_rows.size() == 1) {
sub_fragments.emplace_back(text_fragment_style, true); // This is a single line of formatted text.
// Propagate row number/2 and alignment
const float line_nr = float(pending_rows[0].row_number) / 2.0;
text_settings.line = TextNumber(line_nr, TextUnitType::kLines);
text_settings.region = kRegionTeletextPrefix + std::to_string(int(line_nr));
text_settings.text_alignment = pending_rows[0].alignment;
text_sample = std::make_shared<TextSample>(
"", pending_pts, pts, text_settings, pending_rows[0].fragment);
text_sample->set_sub_stream_index(index);
emit_sample_cb_(text_sample);
page_state_.erase(index);
return;
} else {
int32_t latest_row_nr = -1;
bool last_double_height = false;
bool new_sample = true;
for (const auto& row : pending_rows) {
int row_nr = row.row_number;
bool double_height = row.double_height;
int row_step = last_double_height ? 2 : 1;
if (latest_row_nr != -1) { // Not the first row
if (row_nr != latest_row_nr + row_step) {
// Send what has been collected since not adjacent
text_sample =
std::make_shared<TextSample>("", pending_pts, pts, text_settings,
TextFragment({}, sub_fragments));
text_sample->set_sub_stream_index(index);
emit_sample_cb_(text_sample);
new_sample = true;
} else {
// Add a newline and the next row to the current sample
sub_fragments.push_back(TextFragment({}, true));
sub_fragments.push_back(row.fragment);
new_sample = false;
}
}
if (new_sample) {
const float line_nr = float(row.row_number) / 2.0;
text_settings.line = TextNumber(line_nr, TextUnitType::kLines);
text_settings.region =
kRegionTeletextPrefix + std::to_string(int(line_nr));
text_settings.text_alignment = row.alignment;
sub_fragments.clear();
sub_fragments.push_back(row.fragment);
}
last_double_height = double_height;
latest_row_nr = row_nr;
} }
sub_fragments.pop_back();
TextFragment text_fragment(text_fragment_style, sub_fragments);
text_sample = std::make_shared<TextSample>("", pending_pts, pts,
text_settings, text_fragment);
} }
text_sample = std::make_shared<TextSample>(
"", pending_pts, pts, text_settings, TextFragment({}, sub_fragments));
text_sample->set_sub_stream_index(index); text_sample->set_sub_stream_index(index);
emit_sample_cb_(text_sample); emit_sample_cb_(text_sample);
page_state_.erase(index); page_state_.erase(index);
} }
std::string EsParserTeletext::BuildText(const uint8_t* data_block, // BuildRow builds a row with alignment information.
EsParserTeletext::TextRow EsParserTeletext::BuildRow(const uint8_t* data_block,
const uint8_t row) const { const uint8_t row) const {
std::string next_string; std::string next_string;
next_string.reserve(kPayloadSize * 2); next_string.reserve(kPayloadSize * 2);
bool leading_spaces = true;
const uint16_t index = magazine_ * 100 + page_number_; const uint16_t index = magazine_ * 100 + page_number_;
const auto page_state_itr = page_state_.find(index); const auto page_state_itr = page_state_.find(index);
@ -370,12 +402,19 @@ std::string EsParserTeletext::BuildText(const uint8_t* data_block,
} }
} }
int32_t start_pos = 0;
int32_t end_pos = 0;
bool double_height = false;
TextFragmentStyle text_style = TextFragmentStyle();
text_style.color = "white";
text_style.backgroundColor = "black";
// A typical 40 character line looks like:
// doubleHeight, [color] spaces, Start, Start, text, End End, spaces
for (size_t i = 0; i < kPayloadSize; ++i) { for (size_t i = 0; i < kPayloadSize; ++i) {
if (column_replacement_map) { if (column_replacement_map) {
const auto column_itr = column_replacement_map->find(i); const auto column_itr = column_replacement_map->find(i);
if (column_itr != column_replacement_map->cend()) { if (column_itr != column_replacement_map->cend()) {
next_string.append(column_itr->second); next_string.append(column_itr->second);
leading_spaces = false;
continue; continue;
} }
} }
@ -383,17 +422,68 @@ std::string EsParserTeletext::BuildText(const uint8_t* data_block,
char next_char = char next_char =
static_cast<char>(TELETEXT_BITREVERSE_8[data_block[i]] & 0x7f); static_cast<char>(TELETEXT_BITREVERSE_8[data_block[i]] & 0x7f);
if (next_char < 32) { if (next_char < 0x20) {
next_char = 0x20; // Here are control characters, which are not printable.
// These include colors, double-height, flashing, etc.
// We only handle one-foreground color and double-height.
switch (next_char) {
case 0x0: // Alpha Black (not included in Level 1.5)
// color = ColorBlack
break;
case 0x1:
text_style.color = "red";
break;
case 0x2:
text_style.color = "green";
break;
case 0x3:
text_style.color = "yellow";
break;
case 0x4:
text_style.color = "blue";
break;
case 0x5:
text_style.color = "magenta";
break;
case 0x6:
text_style.color = "cyan";
break;
case 0x7:
text_style.color = "white";
break;
case 0x08: // Flash (not handled)
break;
case 0x09: // Steady (not handled)
break;
case 0xa: // End Box
end_pos = i - 1;
break;
case 0xb: // Start Box, typically twice due to double height
start_pos = i + 1;
continue; // Do not propagate as a space
break;
case 0xc: // Normal size
break;
case 0xd: // Double height, typically always used
double_height = true;
break;
case 0x1c: // Black background (not handled)
break;
case 0x1d: // Set background color from text color.
text_style.backgroundColor = text_style.color;
text_style.color = "black"; // Avoid having same as background
break;
default:
// Rest of codes below 0x20 are not part of Level 1.5 or related to
// mosaic graphics (non-text)
break;
} }
next_char =
if (leading_spaces) { 0x20; // These characters result in a space if between start and end
if (next_char == 0x20) { }
if (start_pos == 0 || end_pos != 0) { // Not between start and end
continue; continue;
} }
leading_spaces = false;
}
switch (next_char) { switch (next_char) {
case '&': case '&':
next_string.append("&amp;"); next_string.append("&amp;");
@ -407,8 +497,25 @@ std::string EsParserTeletext::BuildText(const uint8_t* data_block,
} break; } break;
} }
} }
if (end_pos == 0) {
end_pos = kPayloadSize - 1;
}
return RemoveTrailingSpaces(next_string); // Using start_pos and end_pos we approximated alignment of text
// depending on the number of spaces to the left and right of the text.
auto left_right_diff = start_pos - (kPayloadSize - 1 - end_pos);
TextAlignment alignment;
if (left_right_diff > 4) {
alignment = TextAlignment::kRight;
} else if (left_right_diff < -4) {
alignment = TextAlignment::kLeft;
} else {
alignment = TextAlignment::kCenter;
}
const auto text_row = TextRow(
{alignment, row, double_height, {TextFragment(text_style, next_string)}});
return text_row;
} }
void EsParserTeletext::ParsePacket26(const uint8_t* data_block) { void EsParserTeletext::ParsePacket26(const uint8_t* data_block) {

View File

@ -12,6 +12,7 @@
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
#include <packager/media/base/text_stream_info.h>
#include <packager/media/formats/mp2t/es_parser.h> #include <packager/media/formats/mp2t/es_parser.h>
namespace shaka { namespace shaka {
@ -37,8 +38,15 @@ class EsParserTeletext : public EsParser {
using RowColReplacementMap = using RowColReplacementMap =
std::unordered_map<uint8_t, std::unordered_map<uint8_t, std::string>>; std::unordered_map<uint8_t, std::unordered_map<uint8_t, std::string>>;
struct TextRow {
TextAlignment alignment;
int row_number;
bool double_height;
TextFragment fragment;
};
struct TextBlock { struct TextBlock {
std::vector<std::string> lines; std::vector<TextRow> rows;
RowColReplacementMap packet_26_replacements; RowColReplacementMap packet_26_replacements;
int64_t pts; int64_t pts;
}; };
@ -48,10 +56,10 @@ class EsParserTeletext : public EsParser {
const uint8_t* data_block, const uint8_t* data_block,
const uint8_t packet_nr, const uint8_t packet_nr,
const uint8_t magazine, const uint8_t magazine,
std::string& display_text); TextRow& display_text);
void UpdateCharset(); void UpdateCharset();
void SendPending(const uint16_t index, const int64_t pts); void SendPending(const uint16_t index, const int64_t pts);
std::string BuildText(const uint8_t* data_block, const uint8_t row) const; TextRow BuildRow(const uint8_t* data_block, const uint8_t row) const;
void ParsePacket26(const uint8_t* data_block); void ParsePacket26(const uint8_t* data_block);
void UpdateNationalSubset(const uint8_t national_subset[13][3]); void UpdateNationalSubset(const uint8_t national_subset[13][3]);

View File

@ -166,6 +166,65 @@ const uint8_t PES_8937764[] = {
0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x23, 0xc7, 0x75, 0x8c, 0x1c, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x23, 0xc7, 0x75, 0x8c, 0x1c,
0x04, 0x04, 0x04, 0x86, 0x4f, 0xce, 0x75, 0x75, 0x75, 0x8c, 0x8c}; 0x04, 0x04, 0x04, 0x86, 0x4f, 0xce, 0x75, 0x75, 0x75, 0x8c, 0x8c};
// Start (packet0, page88) with packet26 and row 18 left (packet18)
const uint8_t PES_867681[] = {
0x10, 0x03, 0x2c, 0xf6, 0xe4, 0xa8, 0xa8, 0x0b, 0x0b, 0xa8, 0x0b, 0xa8,
0x0b, 0xf4, 0xa8, 0xb3, 0x83, 0x32, 0xa2, 0x73, 0x2a, 0xa2, 0x73, 0x23,
0x83, 0x73, 0x2a, 0xcb, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04,
0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x03,
0x2c, 0xd4, 0xe4, 0xa8, 0x6d, 0xa8, 0x9e, 0xc9, 0x00, 0x4e, 0x93, 0xa7,
0x90, 0x53, 0xa7, 0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff,
0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff,
0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff, 0x03, 0x2c, 0xd6,
0xe4, 0xa8, 0xe3, 0xb0, 0x04, 0x04, 0x04, 0x04, 0xd0, 0xd0, 0xb5, 0x32,
0xae, 0xd6, 0xa7, 0x04, 0x85, 0x51, 0x51, 0x04, 0x04, 0x04, 0x04, 0x04,
0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04,
0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04};
// row 22 right (packet22)
const uint8_t PES_871281[] = {
0x10, 0x03, 0x2c, 0xf4, 0xe4, 0xa8, 0xd9, 0xb0, 0x04, 0x04, 0x04, 0x04,
0x04, 0x04, 0xd0, 0xd0, 0xb5, 0x52, 0xa7, 0x04, 0x6e, 0x86, 0x97, 0xce,
0x04, 0x86, 0xae, 0x1f, 0x04, 0xc7, 0xf7, 0xae, 0x4f, 0xce, 0x04, 0x26,
0xe5, 0xa7, 0x2f, 0xa7, 0x75, 0x51, 0x51, 0x04, 0x04, 0x04, 0x04};
// End (packet 0, page 88)
const uint8_t PES_1011695[] = {
0x10, 0x03, 0x2c, 0xf6, 0xe4, 0xa8, 0xa8, 0x0b, 0x0b, 0xa8, 0x0b, 0xa8,
0x0b, 0xf4, 0xa8, 0xb3, 0x83, 0x32, 0xa2, 0x73, 0x2a, 0xa2, 0x73, 0x23,
0x83, 0x73, 0x2a, 0xcb, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04,
0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04};
// Start (packet0, page 88) with packet26 and row 20 (centered yellow)
// (packet20)
const uint8_t PES_1033297[] = {
0x10, 0x03, 0x2c, 0xf6, 0xe4, 0xa8, 0xa8, 0x0b, 0x0b, 0xa8, 0x0b, 0xa8,
0x0b, 0xf4, 0xa8, 0xb3, 0x83, 0x32, 0xa2, 0x73, 0x2a, 0xa2, 0x73, 0x23,
0x83, 0x73, 0x2a, 0xcb, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04,
0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x03,
0x2c, 0xd4, 0xe4, 0xa8, 0x6d, 0xa8, 0x06, 0xc9, 0x01, 0x62, 0x93, 0xa6,
0x9e, 0xc9, 0x00, 0xf4, 0xa2, 0x86, 0x06, 0xa3, 0xa7, 0x2e, 0xfe, 0xff,
0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff,
0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff, 0x2e, 0xfe, 0xff, 0x03, 0x2c, 0xd6,
0xe4, 0xa8, 0x31, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04,
0x04, 0x04, 0x04, 0xb0, 0xc1, 0xd0, 0xd0, 0x52, 0xe5, 0x86, 0x97, 0x04,
0x37, 0xf7, 0xae, 0x0e, 0xa7, 0x51, 0x51, 0x04, 0x04, 0x04, 0x04, 0x04,
0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04};
// row 22 centered blue on yellow background (packet22)
const uint8_t PES_1036900[] = {
0x10, 0x03, 0x2c, 0xf4, 0xe4, 0xa8, 0xd9, 0x04, 0x04, 0xb0, 0xc1, 0xb9,
0x20, 0xd0, 0xd0, 0x37, 0xe5, 0x97, 0x76, 0x97, 0x2f, 0x97, 0x86, 0x2f,
0x97, 0xf7, 0x76, 0x04, 0x86, 0x04, 0x37, 0xe5, 0x86, 0x37, 0xe6, 0xa7,
0x46, 0x4f, 0xa7, 0x75, 0x51, 0x51, 0x04, 0x04, 0x04, 0x04, 0x04};
// End (packet 0, page 88)
const uint8_t PES_1173713[] = {
0x10, 0x03, 0x2c, 0xf6, 0xe4, 0xa8, 0xa8, 0x0b, 0x0b, 0xa8, 0x0b, 0xa8,
0x0b, 0xf4, 0xa8, 0xb3, 0x83, 0x32, 0xa2, 0x73, 0x2a, 0xa2, 0x73, 0x23,
0x83, 0x73, 0x2a, 0xcb, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04,
0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04};
const uint32_t kPesPid = 123; const uint32_t kPesPid = 123;
} // namespace } // namespace
@ -179,10 +238,12 @@ class EsParserTeletextTest : public ::testing::Test {
void OnEmitTextSample(uint32_t pes_pid, void OnEmitTextSample(uint32_t pes_pid,
std::shared_ptr<TextSample> text_sample) { std::shared_ptr<TextSample> text_sample) {
text_sample_ = text_sample; text_sample_ = text_sample;
text_samples_.push_back(text_sample);
} }
protected: protected:
std::shared_ptr<StreamInfo> stream_info_; std::shared_ptr<StreamInfo> stream_info_;
std::vector<std::shared_ptr<TextSample>> text_samples_;
std::shared_ptr<TextSample> text_sample_; std::shared_ptr<TextSample> text_sample_;
}; };
@ -230,6 +291,13 @@ TEST_F(EsParserTeletextTest, pes_283413_line_emitted_on_next_pes) {
EXPECT_EQ(283413, text_sample_->start_time()); EXPECT_EQ(283413, text_sample_->start_time());
EXPECT_EQ(407876, text_sample_->EndTime()); EXPECT_EQ(407876, text_sample_->EndTime());
EXPECT_EQ("Bon dia!", text_sample_->body().body); EXPECT_EQ("Bon dia!", text_sample_->body().body);
EXPECT_EQ("black", text_sample_->body().style.backgroundColor);
EXPECT_EQ("white", text_sample_->body().style.color);
TextSettings settings = text_sample_->settings();
EXPECT_EQ(TextAlignment::kCenter, settings.text_alignment);
EXPECT_TRUE(settings.line.has_value());
EXPECT_EQ(11, settings.line.value().value);
EXPECT_EQ(TextUnitType::kLines, settings.line.value().type);
} }
TEST_F(EsParserTeletextTest, multiple_lines_with_same_pts) { TEST_F(EsParserTeletextTest, multiple_lines_with_same_pts) {
@ -260,6 +328,98 @@ TEST_F(EsParserTeletextTest, multiple_lines_with_same_pts) {
EXPECT_EQ("-Sí?", text_sample_->body().sub_fragments[0].body); EXPECT_EQ("-Sí?", text_sample_->body().sub_fragments[0].body);
EXPECT_TRUE(text_sample_->body().sub_fragments[1].newline); EXPECT_TRUE(text_sample_->body().sub_fragments[1].newline);
EXPECT_EQ("-Sí.", text_sample_->body().sub_fragments[2].body); EXPECT_EQ("-Sí.", text_sample_->body().sub_fragments[2].body);
TextSettings settings = text_sample_->settings();
EXPECT_EQ(10, settings.line.value().value);
EXPECT_EQ("ttx_10", settings.region);
EXPECT_EQ(1, text_samples_.size());
}
// separate_lines_with_slightly_different_pts has the original lines
// 18 and 22, with different alignment, which means that they should
// result in two parallel text samples.
TEST_F(EsParserTeletextTest, separate_lines_with_slightly_different_pts) {
auto on_new_stream = std::bind(&EsParserTeletextTest::OnNewStreamInfo, this,
kPesPid, std::placeholders::_1);
auto on_emit_text = std::bind(&EsParserTeletextTest::OnEmitTextSample, this,
kPesPid, std::placeholders::_1);
std::unique_ptr<EsParserTeletext> es_parser_teletext(new EsParserTeletext(
kPesPid, on_new_stream, on_emit_text, DESCRIPTOR, 12));
auto parse_result =
es_parser_teletext->Parse(PES_867681, sizeof(PES_867681), 867681, 0);
EXPECT_TRUE(parse_result);
parse_result =
es_parser_teletext->Parse(PES_871281, sizeof(PES_871281), 871281, 0);
EXPECT_TRUE(parse_result);
parse_result =
es_parser_teletext->Parse(PES_1011695, sizeof(PES_1011695), 1011695, 0);
EXPECT_TRUE(parse_result);
EXPECT_NE(nullptr, text_sample_.get());
EXPECT_EQ(2, text_samples_.size());
// The subtitles should get the same start and end time
EXPECT_EQ(867681, text_samples_[0]->start_time());
EXPECT_EQ(867681, text_samples_[1]->start_time());
EXPECT_EQ(1011695, text_samples_[0]->EndTime());
EXPECT_EQ(1011695, text_samples_[0]->EndTime());
EXPECT_EQ(1, text_samples_[0]->body().sub_fragments.size());
EXPECT_EQ(1, text_samples_[1]->body().sub_fragments.size());
EXPECT_EQ("-Luke !", text_samples_[0]->body().sub_fragments[0].body);
EXPECT_EQ("ttx_9", text_samples_[0]->settings().region);
EXPECT_EQ(TextAlignment::kLeft, text_samples_[0]->settings().text_alignment);
EXPECT_EQ("-Je vais aux cours d'été.",
text_samples_[1]->body().sub_fragments[0].body);
EXPECT_EQ(11, text_samples_[1]->settings().line.value().value);
EXPECT_EQ("ttx_11", text_samples_[1]->settings().region);
EXPECT_EQ(TextAlignment::kCenter,
text_samples_[1]->settings().text_alignment);
}
// consecutive_lines_with_slightly_different_pts has the original lines
// 20 and 22 with same alignment, which means that they should
// result in one text sample with two lines.
TEST_F(EsParserTeletextTest, consecutive_lines_with_slightly_different_pts) {
auto on_new_stream = std::bind(&EsParserTeletextTest::OnNewStreamInfo, this,
kPesPid, std::placeholders::_1);
auto on_emit_text = std::bind(&EsParserTeletextTest::OnEmitTextSample, this,
kPesPid, std::placeholders::_1);
std::unique_ptr<EsParserTeletext> es_parser_teletext(new EsParserTeletext(
kPesPid, on_new_stream, on_emit_text, DESCRIPTOR, 12));
auto parse_result =
es_parser_teletext->Parse(PES_1033297, sizeof(PES_1033297), 1033297, 0);
EXPECT_TRUE(parse_result);
parse_result =
es_parser_teletext->Parse(PES_1036900, sizeof(PES_1036900), 1036900, 0);
EXPECT_TRUE(parse_result);
parse_result =
es_parser_teletext->Parse(PES_1173713, sizeof(PES_1173713), 1173713, 0);
EXPECT_TRUE(parse_result);
EXPECT_NE(nullptr, text_sample_.get());
EXPECT_EQ(1, text_samples_.size());
// The subtitles should get the same start and end time
EXPECT_EQ(1033297, text_sample_->start_time());
EXPECT_EQ(1173713, text_sample_->EndTime());
EXPECT_EQ(3, text_sample_->body().sub_fragments.size());
TextSettings settings = text_sample_->settings();
EXPECT_EQ(10, settings.line.value().value);
EXPECT_EQ("ttx_10", settings.region);
EXPECT_EQ(TextAlignment::kCenter, settings.text_alignment);
EXPECT_EQ("J'ai loupé", text_sample_->body().sub_fragments[0].body);
EXPECT_EQ("yellow", text_sample_->body().sub_fragments[0].style.color);
EXPECT_TRUE(text_sample_->body().sub_fragments[1].newline);
EXPECT_EQ("l'initiation à l'algèbre.",
text_sample_->body().sub_fragments[2].body);
EXPECT_EQ("yellow",
text_sample_->body().sub_fragments[2].style.backgroundColor);
EXPECT_EQ("blue", text_sample_->body().sub_fragments[2].style.color);
} }
} // namespace mp2t } // namespace mp2t

View File

@ -18,6 +18,7 @@ namespace ttml {
namespace { namespace {
constexpr const char* kRegionIdPrefix = "_shaka_region_"; constexpr const char* kRegionIdPrefix = "_shaka_region_";
constexpr const char* kRegionTeletextPrefix = "ttx_";
std::string ToTtmlTime(int64_t time, int32_t timescale) { std::string ToTtmlTime(int64_t time, int32_t timescale) {
int64_t remaining = time * 1000 / timescale; int64_t remaining = time * 1000 / timescale;
@ -54,6 +55,18 @@ void TtmlGenerator::Initialize(const std::map<std::string, TextRegion>& regions,
regions_ = regions; regions_ = regions;
language_ = language; language_ = language;
time_scale_ = time_scale; time_scale_ = time_scale;
// Add ebu_tt_d_regions
float step = 74.1f / 11;
for (int i = 0; i < 12; i++) {
TextRegion region;
float verPos = 10.0 + int(float(step) * float(i));
region.width = TextNumber(80, TextUnitType::kPercent);
region.height = TextNumber(15, TextUnitType::kPercent);
region.window_anchor_x = TextNumber(10, TextUnitType::kPercent);
region.window_anchor_y = TextNumber(verPos, TextUnitType::kPercent);
const std::string id = kRegionTeletextPrefix + std::to_string(i);
regions_.emplace(id, region);
}
} }
void TtmlGenerator::AddSample(const TextSample& sample) { void TtmlGenerator::AddSample(const TextSample& sample) {
@ -66,72 +79,92 @@ void TtmlGenerator::Reset() {
bool TtmlGenerator::Dump(std::string* result) const { bool TtmlGenerator::Dump(std::string* result) const {
xml::XmlNode root("tt"); xml::XmlNode root("tt");
bool ebuTTDFormat = isEbuTTTD();
RCHECK(root.SetStringAttribute("xmlns", kTtNamespace)); RCHECK(root.SetStringAttribute("xmlns", kTtNamespace));
RCHECK(root.SetStringAttribute("xmlns:tts", RCHECK(root.SetStringAttribute("xmlns:tts",
"http://www.w3.org/ns/ttml#styling")); "http://www.w3.org/ns/ttml#styling"));
RCHECK(root.SetStringAttribute("xmlns:tts",
bool did_log = false; "http://www.w3.org/ns/ttml#styling"));
xml::XmlNode head("head");
RCHECK(root.SetStringAttribute("xml:lang", language_)); RCHECK(root.SetStringAttribute("xml:lang", language_));
for (const auto& pair : regions_) {
if (!did_log && (pair.second.region_anchor_x.value != 0 && if (ebuTTDFormat) {
pair.second.region_anchor_y.value != 0)) { RCHECK(root.SetStringAttribute("xmlns:ttp",
LOG(WARNING) << "TTML doesn't support non-0 region anchor"; "http://www.w3.org/ns/ttml#parameter"));
did_log = true; RCHECK(root.SetStringAttribute("xmlns:ttm",
"http://www.w3.org/ns/ttml#metadata"));
RCHECK(root.SetStringAttribute("xmlns:ebuttm", "urn:ebu:tt:metadata"));
RCHECK(root.SetStringAttribute("xmlns:ebutts", "urn:ebu:tt:style"));
RCHECK(root.SetStringAttribute("xml:space", "default"));
RCHECK(root.SetStringAttribute("ttp:timeBase", "media"));
RCHECK(root.SetStringAttribute("ttp:cellResolution", "32 15"));
} }
xml::XmlNode region("region"); xml::XmlNode head("head");
const auto origin = xml::XmlNode styling("styling");
ToTtmlSize(pair.second.window_anchor_x, pair.second.window_anchor_y);
const auto extent = ToTtmlSize(pair.second.width, pair.second.height);
RCHECK(region.SetStringAttribute("xml:id", pair.first));
RCHECK(region.SetStringAttribute("tts:origin", origin));
RCHECK(region.SetStringAttribute("tts:extent", extent));
RCHECK(head.AddChild(std::move(region)));
}
RCHECK(root.AddChild(std::move(head)));
size_t image_count = 0;
xml::XmlNode metadata("metadata"); xml::XmlNode metadata("metadata");
xml::XmlNode layout("layout");
RCHECK(addRegions(layout));
xml::XmlNode body("body"); xml::XmlNode body("body");
if (ebuTTDFormat) {
RCHECK(body.SetStringAttribute("style", "default"));
}
size_t image_count = 0;
std::unordered_set<std::string> fragmentStyles;
xml::XmlNode div("div"); xml::XmlNode div("div");
for (const auto& sample : samples_) { for (const auto& sample : samples_) {
RCHECK(AddSampleToXml(sample, &div, &metadata, &image_count)); RCHECK(
AddSampleToXml(sample, &div, &metadata, fragmentStyles, &image_count));
} }
RCHECK(body.AddChild(std::move(div)));
if (image_count > 0) { if (image_count > 0) {
RCHECK(root.SetStringAttribute( RCHECK(root.SetStringAttribute(
"xmlns:smpte", "http://www.smpte-ra.org/schemas/2052-1/2010/smpte-tt")); "xmlns:smpte", "http://www.smpte-ra.org/schemas/2052-1/2010/smpte-tt"));
RCHECK(root.AddChild(std::move(metadata)));
} }
RCHECK(body.AddChild(std::move(div)));
RCHECK(head.AddChild(std::move(metadata)));
RCHECK(addStyling(styling, fragmentStyles));
RCHECK(head.AddChild(std::move(styling)));
RCHECK(head.AddChild(std::move(layout)));
RCHECK(root.AddChild(std::move(head)));
RCHECK(root.AddChild(std::move(body))); RCHECK(root.AddChild(std::move(body)));
*result = root.ToString(/* comment= */ ""); *result = root.ToString(/* comment= */ "");
return true; return true;
} }
bool TtmlGenerator::AddSampleToXml(const TextSample& sample, bool TtmlGenerator::AddSampleToXml(
const TextSample& sample,
xml::XmlNode* body, xml::XmlNode* body,
xml::XmlNode* metadata, xml::XmlNode* metadata,
std::unordered_set<std::string>& fragmentStyles,
size_t* image_count) const { size_t* image_count) const {
xml::XmlNode p("p"); xml::XmlNode p("p");
if (!isEbuTTTD()) {
RCHECK(p.SetStringAttribute("xml:space", "preserve")); RCHECK(p.SetStringAttribute("xml:space", "preserve"));
}
RCHECK(p.SetStringAttribute("begin", RCHECK(p.SetStringAttribute("begin",
ToTtmlTime(sample.start_time(), time_scale_))); ToTtmlTime(sample.start_time(), time_scale_)));
RCHECK( RCHECK(
p.SetStringAttribute("end", ToTtmlTime(sample.EndTime(), time_scale_))); p.SetStringAttribute("end", ToTtmlTime(sample.EndTime(), time_scale_)));
RCHECK(ConvertFragmentToXml(sample.body(), &p, metadata, image_count)); RCHECK(ConvertFragmentToXml(sample.body(), &p, metadata, fragmentStyles,
image_count));
if (!sample.id().empty()) if (!sample.id().empty())
RCHECK(p.SetStringAttribute("xml:id", sample.id())); RCHECK(p.SetStringAttribute("xml:id", sample.id()));
const auto& settings = sample.settings(); const auto& settings = sample.settings();
if (settings.line || settings.position || settings.width || settings.height) { bool regionFound = false;
// TTML positioning needs to be from a region.
if (!settings.region.empty()) { if (!settings.region.empty()) {
LOG(WARNING) auto reg = regions_.find(settings.region);
<< "Using both text regions and positioning isn't supported in TTML"; if (reg != regions_.end()) {
regionFound = true;
RCHECK(p.SetStringAttribute("region", settings.region));
}
} }
if (!regionFound && (settings.line || settings.position || settings.width ||
settings.height)) {
// TTML positioning needs to be from a region.
const auto origin = ToTtmlSize( const auto origin = ToTtmlSize(
settings.position.value_or(TextNumber(0, TextUnitType::kPixels)), settings.position.value_or(TextNumber(0, TextUnitType::kPixels)),
settings.line.value_or(TextNumber(0, TextUnitType::kPixels))); settings.line.value_or(TextNumber(0, TextUnitType::kPixels)));
@ -146,8 +179,6 @@ bool TtmlGenerator::AddSampleToXml(const TextSample& sample,
RCHECK(region.SetStringAttribute("tts:extent", extent)); RCHECK(region.SetStringAttribute("tts:extent", extent));
RCHECK(p.SetStringAttribute("region", id)); RCHECK(p.SetStringAttribute("region", id));
RCHECK(body->AddChild(std::move(region))); RCHECK(body->AddChild(std::move(region)));
} else if (!settings.region.empty()) {
RCHECK(p.SetStringAttribute("region", settings.region));
} }
if (settings.writing_direction != WritingDirection::kHorizontal) { if (settings.writing_direction != WritingDirection::kHorizontal) {
@ -179,19 +210,22 @@ bool TtmlGenerator::AddSampleToXml(const TextSample& sample,
return true; return true;
} }
bool TtmlGenerator::ConvertFragmentToXml(const TextFragment& body, bool TtmlGenerator::ConvertFragmentToXml(
const TextFragment& body,
xml::XmlNode* parent, xml::XmlNode* parent,
xml::XmlNode* metadata, xml::XmlNode* metadata,
std::unordered_set<std::string>& fragmentStyles,
size_t* image_count) const { size_t* image_count) const {
if (body.newline) { if (body.newline) {
xml::XmlNode br("br"); xml::XmlNode br("br");
return parent->AddChild(std::move(br)); return parent->AddChild(std::move(br));
} }
// If we have new styles, add a new <span>.
xml::XmlNode span("span"); xml::XmlNode span("span");
xml::XmlNode* node = parent; xml::XmlNode* node = parent;
if (body.style.bold || body.style.italic || body.style.underline) { bool useSpan =
(body.style.bold || body.style.italic || body.style.underline ||
!body.style.color.empty() || !body.style.backgroundColor.empty());
if (useSpan) {
node = &span; node = &span;
if (body.style.bold) { if (body.style.bold) {
RCHECK(span.SetStringAttribute("tts:fontWeight", RCHECK(span.SetStringAttribute("tts:fontWeight",
@ -206,6 +240,20 @@ bool TtmlGenerator::ConvertFragmentToXml(const TextFragment& body,
"tts:textDecoration", "tts:textDecoration",
*body.style.underline ? "underline" : "noUnderline")); *body.style.underline ? "underline" : "noUnderline"));
} }
std::string color = "white";
std::string backgroundColor = "black";
if (!body.style.color.empty()) {
color = body.style.color;
}
if (!body.style.backgroundColor.empty()) {
backgroundColor = body.style.backgroundColor;
}
const std::string fragStyle = color + "_" + backgroundColor;
fragmentStyles.insert(fragStyle);
RCHECK(span.SetStringAttribute("style", fragStyle));
} }
if (!body.body.empty()) { if (!body.body.empty()) {
@ -226,16 +274,91 @@ bool TtmlGenerator::ConvertFragmentToXml(const TextFragment& body,
RCHECK(node->SetStringAttribute("smpte:backgroundImage", "#" + id)); RCHECK(node->SetStringAttribute("smpte:backgroundImage", "#" + id));
} else { } else {
for (const auto& frag : body.sub_fragments) { for (const auto& frag : body.sub_fragments) {
if (!ConvertFragmentToXml(frag, node, metadata, image_count)) if (!ConvertFragmentToXml(frag, node, metadata, fragmentStyles,
image_count))
return false; return false;
} }
} }
if (body.style.bold || body.style.italic || body.style.underline) if (useSpan)
RCHECK(parent->AddChild(std::move(span))); RCHECK(parent->AddChild(std::move(span)));
return true; return true;
} }
std::vector<std::string> TtmlGenerator::usedRegions() const {
std::vector<std::string> uRegions;
for (const auto& sample : samples_) {
if (!sample.settings().region.empty()) {
uRegions.push_back(sample.settings().region);
}
}
return uRegions;
}
bool TtmlGenerator::addRegions(xml::XmlNode& layout) const {
auto regNames = usedRegions();
for (const auto& r : regions_) {
bool used = false;
for (const auto& name : regNames) {
if (r.first == name) {
used = true;
}
}
if (used) {
xml::XmlNode region("region");
const auto origin =
ToTtmlSize(r.second.window_anchor_x, r.second.window_anchor_y);
const auto extent = ToTtmlSize(r.second.width, r.second.height);
RCHECK(region.SetStringAttribute("xml:id", r.first));
RCHECK(region.SetStringAttribute("tts:origin", origin));
RCHECK(region.SetStringAttribute("tts:extent", extent));
RCHECK(region.SetStringAttribute("tts:overflow", "visible"));
RCHECK(layout.AddChild(std::move(region)));
}
}
return true;
}
bool TtmlGenerator::addStyling(
xml::XmlNode& styling,
const std::unordered_set<std::string>& fragmentStyles) const {
if (fragmentStyles.empty()) {
return true;
}
// Add default style
xml::XmlNode defaultStyle("style");
RCHECK(defaultStyle.SetStringAttribute("xml:id", "default"));
RCHECK(defaultStyle.SetStringAttribute("tts:fontStyle", "normal"));
RCHECK(defaultStyle.SetStringAttribute("tts:fontFamily", "sansSerif"));
RCHECK(defaultStyle.SetStringAttribute("tts:fontSize", "100%"));
RCHECK(defaultStyle.SetStringAttribute("tts:lineHeight", "normal"));
RCHECK(defaultStyle.SetStringAttribute("tts:textAlign", "center"));
RCHECK(defaultStyle.SetStringAttribute("ebutts:linePadding", "0.5c"));
RCHECK(styling.AddChild(std::move(defaultStyle)));
for (const auto& name : fragmentStyles) {
auto pos = name.find('_');
auto color = name.substr(0, pos);
auto backgroundColor = name.substr(pos + 1, name.size());
xml::XmlNode fragStyle("style");
RCHECK(fragStyle.SetStringAttribute("xml:id", name));
RCHECK(
fragStyle.SetStringAttribute("tts:backgroundColor", backgroundColor));
RCHECK(fragStyle.SetStringAttribute("tts:color", color));
RCHECK(styling.AddChild(std::move(fragStyle)));
}
return true;
}
bool TtmlGenerator::isEbuTTTD() const {
for (const auto& sample : samples_) {
if (sample.settings().region.rfind(kRegionTeletextPrefix, 0) == 0) {
return true;
}
}
return false;
}
} // namespace ttml } // namespace ttml
} // namespace media } // namespace media
} // namespace shaka } // namespace shaka

View File

@ -10,6 +10,7 @@
#include <list> #include <list>
#include <map> #include <map>
#include <string> #include <string>
#include <unordered_set>
#include <packager/media/base/text_sample.h> #include <packager/media/base/text_sample.h>
#include <packager/media/base/text_stream_info.h> #include <packager/media/base/text_stream_info.h>
@ -38,12 +39,20 @@ class TtmlGenerator {
bool AddSampleToXml(const TextSample& sample, bool AddSampleToXml(const TextSample& sample,
xml::XmlNode* body, xml::XmlNode* body,
xml::XmlNode* metadata, xml::XmlNode* metadata,
std::unordered_set<std::string>& fragmentStyles,
size_t* image_count) const; size_t* image_count) const;
bool ConvertFragmentToXml(const TextFragment& fragment, bool ConvertFragmentToXml(const TextFragment& fragment,
xml::XmlNode* parent, xml::XmlNode* parent,
xml::XmlNode* metadata, xml::XmlNode* metadata,
std::unordered_set<std::string>& fragmentStyles,
size_t* image_count) const; size_t* image_count) const;
bool addStyling(xml::XmlNode& styling,
const std::unordered_set<std::string>& fragmentStyles) const;
bool addRegions(xml::XmlNode& layout) const;
std::vector<std::string> usedRegions() const;
bool isEbuTTTD() const;
std::list<TextSample> samples_; std::list<TextSample> samples_;
std::map<std::string, TextRegion> regions_; std::map<std::string, TextRegion> regions_;
std::string language_; std::string language_;

View File

@ -64,7 +64,11 @@ TEST_F(TtmlMuxerTest, WithOneSegmentAndWithOneSample) {
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n" "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n"
" <head/>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout/>\n"
" </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
" <p xml:space=\"preserve\" begin=\"00:00:05.000\" " " <p xml:space=\"preserve\" begin=\"00:00:05.000\" "
@ -83,7 +87,11 @@ TEST_F(TtmlMuxerTest, MultipleFragmentsWithNewlines) {
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n" "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n"
" <head/>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout/>\n"
" </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
" <p xml:space=\"preserve\" begin=\"00:00:05.000\" " " <p xml:space=\"preserve\" begin=\"00:00:05.000\" "
@ -106,7 +114,11 @@ TEST_F(TtmlMuxerTest, HandlesStyles) {
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n" "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n"
" <head/>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout/>\n"
" </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
" <p xml:space=\"preserve\" begin=\"00:00:05.000\" " " <p xml:space=\"preserve\" begin=\"00:00:05.000\" "
@ -136,8 +148,12 @@ TEST_F(TtmlMuxerTest, HandlesRegions) {
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n" "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n"
" <head>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout>\n"
" <region xml:id=\"foo\" tts:origin=\"20px 40px\" " " <region xml:id=\"foo\" tts:origin=\"20px 40px\" "
"tts:extent=\"22% 33%\"/>\n" "tts:extent=\"22% 33%\" tts:overflow=\"visible\"/>\n"
" </layout>\n"
" </head>\n" " </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
@ -166,7 +182,11 @@ TEST_F(TtmlMuxerTest, HandlesLanguage) {
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"foo\">\n" "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"foo\">\n"
" <head/>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout/>\n"
" </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
" <p xml:space=\"preserve\" begin=\"00:00:05.000\" " " <p xml:space=\"preserve\" begin=\"00:00:05.000\" "
@ -187,7 +207,11 @@ TEST_F(TtmlMuxerTest, HandlesPosition) {
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n" "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n"
" <head/>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout/>\n"
" </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
" <region xml:id=\"_shaka_region_0\" tts:origin=\"30% 4em\" " " <region xml:id=\"_shaka_region_0\" tts:origin=\"30% 4em\" "
@ -213,7 +237,11 @@ TEST_F(TtmlMuxerTest, HandlesOtherSettings) {
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n" "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n"
" <head/>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout/>\n"
" </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
" <p xml:space=\"preserve\" begin=\"00:00:05.000\" " " <p xml:space=\"preserve\" begin=\"00:00:05.000\" "
@ -237,7 +265,11 @@ TEST_F(TtmlMuxerTest, HandlesCueId) {
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n" "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n"
" <head/>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout/>\n"
" </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
" <p xml:space=\"preserve\" begin=\"00:00:05.000\" " " <p xml:space=\"preserve\" begin=\"00:00:05.000\" "
@ -260,8 +292,12 @@ TEST_F(TtmlMuxerTest, EscapesSpecialChars) {
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" " "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" "
"xml:lang=\"foo&amp;&quot;a\">\n" "xml:lang=\"foo&amp;&quot;a\">\n"
" <head>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout>\n"
" <region xml:id=\"&lt;a&amp;&quot;\" tts:origin=\"0% 0%\" " " <region xml:id=\"&lt;a&amp;&quot;\" tts:origin=\"0% 0%\" "
"tts:extent=\"100% 100%\"/>\n" "tts:extent=\"100% 100%\" tts:overflow=\"visible\"/>\n"
" </layout>\n"
" </head>\n" " </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
@ -287,7 +323,11 @@ TEST_F(TtmlMuxerTest, HandlesReset) {
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"foobar\">\n" "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"foobar\">\n"
" <head/>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout/>\n"
" </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
" <p xml:space=\"preserve\" begin=\"00:00:05.000\" " " <p xml:space=\"preserve\" begin=\"00:00:05.000\" "
@ -299,7 +339,11 @@ TEST_F(TtmlMuxerTest, HandlesReset) {
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"foobar\">\n" "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"foobar\">\n"
" <head/>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout/>\n"
" </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
" <p xml:space=\"preserve\" begin=\"00:00:08.000\" " " <p xml:space=\"preserve\" begin=\"00:00:08.000\" "
@ -332,11 +376,15 @@ TEST_F(TtmlMuxerTest, HandlesImage) {
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\" " "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\" "
"xmlns:smpte=\"http://www.smpte-ra.org/schemas/2052-1/2010/smpte-tt\">\n" "xmlns:smpte=\"http://www.smpte-ra.org/schemas/2052-1/2010/smpte-tt\">\n"
" <head/>\n" " <head>\n"
" <metadata>\n" " <metadata>\n"
" <smpte:image imageType=\"PNG\" encoding=\"Base64\" xml:id=\"img_1\">" " <smpte:image imageType=\"PNG\" encoding=\"Base64\" "
"xml:id=\"img_1\">"
"AQID</smpte:image>\n" "AQID</smpte:image>\n"
" </metadata>\n" " </metadata>\n"
" <styling/>\n"
" <layout/>\n"
" </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
" <p xml:space=\"preserve\" begin=\"00:00:05.000\" " " <p xml:space=\"preserve\" begin=\"00:00:05.000\" "
@ -357,7 +405,11 @@ TEST_F(TtmlMuxerTest, FormatsTimeWithFixedNumberOfDigits) {
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<tt xmlns=\"http://www.w3.org/ns/ttml\" " "<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n" "xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\">\n"
" <head/>\n" " <head>\n"
" <metadata/>\n"
" <styling/>\n"
" <layout/>\n"
" </head>\n"
" <body>\n" " <body>\n"
" <div>\n" " <div>\n"
" <p xml:space=\"preserve\" begin=\"00:00:00.000\" " " <p xml:space=\"preserve\" begin=\"00:00:00.000\" "
@ -373,6 +425,51 @@ TEST_F(TtmlMuxerTest, FormatsTimeWithFixedNumberOfDigits) {
ParseSingleCue(kExpectedOutput, properties); ParseSingleCue(kExpectedOutput, properties);
} }
TEST_F(TtmlMuxerTest, HandlesTeleTextToEbuTTD) {
const char* kExpectedOutput =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<tt xmlns=\"http://www.w3.org/ns/ttml\" "
"xmlns:tts=\"http://www.w3.org/ns/ttml#styling\" xml:lang=\"\" "
"xmlns:ttp=\"http://www.w3.org/ns/ttml#parameter\" "
"xmlns:ttm=\"http://www.w3.org/ns/ttml#metadata\" "
"xmlns:ebuttm=\"urn:ebu:tt:metadata\" xmlns:ebutts=\"urn:ebu:tt:style\" "
"xml:space=\"default\" "
"ttp:timeBase=\"media\" ttp:cellResolution=\"32 15\">\n"
" <head>\n"
" <metadata/>\n"
" <styling>\n"
" <style xml:id=\"default\" tts:fontStyle=\"normal\" "
"tts:fontFamily=\"sansSerif\" tts:fontSize=\"100%\" "
"tts:lineHeight=\"normal\" "
"tts:textAlign=\"center\" ebutts:linePadding=\"0.5c\"/>\n"
" <style xml:id=\"red_cyan\" tts:backgroundColor=\"cyan\" "
"tts:color=\"red\"/>\n"
" </styling>\n"
" <layout>\n"
" <region xml:id=\"ttx_9\" tts:origin=\"10% 70%\" "
"tts:extent=\"80% 15%\" tts:overflow=\"visible\"/>\n"
" </layout>\n"
" </head>\n"
" <body style=\"default\">\n"
" <div>\n"
" <p begin=\"00:00:05.000\" end=\"00:00:06.000\" region=\"ttx_9\">\n"
" <span style=\"red_cyan\">teletext to EBU-TT-D</span>\n"
" </p>\n"
" </div>\n"
" </body>\n"
"</tt>\n";
TestProperties properties;
properties.settings.line.emplace(9, TextUnitType::kLines);
properties.settings.region = "ttx_9";
properties.settings.height.emplace(1, TextUnitType::kLines);
properties.body.body = "teletext to EBU-TT-D";
properties.body.style.color = "red";
properties.body.style.backgroundColor = "cyan";
ParseSingleCue(kExpectedOutput, properties);
}
} // namespace ttml } // namespace ttml
} // namespace media } // namespace media
} // namespace shaka } // namespace shaka

View File

@ -9,6 +9,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cinttypes> #include <cinttypes>
#include <cmath>
#include <unordered_set> #include <unordered_set>
#include <absl/log/check.h> #include <absl/log/check.h>
@ -23,6 +24,8 @@ namespace media {
namespace { namespace {
constexpr const char* kRegionTeletextPrefix = "ttx_";
bool GetTotalMilliseconds(uint64_t hours, bool GetTotalMilliseconds(uint64_t hours,
uint64_t minutes, uint64_t minutes,
uint64_t seconds, uint64_t seconds,
@ -228,7 +231,9 @@ std::string FloatToString(double number) {
std::string WebVttSettingsToString(const TextSettings& settings) { std::string WebVttSettingsToString(const TextSettings& settings) {
std::string ret; std::string ret;
if (!settings.region.empty()) { if (!settings.region.empty() &&
settings.region.find(kRegionTeletextPrefix) != 0) {
// Don't add teletext ttx_ regions, since accompanied by global line numbers
ret += " region:"; ret += " region:";
ret += settings.region; ret += settings.region;
} }
@ -241,7 +246,8 @@ std::string WebVttSettingsToString(const TextSettings& settings) {
break; break;
case TextUnitType::kLines: case TextUnitType::kLines:
ret += " line:"; ret += " line:";
ret += FloatToString(settings.line->value); // The line number should be an integer
ret += FloatToString(std::round(settings.line->value));
break; break;
case TextUnitType::kPixels: case TextUnitType::kPixels:
LOG(WARNING) << "WebVTT doesn't support pixel line settings"; LOG(WARNING) << "WebVTT doesn't support pixel line settings";

View File

@ -160,6 +160,16 @@ TEST(WebVttUtilsTest, SettingsToString) {
"region:foo line:27% position:42% size:54% direction:rl align:end"); "region:foo line:27% position:42% size:54% direction:rl align:end");
} }
TEST(WebVttUtilsTest, TeletextSettingsToStringRemovesRegionOutputsIntegerLine) {
TextSettings settings;
settings.region = "ttx_9";
settings.line = TextNumber(9.5, TextUnitType::kLines);
settings.text_alignment = TextAlignment::kCenter;
const auto actual = WebVttSettingsToString(settings);
EXPECT_EQ(actual, "line:10 align:center");
}
TEST(WebVttUtilsTest, SettingsToString_IgnoresDefaults) { TEST(WebVttUtilsTest, SettingsToString_IgnoresDefaults) {
TextSettings settings; TextSettings settings;
settings.region = "foo"; settings.region = "foo";