mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
Merge pull request #793 from nwwt/object-audio-tags-support
Audio & object tag support
This commit is contained in:
commit
e99c0dbe15
12 changed files with 56 additions and 32 deletions
|
@ -33,14 +33,18 @@ def media_paths_from_col_path(col_path: str) -> Tuple[str, str]:
|
||||||
|
|
||||||
class MediaManager:
|
class MediaManager:
|
||||||
|
|
||||||
soundRegexps = [r"(?i)(\[sound:(?P<fname>[^]]+)\])"]
|
sound_regexps = [r"(?i)(\[sound:(?P<fname>[^]]+)\])"]
|
||||||
imgRegexps = [
|
html_media_regexps = [
|
||||||
# src element quoted case
|
# src element quoted case
|
||||||
r"(?i)(<img[^>]* src=(?P<str>[\"'])(?P<fname>[^>]+?)(?P=str)[^>]*>)",
|
r"(?i)(<[img|audio][^>]* src=(?P<str>[\"'])(?P<fname>[^>]+?)(?P=str)[^>]*>)",
|
||||||
# unquoted case
|
# unquoted case
|
||||||
r"(?i)(<img[^>]* src=(?!['\"])(?P<fname>[^ >]+)[^>]*?>)",
|
r"(?i)(<[img|audio][^>]* src=(?!['\"])(?P<fname>[^ >]+)[^>]*?>)",
|
||||||
|
# src element quoted case
|
||||||
|
r"(?i)(<object[^>]* data=(?P<str>[\"'])(?P<fname>[^>]+?)(?P=str)[^>]*>)",
|
||||||
|
# unquoted case
|
||||||
|
r"(?i)(<object[^>]* data=(?!['\"])(?P<fname>[^ >]+)[^>]*?>)",
|
||||||
]
|
]
|
||||||
regexps = soundRegexps + imgRegexps
|
regexps = sound_regexps + html_media_regexps
|
||||||
|
|
||||||
def __init__(self, col: anki.collection.Collection, server: bool) -> None:
|
def __init__(self, col: anki.collection.Collection, server: bool) -> None:
|
||||||
self.col = col.weakref()
|
self.col = col.weakref()
|
||||||
|
@ -159,7 +163,11 @@ class MediaManager:
|
||||||
return txt
|
return txt
|
||||||
|
|
||||||
def escapeImages(self, string: str, unescape: bool = False) -> str:
|
def escapeImages(self, string: str, unescape: bool = False) -> str:
|
||||||
"Apply or remove percent encoding to image filenames."
|
"escape_media_filenames alias for compatibility with add-ons."
|
||||||
|
return self.escape_media_filenames(string, unescape)
|
||||||
|
|
||||||
|
def escape_media_filenames(self, string: str, unescape: bool = False) -> str:
|
||||||
|
"Apply or remove percent encoding to filenames in html tags (audio, image, object)."
|
||||||
fn: Callable
|
fn: Callable
|
||||||
if unescape:
|
if unescape:
|
||||||
fn = urllib.parse.unquote
|
fn = urllib.parse.unquote
|
||||||
|
@ -173,7 +181,7 @@ class MediaManager:
|
||||||
return tag
|
return tag
|
||||||
return tag.replace(fname, fn(fname))
|
return tag.replace(fname, fn(fname))
|
||||||
|
|
||||||
for reg in self.imgRegexps:
|
for reg in self.html_media_regexps:
|
||||||
string = re.sub(reg, repl, string)
|
string = re.sub(reg, repl, string)
|
||||||
return string
|
return string
|
||||||
|
|
||||||
|
|
|
@ -46,7 +46,7 @@ def test_strings():
|
||||||
assert sp("aoeu") == "aoeu"
|
assert sp("aoeu") == "aoeu"
|
||||||
assert sp("aoeu[sound:foo.mp3]aoeu") == "aoeuaoeu"
|
assert sp("aoeu[sound:foo.mp3]aoeu") == "aoeuaoeu"
|
||||||
assert sp("a<img src=yo>oeu") == "aoeu"
|
assert sp("a<img src=yo>oeu") == "aoeu"
|
||||||
es = col.media.escapeImages
|
es = col.media.escape_media_filenames
|
||||||
assert es("aoeu") == "aoeu"
|
assert es("aoeu") == "aoeu"
|
||||||
assert es("<img src='http://foo.com'>") == "<img src='http://foo.com'>"
|
assert es("<img src='http://foo.com'>") == "<img src='http://foo.com'>"
|
||||||
assert es('<img src="foo bar.jpg">') == '<img src="foo%20bar.jpg">'
|
assert es('<img src="foo bar.jpg">') == '<img src="foo%20bar.jpg">'
|
||||||
|
|
|
@ -491,12 +491,14 @@ class CardLayout(QDialog):
|
||||||
self.have_autoplayed = True
|
self.have_autoplayed = True
|
||||||
|
|
||||||
if c.autoplay():
|
if c.autoplay():
|
||||||
|
AnkiWebView.setPlaybackRequiresGesture(False)
|
||||||
if self.pform.preview_front.isChecked():
|
if self.pform.preview_front.isChecked():
|
||||||
audio = c.question_av_tags()
|
audio = c.question_av_tags()
|
||||||
else:
|
else:
|
||||||
audio = c.answer_av_tags()
|
audio = c.answer_av_tags()
|
||||||
av_player.play_tags(audio)
|
av_player.play_tags(audio)
|
||||||
else:
|
else:
|
||||||
|
AnkiWebView.setPlaybackRequiresGesture(True)
|
||||||
av_player.clear_queue_and_maybe_interrupt()
|
av_player.clear_queue_and_maybe_interrupt()
|
||||||
|
|
||||||
self.updateCardNames()
|
self.updateCardNames()
|
||||||
|
|
|
@ -450,7 +450,8 @@ class Editor:
|
||||||
return
|
return
|
||||||
|
|
||||||
data = [
|
data = [
|
||||||
(fld, self.mw.col.media.escapeImages(val)) for fld, val in self.note.items()
|
(fld, self.mw.col.media.escape_media_filenames(val))
|
||||||
|
for fld, val in self.note.items()
|
||||||
]
|
]
|
||||||
self.widget.show()
|
self.widget.show()
|
||||||
self.updateTags()
|
self.updateTags()
|
||||||
|
@ -547,11 +548,13 @@ class Editor:
|
||||||
if html.find(">") > -1:
|
if html.find(">") > -1:
|
||||||
# filter html through beautifulsoup so we can strip out things like a
|
# filter html through beautifulsoup so we can strip out things like a
|
||||||
# leading </div>
|
# leading </div>
|
||||||
html_escaped = self.mw.col.media.escapeImages(html)
|
html_escaped = self.mw.col.media.escape_media_filenames(html)
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.simplefilter("ignore", UserWarning)
|
warnings.simplefilter("ignore", UserWarning)
|
||||||
html_escaped = str(BeautifulSoup(html_escaped, "html.parser"))
|
html_escaped = str(BeautifulSoup(html_escaped, "html.parser"))
|
||||||
html = self.mw.col.media.escapeImages(html_escaped, unescape=True)
|
html = self.mw.col.media.escape_media_filenames(
|
||||||
|
html_escaped, unescape=True
|
||||||
|
)
|
||||||
self.note.fields[field] = html
|
self.note.fields[field] = html
|
||||||
if not self.addMode:
|
if not self.addMode:
|
||||||
self.note.flush()
|
self.note.flush()
|
||||||
|
@ -1231,7 +1234,7 @@ def remove_null_bytes(txt, editor):
|
||||||
|
|
||||||
def reverse_url_quoting(txt, editor):
|
def reverse_url_quoting(txt, editor):
|
||||||
# reverse the url quoting we added to get images to display
|
# reverse the url quoting we added to get images to display
|
||||||
return editor.mw.col.media.escapeImages(txt, unescape=True)
|
return editor.mw.col.media.escape_media_filenames(txt, unescape=True)
|
||||||
|
|
||||||
|
|
||||||
gui_hooks.editor_will_use_font_for_field.append(fontMungeHack)
|
gui_hooks.editor_will_use_font_for_field.append(fontMungeHack)
|
||||||
|
|
|
@ -485,7 +485,7 @@ close the profile or restart Anki."""
|
||||||
return anki.sound.strip_av_refs(text)
|
return anki.sound.strip_av_refs(text)
|
||||||
|
|
||||||
def prepare_card_text_for_display(self, text: str) -> str:
|
def prepare_card_text_for_display(self, text: str) -> str:
|
||||||
text = self.col.media.escapeImages(text)
|
text = self.col.media.escape_media_filenames(text)
|
||||||
text = self._add_play_buttons(text)
|
text = self._add_play_buttons(text)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
|
@ -184,6 +184,7 @@ class Previewer(QDialog):
|
||||||
bodyclass = theme_manager.body_classes_for_card_ord(c.ord)
|
bodyclass = theme_manager.body_classes_for_card_ord(c.ord)
|
||||||
|
|
||||||
if c.autoplay():
|
if c.autoplay():
|
||||||
|
AnkiWebView.setPlaybackRequiresGesture(False)
|
||||||
if self._show_both_sides:
|
if self._show_both_sides:
|
||||||
# if we're showing both sides at once, remove any audio
|
# if we're showing both sides at once, remove any audio
|
||||||
# from the answer that's appeared on the question already
|
# from the answer that's appeared on the question already
|
||||||
|
@ -198,6 +199,7 @@ class Previewer(QDialog):
|
||||||
audio = c.answer_av_tags()
|
audio = c.answer_av_tags()
|
||||||
av_player.play_tags(audio)
|
av_player.play_tags(audio)
|
||||||
else:
|
else:
|
||||||
|
AnkiWebView.setPlaybackRequiresGesture(True)
|
||||||
av_player.clear_queue_and_maybe_interrupt()
|
av_player.clear_queue_and_maybe_interrupt()
|
||||||
|
|
||||||
txt = self.mw.prepare_card_text_for_display(txt)
|
txt = self.mw.prepare_card_text_for_display(txt)
|
||||||
|
|
|
@ -23,6 +23,7 @@ from aqt.sound import av_player, getAudio, play_clicked_audio
|
||||||
from aqt.theme import theme_manager
|
from aqt.theme import theme_manager
|
||||||
from aqt.toolbar import BottomBar
|
from aqt.toolbar import BottomBar
|
||||||
from aqt.utils import askUserDialog, downArrow, qtMenuShortcutWorkaround, tooltip
|
from aqt.utils import askUserDialog, downArrow, qtMenuShortcutWorkaround, tooltip
|
||||||
|
from aqt.webview import AnkiWebView
|
||||||
|
|
||||||
|
|
||||||
class ReviewerBottomBar:
|
class ReviewerBottomBar:
|
||||||
|
@ -184,10 +185,12 @@ class Reviewer:
|
||||||
q = c.q()
|
q = c.q()
|
||||||
# play audio?
|
# play audio?
|
||||||
if c.autoplay():
|
if c.autoplay():
|
||||||
|
AnkiWebView.setPlaybackRequiresGesture(False)
|
||||||
sounds = c.question_av_tags()
|
sounds = c.question_av_tags()
|
||||||
gui_hooks.reviewer_will_play_question_sounds(c, sounds)
|
gui_hooks.reviewer_will_play_question_sounds(c, sounds)
|
||||||
av_player.play_tags(sounds)
|
av_player.play_tags(sounds)
|
||||||
else:
|
else:
|
||||||
|
AnkiWebView.setPlaybackRequiresGesture(True)
|
||||||
av_player.clear_queue_and_maybe_interrupt()
|
av_player.clear_queue_and_maybe_interrupt()
|
||||||
sounds = []
|
sounds = []
|
||||||
gui_hooks.reviewer_will_play_question_sounds(c, sounds)
|
gui_hooks.reviewer_will_play_question_sounds(c, sounds)
|
||||||
|
|
|
@ -554,7 +554,7 @@ def restore_combo_history(comboBox: QComboBox, name: str):
|
||||||
|
|
||||||
def mungeQA(col, txt):
|
def mungeQA(col, txt):
|
||||||
print("mungeQA() deprecated; use mw.prepare_card_text_for_display()")
|
print("mungeQA() deprecated; use mw.prepare_card_text_for_display()")
|
||||||
txt = col.media.escapeImages(txt)
|
txt = col.media.escape_media_filenames(txt)
|
||||||
return txt
|
return txt
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -340,6 +340,12 @@ class AnkiWebView(QWebEngineView):
|
||||||
newFactor = desiredScale / qtIntScale
|
newFactor = desiredScale / qtIntScale
|
||||||
return max(1, newFactor)
|
return max(1, newFactor)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setPlaybackRequiresGesture(value: bool) -> None:
|
||||||
|
QWebEngineSettings.globalSettings().setAttribute(
|
||||||
|
QWebEngineSettings.PlaybackRequiresUserGesture, value
|
||||||
|
)
|
||||||
|
|
||||||
def _getQtIntScale(self, screen) -> int:
|
def _getQtIntScale(self, screen) -> int:
|
||||||
# try to detect if Qt has scaled the screen
|
# try to detect if Qt has scaled the screen
|
||||||
# - qt will round the scale factor to a whole number, so a dpi of 125% = 1x,
|
# - qt will round the scale factor to a whole number, so a dpi of 125% = 1x,
|
||||||
|
|
|
@ -10,7 +10,7 @@ use crate::{
|
||||||
err::{AnkiError, Result},
|
err::{AnkiError, Result},
|
||||||
notetype::{CardGenContext, NoteField, NoteType, NoteTypeID},
|
notetype::{CardGenContext, NoteField, NoteType, NoteTypeID},
|
||||||
template::field_is_empty,
|
template::field_is_empty,
|
||||||
text::{ensure_string_in_nfc, normalize_to_nfc, strip_html_preserving_image_filenames},
|
text::{ensure_string_in_nfc, normalize_to_nfc, strip_html_preserving_media_filenames},
|
||||||
timestamp::TimestampSecs,
|
timestamp::TimestampSecs,
|
||||||
types::Usn,
|
types::Usn,
|
||||||
};
|
};
|
||||||
|
@ -100,12 +100,12 @@ impl Note {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let field1_nohtml = strip_html_preserving_image_filenames(&self.fields()[0]);
|
let field1_nohtml = strip_html_preserving_media_filenames(&self.fields()[0]);
|
||||||
let checksum = field_checksum(field1_nohtml.as_ref());
|
let checksum = field_checksum(field1_nohtml.as_ref());
|
||||||
let sort_field = if nt.config.sort_field_idx == 0 {
|
let sort_field = if nt.config.sort_field_idx == 0 {
|
||||||
field1_nohtml
|
field1_nohtml
|
||||||
} else {
|
} else {
|
||||||
strip_html_preserving_image_filenames(
|
strip_html_preserving_media_filenames(
|
||||||
self.fields
|
self.fields
|
||||||
.get(nt.config.sort_field_idx as usize)
|
.get(nt.config.sort_field_idx as usize)
|
||||||
.map(AsRef::as_ref)
|
.map(AsRef::as_ref)
|
||||||
|
@ -208,7 +208,7 @@ impl From<pb::Note> for Note {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text must be passed to strip_html_preserving_image_filenames() by
|
/// Text must be passed to strip_html_preserving_media_filenames() by
|
||||||
/// caller prior to passing in here.
|
/// caller prior to passing in here.
|
||||||
pub(crate) fn field_checksum(text: &str) -> u32 {
|
pub(crate) fn field_checksum(text: &str) -> u32 {
|
||||||
let digest = sha1::Sha1::from(text).digest().bytes();
|
let digest = sha1::Sha1::from(text).digest().bytes();
|
||||||
|
@ -429,7 +429,7 @@ impl Collection {
|
||||||
} else {
|
} else {
|
||||||
field1.into()
|
field1.into()
|
||||||
};
|
};
|
||||||
let stripped = strip_html_preserving_image_filenames(&field1);
|
let stripped = strip_html_preserving_media_filenames(&field1);
|
||||||
if stripped.trim().is_empty() {
|
if stripped.trim().is_empty() {
|
||||||
Ok(DuplicateState::Empty)
|
Ok(DuplicateState::Empty)
|
||||||
} else {
|
} else {
|
||||||
|
@ -438,7 +438,7 @@ impl Collection {
|
||||||
self.storage
|
self.storage
|
||||||
.note_fields_by_checksum(note.id, note.notetype_id, csum)?
|
.note_fields_by_checksum(note.id, note.notetype_id, csum)?
|
||||||
{
|
{
|
||||||
if strip_html_preserving_image_filenames(&field) == stripped {
|
if strip_html_preserving_media_filenames(&field) == stripped {
|
||||||
return Ok(DuplicateState::Duplicate);
|
return Ok(DuplicateState::Duplicate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ use crate::{
|
||||||
notes::field_checksum,
|
notes::field_checksum,
|
||||||
notetype::NoteTypeID,
|
notetype::NoteTypeID,
|
||||||
text::{matches_wildcard, text_to_re},
|
text::{matches_wildcard, text_to_re},
|
||||||
text::{normalize_to_nfc, strip_html_preserving_image_filenames, without_combining},
|
text::{normalize_to_nfc, strip_html_preserving_media_filenames, without_combining},
|
||||||
timestamp::TimestampSecs,
|
timestamp::TimestampSecs,
|
||||||
};
|
};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
@ -424,7 +424,7 @@ impl SqlWriter<'_> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_dupes(&mut self, ntid: NoteTypeID, text: &str) {
|
fn write_dupes(&mut self, ntid: NoteTypeID, text: &str) {
|
||||||
let text_nohtml = strip_html_preserving_image_filenames(text);
|
let text_nohtml = strip_html_preserving_media_filenames(text);
|
||||||
let csum = field_checksum(text_nohtml.as_ref());
|
let csum = field_checksum(text_nohtml.as_ref());
|
||||||
write!(
|
write!(
|
||||||
self.sql,
|
self.sql,
|
||||||
|
|
|
@ -32,10 +32,10 @@ lazy_static! {
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
static ref IMG_TAG: Regex = Regex::new(
|
static ref HTML_MEDIA_TAGS: Regex = Regex::new(
|
||||||
r#"(?xsi)
|
r#"(?xsi)
|
||||||
# the start of the image tag
|
# the start of the image, audio, or object tag
|
||||||
<img[^>]+src=
|
<\b(?:img|audio|object)\b[^>]+\b(?:src|data)\b=
|
||||||
(?:
|
(?:
|
||||||
# 1: double-quoted filename
|
# 1: double-quoted filename
|
||||||
"
|
"
|
||||||
|
@ -149,7 +149,7 @@ pub(crate) struct MediaRef<'a> {
|
||||||
pub(crate) fn extract_media_refs(text: &str) -> Vec<MediaRef> {
|
pub(crate) fn extract_media_refs(text: &str) -> Vec<MediaRef> {
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
|
|
||||||
for caps in IMG_TAG.captures_iter(text) {
|
for caps in HTML_MEDIA_TAGS.captures_iter(text) {
|
||||||
let fname = caps
|
let fname = caps
|
||||||
.get(1)
|
.get(1)
|
||||||
.or_else(|| caps.get(2))
|
.or_else(|| caps.get(2))
|
||||||
|
@ -213,8 +213,8 @@ fn tts_tag_from_string<'a>(field_text: &'a str, args: &'a str) -> AVTag {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn strip_html_preserving_image_filenames(html: &str) -> Cow<str> {
|
pub fn strip_html_preserving_media_filenames(html: &str) -> Cow<str> {
|
||||||
let without_fnames = IMG_TAG.replace_all(html, r" ${1}${2}${3} ");
|
let without_fnames = HTML_MEDIA_TAGS.replace_all(html, r" ${1}${2}${3} ");
|
||||||
let without_html = HTML.replace_all(&without_fnames, "");
|
let without_html = HTML.replace_all(&without_fnames, "");
|
||||||
// no changes?
|
// no changes?
|
||||||
if let Cow::Borrowed(b) = without_html {
|
if let Cow::Borrowed(b) = without_html {
|
||||||
|
@ -306,7 +306,7 @@ mod test {
|
||||||
use super::matches_wildcard;
|
use super::matches_wildcard;
|
||||||
use crate::text::without_combining;
|
use crate::text::without_combining;
|
||||||
use crate::text::{
|
use crate::text::{
|
||||||
extract_av_tags, strip_av_tags, strip_html, strip_html_preserving_image_filenames, AVTag,
|
extract_av_tags, strip_av_tags, strip_html, strip_html_preserving_media_filenames, AVTag,
|
||||||
};
|
};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
@ -317,14 +317,14 @@ mod test {
|
||||||
assert_eq!(strip_html("so<SCRIPT>t<b>e</b>st</script>me"), "some");
|
assert_eq!(strip_html("so<SCRIPT>t<b>e</b>st</script>me"), "some");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_html_preserving_image_filenames("<img src=foo.jpg>"),
|
strip_html_preserving_media_filenames("<img src=foo.jpg>"),
|
||||||
" foo.jpg "
|
" foo.jpg "
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_html_preserving_image_filenames("<img src='foo.jpg'><html>"),
|
strip_html_preserving_media_filenames("<img src='foo.jpg'><html>"),
|
||||||
" foo.jpg "
|
" foo.jpg "
|
||||||
);
|
);
|
||||||
assert_eq!(strip_html_preserving_image_filenames("<html>"), "");
|
assert_eq!(strip_html_preserving_media_filenames("<html>"), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
Loading…
Reference in a new issue