This commit is contained in:
Toby Penner 2025-09-17 15:23:46 +08:00 committed by GitHub
commit 2623ae4c43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 222 additions and 34 deletions

View file

@ -189,7 +189,7 @@ Christian Donat <https://github.com/cdonat2>
Asuka Minato <https://asukaminato.eu.org> Asuka Minato <https://asukaminato.eu.org>
Dillon Baldwin <https://github.com/DillBal> Dillon Baldwin <https://github.com/DillBal>
Voczi <https://github.com/voczi> Voczi <https://github.com/voczi>
Ben Nguyen <105088397+bpnguyen107@users.noreply.github.com> Ben Nguyen <105088397+bpnguyen107@users.noreply.github.com>
Themis Demetriades <themis100@outlook.com> Themis Demetriades <themis100@outlook.com>
Luke Bartholomew <lukesbart@icloud.com> Luke Bartholomew <lukesbart@icloud.com>
Gregory Abrasaldo <degeemon@gmail.com> Gregory Abrasaldo <degeemon@gmail.com>
@ -243,6 +243,7 @@ Lee Doughty <32392044+leedoughty@users.noreply.github.com>
memchr <memchr@proton.me> memchr <memchr@proton.me>
Max Romanowski <maxr777@proton.me> Max Romanowski <maxr777@proton.me>
Aldlss <ayaldlss@gmail.com> Aldlss <ayaldlss@gmail.com>
Toby Penner <tobypenner01@gmail.com>
******************** ********************

View file

