mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00

- The front and back are rendered in one call now. If the front side contains no custom filters, we can bake {{FrontSide}} into the rear side. If it did contain custom filters, we return the partially complete rear template instead, and the calling code can inject the FrontSide in after it has been fully rendered. - Instead of modifying "cloze" into something like "cq-2", the card ordinal and whether we're rendering the question or answer are now passed in to the rendering filters as context. - The Rust code doesn't need to support filter names split on '-' anymore. - Drop the "Show" part of hint descriptions so i18n support can be deferred. - Ignore blank filter names caused by user using two colons instead of one. - Fixed hint field and text transposition.
135 lines
4.5 KiB
Python
135 lines
4.5 KiB
Python
# Copyright: Ankitects Pty Ltd and contributors
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
"""
|
|
This file contains the Python portion of the template rendering code.
|
|
|
|
Templates can have filters applied to field replacements. The Rust template
|
|
rendering code will apply any built in filters, and stop at the first
|
|
unrecognized filter. The remaining filters are returned to Python,
|
|
and applied using the hook system. For example,
|
|
{{myfilter:hint:text:Field}} will apply the built in text and hint filters,
|
|
and then attempt to apply myfilter. If no add-ons have provided the filter,
|
|
the filter is skipped.
|
|
|
|
Add-ons can register a filter by adding a hook to "fmod_<filter name>".
|
|
As standard filters will not be run after a custom filter, it is up to the
|
|
add-on to do any further processing that is required.
|
|
|
|
The hook is called with the arguments
|
|
(field_text, filter_args, field_map, field_name, "").
|
|
The last argument is no longer used.
|
|
If the field name contains a hyphen, it is split on the hyphen, eg
|
|
{{foo-bar:baz}} calls fmod_foo with filter_args set to "bar".
|
|
|
|
A Python implementation of the standard filters is currently available in the
|
|
template_legacy.py file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
import anki
|
|
from anki.hooks import runFilter
|
|
from anki.rsbackend import TemplateReplacementList
|
|
|
|
|
|
def render_card(
|
|
col: anki.storage._Collection,
|
|
qfmt: str,
|
|
afmt: str,
|
|
fields: Dict[str, str],
|
|
card_ord: int,
|
|
) -> Tuple[str, str]:
|
|
"Renders the provided templates, returning rendered q & a text."
|
|
|
|
(qnodes, anodes) = col.backend.render_card(qfmt, afmt, fields, card_ord)
|
|
|
|
qtext = apply_custom_filters(qnodes, fields, front_side=None)
|
|
atext = apply_custom_filters(anodes, fields, front_side=qtext)
|
|
|
|
return qtext, atext
|
|
|
|
|
|
def apply_custom_filters(
|
|
rendered: TemplateReplacementList, fields: Dict[str, str], front_side: Optional[str]
|
|
) -> str:
|
|
"Complete rendering by applying any pending custom filters."
|
|
# template already fully rendered?
|
|
if len(rendered) == 1 and isinstance(rendered[0], str):
|
|
return rendered[0]
|
|
|
|
res = ""
|
|
for node in rendered:
|
|
if isinstance(node, str):
|
|
res += node
|
|
else:
|
|
# do we need to inject in FrontSide?
|
|
if node.field_name == "FrontSide" and front_side is not None:
|
|
node.current_text = front_side
|
|
|
|
res += apply_field_filters(
|
|
node.field_name, node.current_text, fields, node.filters
|
|
)
|
|
return res
|
|
|
|
|
|
def apply_field_filters(
|
|
field_name: str, field_text: str, fields: Dict[str, str], filters: List[str]
|
|
) -> str:
|
|
"""Apply filters to field text, returning modified text."""
|
|
for filter in filters:
|
|
if "-" in filter:
|
|
filter_base, filter_args = filter.split("-", maxsplit=1)
|
|
else:
|
|
filter_base = filter
|
|
filter_args = ""
|
|
|
|
# the fifth argument is no longer used
|
|
field_text = runFilter(
|
|
"fmod_" + filter_base, field_text, filter_args, fields, field_name, ""
|
|
)
|
|
return field_text
|
|
|
|
|
|
# Cloze handling
|
|
##########################################################################
|
|
|
|
# Matches a {{c123::clozed-out text::hint}} Cloze deletion, case-insensitively.
|
|
# The regex should be interpolated with a regex number and creates the following
|
|
# named groups:
|
|
# - tag: The lowercase or uppercase 'c' letter opening the Cloze.
|
|
# The c/C difference is only relevant to the legacy code.
|
|
# - content: Clozed-out content.
|
|
# - hint: Cloze hint, if provided.
|
|
clozeReg = r"(?si)\{\{(?P<tag>c)%s::(?P<content>.*?)(::(?P<hint>.*?))?\}\}"
|
|
|
|
# Constants referring to group names within clozeReg.
|
|
CLOZE_REGEX_MATCH_GROUP_TAG = "tag"
|
|
CLOZE_REGEX_MATCH_GROUP_CONTENT = "content"
|
|
CLOZE_REGEX_MATCH_GROUP_HINT = "hint"
|
|
|
|
# used by the media check functionality
|
|
def expand_clozes(string: str) -> List[str]:
|
|
"Render all clozes in string."
|
|
ords = set(re.findall(r"{{c(\d+)::.+?}}", string))
|
|
strings = []
|
|
|
|
def qrepl(m):
|
|
if m.group(CLOZE_REGEX_MATCH_GROUP_HINT):
|
|
return "[%s]" % m.group(CLOZE_REGEX_MATCH_GROUP_HINT)
|
|
else:
|
|
return "[...]"
|
|
|
|
def arepl(m):
|
|
return m.group(CLOZE_REGEX_MATCH_GROUP_CONTENT)
|
|
|
|
for ord in ords:
|
|
s = re.sub(clozeReg % ord, qrepl, string)
|
|
s = re.sub(clozeReg % ".+?", arepl, s)
|
|
strings.append(s)
|
|
strings.append(re.sub(clozeReg % ".+?", arepl, string))
|
|
|
|
return strings
|