2020-01-10 12:01:23 +01:00
|
|
|
// Copyright: Ankitects Pty Ltd and contributors
|
|
|
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
|
|
|
|
use lazy_static::lazy_static;
|
2020-01-21 05:44:27 +01:00
|
|
|
use regex::{Captures, Regex};
|
2020-01-10 12:01:23 +01:00
|
|
|
use std::borrow::Cow;
|
|
|
|
use std::ptr;
|
2020-11-18 23:46:27 +01:00
|
|
|
use unicase::eq as uni_eq;
|
2020-03-21 06:15:59 +01:00
|
|
|
use unicode_normalization::{
|
|
|
|
char::is_combining_mark, is_nfc, is_nfkd_quick, IsNormalized, UnicodeNormalization,
|
|
|
|
};
|
2020-01-10 12:01:23 +01:00
|
|
|
|
2020-01-20 10:12:34 +01:00
|
|
|
#[derive(Debug, PartialEq)]
|
2020-01-24 02:06:11 +01:00
|
|
|
pub enum AVTag {
|
|
|
|
SoundOrVideo(String),
|
2020-01-20 10:12:34 +01:00
|
|
|
TextToSpeech {
|
2020-01-24 02:06:11 +01:00
|
|
|
field_text: String,
|
|
|
|
lang: String,
|
|
|
|
voices: Vec<String>,
|
2020-01-26 05:28:17 +01:00
|
|
|
speed: f32,
|
2020-01-24 02:06:11 +01:00
|
|
|
other_args: Vec<String>,
|
2020-01-20 10:12:34 +01:00
|
|
|
},
|
|
|
|
}
|
|
|
|
|
2020-01-10 12:01:23 +01:00
|
|
|
lazy_static! {
|
|
|
|
static ref HTML: Regex = Regex::new(concat!(
|
|
|
|
"(?si)",
|
|
|
|
// wrapped text
|
|
|
|
r"(<!--.*?-->)|(<style.*?>.*?</style>)|(<script.*?>.*?</script>)",
|
|
|
|
// html tags
|
|
|
|
r"|(<.*?>)",
|
|
|
|
))
|
|
|
|
.unwrap();
|
|
|
|
|
2020-11-10 14:50:17 +01:00
|
|
|
static ref HTML_MEDIA_TAGS: Regex = Regex::new(
|
2020-02-10 05:19:39 +01:00
|
|
|
r#"(?xsi)
|
2020-10-14 01:56:57 +02:00
|
|
|
# the start of the image, audio, or object tag
|
|
|
|
<\b(?:img|audio|object)\b[^>]+\b(?:src|data)\b=
|
2020-02-10 05:19:39 +01:00
|
|
|
(?:
|
|
|
|
# 1: double-quoted filename
|
|
|
|
"
|
|
|
|
([^"]+?)
|
|
|
|
"
|
|
|
|
[^>]*>
|
|
|
|
|
|
|
|
|
# 2: single-quoted filename
|
|
|
|
'
|
|
|
|
([^']+?)
|
|
|
|
'
|
|
|
|
[^>]*>
|
|
|
|
|
|
|
|
|
# 3: unquoted filename
|
|
|
|
([^ >]+?)
|
|
|
|
(?:
|
|
|
|
# then either a space and the rest
|
|
|
|
\x20[^>]*>
|
|
|
|
|
|
|
|
|
# or the tag immediately ends
|
|
|
|
>
|
|
|
|
)
|
|
|
|
)
|
|
|
|
"#
|
2020-01-10 12:01:23 +01:00
|
|
|
).unwrap();
|
|
|
|
|
2020-01-20 10:12:34 +01:00
|
|
|
// videos are also in sound tags
|
|
|
|
static ref AV_TAGS: Regex = Regex::new(
|
|
|
|
r#"(?xs)
|
2020-05-12 12:53:50 +02:00
|
|
|
\[sound:(.+?)\] # 1 - the filename in a sound tag
|
2020-01-20 10:12:34 +01:00
|
|
|
|
|
|
|
|
\[anki:tts\]
|
|
|
|
\[(.*?)\] # 2 - arguments to tts call
|
|
|
|
(.*?) # 3 - field text
|
|
|
|
\[/anki:tts\]
|
|
|
|
"#).unwrap();
|
2020-01-10 12:01:23 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
pub fn strip_html(html: &str) -> Cow<str> {
|
2020-02-11 04:11:20 +01:00
|
|
|
let mut out: Cow<str> = html.into();
|
|
|
|
|
2020-04-30 03:17:38 +02:00
|
|
|
if let Cow::Owned(o) = strip_html_preserving_entities(html) {
|
2020-02-11 04:11:20 +01:00
|
|
|
out = o.into();
|
|
|
|
}
|
|
|
|
|
|
|
|
if let Cow::Owned(o) = decode_entities(out.as_ref()) {
|
|
|
|
out = o.into();
|
|
|
|
}
|
|
|
|
|
|
|
|
out
|
2020-01-10 12:01:23 +01:00
|
|
|
}
|
|
|
|
|
2020-04-30 03:17:38 +02:00
|
|
|
pub fn strip_html_preserving_entities(html: &str) -> Cow<str> {
|
|
|
|
HTML.replace_all(html, "")
|
|
|
|
}
|
|
|
|
|
2020-01-20 10:12:34 +01:00
|
|
|
pub fn decode_entities(html: &str) -> Cow<str> {
|
|
|
|
if html.contains('&') {
|
|
|
|
match htmlescape::decode_html(html) {
|
2020-08-30 03:23:12 +02:00
|
|
|
Ok(text) => text.replace('\u{a0}', " "),
|
2020-01-20 10:12:34 +01:00
|
|
|
Err(e) => format!("{:?}", e),
|
|
|
|
}
|
|
|
|
.into()
|
|
|
|
} else {
|
|
|
|
// nothing to do
|
|
|
|
html.into()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn strip_html_for_tts(html: &str) -> Cow<str> {
|
|
|
|
match HTML.replace_all(html, " ") {
|
|
|
|
Cow::Borrowed(_) => decode_entities(html),
|
|
|
|
Cow::Owned(s) => decode_entities(&s).to_string().into(),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn strip_av_tags(text: &str) -> Cow<str> {
|
|
|
|
AV_TAGS.replace_all(text, "")
|
|
|
|
}
|
|
|
|
|
2020-01-24 02:06:11 +01:00
|
|
|
/// Extract audio tags from string, replacing them with [anki:play] refs
|
2021-02-20 05:13:03 +01:00
|
|
|
pub fn extract_av_tags(text: &str, question_side: bool) -> (Cow<str>, Vec<AVTag>) {
|
2020-01-24 02:06:11 +01:00
|
|
|
let mut tags = vec![];
|
|
|
|
let context = if question_side { 'q' } else { 'a' };
|
|
|
|
let replaced_text = AV_TAGS.replace_all(text, |caps: &Captures| {
|
|
|
|
// extract
|
|
|
|
let tag = if let Some(av_file) = caps.get(1) {
|
|
|
|
AVTag::SoundOrVideo(decode_entities(av_file.as_str()).into())
|
2020-01-20 10:12:34 +01:00
|
|
|
} else {
|
|
|
|
let args = caps.get(2).unwrap();
|
|
|
|
let field_text = caps.get(3).unwrap();
|
2020-01-21 03:41:37 +01:00
|
|
|
tts_tag_from_string(field_text.as_str(), args.as_str())
|
2020-01-24 02:06:11 +01:00
|
|
|
};
|
|
|
|
tags.push(tag);
|
|
|
|
|
|
|
|
// and replace with reference
|
|
|
|
format!("[anki:play:{}:{}]", context, tags.len() - 1)
|
|
|
|
});
|
|
|
|
|
|
|
|
(replaced_text, tags)
|
2020-01-10 12:01:23 +01:00
|
|
|
}
|
|
|
|
|
2020-02-10 05:19:39 +01:00
|
|
|
#[derive(Debug)]
|
|
|
|
pub(crate) struct MediaRef<'a> {
|
|
|
|
pub full_ref: &'a str,
|
|
|
|
pub fname: &'a str,
|
2020-09-04 01:26:21 +02:00
|
|
|
/// audio files may have things like & that need decoding
|
|
|
|
pub fname_decoded: Cow<'a, str>,
|
2020-02-10 05:19:39 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
pub(crate) fn extract_media_refs(text: &str) -> Vec<MediaRef> {
|
|
|
|
let mut out = vec![];
|
|
|
|
|
2020-11-10 14:50:17 +01:00
|
|
|
for caps in HTML_MEDIA_TAGS.captures_iter(text) {
|
2020-09-04 01:26:21 +02:00
|
|
|
let fname = caps
|
|
|
|
.get(1)
|
|
|
|
.or_else(|| caps.get(2))
|
|
|
|
.or_else(|| caps.get(3))
|
|
|
|
.unwrap()
|
|
|
|
.as_str();
|
|
|
|
let fname_decoded = fname.into();
|
2020-02-10 05:19:39 +01:00
|
|
|
out.push(MediaRef {
|
|
|
|
full_ref: caps.get(0).unwrap().as_str(),
|
2020-09-04 01:26:21 +02:00
|
|
|
fname,
|
|
|
|
fname_decoded,
|
2020-02-10 05:19:39 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
for caps in AV_TAGS.captures_iter(text) {
|
|
|
|
if let Some(m) = caps.get(1) {
|
2020-09-04 01:26:21 +02:00
|
|
|
let fname = m.as_str();
|
|
|
|
let fname_decoded = decode_entities(fname);
|
2020-02-10 05:19:39 +01:00
|
|
|
out.push(MediaRef {
|
|
|
|
full_ref: caps.get(0).unwrap().as_str(),
|
2020-09-04 01:26:21 +02:00
|
|
|
fname,
|
|
|
|
fname_decoded,
|
2020-02-10 05:19:39 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
out
|
|
|
|
}
|
|
|
|
|
2020-01-24 02:06:11 +01:00
|
|
|
fn tts_tag_from_string<'a>(field_text: &'a str, args: &'a str) -> AVTag {
|
2020-01-21 03:41:37 +01:00
|
|
|
let mut other_args = vec![];
|
2020-01-26 05:28:17 +01:00
|
|
|
let mut split_args = args.split_ascii_whitespace();
|
2020-01-21 03:41:37 +01:00
|
|
|
let lang = split_args.next().unwrap_or("");
|
|
|
|
let mut voices = None;
|
2020-01-26 05:28:17 +01:00
|
|
|
let mut speed = 1.0;
|
2020-01-21 03:41:37 +01:00
|
|
|
|
|
|
|
for remaining_arg in split_args {
|
|
|
|
if remaining_arg.starts_with("voices=") {
|
|
|
|
voices = remaining_arg
|
|
|
|
.split('=')
|
|
|
|
.nth(1)
|
2020-01-24 02:06:11 +01:00
|
|
|
.map(|voices| voices.split(',').map(ToOwned::to_owned).collect());
|
2020-01-26 05:28:17 +01:00
|
|
|
} else if remaining_arg.starts_with("speed=") {
|
|
|
|
speed = remaining_arg
|
|
|
|
.split('=')
|
|
|
|
.nth(1)
|
|
|
|
.unwrap()
|
|
|
|
.parse()
|
|
|
|
.unwrap_or(1.0);
|
2020-01-21 03:41:37 +01:00
|
|
|
} else {
|
2020-01-24 02:06:11 +01:00
|
|
|
other_args.push(remaining_arg.to_owned());
|
2020-01-21 03:41:37 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
AVTag::TextToSpeech {
|
2020-01-24 02:06:11 +01:00
|
|
|
field_text: strip_html_for_tts(field_text).into(),
|
|
|
|
lang: lang.into(),
|
2020-01-21 03:41:37 +01:00
|
|
|
voices: voices.unwrap_or_else(Vec::new),
|
2020-01-26 05:28:17 +01:00
|
|
|
speed,
|
2020-01-21 03:41:37 +01:00
|
|
|
other_args,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-11-10 14:50:17 +01:00
|
|
|
pub fn strip_html_preserving_media_filenames(html: &str) -> Cow<str> {
|
|
|
|
let without_fnames = HTML_MEDIA_TAGS.replace_all(html, r" ${1}${2}${3} ");
|
2020-12-14 13:07:28 +01:00
|
|
|
let without_html = strip_html(&without_fnames);
|
2020-01-10 12:01:23 +01:00
|
|
|
// no changes?
|
|
|
|
if let Cow::Borrowed(b) = without_html {
|
|
|
|
if ptr::eq(b, html) {
|
|
|
|
return Cow::Borrowed(html);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// make borrow checker happy
|
|
|
|
without_html.into_owned().into()
|
|
|
|
}
|
|
|
|
|
2021-02-06 06:02:40 +01:00
|
|
|
#[allow(dead_code)]
|
|
|
|
pub(crate) fn sanitize_html(html: &str) -> String {
|
|
|
|
ammonia::clean(html)
|
|
|
|
}
|
|
|
|
|
|
|
|
pub(crate) fn sanitize_html_no_images(html: &str) -> String {
|
|
|
|
ammonia::Builder::default()
|
|
|
|
.rm_tags(&["img"])
|
|
|
|
.clean(html)
|
|
|
|
.to_string()
|
|
|
|
}
|
|
|
|
|
2020-02-08 11:56:30 +01:00
|
|
|
pub(crate) fn normalize_to_nfc(s: &str) -> Cow<str> {
|
|
|
|
if !is_nfc(s) {
|
|
|
|
s.chars().nfc().collect::<String>().into()
|
|
|
|
} else {
|
|
|
|
s.into()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-12 09:45:21 +02:00
|
|
|
pub(crate) fn ensure_string_in_nfc(s: &mut String) {
|
|
|
|
if !is_nfc(s) {
|
|
|
|
*s = s.chars().nfc().collect()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-21 06:15:59 +01:00
|
|
|
/// Convert provided string to NFKD form and strip combining characters.
|
|
|
|
pub(crate) fn without_combining(s: &str) -> Cow<str> {
|
|
|
|
// if the string is already normalized
|
|
|
|
if matches!(is_nfkd_quick(s.chars()), IsNormalized::Yes) {
|
|
|
|
// and no combining characters found, return unchanged
|
|
|
|
if !s.chars().any(is_combining_mark) {
|
|
|
|
return s.into();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// we need to create a new string without the combining marks
|
|
|
|
s.chars()
|
|
|
|
.nfkd()
|
|
|
|
.filter(|c| !is_combining_mark(*c))
|
|
|
|
.collect::<String>()
|
|
|
|
.into()
|
|
|
|
}
|
|
|
|
|
2020-11-17 12:49:37 +01:00
|
|
|
/// Check if string contains an unescaped wildcard.
|
|
|
|
pub(crate) fn is_glob(txt: &str) -> bool {
|
|
|
|
// even number of \s followed by a wildcard
|
|
|
|
lazy_static! {
|
|
|
|
static ref RE: Regex = Regex::new(
|
|
|
|
r#"(?x)
|
|
|
|
(?:^|[^\\]) # not a backslash
|
|
|
|
(?:\\\\)* # even number of backslashes
|
|
|
|
[*_] # wildcard
|
|
|
|
"#
|
|
|
|
)
|
|
|
|
.unwrap();
|
|
|
|
}
|
|
|
|
|
|
|
|
RE.is_match(txt)
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Convert to a RegEx respecting Anki wildcards.
|
|
|
|
pub(crate) fn to_re(txt: &str) -> Cow<str> {
|
|
|
|
to_custom_re(txt, ".")
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Convert Anki style to RegEx using the provided wildcard.
|
|
|
|
pub(crate) fn to_custom_re<'a>(txt: &'a str, wildcard: &str) -> Cow<'a, str> {
|
|
|
|
lazy_static! {
|
2020-11-17 15:39:54 +01:00
|
|
|
static ref RE: Regex = Regex::new(r"\\?.").unwrap();
|
2020-11-17 12:49:37 +01:00
|
|
|
}
|
|
|
|
RE.replace_all(&txt, |caps: &Captures| {
|
|
|
|
let s = &caps[0];
|
|
|
|
match s {
|
|
|
|
r"\\" | r"\*" => s.to_string(),
|
|
|
|
r"\_" => "_".to_string(),
|
|
|
|
"*" => format!("{}*", wildcard),
|
|
|
|
"_" => wildcard.to_string(),
|
|
|
|
s => regex::escape(s),
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Convert to SQL respecting Anki wildcards.
|
2020-11-24 11:13:05 +01:00
|
|
|
pub(crate) fn to_sql(txt: &str) -> Cow<str> {
|
2020-11-17 12:49:37 +01:00
|
|
|
// escape sequences and unescaped special characters which need conversion
|
|
|
|
lazy_static! {
|
|
|
|
static ref RE: Regex = Regex::new(r"\\[\\*]|[*%]").unwrap();
|
|
|
|
}
|
|
|
|
RE.replace_all(&txt, |caps: &Captures| {
|
|
|
|
let s = &caps[0];
|
|
|
|
match s {
|
|
|
|
r"\\" => r"\\",
|
|
|
|
r"\*" => "*",
|
|
|
|
"*" => "%",
|
|
|
|
"%" => r"\%",
|
|
|
|
_ => unreachable!(),
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Unescape everything.
|
|
|
|
pub(crate) fn to_text(txt: &str) -> Cow<str> {
|
|
|
|
lazy_static! {
|
|
|
|
static ref RE: Regex = Regex::new(r"\\(.)").unwrap();
|
|
|
|
}
|
|
|
|
RE.replace_all(&txt, "$1")
|
|
|
|
}
|
|
|
|
|
2021-01-09 10:50:08 +01:00
|
|
|
/// Escape Anki wildcards and the backslash for escaping them: \*_
|
2021-02-11 03:19:36 +01:00
|
|
|
pub(crate) fn escape_anki_wildcards(txt: &str) -> String {
|
2021-01-09 10:50:08 +01:00
|
|
|
lazy_static! {
|
|
|
|
static ref RE: Regex = Regex::new(r"[\\*_]").unwrap();
|
|
|
|
}
|
2021-02-11 03:19:36 +01:00
|
|
|
RE.replace_all(&txt, r"\$0").into()
|
2021-01-09 10:50:08 +01:00
|
|
|
}
|
|
|
|
|
2020-11-18 23:46:27 +01:00
|
|
|
/// Compare text with a possible glob, folding case.
|
|
|
|
pub(crate) fn matches_glob(text: &str, search: &str) -> bool {
|
|
|
|
if is_glob(search) {
|
|
|
|
let search = format!("^(?i){}$", to_re(search));
|
|
|
|
Regex::new(&search).unwrap().is_match(text)
|
|
|
|
} else {
|
|
|
|
uni_eq(text, &to_text(search))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-10 12:01:23 +01:00
|
|
|
#[cfg(test)]
|
|
|
|
mod test {
|
2020-11-20 09:45:53 +01:00
|
|
|
use super::*;
|
2020-03-21 06:15:59 +01:00
|
|
|
use std::borrow::Cow;
|
2020-01-10 12:01:23 +01:00
|
|
|
|
|
|
|
#[test]
|
2020-02-13 00:10:52 +01:00
|
|
|
fn stripping() {
|
2020-01-10 12:01:23 +01:00
|
|
|
assert_eq!(strip_html("test"), "test");
|
|
|
|
assert_eq!(strip_html("t<b>e</b>st"), "test");
|
|
|
|
assert_eq!(strip_html("so<SCRIPT>t<b>e</b>st</script>me"), "some");
|
|
|
|
|
|
|
|
assert_eq!(
|
2020-11-10 14:50:17 +01:00
|
|
|
strip_html_preserving_media_filenames("<img src=foo.jpg>"),
|
2020-01-10 12:01:23 +01:00
|
|
|
" foo.jpg "
|
|
|
|
);
|
|
|
|
assert_eq!(
|
2020-11-10 14:50:17 +01:00
|
|
|
strip_html_preserving_media_filenames("<img src='foo.jpg'><html>"),
|
2020-01-10 12:01:23 +01:00
|
|
|
" foo.jpg "
|
|
|
|
);
|
2020-11-10 14:50:17 +01:00
|
|
|
assert_eq!(strip_html_preserving_media_filenames("<html>"), "");
|
2020-01-10 12:01:23 +01:00
|
|
|
}
|
|
|
|
|
2020-01-20 10:12:34 +01:00
|
|
|
#[test]
|
2020-02-13 00:10:52 +01:00
|
|
|
fn audio() {
|
2020-01-21 03:41:37 +01:00
|
|
|
let s =
|
2020-01-26 05:28:17 +01:00
|
|
|
"abc[sound:fo&o.mp3]def[anki:tts][en_US voices=Bob,Jane speed=1.2]foo<br>1>2[/anki:tts]gh";
|
2020-01-20 10:12:34 +01:00
|
|
|
assert_eq!(strip_av_tags(s), "abcdefgh");
|
2020-01-24 02:06:11 +01:00
|
|
|
|
|
|
|
let (text, tags) = extract_av_tags(s, true);
|
|
|
|
assert_eq!(text, "abc[anki:play:q:0]def[anki:play:q:1]gh");
|
|
|
|
|
2020-01-20 10:12:34 +01:00
|
|
|
assert_eq!(
|
2020-01-24 02:06:11 +01:00
|
|
|
tags,
|
2020-01-20 10:12:34 +01:00
|
|
|
vec![
|
|
|
|
AVTag::SoundOrVideo("fo&o.mp3".into()),
|
|
|
|
AVTag::TextToSpeech {
|
2020-01-21 03:41:37 +01:00
|
|
|
field_text: "foo 1>2".into(),
|
2020-01-24 02:06:11 +01:00
|
|
|
lang: "en_US".into(),
|
|
|
|
voices: vec!["Bob".into(), "Jane".into()],
|
2020-01-26 05:28:17 +01:00
|
|
|
other_args: vec![],
|
|
|
|
speed: 1.2
|
2020-01-20 10:12:34 +01:00
|
|
|
},
|
|
|
|
]
|
|
|
|
);
|
|
|
|
}
|
2020-03-17 08:02:58 +01:00
|
|
|
|
2020-03-21 06:15:59 +01:00
|
|
|
#[test]
|
|
|
|
fn combining() {
|
|
|
|
assert!(matches!(without_combining("test"), Cow::Borrowed(_)));
|
|
|
|
assert!(matches!(without_combining("Über"), Cow::Owned(_)));
|
|
|
|
}
|
2020-11-20 09:45:53 +01:00
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn conversion() {
|
|
|
|
assert_eq!(&to_re(r"[te\*st]"), r"\[te\*st\]");
|
|
|
|
assert_eq!(&to_custom_re("f_o*", r"\d"), r"f\do\d*");
|
|
|
|
assert_eq!(&to_sql("%f_o*"), r"\%f_o%");
|
|
|
|
assert_eq!(&to_text(r"\*\_*_"), "*_*_");
|
|
|
|
assert!(is_glob(r"\\\\_"));
|
|
|
|
assert!(!is_glob(r"\\\_"));
|
|
|
|
assert!(matches_glob("foo*bar123", r"foo\*bar*"));
|
|
|
|
}
|
2020-01-10 12:01:23 +01:00
|
|
|
}
|