Skip to content

Commit

Permalink
typeanswer: [type:nc] – ignores combining characters
Browse files Browse the repository at this point in the history
Adds a comparison variant to [type] which ignores when combining characters of the expected field are missing from the typed input. It still shows these characters in the 'expected' line for reference.

It's useful for languages with e.g. diacritics that are required for reference (such as in dictionaries), but rarely actually learned or used in everyday writing. Among these languages: Arabic, Hebrew, Persian, Urdu.

The bool 'combining' controls it as new final parameter of both relevant compare_answer functions. On the Python side, it's set to true by default.

Use on the note templates: [type:nc:field] (only the front needs to include :nc)

This also removes the need to have both variants of words/sentences present as separate fields, to show them redundantly, etc.
  • Loading branch information
twwn committed Sep 22, 2024
1 parent fa0b4c6 commit 331f027
Show file tree
Hide file tree
Showing 7 changed files with 171 additions and 39 deletions.
1 change: 1 addition & 0 deletions proto/anki/card_rendering.proto
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ message HtmlToTextLineRequest {
message CompareAnswerRequest {
string expected = 1;
string provided = 2;
bool combining = 3;
}

message ExtractClozeForTypingRequest {
Expand Down
8 changes: 6 additions & 2 deletions pylib/anki/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,8 +1152,12 @@ def render_markdown(self, text: str, sanitize: bool = True) -> str:
"Not intended for public consumption at this time."
return self._backend.render_markdown(markdown=text, sanitize=sanitize)

def compare_answer(self, expected: str, provided: str) -> str:
return self._backend.compare_answer(expected=expected, provided=provided)
def compare_answer(
self, expected: str, provided: str, combining: bool = True
) -> str:
return self._backend.compare_answer(
expected=expected, provided=provided, combining=combining
)

def extract_cloze_for_typing(self, text: str, ordinal: int) -> str:
return self._backend.extract_cloze_for_typing(text=text, ordinal=ordinal)
Expand Down
7 changes: 6 additions & 1 deletion qt/aqt/reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ def __init__(self, mw: AnkiQt) -> None:
self.previous_card: Card | None = None
self._answeredIds: list[CardId] = []
self._recordedAudio: str | None = None
self.combining: bool = True
self.typeCorrect: str | None = None # web init happens before this is set
self.state: Literal["question", "answer", "transition"] | None = None
self._refresh_needed: RefreshNeeded | None = None
Expand Down Expand Up @@ -699,6 +700,7 @@ def typeAnsFilter(self, buf: str) -> str:
return self.typeAnsAnswerFilter(buf)

def typeAnsQuestionFilter(self, buf: str) -> str:
self.combining = True
self.typeCorrect = None
clozeIdx = None
m = re.search(self.typeAnsPat, buf)
Expand All @@ -711,6 +713,9 @@ def typeAnsQuestionFilter(self, buf: str) -> str:
clozeIdx = self.card.ord + 1
fld = fld.split(":")[1]
# loop through fields for a match
if fld.startswith("nc:"):
self.combining = False
fld = fld.split(":")[1]
for f in self.card.note_type()["flds"]:
if f["name"] == fld:
self.typeCorrect = self.card.note()[f["name"]]
Expand Down Expand Up @@ -750,7 +755,7 @@ def typeAnsAnswerFilter(self, buf: str) -> str:
hadHR = len(buf) != origSize
expected = self.typeCorrect
provided = self.typedAnswer
output = self.mw.col.compare_answer(expected, provided)
output = self.mw.col.compare_answer(expected, provided, self.combining)

# and update the type answer area
def repl(match: Match) -> str:
Expand Down
2 changes: 1 addition & 1 deletion rslib/src/card_rendering/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ impl crate::services::CardRenderingService for Collection {
&mut self,
input: anki_proto::card_rendering::CompareAnswerRequest,
) -> Result<generic::String> {
Ok(compare_answer(&input.expected, &input.provided).into())
Ok(compare_answer(&input.expected, &input.provided, input.combining).into())
}

fn extract_cloze_for_typing(
Expand Down
12 changes: 12 additions & 0 deletions rslib/src/template_filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ pub(crate) fn apply_filters<'a>(
// type:cloze is handled specially
let filters = if filters == ["cloze", "type"] {
&["type-cloze"]
} else if filters == ["nc", "type"] {
&["type-nc"]
} else {
filters
};
Expand Down Expand Up @@ -80,6 +82,7 @@ fn apply_filter(
"kana" => kana_filter(text),
"type" => type_filter(field_name),
"type-cloze" => type_cloze_filter(field_name),
"type-nc" => type_nc_filter(field_name),
"hint" => hint_filter(text, field_name),
"cloze" => cloze_filter(text, context),
"cloze-only" => cloze_only_filter(text, context),
Expand Down Expand Up @@ -171,6 +174,10 @@ fn type_cloze_filter<'a>(field_name: &str) -> Cow<'a, str> {
format!("[[type:cloze:{}]]", field_name).into()
}

fn type_nc_filter<'a>(field_name: &str) -> Cow<'a, str> {
format!("[[type:nc:{}]]", field_name).into()
}

fn hint_filter<'a>(text: &'a str, field_name: &str) -> Cow<'a, str> {
if text.trim().is_empty() {
return text.into();
Expand Down Expand Up @@ -238,6 +245,7 @@ field</a>
fn typing() {
assert_eq!(type_filter("Front"), "[[type:Front]]");
assert_eq!(type_cloze_filter("Front"), "[[type:cloze:Front]]");
assert_eq!(type_nc_filter("Front"), "[[type:nc:Front]]");
let ctx = RenderContext {
fields: &Default::default(),
nonempty_fields: &Default::default(),
Expand All @@ -249,6 +257,10 @@ field</a>
apply_filters("ignored", &["cloze", "type"], "Text", &ctx),
("[[type:cloze:Text]]".into(), vec![])
);
assert_eq!(
apply_filters("ignored", &["nc", "type"], "Text", &ctx),
("[[type:nc:Text]]".into(), vec![])
);
}

#[test]
Expand Down
15 changes: 11 additions & 4 deletions rslib/src/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use regex::Regex;
use unicase::eq as uni_eq;
use unicode_normalization::char::is_combining_mark;
use unicode_normalization::is_nfc;
use unicode_normalization::is_nfkd;
use unicode_normalization::is_nfkd_quick;
use unicode_normalization::IsNormalized;
use unicode_normalization::UnicodeNormalization;
Expand Down Expand Up @@ -367,10 +368,9 @@ pub(crate) fn sanitize_html_no_images(html: &str) -> String {
}

pub(crate) fn normalize_to_nfc(s: &str) -> Cow<str> {
if !is_nfc(s) {
s.chars().nfc().collect::<String>().into()
} else {
s.into()
match is_nfc(s) {
false => s.chars().nfc().collect::<String>().into(),
true => s.into(),
}
}

Expand All @@ -380,6 +380,13 @@ pub(crate) fn ensure_string_in_nfc(s: &mut String) {
}
}

pub(crate) fn normalize_to_nfkd(s: &str) -> Cow<str> {
match is_nfkd(s) {
false => s.chars().nfkd().collect::<String>().into(),
true => s.into(),
}
}

static EXTRA_NO_COMBINING_REPLACEMENTS: phf::Map<char, &str> = phf::phf_map! {
'€' => "E",
'Æ' => "AE",
Expand Down
Loading

0 comments on commit 331f027

Please sign in to comment.