feat: add text formatting (bold/italic/strikethrough)

This commit is contained in:
ThetaDev 2024-04-03 03:28:13 +02:00
parent 449fc0128e
commit b8825f9199
No known key found for this signature in database
GPG key ID: E319D3C5148D65B6
10 changed files with 858 additions and 145 deletions

View file

@ -0,0 +1,50 @@
---
source: src/serializer/text.rs
expression: split
---
[
TextComponents(
[
Text {
text: "Hello",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Text {
text: " World",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
],
),
TextComponents(
[
Text {
text: "T2",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
],
),
TextComponents(
[
Text {
text: "T3",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
],
),
]

View file

@ -0,0 +1,74 @@
---
source: src/serializer/text.rs
expression: res
---
SAttributed {
ln: TextComponents(
[
Text {
text: "Bold: ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Text {
text: "Awesome",
style: Style {
bold: true,
italic: false,
strikethrough: false,
},
},
Text {
text: "\nItalic: ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Text {
text: "Great",
style: Style {
bold: false,
italic: true,
strikethrough: false,
},
},
Text {
text: "\nCut: ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Text {
text: "Dumb",
style: Style {
bold: false,
italic: false,
strikethrough: true,
},
},
Text {
text: "\n\nMixed: ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Text {
text: "Mixer",
style: Style {
bold: true,
italic: true,
strikethrough: true,
},
},
],
),
}

View file

@ -7,6 +7,11 @@ SAttributed {
[
Text {
text: "🎧Listen and download aespa's debut single \"Black Mamba\": ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Web {
text: "https://smarturl.it/aespa_BlackMamba",
@ -14,6 +19,11 @@ SAttributed {
},
Text {
text: "\n🐍The Debut Stage ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Video {
text: "aespa 에스파 'Black ...",
@ -23,6 +33,11 @@ SAttributed {
},
Text {
text: "\n\n🎟\u{fe0f} aespa Showcase SYNK in LA! Tickets now on sale: ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Web {
text: "https://www.ticketmaster.com/event/0A...",
@ -30,6 +45,11 @@ SAttributed {
},
Text {
text: "\n\nSubscribe to aespa Official YouTube Channel!\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Web {
text: "https://www.youtube.com/aespa?sub_con...",
@ -37,6 +57,11 @@ SAttributed {
},
Text {
text: "\n\naespa official\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Web {
text: "aespa",
@ -44,6 +69,11 @@ SAttributed {
},
Text {
text: "\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Web {
text: "https://www.instagram.com/aespa_official",
@ -51,6 +81,11 @@ SAttributed {
},
Text {
text: "\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Web {
text: "https://www.tiktok.com/@aespa_official",
@ -58,6 +93,11 @@ SAttributed {
},
Text {
text: "\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Web {
text: "https://twitter.com/aespa_Official",
@ -65,6 +105,11 @@ SAttributed {
},
Text {
text: "\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Web {
text: "https://www.facebook.com/aespa.official",
@ -72,6 +117,11 @@ SAttributed {
},
Text {
text: "\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Web {
text: "https://weibo.com/aespa",
@ -79,6 +129,11 @@ SAttributed {
},
Text {
text: "\n\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Browse {
text: "#aespa",
@ -87,6 +142,11 @@ SAttributed {
},
Text {
text: " ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Browse {
text: "#æspa",
@ -95,6 +155,11 @@ SAttributed {
},
Text {
text: " ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Browse {
text: "#BlackMamba",
@ -103,6 +168,11 @@ SAttributed {
},
Text {
text: " ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Browse {
text: "#블랙맘바",
@ -111,6 +181,11 @@ SAttributed {
},
Text {
text: " ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Browse {
text: "#에스파",
@ -119,6 +194,11 @@ SAttributed {
},
Text {
text: "\naespa 에스파 'Black Mamba' MV ℗ SM Entertainment",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
],
),

View file

@ -9,7 +9,7 @@ use crate::{
client::response::url_endpoint::{
MusicPage, MusicPageType, MusicVideoType, NavigationEndpoint, PageType,
},
model::UrlTarget,
model::{richtext::Style, UrlTarget},
util,
};
@ -110,6 +110,7 @@ pub(crate) enum TextComponent {
},
Text {
text: String,
style: Style,
},
}
@ -130,6 +131,12 @@ struct RichTextRun {
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
navigation_endpoint: Option<NavigationEndpoint>,
#[serde(default)]
bold: bool,
#[serde(default)]
italic: bool,
#[serde(default)]
strikethrough: bool,
}
/// This is a new rich text representation format that YouTube is A/B testing
@ -142,31 +149,117 @@ pub(crate) struct AttributedText {
content: String,
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
command_runs: Vec<AttributedTextRun>,
command_runs: Vec<CommandRun>,
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
style_runs: Vec<StyleRun>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AttributedTextRun {
struct CommandRun {
start_index: usize,
length: usize,
on_tap: AttributedTextOnTap,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct StyleRun {
start_index: usize,
length: usize,
#[serde(default)]
weight_label: WeightLabel,
#[serde(default)]
italic: bool,
#[serde(default)]
strikethrough: Strikethrough,
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum WeightLabel {
FontWeightMedium,
#[default]
#[serde(other)]
FontWeightNormal,
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum Strikethrough {
LineStyleSingle,
#[default]
#[serde(other)]
None,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AttributedTextOnTap {
innertube_command: NavigationEndpoint,
}
struct AttributedTextRun {
start_index: usize,
length: usize,
content: AttributedTextRunContent,
}
enum AttributedTextRunContent {
Link(NavigationEndpoint),
Style(Style),
}
impl From<RichTextRun> for TextComponent {
fn from(run: RichTextRun) -> Self {
map_text_component(run.text, run.navigation_endpoint)
map_text_component(
run.text,
Style {
bold: run.bold,
italic: run.italic,
strikethrough: run.strikethrough,
},
run.navigation_endpoint,
)
}
}
impl From<CommandRun> for AttributedTextRun {
fn from(value: CommandRun) -> Self {
Self {
start_index: value.start_index,
length: value.length,
content: AttributedTextRunContent::Link(value.on_tap.innertube_command),
}
}
}
impl StyleRun {
fn into_attributed_text_run(self) -> Option<AttributedTextRun> {
let style = Style {
bold: matches!(self.weight_label, WeightLabel::FontWeightMedium),
italic: self.italic,
strikethrough: matches!(self.strikethrough, Strikethrough::LineStyleSingle),
};
if style.is_styled() {
Some(AttributedTextRun {
start_index: self.start_index,
length: self.length,
content: AttributedTextRunContent::Style(style),
})
} else {
None
}
}
}
/// Map a single component of a rich text
fn map_text_component(text: String, nav: Option<NavigationEndpoint>) -> TextComponent {
fn map_text_component(
text: String,
style: Style,
nav: Option<NavigationEndpoint>,
) -> TextComponent {
match nav {
Some(NavigationEndpoint::Watch { watch_endpoint }) => TextComponent::Video {
text,
@ -185,7 +278,7 @@ fn map_text_component(text: String, nav: Option<NavigationEndpoint>) -> TextComp
Some(bc) => bc.browse_endpoint_context_music_config.page_type,
None => match &command_metadata {
Some(cm) => cm.web_command_metadata.web_page_type,
None => return TextComponent::Text { text },
None => return TextComponent::Text { text, style },
},
},
text,
@ -202,7 +295,7 @@ fn map_text_component(text: String, nav: Option<NavigationEndpoint>) -> TextComp
page_type: PageType::Playlist,
browse_id: watch_playlist_endpoint.playlist_id,
},
None => TextComponent::Text { text },
None => TextComponent::Text { text, style },
}
}
@ -267,37 +360,51 @@ impl<'de> DeserializeAs<'de, TextComponents> for AttributedText {
buf
};
let mut components = Vec::with_capacity(text.command_runs.len() + 1);
text.command_runs.into_iter().for_each(|cmd| {
let txt_before = take_chars(cmd.start_index);
let txt_link = take_chars(cmd.start_index + cmd.length);
let mut runs = text
.command_runs
.into_iter()
.map(AttributedTextRun::from)
.collect::<Vec<_>>();
runs.extend(
text.style_runs
.into_iter()
.filter_map(StyleRun::into_attributed_text_run),
);
runs.sort_by_key(|run| run.start_index);
// Trim link text:
// 3xnbsp, (/ •), nbsp, Name, 2xnbsp
// Channel: `\u{a0}\u{a0}\u{a0}/\u{a0}aespa\u{a0}\u{a0}`
// Video: `\u{a0}\u{a0}\u{a0}•\u{a0}aespa\u{a0}에스파\u{a0}'Black\u{a0}...\u{a0}\u{a0}`
// Replace no-break spaces, trim off whitespace and prefix character
let txt_link = txt_link.trim();
let txt_link = txt_link.replace('\u{a0}', " ");
static LINK_PREFIX: Lazy<Regex> = Lazy::new(|| Regex::new("^[/•] *").unwrap());
let txt_link = LINK_PREFIX.replace(&txt_link, "");
let mut components = Vec::with_capacity(runs.len() + 1);
for run in runs {
let txt_before = take_chars(run.start_index);
let txt_run = take_chars(run.start_index + run.length);
if !txt_before.is_empty() {
components.push(TextComponent::Text { text: txt_before });
components.push(TextComponent::new(txt_before));
}
components.push(map_text_component(
txt_link.to_string(),
Some(cmd.on_tap.innertube_command),
));
});
components.push(match run.content {
AttributedTextRunContent::Link(link) => {
// Trim link text:
// 3xnbsp, (/ •), nbsp, Name, 2xnbsp
// Channel: `\u{a0}\u{a0}\u{a0}/\u{a0}aespa\u{a0}\u{a0}`
// Video: `\u{a0}\u{a0}\u{a0}•\u{a0}aespa\u{a0}에스파\u{a0}'Black\u{a0}...\u{a0}\u{a0}`
// Replace no-break spaces, trim off whitespace and prefix character
let txt_link = txt_run.trim();
let txt_link = txt_link.replace('\u{a0}', " ");
static LINK_PREFIX: Lazy<Regex> = Lazy::new(|| Regex::new("^[/•] *").unwrap());
let txt_link = LINK_PREFIX.replace(&txt_link, "");
map_text_component(txt_link.to_string(), Style::default(), Some(link))
}
AttributedTextRunContent::Style(style) => {
map_text_component(txt_run.to_string(), style, None)
}
})
}
let end = chars.as_str();
if !end.is_empty() {
components.push(TextComponent::Text {
text: end.to_owned(),
});
components.push(TextComponent::new(end));
}
Ok(TextComponents(components))
@ -376,7 +483,7 @@ impl From<TextComponent> for crate::model::ArtistId {
},
TextComponent::Video { text, .. }
| TextComponent::Web { text, .. }
| TextComponent::Text { text } => Self {
| TextComponent::Text { text, .. } => Self {
id: None,
name: text,
},
@ -405,13 +512,16 @@ impl From<TextComponent> for crate::model::richtext::TextComponent {
browse_id,
} => match page_type.to_url_target(browse_id) {
Some(target) => Self::YouTube { text, target },
None => Self::Text { text },
None => Self::Text {
text,
style: Default::default(),
},
},
TextComponent::Web { text, url } => Self::Web {
text,
url: util::sanitize_yt_url(&url),
},
TextComponent::Text { text } => Self::Text { text },
TextComponent::Text { text, style } => Self::Text { text, style },
}
}
}
@ -423,12 +533,19 @@ impl From<TextComponents> for crate::model::richtext::RichText {
}
impl TextComponent {
pub fn new<S: Into<String>>(s: S) -> Self {
Self::Text {
text: s.into(),
style: Style::default(),
}
}
pub fn as_str(&self) -> &str {
match self {
TextComponent::Video { text, .. }
| TextComponent::Browse { text, .. }
| TextComponent::Web { text, .. }
| TextComponent::Text { text } => text,
| TextComponent::Text { text, .. } => text,
}
}
@ -456,7 +573,7 @@ impl From<TextComponent> for String {
TextComponent::Video { text, .. }
| TextComponent::Browse { text, .. }
| TextComponent::Web { text, .. }
| TextComponent::Text { text } => text,
| TextComponent::Text { text, .. } => text,
}
}
}
@ -732,6 +849,11 @@ mod tests {
SLink {
ln: Text {
text: "Hello World",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
}
"###);
@ -823,6 +945,11 @@ mod tests {
},
Text {
text: " & ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
},
Browse {
text: "Maite Kelly",
@ -851,57 +978,26 @@ mod tests {
insta::assert_debug_snapshot!(res);
}
#[test]
fn styled_comment() {
let json_path = path!(*TESTFILES / "text" / "styled_comment.json");
let json_file = File::open(json_path).unwrap();
let res: SAttributed = serde_json::from_reader(BufReader::new(json_file)).unwrap();
insta::assert_debug_snapshot!(res);
}
#[test]
fn split_text_cmp() {
let text = TextComponents(vec![
TextComponent::Text {
text: "Hello".to_owned(),
},
TextComponent::Text {
text: " World".to_owned(),
},
TextComponent::Text {
text: util::DOT_SEPARATOR.to_owned(),
},
TextComponent::Text {
text: "T2".to_owned(),
},
TextComponent::Text {
text: util::DOT_SEPARATOR.to_owned(),
},
TextComponent::Text {
text: "T3".to_owned(),
},
TextComponent::new("Hello"),
TextComponent::new(" World"),
TextComponent::new(util::DOT_SEPARATOR),
TextComponent::new("T2"),
TextComponent::new(util::DOT_SEPARATOR),
TextComponent::new("T3"),
]);
let split = text.split(util::DOT_SEPARATOR);
insta::assert_debug_snapshot!(split, @r###"
[
TextComponents(
[
Text {
text: "Hello",
},
Text {
text: " World",
},
],
),
TextComponents(
[
Text {
text: "T2",
},
],
),
TextComponents(
[
Text {
text: "T3",
},
],
),
]
"###);
insta::assert_debug_snapshot!(split);
}
}