@ -10,6 +10,7 @@ use std::sync::LazyLock;
use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusion; use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusion;
use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionShape; use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionShape;
use htmlescape::encode_attribute; use htmlescape::encode_attribute;
use itertools::Itertools;
use nom::branch::alt; use nom::branch::alt;
use nom::bytes::complete::tag; use nom::bytes::complete::tag;
use nom::bytes::complete::take_while; use nom::bytes::complete::take_while;
@ -26,7 +27,7 @@ use crate::template::RenderContext;
use crate::text::strip_html_preserving_entities; use crate::text::strip_html_preserving_entities;
static CLOZE: LazyLock<Regex> = static CLOZE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)\{\{c\d+::(.*?)(::.*?)?\}\}").unwrap()); LazyLock::new(|| Regex::new(r"(?s)\{\{c[\d,]+::(.*?)(::.*?)?\}\}").unwrap());
static MATHJAX: LazyLock<Regex> = LazyLock::new(|| { static MATHJAX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new( Regex::new(
@ -48,7 +49,7 @@ mod mathjax_caps {
#[derive(Debug)] #[derive(Debug)]
enum Token<'a> { enum Token<'a> {
// The parameter is the cloze number as is appears in the field content. // The parameter is the cloze number as is appears in the field content.
OpenCloze(u16), OpenCloze(Vec<u16>),
Text(&'a str), Text(&'a str),
CloseCloze, CloseCloze,
} }
@ -58,21 +59,24 @@ fn tokenize(mut text: &str) -> impl Iterator<Item = Token<'_>> {
fn open_cloze(text: &str) -> IResult<&str, Token<'_>> { fn open_cloze(text: &str) -> IResult<&str, Token<'_>> {
// opening brackets and 'c' // opening brackets and 'c'
let (text, _opening_brackets_and_c) = tag("{{c")(text)?; let (text, _opening_brackets_and_c) = tag("{{c")(text)?;
// following number // following comma-seperated numbers
let (text, digits) = take_while(|c: char| c.is_ascii_digit())(text)?; let (text, ordinals) = take_while(|c: char| c.is_ascii_digit() || c == ',')(text)?;
let digits: u16 = match digits.parse() { let ordinals: Vec<u16> = ordinals
Ok(digits) => digits, .split(',')
Err(_) => { .filter_map(|s| s.parse().ok())
// not a valid number; fail to recognize .collect::<HashSet<_>>() // deduplicate
return Err(nom::Err::Error(nom::error::make_error( .into_iter()
text, .sorted() // set conversion can de-order
nom::error::ErrorKind::Digit, .collect();
))); if ordinals.is_empty() {
} return Err(nom::Err::Error(nom::error::make_error(
}; text,
nom::error::ErrorKind::Digit,
)));
}
// :: // ::
let (text, _colons) = tag("::")(text)?; let (text, _colons) = tag("::")(text)?;
Ok((text, Token::OpenCloze(digits))) Ok((text, Token::OpenCloze(ordinals)))
} }
fn close_cloze(text: &str) -> IResult<&str, Token<'_>> { fn close_cloze(text: &str) -> IResult<&str, Token<'_>> {
@ -121,11 +125,20 @@ enum TextOrCloze<'a> {
#[derive(Debug)] #[derive(Debug)]
struct ExtractedCloze<'a> { struct ExtractedCloze<'a> {
// `ordinal` is the cloze number as is appears in the field content. // `ordinal` is the cloze number as is appears in the field content.
ordinal: u16, ordinals: Vec<u16>,
nodes: Vec<TextOrCloze<'a>>, nodes: Vec<TextOrCloze<'a>>,
hint: Option<&'a str>, hint: Option<&'a str>,
} }
/// Generate a string representation of the ordinals for HTML
fn ordinals_str(ordinals: &[u16]) -> String {
ordinals
.iter()
.map(|o| o.to_string())
.collect::<Vec<_>>()
.join(",")
}
impl ExtractedCloze<'_> { impl ExtractedCloze<'_> {
/// Return the cloze's hint, or "..." if none was provided. /// Return the cloze's hint, or "..." if none was provided.
fn hint(&self) -> &str { fn hint(&self) -> &str {
@ -151,6 +164,11 @@ impl ExtractedCloze<'_> {
buf.into() buf.into()
} }
/// Checks if this cloze is active for a given ordinal
fn contains_ordinal(&self, ordinal: u16) -> bool {
self.ordinals.contains(&ordinal)
}
/// If cloze starts with image-occlusion:, return the text following that. /// If cloze starts with image-occlusion:, return the text following that.
fn image_occlusion(&self) -> Option<&str> { fn image_occlusion(&self) -> Option<&str> {
let TextOrCloze::Text(text) = self.nodes.first()? else { let TextOrCloze::Text(text) = self.nodes.first()? else {
@ -165,10 +183,10 @@ fn parse_text_with_clozes(text: &str) -> Vec<TextOrCloze<'_>> {
let mut output = vec![]; let mut output = vec![];
for token in tokenize(text) { for token in tokenize(text) {
match token { match token {
Token::OpenCloze(ordinal) => { Token::OpenCloze(ordinals) => {
if open_clozes.len() < 10 { if open_clozes.len() < 10 {
open_clozes.push(ExtractedCloze { open_clozes.push(ExtractedCloze {
ordinal, ordinals,
nodes: Vec::with_capacity(1), // common case nodes: Vec::with_capacity(1), // common case
hint: None, hint: None,
}) })
@ -214,7 +232,7 @@ fn reveal_cloze_text_in_nodes(
output: &mut Vec<String>, output: &mut Vec<String>,
) { ) {
if let TextOrCloze::Cloze(cloze) = node { if let TextOrCloze::Cloze(cloze) = node {
if cloze.ordinal == cloze_ord { if cloze.contains_ordinal(cloze_ord) {
if question { if question {
output.push(cloze.hint().into()) output.push(cloze.hint().into())
} else { } else {
@ -234,14 +252,16 @@ fn reveal_cloze(
active_cloze_found_in_text: &mut bool, active_cloze_found_in_text: &mut bool,
buf: &mut String, buf: &mut String,
) { ) {
let active = cloze.ordinal == cloze_ord; let active = cloze.contains_ordinal(cloze_ord);
*active_cloze_found_in_text |= active; *active_cloze_found_in_text |= active;
if let Some(image_occlusion_text) = cloze.image_occlusion() { if let Some(image_occlusion_text) = cloze.image_occlusion() {
buf.push_str(&render_image_occlusion( buf.push_str(&render_image_occlusion(
image_occlusion_text, image_occlusion_text,
question, question,
active, active,
cloze.ordinal, cloze_ord,
&cloze.ordinals,
)); ));
return; return;
} }
@ -265,7 +285,7 @@ fn reveal_cloze(
buf, buf,
r#"<span class="cloze" data-cloze="{}" data-ordinal="{}">[{}]</span>"#, r#"<span class="cloze" data-cloze="{}" data-ordinal="{}">[{}]</span>"#,
encode_attribute(&content_buf), encode_attribute(&content_buf),
cloze.ordinal, ordinals_str(&cloze.ordinals),
cloze.hint() cloze.hint()
) )
.unwrap(); .unwrap();
@ -274,7 +294,7 @@ fn reveal_cloze(
write!( write!(
buf, buf,
r#"<span class="cloze" data-ordinal="{}">"#, r#"<span class="cloze" data-ordinal="{}">"#,
cloze.ordinal ordinals_str(&cloze.ordinals)
) )
.unwrap(); .unwrap();
for node in &cloze.nodes { for node in &cloze.nodes {
@ -292,7 +312,7 @@ fn reveal_cloze(
write!( write!(
buf, buf,
r#"<span class="cloze-inactive" data-ordinal="{}">"#, r#"<span class="cloze-inactive" data-ordinal="{}">"#,
cloze.ordinal ordinals_str(&cloze.ordinals)
) )
.unwrap(); .unwrap();
for node in &cloze.nodes { for node in &cloze.nodes {
@ -308,23 +328,29 @@ fn reveal_cloze(
} }
} }
fn render_image_occlusion(text: &str, question_side: bool, active: bool, ordinal: u16) -> String { fn render_image_occlusion(
text: &str,
question_side: bool,
active: bool,
ordinal: u16,
ordinals: &[u16],
) -> String {
if (question_side && active) || ordinal == 0 { if (question_side && active) || ordinal == 0 {
format!( format!(
r#"<div class="cloze" data-ordinal="{}" {}></div>"#, r#"<div class="cloze" data-ordinal="{}" {}></div>"#,
ordinal, ordinals_str(ordinals),
&get_image_cloze_data(text) &get_image_cloze_data(text)
) )
} else if !active { } else if !active {
format!( format!(
r#"<div class="cloze-inactive" data-ordinal="{}" {}></div>"#, r#"<div class="cloze-inactive" data-ordinal="{}" {}></div>"#,
ordinal, ordinals_str(ordinals),
&get_image_cloze_data(text) &get_image_cloze_data(text)
) )
} else if !question_side && active { } else if !question_side && active {
format!( format!(
r#"<div class="cloze-highlight" data-ordinal="{}" {}></div>"#, r#"<div class="cloze-highlight" data-ordinal="{}" {}></div>"#,
ordinal, ordinals_str(ordinals),
&get_image_cloze_data(text) &get_image_cloze_data(text)
) )
} else { } else {
@ -338,7 +364,10 @@ pub fn parse_image_occlusions(text: &str) -> Vec<ImageOcclusion> {
if let TextOrCloze::Cloze(cloze) = node { if let TextOrCloze::Cloze(cloze) = node {
if cloze.image_occlusion().is_some() { if cloze.image_occlusion().is_some() {
if let Some(shape) = parse_image_cloze(cloze.image_occlusion().unwrap()) { if let Some(shape) = parse_image_cloze(cloze.image_occlusion().unwrap()) {
occlusions.entry(cloze.ordinal).or_default().push(shape); // Associate this occlusion with all ordinals in this cloze
for &ordinal in &cloze.ordinals {
occlusions.entry(ordinal).or_default().push(shape.clone());
}
} }
} }
} }
@ -420,7 +449,7 @@ pub fn expand_clozes_to_reveal_latex(text: &str) -> String {
pub(crate) fn contains_cloze(text: &str) -> bool { pub(crate) fn contains_cloze(text: &str) -> bool {
parse_text_with_clozes(text) parse_text_with_clozes(text)
.iter() .iter()
.any(|node| matches!(node, TextOrCloze::Cloze(e) if e.ordinal != 0)) .any(|node| matches!(node, TextOrCloze::Cloze(e) if e.ordinals.iter().any(|&o| o != 0)))
} }
/// Returns the set of cloze number as they appear in the fields's content. /// Returns the set of cloze number as they appear in the fields's content.
@ -433,10 +462,12 @@ pub fn cloze_numbers_in_string(html: &str) -> HashSet<u16> {
fn add_cloze_numbers_in_text_with_clozes(nodes: &[TextOrCloze], set: &mut HashSet<u16>) { fn add_cloze_numbers_in_text_with_clozes(nodes: &[TextOrCloze], set: &mut HashSet<u16>) {
for node in nodes { for node in nodes {
if let TextOrCloze::Cloze(cloze) = node { if let TextOrCloze::Cloze(cloze) = node {
if cloze.ordinal != 0 { for &ordinal in &cloze.ordinals {
set.insert(cloze.ordinal); if ordinal != 0 {
add_cloze_numbers_in_text_with_clozes(&cloze.nodes, set); set.insert(ordinal);
}
} }
add_cloze_numbers_in_text_with_clozes(&cloze.nodes, set);
} }
} }
} }
@ -654,4 +685,160 @@ mod test {
) )
); );
} }
#[test]
fn multi_card_card_generation() {
let text = "{{c1,2,3::multi}}";
assert_eq!(
cloze_number_in_fields(vec![text]),
vec![1, 2, 3].into_iter().collect::<HashSet<u16>>()
);
}
#[test]
fn multi_card_cloze_basic() {
let text = "{{c1,2::shared}} word and {{c1::first}} vs {{c2::second}}";
assert_eq!(
strip_html(&reveal_cloze_text(text, 1, true)).as_ref(),
"[...] word and [...] vs second"
);
assert_eq!(
strip_html(&reveal_cloze_text(text, 2, true)).as_ref(),
"[...] word and first vs [...]"
);
assert_eq!(
strip_html(&reveal_cloze_text(text, 1, false)).as_ref(),
"shared word and first vs second"
);
assert_eq!(
strip_html(&reveal_cloze_text(text, 2, false)).as_ref(),
"shared word and first vs second"
);
assert_eq!(
cloze_numbers_in_string(text),
vec![1, 2].into_iter().collect::<HashSet<u16>>()
);
}
#[test]
fn multi_card_cloze_html_attributes() {
let text = "{{c1,2,3::multi}}";
let card1_html = reveal_cloze_text(text, 1, true);
assert!(card1_html.contains(r#"data-ordinal="1,2,3""#));
let card2_html = reveal_cloze_text(text, 2, true);
assert!(card2_html.contains(r#"data-ordinal="1,2,3""#));
let card3_html = reveal_cloze_text(text, 3, true);
assert!(card3_html.contains(r#"data-ordinal="1,2,3""#));
}
#[test]
fn multi_card_cloze_with_hints() {
let text = "{{c1,2::answer::hint}}";
assert_eq!(
strip_html(&reveal_cloze_text(text, 1, true)).as_ref(),
"[hint]"
);
assert_eq!(
strip_html(&reveal_cloze_text(text, 2, true)).as_ref(),
"[hint]"
);
assert_eq!(
strip_html(&reveal_cloze_text(text, 1, false)).as_ref(),
"answer"
);
assert_eq!(
strip_html(&reveal_cloze_text(text, 2, false)).as_ref(),
"answer"
);
}
#[test]
fn multi_card_cloze_edge_cases() {
assert_eq!(
cloze_numbers_in_string("{{c1,1,2::test}}"),
vec![1, 2].into_iter().collect::<HashSet<u16>>()
);
assert_eq!(
cloze_numbers_in_string("{{c0,1,2::test}}"),
vec![1, 2].into_iter().collect::<HashSet<u16>>()
);
assert_eq!(
cloze_numbers_in_string("{{c1,,3::test}}"),
vec![1, 3].into_iter().collect::<HashSet<u16>>()
);
}
#[test]
fn multi_card_cloze_only_filter() {
let text = "{{c1,2::shared}} and {{c1::first}} vs {{c2::second}}";
assert_eq!(reveal_cloze_text_only(text, 1, true), "..., ...");
assert_eq!(reveal_cloze_text_only(text, 2, true), "..., ...");
assert_eq!(reveal_cloze_text_only(text, 1, false), "shared, first");
assert_eq!(reveal_cloze_text_only(text, 2, false), "shared, second");
}
#[test]
fn multi_card_nested_cloze() {
let text = "{{c1,2::outer {{c3::inner}}}}";
assert_eq!(
strip_html(&reveal_cloze_text(text, 1, true)).as_ref(),
"[...]"
);
assert_eq!(
strip_html(&reveal_cloze_text(text, 2, true)).as_ref(),
"[...]"
);
assert_eq!(
strip_html(&reveal_cloze_text(text, 3, true)).as_ref(),
"outer [...]"
);
assert_eq!(
cloze_numbers_in_string(text),
vec![1, 2, 3].into_iter().collect::<HashSet<u16>>()
);
}
#[test]
fn nested_parent_child_card_same_cloze() {
let text = "{{c1::outer {{c1::inner}}}}";
assert_eq!(
strip_html(&reveal_cloze_text(text, 1, true)).as_ref(),
"[...]"
);
assert_eq!(
cloze_numbers_in_string(text),
vec![1].into_iter().collect::<HashSet<u16>>()
);
}
#[test]
fn multi_card_image_occlusion() {
let text = "{{c1,2::image-occlusion:rect:left=10:top=20:width=30:height=40}}";
let occlusions = parse_image_occlusions(text);
assert_eq!(occlusions.len(), 2);
assert!(occlusions.iter().any(|o| o.ordinal == 1));
assert!(occlusions.iter().any(|o| o.ordinal == 2));
let card1_html = reveal_cloze_text(text, 1, true);
assert!(card1_html.contains(r#"data-ordinal="1,2""#));
let card2_html = reveal_cloze_text(text, 2, true);
assert!(card2_html.contains(r#"data-ordinal="1,2""#));
}
} }