use derive_more::{Display, IsVariant};
use logos::Logos;
use core::fmt;
pub use tree::*;
mod tree;
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, IsVariant)]
pub enum Tag {
#[display("b")]
Bold,
#[display("i")]
Italic,
#[display("u")]
Underline,
#[display("c")]
Class,
#[display("ruby")]
Ruby,
#[display("rt")]
RubyText,
#[display("v")]
Voice,
#[display("lang")]
Lang,
}
#[derive(Debug, Logos)]
enum RawCueToken<'a> {
#[regex(r"[^<]+")]
Text(&'a str),
#[token("</b>")]
EndBold,
#[token("</i>")]
EndItalic,
#[token("</u>")]
EndUnderline,
#[token("</c>")]
EndClass,
#[token("</ruby>")]
EndRuby,
#[token("</rt>")]
EndRubyText,
#[token("</v>")]
EndVoice,
#[token("</lang>")]
EndLang,
#[regex(r"<b[. \t\n\x0C][^>]*>|<b>")]
StartBold(&'a str),
#[regex(r"<i[. \t\n\x0C][^>]*>|<i>")]
StartItalic(&'a str),
#[regex(r"<u[. \t\n\x0C][^>]*>|<u>")]
StartUnderline(&'a str),
#[regex(r"<c[. \t\n\x0C][^>]*>|<c>")]
StartClass(&'a str),
#[regex(r"<ruby[. \t\n\x0C][^>]*>|<ruby>")]
StartRuby(&'a str),
#[regex(r"<rt[. \t\n\x0C][^>]*>|<rt>")]
StartRubyText(&'a str),
#[regex(r"<v[. \t\n\x0C][^>]*>|<v>")]
StartVoice(&'a str),
#[regex(r"<lang[. \t\n\x0C][^>]*>|<lang>")]
StartLang(&'a str),
#[regex(r"<(?:[0-9]+:)?[0-5][0-9]:[0-5][0-9]\.[0-9]{3}>")]
Timestamp(&'a str),
#[regex(r"<[^>]*>")]
UnknownTag,
#[regex(r"<[^>]*")]
UnterminatedTag,
}
pub struct CueStr<'a> {
raw: &'a str,
requires_normalization: bool,
#[cfg(any(feature = "alloc", feature = "std"))]
normalized: core::cell::OnceCell<std::string::String>,
}
impl<'a> CueStr<'a> {
pub const fn borrowed(s: &'a str) -> Self {
Self {
raw: s,
requires_normalization: false,
#[cfg(any(feature = "alloc", feature = "std"))]
normalized: core::cell::OnceCell::new(),
}
}
pub const fn needs_normalization(s: &'a str) -> Self {
Self {
raw: s,
requires_normalization: true,
#[cfg(any(feature = "alloc", feature = "std"))]
normalized: core::cell::OnceCell::new(),
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn as_raw(&self) -> &'a str {
self.raw
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn requires_normalization(&self) -> bool {
self.requires_normalization
}
pub fn normalize(&self) -> &str {
if !self.requires_normalization {
return self.raw;
}
#[cfg(any(feature = "alloc", feature = "std"))]
{
self.normalized.get_or_init(|| self.decode_char_refs())
}
#[cfg(not(any(feature = "alloc", feature = "std")))]
{
self.raw
}
}
#[cfg(any(feature = "alloc", feature = "std"))]
fn decode_char_refs(&self) -> std::string::String {
let input = self.as_raw();
let bytes = input.as_bytes();
#[cfg(all(feature = "memchr", not(miri)))]
let has_special = memchr::memchr2(b'&', 0, bytes).is_some();
#[cfg(not(all(feature = "memchr", not(miri))))]
let has_special = bytes.iter().any(|&b| b == b'&' || b == 0);
if !has_special {
return std::string::String::from(input);
}
let mut out = std::string::String::with_capacity(bytes.len());
decode_char_refs_into(input, &mut out);
out
}
}
#[cfg(any(feature = "alloc", feature = "std"))]
trait DecodeSink {
fn append_str(&mut self, text: &str);
fn append_char(&mut self, c: char);
}
#[cfg(any(feature = "alloc", feature = "std"))]
impl DecodeSink for std::string::String {
#[cfg_attr(not(tarpaulin), inline(always))]
fn append_str(&mut self, text: &str) {
self.push_str(text);
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn append_char(&mut self, c: char) {
self.push(c);
}
}
#[cfg(any(feature = "alloc", feature = "std"))]
struct CollapsingSink<'o> {
out: &'o mut std::string::String,
pending_space: bool,
}
#[cfg(any(feature = "alloc", feature = "std"))]
impl CollapsingSink<'_> {
#[cfg_attr(not(tarpaulin), inline(always))]
fn push(&mut self, c: char) {
if ASCII_WHITESPACE.contains(&c) {
self.pending_space = true;
return;
}
if core::mem::take(&mut self.pending_space) && !self.out.is_empty() {
self.out.push(' ');
}
self.out.push(c);
}
}
#[cfg(any(feature = "alloc", feature = "std"))]
impl DecodeSink for CollapsingSink<'_> {
fn append_str(&mut self, text: &str) {
for c in text.chars() {
self.push(c);
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn append_char(&mut self, c: char) {
self.push(c);
}
}
#[cfg(any(feature = "alloc", feature = "std"))]
fn decode_char_refs_into<S: DecodeSink + ?Sized>(input: &str, sink: &mut S) {
let bytes = input.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == 0 {
sink.append_char('\u{FFFD}');
i += 1;
} else if bytes[i] == b'&' {
i += 1; if i >= len {
sink.append_char('&');
continue;
}
if bytes[i] == b'#' {
i += 1;
if i >= len {
sink.append_str("&#");
continue;
}
let hex = bytes[i] == b'x' || bytes[i] == b'X';
if hex {
i += 1;
}
let start = i;
if hex {
while i < len && bytes[i].is_ascii_hexdigit() {
i += 1;
}
} else {
while i < len && bytes[i].is_ascii_digit() {
i += 1;
}
}
if start == i {
sink.append_str(if hex { "&#x" } else { "&#" });
continue;
}
let digits = &input[start..i];
let code_point = if hex {
u32::from_str_radix(digits, 16).unwrap_or(0xFFFD)
} else {
digits.parse::<u32>().unwrap_or(0xFFFD)
};
if i < len && bytes[i] == b';' {
i += 1;
}
if code_point == 0 {
sink.append_char('\u{FFFD}');
} else if let Some(c) = char::from_u32(replace_legacy_c1(code_point)) {
sink.append_char(c);
} else {
sink.append_char('\u{FFFD}');
}
} else if bytes[i].is_ascii_alphanumeric() {
let ref_start = i;
while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b';') {
i += 1;
if bytes[i - 1] == b';' {
break;
}
}
let candidate = &input[ref_start..i];
match find_longest_entity_match(candidate) {
Some((matched_len, decoded)) => {
sink.append_str(decoded);
i = ref_start + matched_len;
}
None => {
sink.append_char('&');
i = ref_start; }
}
} else {
sink.append_char('&');
}
} else {
let start = i;
while i < len && bytes[i] != b'&' && bytes[i] != 0 {
i += 1;
}
sink.append_str(&input[start..i]);
}
}
}
#[cfg(any(feature = "alloc", feature = "std"))]
const fn replace_legacy_c1(code_point: u32) -> u32 {
match code_point {
0x80 => 0x20AC, 0x82 => 0x201A, 0x83 => 0x0192, 0x84 => 0x201E, 0x85 => 0x2026, 0x86 => 0x2020, 0x87 => 0x2021, 0x88 => 0x02C6, 0x89 => 0x2030, 0x8A => 0x0160, 0x8B => 0x2039, 0x8C => 0x0152, 0x8E => 0x017D, 0x91 => 0x2018, 0x92 => 0x2019, 0x93 => 0x201C, 0x94 => 0x201D, 0x95 => 0x2022, 0x96 => 0x2013, 0x97 => 0x2014, 0x98 => 0x02DC, 0x99 => 0x2122, 0x9A => 0x0161, 0x9B => 0x203A, 0x9C => 0x0153, 0x9E => 0x017E, 0x9F => 0x0178, other => other,
}
}
#[cfg(any(feature = "alloc", feature = "std"))]
fn find_longest_entity_match(candidate: &str) -> Option<(usize, &'static str)> {
use super::html5_entities::HTML5_ENTITIES;
const MAX_ENTITY_LEN: usize = 32;
let mut best: Option<(usize, &'static str)> = None;
let limit = candidate.len().min(MAX_ENTITY_LEN);
for end in 1..=limit {
let prefix = &candidate[..end];
if let Some(s) = HTML5_ENTITIES.get(prefix) {
best = Some((end, s));
if prefix.ends_with(';') {
break;
}
}
}
best
}
impl Clone for CueStr<'_> {
fn clone(&self) -> Self {
Self {
raw: self.raw,
requires_normalization: self.requires_normalization,
#[cfg(any(feature = "alloc", feature = "std"))]
normalized: self.normalized.clone(),
}
}
}
impl fmt::Debug for CueStr<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CueStr")
.field("raw", &self.raw)
.field("requires_normalization", &self.requires_normalization)
.finish()
}
}
impl PartialEq for CueStr<'_> {
fn eq(&self, other: &Self) -> bool {
self.raw == other.raw && self.requires_normalization == other.requires_normalization
}
}
impl Eq for CueStr<'_> {}
impl fmt::Display for CueStr<'_> {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
#[cfg(any(feature = "alloc", feature = "std"))]
{
f.write_str(self.normalize())
}
#[cfg(not(any(feature = "alloc", feature = "std")))]
{
f.write_str(self.raw)
}
}
}
pub struct Annotation<'a> {
raw: &'a str,
requires_normalization: bool,
#[cfg(any(feature = "alloc", feature = "std"))]
normalized: core::cell::OnceCell<std::string::String>,
}
impl<'a> Annotation<'a> {
pub const fn new(raw: &'a str) -> Self {
Self {
raw,
requires_normalization: is_outside_annotation_normal_form(raw),
#[cfg(any(feature = "alloc", feature = "std"))]
normalized: core::cell::OnceCell::new(),
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn as_raw(&self) -> &'a str {
self.raw
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn requires_normalization(&self) -> bool {
self.requires_normalization
}
pub fn normalize(&self) -> &str {
if !self.requires_normalization {
return self.raw;
}
#[cfg(any(feature = "alloc", feature = "std"))]
{
self.normalized.get_or_init(|| {
let mut out = std::string::String::with_capacity(self.raw.len());
let mut sink = CollapsingSink {
out: &mut out,
pending_space: false,
};
decode_char_refs_into(self.raw, &mut sink);
out
})
}
#[cfg(not(any(feature = "alloc", feature = "std")))]
{
self.raw
}
}
}
const fn is_outside_annotation_normal_form(raw: &str) -> bool {
let bytes = raw.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'&' | 0 | b'\t' | b'\n' | 0x0C | b'\r' => return true,
b' ' if i == 0 || i + 1 == bytes.len() || bytes[i + 1] == b' ' => return true,
_ => i += 1,
}
}
false
}
impl Clone for Annotation<'_> {
fn clone(&self) -> Self {
Self {
raw: self.raw,
requires_normalization: self.requires_normalization,
#[cfg(any(feature = "alloc", feature = "std"))]
normalized: self.normalized.clone(),
}
}
}
impl fmt::Debug for Annotation<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Annotation")
.field("raw", &self.raw)
.field("requires_normalization", &self.requires_normalization)
.finish()
}
}
impl PartialEq for Annotation<'_> {
fn eq(&self, other: &Self) -> bool {
self.raw == other.raw
}
}
impl Eq for Annotation<'_> {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CueToken<'a> {
Text(CueStr<'a>),
StartTag {
tag: Tag,
classes: &'a str,
annotation: Option<Annotation<'a>>,
},
EndTag(Tag),
Timestamp(crate::vtt::Timestamp),
}
pub struct CueParser<'a> {
lexer: logos::Lexer<'a, RawCueToken<'a>>,
}
impl<'a> CueParser<'a> {
pub fn new(input: &'a str) -> Self {
Self {
lexer: RawCueToken::lexer(input),
}
}
}
const ANNOTATION_DELIMITERS: [char; 4] = ['\t', '\n', '\u{000C}', ' '];
const ASCII_WHITESPACE: [char; 5] = ['\t', '\n', '\u{000C}', '\r', ' '];
#[cfg_attr(not(tarpaulin), inline(always))]
fn parse_tag_attrs(after_name: &str) -> (&str, Option<Annotation<'_>>) {
if after_name.is_empty() {
return ("", None);
}
let (tag_rest, annotation) = match after_name.find(ANNOTATION_DELIMITERS) {
Some(idx) => {
let ann = after_name[idx + 1..].trim_matches(ASCII_WHITESPACE);
(
&after_name[..idx],
if ann.is_empty() {
None
} else {
Some(Annotation::new(ann))
},
)
}
None => (after_name, None),
};
let classes = tag_rest.strip_prefix('.').unwrap_or("");
(classes, annotation)
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn make_start_tag<'a>(tag: Tag, slice: &'a str, name_len: usize) -> CueToken<'a> {
let inner = &slice[1 + name_len..slice.len() - 1];
let (classes, annotation) = parse_tag_attrs(inner);
CueToken::StartTag {
tag,
classes,
annotation,
}
}
impl<'a> Iterator for CueParser<'a> {
type Item = CueToken<'a>;
fn next(&mut self) -> Option<Self::Item> {
loop {
let token = self.lexer.next()?;
match token {
Ok(RawCueToken::Text(text)) => {
let needs_norm = text.as_bytes().iter().any(|&b| b == b'&' || b == 0);
return Some(CueToken::Text(if needs_norm {
CueStr::needs_normalization(text)
} else {
CueStr::borrowed(text)
}));
}
Ok(RawCueToken::EndBold) => return Some(CueToken::EndTag(Tag::Bold)),
Ok(RawCueToken::EndItalic) => return Some(CueToken::EndTag(Tag::Italic)),
Ok(RawCueToken::EndUnderline) => return Some(CueToken::EndTag(Tag::Underline)),
Ok(RawCueToken::EndClass) => return Some(CueToken::EndTag(Tag::Class)),
Ok(RawCueToken::EndRuby) => return Some(CueToken::EndTag(Tag::Ruby)),
Ok(RawCueToken::EndRubyText) => return Some(CueToken::EndTag(Tag::RubyText)),
Ok(RawCueToken::EndVoice) => return Some(CueToken::EndTag(Tag::Voice)),
Ok(RawCueToken::EndLang) => return Some(CueToken::EndTag(Tag::Lang)),
Ok(RawCueToken::StartBold(s)) => return Some(make_start_tag(Tag::Bold, s, 1)),
Ok(RawCueToken::StartItalic(s)) => return Some(make_start_tag(Tag::Italic, s, 1)),
Ok(RawCueToken::StartUnderline(s)) => {
return Some(make_start_tag(Tag::Underline, s, 1));
}
Ok(RawCueToken::StartClass(s)) => return Some(make_start_tag(Tag::Class, s, 1)),
Ok(RawCueToken::StartRuby(s)) => return Some(make_start_tag(Tag::Ruby, s, 4)),
Ok(RawCueToken::StartRubyText(s)) => {
return Some(make_start_tag(Tag::RubyText, s, 2));
}
Ok(RawCueToken::StartVoice(s)) => return Some(make_start_tag(Tag::Voice, s, 1)),
Ok(RawCueToken::StartLang(s)) => return Some(make_start_tag(Tag::Lang, s, 4)),
Ok(RawCueToken::Timestamp(s)) => {
let content = &s[1..s.len() - 1]; if let Ok(ts) = super::parse_timestamp(content) {
return Some(CueToken::Timestamp(ts));
}
}
Ok(RawCueToken::UnknownTag) | Err(()) => {}
Ok(RawCueToken::UnterminatedTag) => {
let s = self.lexer.slice();
if let Some(token) = try_parse_unterminated(s) {
return Some(token);
}
}
}
}
}
}
fn try_parse_unterminated<'a>(slice: &'a str) -> Option<CueToken<'a>> {
let inner = &slice[1..]; if inner.is_empty() {
return None;
}
if inner.as_bytes()[0].is_ascii_digit() {
if let Ok(ts) = super::parse_timestamp_cue(inner) {
return Some(CueToken::Timestamp(ts));
}
return None;
}
const DELIM: [u8; 5] = [b'.', b'\t', b'\n', 0x0C, b' '];
let follows_name = |byte: u8| DELIM.contains(&byte);
let (tag, name_len) = match inner.as_bytes() {
[b'b', next, ..] if follows_name(*next) => (Tag::Bold, 1),
[b'b'] => (Tag::Bold, 1),
[b'i', next, ..] if follows_name(*next) => (Tag::Italic, 1),
[b'i'] => (Tag::Italic, 1),
[b'u', next, ..] if follows_name(*next) => (Tag::Underline, 1),
[b'u'] => (Tag::Underline, 1),
[b'c', next, ..] if follows_name(*next) => (Tag::Class, 1),
[b'c'] => (Tag::Class, 1),
[b'v', next, ..] if follows_name(*next) => (Tag::Voice, 1),
[b'v'] => (Tag::Voice, 1),
_ if inner.starts_with("ruby") => {
if inner.len() == 4 || follows_name(inner.as_bytes()[4]) {
(Tag::Ruby, 4)
} else {
return None;
}
}
_ if inner.starts_with("rt") => {
if inner.len() == 2 || follows_name(inner.as_bytes()[2]) {
(Tag::RubyText, 2)
} else {
return None;
}
}
_ if inner.starts_with("lang") && (inner.len() == 4 || follows_name(inner.as_bytes()[4])) => {
(Tag::Lang, 4)
}
_ => return None,
};
let after_name = &inner[name_len..];
let (classes, annotation) = parse_tag_attrs(after_name);
Some(CueToken::StartTag {
tag,
classes,
annotation,
})
}