mirror of
https://github.com/steredbits/pelagia.git
synced 2026-10-21 10:01:45 +02:00
211 lines
6.5 KiB
C++
211 lines
6.5 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2026 steredbits and Pelagia contributors
|
|
#include "util/subtitles.h"
|
|
|
|
#include <algorithm>
|
|
#include <cstdlib>
|
|
|
|
#include "util/text.h"
|
|
|
|
namespace util {
|
|
|
|
namespace {
|
|
|
|
// Reads a decimal number from *pos; false if there is no digit.
|
|
bool read_number(const std::string& s, size_t* pos, int64_t* out) {
|
|
size_t i = *pos;
|
|
int64_t v = 0;
|
|
size_t digits = 0;
|
|
while (i < s.size() && s[i] >= '0' && s[i] <= '9' && digits < 9) {
|
|
v = v * 10 + (s[i] - '0');
|
|
++i;
|
|
++digits;
|
|
}
|
|
if (digits == 0) return false;
|
|
*pos = i;
|
|
*out = v;
|
|
return true;
|
|
}
|
|
|
|
// "HH:MM:SS,mmm" (or ".", hours on 1 digit or more, milliseconds on 1 to 3 digits).
|
|
bool parse_time(const std::string& s, size_t* pos, int64_t* out_ms) {
|
|
size_t i = *pos;
|
|
while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i;
|
|
int64_t h = 0, m = 0, sec = 0, ms = 0;
|
|
if (!read_number(s, &i, &h) || i >= s.size() || s[i] != ':') return false;
|
|
++i;
|
|
if (!read_number(s, &i, &m) || i >= s.size() || s[i] != ':') return false;
|
|
++i;
|
|
if (!read_number(s, &i, &sec)) return false;
|
|
if (i < s.size() && (s[i] == ',' || s[i] == '.')) {
|
|
++i;
|
|
const size_t start = i;
|
|
if (!read_number(s, &i, &ms)) return false;
|
|
size_t digits = i - start;
|
|
while (digits < 3) { // ",5" = 500 ms
|
|
ms *= 10;
|
|
++digits;
|
|
}
|
|
while (digits > 3) {
|
|
ms /= 10;
|
|
--digits;
|
|
}
|
|
}
|
|
*pos = i;
|
|
*out_ms = ((h * 60 + m) * 60 + sec) * 1000 + ms;
|
|
return true;
|
|
}
|
|
|
|
// Removes HTML tags, {\...} blocks and decodes entities; \N -> line break.
|
|
std::string clean_cue_text(const std::string& raw) {
|
|
std::string out;
|
|
for (size_t i = 0; i < raw.size();) {
|
|
const char c = raw[i];
|
|
if (c == '<') {
|
|
const size_t end = raw.find('>', i);
|
|
const char next = i + 1 < raw.size() ? raw[i + 1] : '\0';
|
|
const bool tag = next == '/' || (next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z');
|
|
if (tag && end != std::string::npos) {
|
|
i = end + 1;
|
|
continue;
|
|
}
|
|
} else if (c == '{' && i + 1 < raw.size() && raw[i + 1] == '\\') {
|
|
const size_t end = raw.find('}', i);
|
|
if (end != std::string::npos) {
|
|
i = end + 1;
|
|
continue;
|
|
}
|
|
} else if (c == '\\' && i + 1 < raw.size() && (raw[i + 1] == 'N' || raw[i + 1] == 'n')) {
|
|
out += '\n';
|
|
i += 2;
|
|
continue;
|
|
} else if (c == '&') {
|
|
static const struct { const char* name; const char* value; } kEntities[] = {
|
|
{"&", "&"}, {"<", "<"}, {">", ">"}, {""", "\""},
|
|
{"'", "'"}, {"'", "'"}, {" ", " "}};
|
|
bool done = false;
|
|
for (const auto& e : kEntities) {
|
|
const std::string name = e.name;
|
|
if (raw.compare(i, name.size(), name) == 0) {
|
|
out += e.value;
|
|
i += name.size();
|
|
done = true;
|
|
break;
|
|
}
|
|
}
|
|
if (done) continue;
|
|
}
|
|
out += c;
|
|
++i;
|
|
}
|
|
// Cleaned lines (invisible characters removed, edge spaces), empty lines removed.
|
|
std::string result;
|
|
size_t start = 0;
|
|
while (start <= out.size()) {
|
|
size_t end = out.find('\n', start);
|
|
if (end == std::string::npos) end = out.size();
|
|
const std::string line = clean_display_text(out.substr(start, end - start));
|
|
if (!line.empty()) {
|
|
if (!result.empty()) result += '\n';
|
|
result += line;
|
|
}
|
|
start = end + 1;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool parse_srt(const std::string& data, std::vector<SubtitleCue>* out) {
|
|
out->clear();
|
|
std::string text;
|
|
text.reserve(data.size());
|
|
size_t begin = 0;
|
|
if (data.compare(0, 3, "\xEF\xBB\xBF") == 0) begin = 3; // BOM
|
|
for (size_t i = begin; i < data.size(); ++i) {
|
|
if (data[i] == '\r') {
|
|
text += '\n';
|
|
if (i + 1 < data.size() && data[i + 1] == '\n') ++i;
|
|
} else {
|
|
text += data[i];
|
|
}
|
|
}
|
|
|
|
size_t pos = 0;
|
|
while (pos < text.size()) {
|
|
// A block: lines up to the first empty line.
|
|
std::vector<std::string> lines;
|
|
while (pos < text.size()) {
|
|
size_t end = text.find('\n', pos);
|
|
if (end == std::string::npos) end = text.size();
|
|
std::string line = text.substr(pos, end - pos);
|
|
pos = end + 1;
|
|
if (line.find_first_not_of(" \t") == std::string::npos) {
|
|
if (lines.empty()) continue; // leading empty lines
|
|
break;
|
|
}
|
|
lines.push_back(line);
|
|
}
|
|
// Timing line: one of the first two (the number is optional).
|
|
size_t time_line = lines.size();
|
|
for (size_t i = 0; i < lines.size() && i < 2; ++i) {
|
|
if (lines[i].find("-->") != std::string::npos) {
|
|
time_line = i;
|
|
break;
|
|
}
|
|
}
|
|
if (time_line == lines.size()) continue;
|
|
const std::string& t = lines[time_line];
|
|
size_t p = 0;
|
|
int64_t start = 0, end = 0;
|
|
if (!parse_time(t, &p, &start)) continue;
|
|
const size_t arrow = t.find("-->", p);
|
|
if (arrow == std::string::npos) continue;
|
|
p = arrow + 3;
|
|
if (!parse_time(t, &p, &end) || end <= start) continue;
|
|
std::string raw;
|
|
for (size_t i = time_line + 1; i < lines.size(); ++i) {
|
|
if (!raw.empty()) raw += '\n';
|
|
raw += lines[i];
|
|
}
|
|
SubtitleCue cue;
|
|
cue.start_ms = start;
|
|
cue.end_ms = end;
|
|
cue.text = clean_cue_text(raw);
|
|
if (!cue.text.empty()) out->push_back(cue);
|
|
}
|
|
std::stable_sort(out->begin(), out->end(), [](const SubtitleCue& a, const SubtitleCue& b) {
|
|
return a.start_ms < b.start_ms;
|
|
});
|
|
return !out->empty();
|
|
}
|
|
|
|
void SubtitleTrack::set_cues(std::vector<SubtitleCue> cues) {
|
|
cues_ = std::move(cues);
|
|
std::stable_sort(cues_.begin(), cues_.end(), [](const SubtitleCue& a, const SubtitleCue& b) {
|
|
return a.start_ms < b.start_ms;
|
|
});
|
|
max_duration_ms_ = 0;
|
|
for (const SubtitleCue& c : cues_) {
|
|
max_duration_ms_ = std::max(max_duration_ms_, c.end_ms - c.start_ms);
|
|
}
|
|
}
|
|
|
|
std::vector<const SubtitleCue*> SubtitleTrack::active_at(int64_t position_ms) const {
|
|
std::vector<const SubtitleCue*> active;
|
|
// First cue that starts after the position, then walk backwards as long as an
|
|
// older cue (even a very long one) could still cover the position.
|
|
size_t hi = std::upper_bound(cues_.begin(), cues_.end(), position_ms,
|
|
[](int64_t pos, const SubtitleCue& c) { return pos < c.start_ms; }) -
|
|
cues_.begin();
|
|
for (size_t i = hi; i > 0; --i) {
|
|
const SubtitleCue& c = cues_[i - 1];
|
|
if (c.start_ms + max_duration_ms_ <= position_ms) break;
|
|
if (position_ms < c.end_ms) active.push_back(&c);
|
|
}
|
|
std::reverse(active.begin(), active.end());
|
|
return active;
|
|
}
|
|
|
|
} // namespace util
|