This commit is contained in:
2026-09-15 17:33:57 +00:00
parent 6ed1ce4154
commit d8e20bf925
136 changed files with 553 additions and 13 deletions
+367
View File
@@ -23,6 +23,36 @@ pub struct ReadingText {
pub tokens: &'static [Token],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RuleTable {
pub headers: &'static [&'static str],
pub rows: &'static [&'static [&'static str]],
pub audio_columns: &'static [RuleAudioColumn],
pub audio_rows: &'static [&'static [&'static [&'static str]]],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RuleAudioColumn {
pub after_text_column: usize,
pub header: &'static str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RuleBlock {
Paragraph(&'static str),
Subheading(&'static str),
Table(RuleTable),
Bullets(&'static [&'static str]),
Steps(&'static [&'static str]),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RuleSection {
pub number: u8,
pub title: &'static str,
pub blocks: &'static [RuleBlock],
}
macro_rules! entry {
($id:literal, $ipa:literal, $hint:literal) => {
Word {
@@ -53,6 +83,19 @@ macro_rules! punct {
Token::Punctuation($value)
};
}
macro_rules! audio_row {
($($file:literal),+ $(,)?) => {
&[&[$(concat!("/rules-audio/", $file, ".mp3")),+]]
};
}
macro_rules! audio_pair_row {
($left:literal, $right:literal) => {
&[
&[concat!("/rules-audio/", $left, ".mp3")],
&[concat!("/rules-audio/", $right, ".mp3")],
]
};
}
pub const DICTIONARY: &[Word] = &[
entry!("sam", "sæm", "сэм"),
@@ -724,6 +767,141 @@ pub const READING_TEXTS: &[ReadingText] = &[ReadingText {
tokens: BASIC_RULES_TOKENS,
}];
pub const READING_RULES_CAVEAT: &str = "Английское чтение имеет много исключений. Правила ниже помогают прочитать большинство простых слов, но незнакомое слово иногда нужно проверить в словаре.";
pub const READING_RULES: &[RuleSection] = &[
RuleSection { number: 1, title: "Алфавит", blocks: &[
RuleBlock::Table(RuleTable { headers: &["Буква", "Название", "Транскрипция"], rows: &[
&["A", "[эй]", "[eɪ]"], &["B", "[би]", "[biː]"], &["C", "[си]", "[siː]"], &["D", "[ди]", "[diː]"], &["E", "[и]", "[iː]"], &["F", "[эф]", "[ef]"], &["G", "[джи]", "[dʒiː]"], &["H", "[эйч]", "[eɪtʃ]"], &["I", "[ай]", "[aɪ]"], &["J", "[джей]", "[dʒeɪ]"], &["K", "[кей]", "[keɪ]"], &["L", "[эл]", "[el]"], &["M", "[эм]", "[em]"],
&["N", "[эн]", "[en]"], &["O", "[оу]", "[əʊ] / [oʊ]"], &["P", "[пи]", "[piː]"], &["Q", "[кью]", "[kjuː]"], &["R", "[а(р)]", "[ɑːr]"], &["S", "[эс]", "[es]"], &["T", "[ти]", "[tiː]"], &["U", "[ю:]", "[juː]"], &["V", "[ви]", "[viː]"], &["W", "[дабл-ю]", "[ˈdʌbəljuː]"], &["X", "[экс]", "[eks]"], &["Y", "[уай]", "[waɪ]"], &["Z", "[зи] / [зэд]", "[ziː] / [zed]"],
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио" }], audio_rows: &[
audio_row!("a"), audio_row!("b"), audio_row!("c"), audio_row!("d"), audio_row!("e"), audio_row!("f"), audio_row!("g"), audio_row!("h"), audio_row!("i"), audio_row!("j"), audio_row!("k"), audio_row!("l"), audio_row!("m"),
audio_row!("n"), audio_row!("o"), audio_row!("p"), audio_row!("q"), audio_row!("r"), audio_row!("s"), audio_row!("t"), audio_row!("u"), audio_row!("v"), audio_row!("w"), audio_row!("x"), audio_row!("y"), audio_row!("z"),
] }),
] },
RuleSection { number: 2, title: "Краткие гласные (закрытый слог)", blocks: &[
RuleBlock::Paragraph("В закрытом слоге гласная обычно читается кратко. Закрытый слог обычно заканчивается согласной: `cat`, `bed`."),
RuleBlock::Table(RuleTable { headers: &["Буква", "Звук", "Примеры"], rows: &[
&["a", "[æ]", "`cat` [kæt], `map` [mæp]"], &["e", "[e]", "`bed` [bed], `pen` [pen]"], &["i", "[ɪ]", "`sit` [sɪt], `big` [bɪɡ]"], &["o", "[ɑ]", "`hot` [hɑt], `box` [bɑks]"], &["u", "[ʌ]", "`cup` [kʌp], `sun` [sʌn]"], &["y", "[ɪ]", "`gym` [dʒɪm], `system` [ˈsɪstəm]"],
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио примеров" }], audio_rows: &[
audio_row!("cat", "map"), audio_row!("bed", "pen"), audio_row!("sit", "big"), audio_row!("hot", "box"), audio_row!("cup", "sun"), audio_row!("gym", "system"),
] }),
] },
RuleSection { number: 3, title: "Долгие гласные (открытый слог)", blocks: &[
RuleBlock::Paragraph("Долгий звук часто совпадает с названием буквы. В таблице использовано американское произношение."),
RuleBlock::Table(RuleTable { headers: &["Буква", "Звук", "Примеры"], rows: &[
&["a", "[eɪ]", "`name` [neɪm], `late` [leɪt]"], &["e", "[i:]", "`these` [ðiːz], `theme` [θiːm]"], &["i", "[aɪ]", "`time` [taɪm], `five` [faɪv]"], &["o", "[oʊ]", "`home` [hoʊm], `note` [noʊt]"], &["u", "[ju:] или [u:]", "`cute` [kjuːt], `rule` [ruːl]"],
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио примеров" }], audio_rows: &[
audio_row!("name", "late"), audio_row!("these", "theme"), audio_row!("time", "five"), audio_row!("home", "note"), audio_row!("cute", "rule"),
] }),
] },
RuleSection { number: 4, title: "Немая `e`", blocks: &[
RuleBlock::Paragraph("В модели `гласная + согласная + e` последняя `e` обычно не читается, а первая гласная становится долгой (слог стал открытым). В таблице использовано американское произношение."),
RuleBlock::Table(RuleTable { headers: &["Короткий вариант", "Длинный вариант"], rows: &[
&["`cap` [kæp]", "`cape` [keɪp]"], &["`kit` [kɪt]", "`kite` [kaɪt]"], &["`hop` [hɑp]", "`hope` [hoʊp]"], &["`cub` [kʌb]", "`cube` [kjuːb]"],
], audio_columns: &[
RuleAudioColumn { after_text_column: 1, header: "Аудио" },
RuleAudioColumn { after_text_column: 2, header: "Аудио" },
], audio_rows: &[
audio_pair_row!("cap", "cape"), audio_pair_row!("kit", "kite"), audio_pair_row!("hop", "hope"), audio_pair_row!("cub", "cube"),
] }),
RuleBlock::Subheading("Исключения"),
RuleBlock::Table(RuleTable { headers: &["Слово", "Транскрипция"], rows: &[
&["`have`", "[hæv]"], &["`give`", "[ɡɪv]"], &["`love`", "[lʌv]"], &["`move`", "[muːv]"], &["`come`", "[kʌm]"],
], audio_columns: &[RuleAudioColumn { after_text_column: 2, header: "Аудио" }], audio_rows: &[
audio_row!("have"), audio_row!("give"), audio_row!("love"), audio_row!("move"), audio_row!("come"),
] }),
] },
RuleSection { number: 5, title: "Сочетания гласных", blocks: &[
RuleBlock::Subheading("Сочетания с предсказуемым чтением"),
RuleBlock::Paragraph("Эти сочетания обычно читаются одинаково в большинстве знакомых слов:"),
RuleBlock::Table(RuleTable { headers: &["Сочетание", "Звук", "Примеры"], rows: &[
&["`ee`", "[i:]", "`see` [si:], `green` [ɡri:n]"], &["`ai` в середине слова", "[eɪ]", "`rain` [reɪn]"], &["`ay` в конце слова", "[eɪ]", "`day` [deɪ]"], &["`oa`", "[oʊ]", "`boat` [boʊt], `road` [roʊd]"], &["`oi` в середине слова", "[ɔɪ]", "`coin` [kɔɪn]"], &["`oy` в конце слова", "[ɔɪ]", "`boy` [bɔɪ]"], &["`igh`", "[aɪ]", "`night` [naɪt], `light` [laɪt]"], &["`au`, `aw`", "[ɔ:]", "`autumn` [ˈɔ:təm], `saw` [sɔ:]"],
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио" }], audio_rows: &[
audio_row!("see", "green"), audio_row!("rain"), audio_row!("day"), audio_row!("boat", "road"), audio_row!("coin"), audio_row!("boy"), audio_row!("night", "light"), audio_row!("autumn", "saw"),
] }),
] },
RuleSection { number: 6, title: "Основные согласные", blocks: &[
RuleBlock::Paragraph("Большинство согласных читаются стабильно: `b` — [b] (`book`), `d` — [d] (`dog`), `f` — [f] (`fish`), `h` — [h] (`house`), `k` — [k] (`kite`), `l` — [l] (`leg`), `m` — [m] (`man`), `n` — [n] (`name`), `p` — [p] (`pen`), `r` — английский [ɹ] (`red`), `t` — [t] (`ten`), `v` — [v] (`van`), `z` — [z] (`zoo`). Английский [ɹ] не нужно раскатывать, как русский [р]."),
] },
RuleSection { number: 7, title: "Буква `c`", blocks: &[
RuleBlock::Bullets(&["перед `e`, `i`, `y` обычно читается [s]: `city`, `cent`, `cycle`;", "перед `a`, `o`, `u` обычно читается [k]: `cat`, `cold`, `cup`."]),
RuleBlock::Paragraph("Исключения: `school`, `muscle`, `ocean`."),
] },
RuleSection { number: 8, title: "Буква `g`", blocks: &[
RuleBlock::Bullets(&["перед `e`, `i`, `y` часто читается [dʒ]: `giant`, `gym`, `page`;", "перед `a`, `o`, `u` обычно читается [g]: `game`, `go`, `gum`."]),
RuleBlock::Paragraph("Исключения: `get`, `give`, `girl`, `gear`."),
] },
RuleSection { number: 9, title: "Сочетания согласных", blocks: &[
RuleBlock::Table(RuleTable { headers: &["Сочетание", "Звук", "Примеры"], rows: &[
&["`ch`", "[tʃ]", "`chair` [tʃer], `chicken` [ˈtʃɪkən]"], &["`sh`", "[ʃ]", "`ship` [ʃɪp], `fish` [fɪʃ]"], &["`ph`", "[f]", "`phone` [foʊn], `photo` [ˈfoʊtoʊ]"], &["`th`", "[θ]", "`think` [θɪŋk], `three` [θriː]"], &["`th`", "[ð]", "`this` [ðɪs], `mother` [ˈmʌðər]"], &["`wh`", "[w]", "`what` [wʌt], `when` [wen]"], &["`ck`", "[k]", "`back` [bæk], `black` [blæk]"], &["`ng`", "[ŋ]", "`sing` [sɪŋ], `long` [lɔŋ]"], &["`qu`", "[kw]", "`queen` [kwiːn], `quick` [kwɪk]"], &["`tch`", "[tʃ]", "`match` [mætʃ], `catch` [kætʃ]"],
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио" }], audio_rows: &[
audio_row!("chair", "chicken"), audio_row!("ship", "fish"), audio_row!("phone", "photo"), audio_row!("think", "three"), audio_row!("this", "mother"), audio_row!("what", "when"), audio_row!("back", "black"), audio_row!("sing", "long"), audio_row!("queen", "quick"), audio_row!("match", "catch"),
] }),
] },
RuleSection { number: 10, title: "Буквы `x` и `y`", blocks: &[
RuleBlock::Subheading("`x`"),
RuleBlock::Table(RuleTable { headers: &["Положение или случай", "Звук", "Примеры"], rows: &[
&["внутри и в конце слова", "[ks]", "`box` [bɑks], `six` [sɪks]"], &["в начале слов греческого происхождения", "[z]", "`xylophone` [ˈzaɪləfoʊn]"], &["в некоторых словах исключениях", "[gz]", "`exam` [ɪɡˈzæm], `exact` [ɪɡˈzækt]"], &["исключение `X-ray`", "[eks]", "`X-ray` [ˈɛksreɪ]"],
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио" }], audio_rows: &[
audio_row!("box", "six"), audio_row!("xylophone"), audio_row!("exam", "exact"), audio_row!("x-ray"),
] }),
RuleBlock::Subheading("`y`"),
RuleBlock::Paragraph("Буква `y` может вести себя как согласная или как гласная:"),
RuleBlock::Table(RuleTable { headers: &["Роль", "Положение", "Звук", "Примеры"], rows: &[
&["Согласная", "в начале слова", "[j]", "`yes` [jes], `yellow` [ˈjeloʊ]"], &["Гласная", "в конце односложного слова", "[aɪ]", "`my` [maɪ], `cry` [kraɪ]"], &["Гласная", "в конце многосложного слова", "[i]", "`happy` [ˈhæpi], `city` [ˈsɪti]"], &["Гласная", "внутри слова, часто", "[ɪ]", "`gym` [dʒɪm], `system` [ˈsɪstəm]"],
], audio_columns: &[RuleAudioColumn { after_text_column: 4, header: "Аудио" }], audio_rows: &[
audio_row!("yes", "yellow"), audio_row!("my", "cry"), audio_row!("happy", "city"), audio_row!("gym", "system"),
] }),
RuleBlock::Paragraph("Итак, в `yes` буква `y` — согласная, а в `my`, `happy`, `gym` и `system` — гласная."),
] },
RuleSection { number: 11, title: "Немые буквы: применимые шаблоны", blocks: &[
RuleBlock::Table(RuleTable { headers: &["Шаблон", "Правило", "Примеры"], rows: &[
&["`kn-` в начале слова", "`k` не читается", "`know` [noʊ], `knife` [naɪf]"], &["`wr-` в начале слова", "`w` не читается", "`wrap` [ræp], `wrist` [rɪst]"], &["`-mb` в конце слова", "`b` не читается", "`lamb` [læm], `thumb` [θʌm]"], &["`gn-` в начале слова", "`g` не читается", "`gnome` [noʊm], `gnat` [næt]"], &["`-ould`", "`l` не читается", "`could` [kʊd], `would` [wʊd], `should` [ʃʊd]"], &["`-sten`", "`t` не читается", "`listen` [ˈlɪsən], `fasten` [ˈfæsən]"], &["`-stle`", "`t` не читается", "`castle` [ˈkæsəl], `whistle` [ˈwɪsəl]"],
], audio_columns: &[], audio_rows: &[] }),
] },
RuleSection { number: 12, title: "Окончания: применимые шаблоны", blocks: &[
RuleBlock::Table(RuleTable { headers: &["Шаблон", "Звук", "Примеры"], rows: &[
&["`-tion`", "[ʃən]", "`station`, `nation`, `information`"], &["`-stion`", "[tʃən]", "`question`, `suggestion`"], &["`-cian`", "[ʃən]", "`musician`, `politician`"], &["`-ssion`", "[ʃən]", "`mission`, `expression`"],
], audio_columns: &[], audio_rows: &[] }),
RuleBlock::Paragraph("Окончание `-sion` без второго `s` не вынесено в правило: его чтение нужно проверять в словаре и запоминать со словом."),
] },
RuleSection { number: 13, title: "Окончания `-s` и `-es`", blocks: &[
RuleBlock::Table(RuleTable { headers: &["Произношение", "Когда", "Примеры"], rows: &[
&["[s]", "после глухих согласных, кроме шипящих", "`cats`, `books`, `cups`"], &["[z]", "после звонких звуков и гласных", "`dogs`, `pens`, `plays`"], &["[ɪz]", "после шипящих звуков", "`boxes`, `washes`, `buses`"],
], audio_columns: &[], audio_rows: &[] }),
] },
RuleSection { number: 14, title: "Окончания `-er` и `-or`", blocks: &[
RuleBlock::Paragraph("В безударном положении они часто читаются как [ə]:"),
RuleBlock::Bullets(&["`teacher` — [ˈti:tʃə];", "`doctor` — [ˈdɒktə];", "`actor` — [ˈæktə]."]),
RuleBlock::Paragraph("В американском английском конечный `r` обычно произносится."),
] },
RuleSection { number: 15, title: "Безударная гласная", blocks: &[
RuleBlock::Paragraph("В безударном слоге гласная часто превращается в нейтральный звук [ə]:"),
RuleBlock::Bullets(&["`about` — [əˈbaʊt];", "`banana` — [bəˈnɑ:nə];", "`teacher` — [ˈti:tʃə]."]),
] },
RuleSection { number: 16, title: "Деление на слоги", blocks: &[
RuleBlock::Bullets(&["один согласный между гласными часто относится к следующему слогу: `pa-per`;", "две согласные обычно разделяются: `win-ter`;", "конечное `le` образует отдельный слог: `ta-ble`, `lit-tle`."]),
] },
RuleSection { number: 17, title: "Ударение", blocks: &[
RuleBlock::Paragraph("Ударный слог произносится сильнее, дольше и выше. В таблицах используется американское произношение."),
RuleBlock::Table(RuleTable { headers: &["Слово", "Транскрипция"], rows: &[
&["`TAble`", "[ˈteɪbəl]"], &["`WINdow`", "[ˈwɪndoʊ]"], &["`beGIN`", "[bɪˈɡɪn]"], &["`imPORtant`", "[ɪmˈpɔrtənt]"],
], audio_columns: &[RuleAudioColumn { after_text_column: 2, header: "Аудио" }], audio_rows: &[
audio_row!("table"), audio_row!("window"), audio_row!("begin"), audio_row!("important"),
] }),
RuleBlock::Paragraph("Ударение может менять значение или часть речи:"),
RuleBlock::Table(RuleTable { headers: &["Значение или роль", "Слово", "Транскрипция", "Контекст в аудио"], rows: &[
&["запись", "`REcord`", "[ˈrɛkɚd]", "`a record`"], &["записывать", "`reCORD`", "[rɪˈkɔrd]", "`record it`"], &["подарок / настоящий", "`PREsent`", "[ˈprɛzənt]", "`a present`"], &["представлять", "`preSENT`", "[prɪˈzɛnt]", "`present it`"],
], audio_columns: &[RuleAudioColumn { after_text_column: 4, header: "Аудио" }], audio_rows: &[
audio_row!("record-noun"), audio_row!("record-verb"), audio_row!("present-noun"), audio_row!("present-verb"),
] }),
] },
RuleSection { number: 18, title: "Алгоритм чтения нового слова", blocks: &[
RuleBlock::Steps(&["Найди гласные и согласные.", "Определи, закрытый ли это слог.", "Проверь правило немой `e`.", "Найди сочетания гласных и согласных.", "Проверь, нет ли немых букв.", "Раздели длинное слово на слоги.", "Поставь ударение.", "Проверь слово в словаре, если чтение не подходит."]),
] },
];
pub fn word_by_id(id: &str) -> Option<&'static Word> {
DICTIONARY.iter().find(|word| word.id == id)
}
@@ -828,4 +1006,193 @@ mod tests {
}
}
}
#[test]
fn reading_rules_cover_all_source_sections_with_source_derived_content() {
let source = include_str!("../../english/ex/theme-13-reading-rules/teoria-from-zero.md");
let source_audio_paths = source
.split("src=\"audio/")
.skip(1)
.map(|fragment| {
let (filename, _) = fragment
.split_once('"')
.expect("source audio path must end with a quote");
format!("/rules-audio/{filename}")
})
.collect::<Vec<_>>();
let expected_titles = [
"Алфавит",
"Краткие гласные (закрытый слог)",
"Долгие гласные (открытый слог)",
"Немая `e`",
"Сочетания гласных",
"Основные согласные",
"Буква `c`",
"Буква `g`",
"Сочетания согласных",
"Буквы `x` и `y`",
"Немые буквы: применимые шаблоны",
"Окончания: применимые шаблоны",
"Окончания `-s` и `-es`",
"Окончания `-er` и `-or`",
"Безударная гласная",
"Деление на слоги",
"Ударение",
"Алгоритм чтения нового слова",
];
let expected_audio_schemas = vec![
(
1,
vec!["Буква", "Название", "Транскрипция"],
vec![(3, "Аудио")],
),
(
2,
vec!["Буква", "Звук", "Примеры"],
vec![(3, "Аудио примеров")],
),
(
3,
vec!["Буква", "Звук", "Примеры"],
vec![(3, "Аудио примеров")],
),
(
4,
vec!["Короткий вариант", "Длинный вариант"],
vec![(1, "Аудио"), (2, "Аудио")],
),
(4, vec!["Слово", "Транскрипция"], vec![(2, "Аудио")]),
(5, vec!["Сочетание", "Звук", "Примеры"], vec![(3, "Аудио")]),
(9, vec!["Сочетание", "Звук", "Примеры"], vec![(3, "Аудио")]),
(
10,
vec!["Положение или случай", "Звук", "Примеры"],
vec![(3, "Аудио")],
),
(
10,
vec!["Роль", "Положение", "Звук", "Примеры"],
vec![(4, "Аудио")],
),
(17, vec!["Слово", "Транскрипция"], vec![(2, "Аудио")]),
(
17,
vec![
"Значение или роль",
"Слово",
"Транскрипция",
"Контекст в аудио",
],
vec![(4, "Аудио")],
),
];
assert_eq!(READING_RULES.len(), expected_titles.len());
assert_eq!(
READING_RULES
.iter()
.map(|section| section.number)
.collect::<Vec<_>>(),
(1..=18).collect::<Vec<_>>()
);
assert_eq!(
READING_RULES
.iter()
.map(|section| section.title)
.collect::<Vec<_>>(),
expected_titles
);
assert_eq!(
READING_RULES_CAVEAT,
"Английское чтение имеет много исключений. Правила ниже помогают прочитать большинство простых слов, но незнакомое слово иногда нужно проверить в словаре."
);
assert!(source.contains(READING_RULES_CAVEAT));
let mut table_count = 0;
let mut row_count = 0;
let mut step_count = 0;
let mut rule_audio_paths = Vec::new();
let mut actual_audio_schemas = Vec::new();
for section in READING_RULES {
assert!(
source.contains(section.title),
"missing title: {}",
section.title
);
for block in section.blocks {
match block {
RuleBlock::Paragraph(text) | RuleBlock::Subheading(text) => {
assert!(source.contains(text), "missing text: {text}");
}
RuleBlock::Bullets(items) | RuleBlock::Steps(items) => {
for item in *items {
assert!(source.contains(item), "missing list item: {item}");
}
if let RuleBlock::Steps(items) = block {
step_count += items.len();
}
}
RuleBlock::Table(table) => {
table_count += 1;
row_count += table.rows.len();
for header in table.headers {
assert!(source.contains(header), "missing table header: {header}");
}
for row in table.rows {
for cell in *row {
assert!(source.contains(cell), "missing table cell: {cell}");
}
}
if table.audio_columns.is_empty() {
assert!(table.audio_rows.is_empty());
} else {
actual_audio_schemas.push((
section.number,
table.headers.to_vec(),
table
.audio_columns
.iter()
.map(|column| (column.after_text_column, column.header))
.collect::<Vec<_>>(),
));
assert_eq!(table.audio_rows.len(), table.rows.len());
for audio_row in table.audio_rows {
assert_eq!(audio_row.len(), table.audio_columns.len());
for paths in *audio_row {
assert!(!paths.is_empty());
for path in *paths {
let source_path = format!(
"src=\"audio/{}\"",
path.trim_start_matches("/rules-audio/")
);
assert!(
source.contains(&source_path),
"missing source audio: {path}"
);
let asset = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("public")
.join(path.trim_start_matches('/'));
assert!(
asset.is_file(),
"missing rules audio: {}",
asset.display()
);
rule_audio_paths.push((*path).to_string());
}
}
}
}
}
}
}
}
rule_audio_paths.sort_unstable();
let mut source_audio_paths = source_audio_paths;
source_audio_paths.sort_unstable();
assert_eq!(rule_audio_paths, source_audio_paths);
assert_eq!(actual_audio_schemas, expected_audio_schemas);
assert_eq!(table_count, 14);
assert_eq!(row_count, 94);
assert_eq!(step_count, 8);
}
}
+150 -9
View File
@@ -1,6 +1,9 @@
mod data;
use data::{word_by_id, ReadingText, Token, Word, DICTIONARY, READING_TEXTS};
use data::{
word_by_id, ReadingText, RuleBlock, RuleTable, Token, Word, DICTIONARY, READING_RULES,
READING_RULES_CAVEAT, READING_TEXTS,
};
use leptos::ev::KeyboardEvent;
use leptos::prelude::*;
use wasm_bindgen_futures::{spawn_local, JsFuture};
@@ -38,6 +41,13 @@ fn App() -> impl IntoView {
audio_state.set(AudioState::Ready);
selected_id.set(Some(id));
};
let clear_selection = move |_| {
if let Some(audio) = audio_ref.get() {
let _ = audio.pause();
}
audio_state.set(AudioState::Ready);
selected_id.set(None);
};
let activate_key = move |event: KeyboardEvent, id: &'static str| {
if event.key() == "Enter" || event.key() == " " {
event.prevent_default();
@@ -74,15 +84,27 @@ fn App() -> impl IntoView {
</header>
<div class="reader-workspace">
<section class="detail-panel" aria-live="polite" aria-labelledby="detail-heading">
{move || selected_id.get().and_then(word_by_id).map(|word| detail_view(word, audio_ref, audio_state, toggle_audio)).unwrap_or_else(|| view! {
<div class="detail-empty">
<p class="detail-kicker">"Словарная карточка"</p>
<h2 id="detail-heading">"Выберите слово в тексте"</h2>
<p>"Карточка покажет написание, американское произношение и русскую подсказку."</p>
<aside class="study-sidebar" aria-labelledby="rules-heading">
<section class="detail-panel" aria-live="polite" aria-labelledby="detail-heading">
{move || selected_id.get().and_then(word_by_id).map(|word| detail_view(word, audio_ref, audio_state, toggle_audio, clear_selection)).unwrap_or_else(|| view! {
<div class="detail-empty">
<p class="detail-kicker">"Словарная карточка"</p>
<h2 id="detail-heading">"Выберите слово в тексте"</h2>
<p>"Карточка покажет написание, американское произношение и русскую подсказку."</p>
</div>
}.into_any())}
</section>
<section class="rules-panel" aria-labelledby="rules-heading">
<div class="rules-heading">
<p class="section-number">"02"</p>
<div>
<h2 id="rules-heading">"Правила чтения"</h2>
<p>"Полный справочник урока: правила, исключения и алгоритм."</p>
</div>
</div>
}.into_any())}
</section>
<div class="rules-scroll">{rules_view()}</div>
</section>
</aside>
<section class="reading-card" aria-labelledby="reading-heading">
<div class="section-heading">
@@ -144,6 +166,121 @@ fn App() -> impl IntoView {
}
}
fn rules_view() -> AnyView {
view! {
<p class="rules-caveat">{render_rule_text(READING_RULES_CAVEAT)}</p>
{READING_RULES.iter().map(|section| {
let section_id = format!("rule-section-{}", section.number);
let heading_id = section_id.clone();
view! {
<section class="rule-section" aria-labelledby=section_id>
<div class="rule-heading">
<p class="rule-number">{format!("{:02}", section.number)}</p>
<h3 id=heading_id>{render_rule_text(section.title)}</h3>
</div>
{section.blocks.iter().map(render_rule_block).collect_view()}
</section>
}
}).collect_view()}
}
.into_any()
}
fn render_rule_block(block: &RuleBlock) -> AnyView {
match block {
RuleBlock::Paragraph(text) => view! {
<p class="rule-copy">{render_rule_text(text)}</p>
}
.into_any(),
RuleBlock::Subheading(text) => view! {
<h4 class="rule-subheading">{render_rule_text(text)}</h4>
}
.into_any(),
RuleBlock::Table(table) => rule_table_view(*table),
RuleBlock::Bullets(items) => view! {
<ul class="rule-list">
{items.iter().map(|item| view! { <li>{render_rule_text(item)}</li> }).collect_view()}
</ul>
}
.into_any(),
RuleBlock::Steps(items) => view! {
<ol class="rule-list rule-steps">
{items.iter().map(|item| view! { <li>{render_rule_text(item)}</li> }).collect_view()}
</ol>
}
.into_any(),
}
}
fn rule_table_view(table: RuleTable) -> AnyView {
view! {
<div class="rule-table-wrap">
<table class="rule-table">
<thead>
<tr>
{table.headers.iter().enumerate().map(|(text_index, header)| view! {
<th scope="col">{render_rule_text(header)}</th>
{table.audio_columns.iter().filter(|column| column.after_text_column == text_index + 1).map(|column| view! {
<th scope="col">{render_rule_text(column.header)}</th>
}).collect_view()}
}).collect_view()}
</tr>
</thead>
<tbody>
{table.rows.iter().enumerate().map(|(row_index, row)| {
let audio_cells = table.audio_rows.get(row_index).copied().unwrap_or(&[]);
view! {
<tr>
{row.iter().enumerate().map(|(text_index, cell)| view! {
<td>{render_rule_text(cell)}</td>
{table.audio_columns.iter().enumerate().filter(|(_, column)| column.after_text_column == text_index + 1).map(|(audio_index, _)| {
let paths = audio_cells.get(audio_index).copied().unwrap_or(&[]);
rule_audio_cell_view(paths)
}).collect_view()}
}).collect_view()}
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}
.into_any()
}
fn rule_audio_cell_view(paths: &'static [&'static str]) -> AnyView {
view! {
<td class="rule-audio-cell">
{paths.iter().map(|path| view! {
<audio
class="rule-audio"
controls=true
preload="none"
src=*path
aria-label=format!("Аудио примера: {}", path.trim_start_matches("/rules-audio/").trim_end_matches(".mp3"))
>
"Ваш браузер не поддерживает аудио."
</audio>
}).collect_view()}
</td>
}
.into_any()
}
fn render_rule_text(text: &'static str) -> AnyView {
text.split('`')
.enumerate()
.map(|(index, fragment)| {
if index % 2 == 0 {
view! { {fragment} }.into_any()
} else {
view! { <code>{fragment}</code> }.into_any()
}
})
.collect_view()
.into_any()
}
fn render_reading_text(
reading_text: &'static ReadingText,
selected_id: RwSignal<Option<&'static str>>,
@@ -186,6 +323,7 @@ fn detail_view(
audio_ref: NodeRef<leptos::html::Audio>,
audio_state: RwSignal<AudioState>,
toggle_audio: impl Fn(leptos::ev::MouseEvent) + Copy + 'static,
clear_selection: impl Fn(leptos::ev::MouseEvent) + Copy + 'static,
) -> AnyView {
view! {
<div class="detail-content">
@@ -215,6 +353,9 @@ fn detail_view(
{move || audio_button_text(audio_state.get())}
</button>
<p class="audio-note" aria-live="polite">{move || audio_status_text(audio_state.get())}</p>
<button class="clear-selection" type="button" on:click=clear_selection>
"Сбросить выбор слова"
</button>
</div>
}.into_any()
}