Skip to main content

msime_engine/
punctuation.rs

1//! Chinese punctuation translation (core-session.md §5.6, overlays.md §7.1, `contracts/punctuation/policy.json`): a fixed map, alternating quotes, and nested book-title marks, with a second set of characters for the quote and square-bracket keys (`styles`) that the host picks from preferences. Quotes always alternate and book titles always nest, whether pairing is on or not; pairing only decides whether a host-side auto-close pays back the nesting count.
2
3/// `policy.json` 的 `simple`,按文件顺序。前 15 行是第 1 版的表;第 5 版在末尾加了 `{` → 「、`}` → 」:`Shift+[` `Shift+]` 固定出左右直角引号,不交替、不随引号样式变(#10047)。
4const SIMPLE: [(u8, &str); 17] = [
5    (b',', ","),
6    (b'.', "。"),
7    (b'?', "?"),
8    (b'!', "!"),
9    (b';', ";"),
10    (b':', ":"),
11    (b'(', "("),
12    (b')', ")"),
13    (b'[', "【"),
14    (b']', "】"),
15    (b'\\', "、"),
16    (b'`', "·"),
17    (b'$', "¥"),
18    (b'^', "……"),
19    (b'_', "——"),
20    (b'{', "「"),
21    (b'}', "」"),
22];
23
24const DOUBLE_QUOTE: (&str, &str) = ("“", "”");
25const SINGLE_QUOTE: (&str, &str) = ("‘", "’");
26
27/// `policy.json` 的 `styles.cornerQuotes`:直角引号下 `"` 和 `'` 的两组字。
28const CORNER_DOUBLE_QUOTE: (&str, &str) = ("『", "』");
29const CORNER_SINGLE_QUOTE: (&str, &str) = ("「", "」");
30
31/// `policy.json` 的 `styles.hollowBrackets`:空心方头括号替换 `[` `]` 在 `SIMPLE` 里的字。
32const HOLLOW_BRACKETS: [(u8, &str); 2] = [(b'[', "〖"), (b']', "〗")];
33
34/// `policy.json` 第 4 版的 `enumerationComma`:可被偏好关掉的顿号键。`\` 同时在 `SIMPLE` 里(第 1 版起的无条件映射,`translate` 按开关拦它);`/` 只在这一段,开关没打开时它是普通字符。
35const ENUMERATION_COMMA: [(u8, &str); 2] = [(b'\\', "、"), (b'/', "、")];
36
37/// 引号键出哪一组字。`Corner` 下 `'` 出「」、`"` 出『』,按键对应,不按语义对应。
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum QuoteMarks {
40    #[default]
41    Curly,
42    Corner,
43}
44
45/// `[` `]` 出实心【】还是空心〖〗。
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum BracketMarks {
48    #[default]
49    Solid,
50    Hollow,
51}
52
53/// 已经解析好的标点样式与顿号键:宿主从偏好算出它(`corner_when_traditional` 在那之前就按繁体开关定成两者之一,「至少留一个」也已归一),引擎只认确定的值。缺省就是契约第 1 版的表加「只有 `\` 出顿号」。
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct PunctuationMarks {
56    pub quotes: QuoteMarks,
57    pub brackets: BracketMarks,
58    /// `\` 键出顿号「、」。
59    pub enumeration_comma_backslash: bool,
60    /// `/` 键出顿号「、」。
61    pub enumeration_comma_slash: bool,
62}
63
64impl Default for PunctuationMarks {
65    fn default() -> Self {
66        Self {
67            quotes: QuoteMarks::Curly,
68            brackets: BracketMarks::Solid,
69            enumeration_comma_backslash: true,
70            enumeration_comma_slash: false,
71        }
72    }
73}
74
75const NESTED_OPENING_INPUT: u8 = b'<';
76const NESTED_CLOSING_INPUT: u8 = b'>';
77const NESTED_OPENING: &str = "《";
78const NESTED_OPENING_INNER: &str = "〈";
79const NESTED_CLOSING: &str = "》";
80const NESTED_CLOSING_INNER: &str = "〉";
81
82#[derive(Debug, Clone)]
83pub struct PunctuationPolicy {
84    paired_enabled: bool,
85    marks: PunctuationMarks,
86    next_double_quote_is_opening: bool,
87    next_single_quote_is_opening: bool,
88    book_title_nesting: u32,
89    /// 最近一次交替引号的按键;空闲退格若正好删掉这枚引号,可以把交替状态撤回。
90    last_alternating_quote: Option<u8>,
91}
92
93impl Default for PunctuationPolicy {
94    fn default() -> Self {
95        Self {
96            paired_enabled: true,
97            marks: PunctuationMarks::default(),
98            next_double_quote_is_opening: true,
99            next_single_quote_is_opening: true,
100            book_title_nesting: 0,
101            last_alternating_quote: None,
102        }
103    }
104}
105
106impl PunctuationPolicy {
107    /// 将 ASCII 按键转成中文标点并推进引号与嵌套状态;没有映射的键(如 `@`、`-`)返回 `None`。
108    pub fn translate(&mut self, key: u8) -> Option<&'static str> {
109        if self.marks.brackets == BracketMarks::Hollow {
110            if let Some(&(_, output)) = HOLLOW_BRACKETS.iter().find(|(input, _)| *input == key) {
111                self.last_alternating_quote = None;
112                return Some(output);
113            }
114        }
115        // 顿号键可被偏好关掉(enumerationComma 段):没启用的键返回 `None`,按普通字符交回宿主。`\` 同时在 `SIMPLE` 里,由这段先拦下;`/` 不在 `SIMPLE`,启用了才出顿号。
116        if let Some(&(input, output)) = ENUMERATION_COMMA.iter().find(|(input, _)| *input == key) {
117            let enabled = match input {
118                b'\\' => self.marks.enumeration_comma_backslash,
119                _ => self.marks.enumeration_comma_slash,
120            };
121            self.last_alternating_quote = None;
122            return enabled.then_some(output);
123        }
124        if let Some(&(_, output)) = SIMPLE.iter().find(|(input, _)| *input == key) {
125            self.last_alternating_quote = None;
126            return Some(output);
127        }
128        let corner = self.marks.quotes == QuoteMarks::Corner;
129        if !matches!(key, b'"' | b'\'') {
130            self.last_alternating_quote = None;
131        }
132        // Without host pairing this alternation is the only way to type a closing quote, and a host that does pair rewrites the closing quote itself, so it runs either way (punctuation_policy.cpp:12).
133        // 交替状态按键保存,和出哪一组字无关:换样式不重置它。
134        match key {
135            b'"' => {
136                self.last_alternating_quote = Some(key);
137                Some(alternate(
138                    &mut self.next_double_quote_is_opening,
139                    if corner {
140                        CORNER_DOUBLE_QUOTE
141                    } else {
142                        DOUBLE_QUOTE
143                    },
144                ))
145            }
146            b'\'' => {
147                self.last_alternating_quote = Some(key);
148                Some(alternate(
149                    &mut self.next_single_quote_is_opening,
150                    if corner {
151                        CORNER_SINGLE_QUOTE
152                    } else {
153                        SINGLE_QUOTE
154                    },
155                ))
156            }
157            NESTED_OPENING_INPUT => {
158                self.book_title_nesting += 1;
159                Some(if self.book_title_nesting == 1 {
160                    NESTED_OPENING
161                } else {
162                    NESTED_OPENING_INNER
163                })
164            }
165            // An unmatched `>` leaves the count at zero, so the next `<` still opens the outer pair.
166            NESTED_CLOSING_INPUT => {
167                if self.book_title_nesting == 0 {
168                    return Some(NESTED_CLOSING);
169                }
170                self.book_title_nesting -= 1;
171                Some(if self.book_title_nesting == 0 {
172                    NESTED_CLOSING
173                } else {
174                    NESTED_CLOSING_INNER
175                })
176            }
177            _ => None,
178        }
179    }
180
181    /// 记住一次非标点输入或编辑动作,使退格不会撤销更早的引号。
182    pub fn clear_quote_undo(&mut self) {
183        self.last_alternating_quote = None;
184    }
185
186    /// 撤销最近一次交替引号。宿主只在空闲退格确实删掉刚才的标点时调用。
187    pub fn undo_last_quote(&mut self) {
188        let Some(key) = self.last_alternating_quote.take() else {
189            return;
190        };
191        match key {
192            b'"' => self.next_double_quote_is_opening = !self.next_double_quote_is_opening,
193            b'\'' => self.next_single_quote_is_opening = !self.next_single_quote_is_opening,
194            _ => {}
195        }
196    }
197
198    pub fn set_paired_enabled(&mut self, enabled: bool) {
199        self.paired_enabled = enabled;
200    }
201
202    pub fn set_marks(&mut self, marks: PunctuationMarks) {
203        self.marks = marks;
204    }
205
206    /// The host emitted the closing half of `opening` itself; only `<` with pairing on and a positive nesting count changes anything.
207    pub fn balance_after_auto_close(&mut self, opening: u8) {
208        if self.paired_enabled && opening == NESTED_OPENING_INPUT && self.book_title_nesting > 0 {
209            self.book_title_nesting -= 1;
210        }
211    }
212}
213
214fn alternate(
215    next_is_opening: &mut bool,
216    (opening, closing): (&'static str, &'static str),
217) -> &'static str {
218    let output = if *next_is_opening { opening } else { closing };
219    *next_is_opening = !*next_is_opening;
220    output
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn translate_all(policy: &mut PunctuationPolicy, keys: &str) -> Vec<Option<&'static str>> {
228        keys.bytes().map(|key| policy.translate(key)).collect()
229    }
230
231    /// The table is the one `shared/contracts/punctuation/policy.json` defines, and `policy.h`, which English-mode output on macOS, IBus and Fcitx5 reads, carries the same rows, so a key gives the same mark in either mode.
232    #[test]
233    fn matches_the_shared_contract() {
234        let spec: serde_json::Value = serde_json::from_str(include_str!(
235            "../../../shared/contracts/punctuation/policy.json"
236        ))
237        .expect("policy.json");
238        assert_eq!(spec["contractVersion"], 5);
239        let simple = spec["simple"].as_array().expect("simple");
240        assert_eq!(simple.len(), SIMPLE.len());
241        for ((input, output), row) in SIMPLE.iter().zip(simple) {
242            assert_eq!(
243                row["input"].as_str().map(str::as_bytes),
244                Some(&[*input][..])
245            );
246            assert_eq!(row["output"], *output);
247        }
248        let alternating = spec["alternating"].as_array().expect("alternating");
249        assert_eq!(alternating.len(), 2);
250        for (row, (input, (opening, closing))) in alternating
251            .iter()
252            .zip([("\"", DOUBLE_QUOTE), ("'", SINGLE_QUOTE)])
253        {
254            assert_eq!(row["input"], input);
255            assert_eq!(row["opening"], opening);
256            assert_eq!(row["closing"], closing);
257        }
258        let nested = &spec["nested"];
259        assert_eq!(
260            nested["openingInput"].as_str().map(str::as_bytes),
261            Some(&[NESTED_OPENING_INPUT][..])
262        );
263        assert_eq!(
264            nested["closingInput"].as_str().map(str::as_bytes),
265            Some(&[NESTED_CLOSING_INPUT][..])
266        );
267        assert_eq!(nested["opening"], NESTED_OPENING);
268        assert_eq!(nested["nestedOpening"], NESTED_OPENING_INNER);
269        assert_eq!(nested["closing"], NESTED_CLOSING);
270        assert_eq!(nested["nestedClosing"], NESTED_CLOSING_INNER);
271
272        let styles = &spec["styles"];
273        let corner = styles["cornerQuotes"].as_array().expect("cornerQuotes");
274        assert_eq!(corner.len(), 2);
275        for (row, (input, (opening, closing))) in corner
276            .iter()
277            .zip([("\"", CORNER_DOUBLE_QUOTE), ("'", CORNER_SINGLE_QUOTE)])
278        {
279            assert_eq!(row["input"], input);
280            assert_eq!(row["opening"], opening);
281            assert_eq!(row["closing"], closing);
282        }
283        let hollow = styles["hollowBrackets"].as_array().expect("hollowBrackets");
284        assert_eq!(hollow.len(), HOLLOW_BRACKETS.len());
285        for ((input, output), row) in HOLLOW_BRACKETS.iter().zip(hollow) {
286            assert_eq!(
287                row["input"].as_str().map(str::as_bytes),
288                Some(&[*input][..])
289            );
290            assert_eq!(row["output"], *output);
291        }
292        let enumeration = spec["enumerationComma"]
293            .as_array()
294            .expect("enumerationComma");
295        assert_eq!(enumeration.len(), ENUMERATION_COMMA.len());
296        for ((input, output), row) in ENUMERATION_COMMA.iter().zip(enumeration) {
297            assert_eq!(
298                row["input"].as_str().map(str::as_bytes),
299                Some(&[*input][..])
300            );
301            assert_eq!(row["output"], *output);
302        }
303
304        let header = include_str!("../../../shared/contracts/punctuation/policy.h");
305        let literal = |input: u8| match input {
306            b'\\' => "'\\\\'".to_owned(),
307            b'\'' => "'\\''".to_owned(),
308            _ => format!("'{}'", input as char),
309        };
310        assert!(header.contains(&format!("std::array<Mapping, {}> simple", SIMPLE.len())));
311        for (input, output) in SIMPLE {
312            let row = format!("{{{}, \"{output}\"}}", literal(input));
313            assert!(header.contains(&row), "policy.h lacks {row}");
314        }
315        for (input, (opening, closing)) in [
316            (b'"', DOUBLE_QUOTE),
317            (b'\'', SINGLE_QUOTE),
318            (b'"', CORNER_DOUBLE_QUOTE),
319            (b'\'', CORNER_SINGLE_QUOTE),
320        ] {
321            let row = format!("{{{}, \"{opening}\", \"{closing}\"}}", literal(input));
322            assert!(header.contains(&row), "policy.h lacks {row}");
323        }
324        assert!(header.contains("std::array<AlternatingMapping, 2> alternating"));
325        assert!(header.contains("std::array<AlternatingMapping, 2> corner_quotes"));
326        assert!(header.contains(&format!(
327            "std::array<Mapping, {}> hollow_brackets",
328            HOLLOW_BRACKETS.len()
329        )));
330        for (input, output) in HOLLOW_BRACKETS {
331            let row = format!("{{{}, \"{output}\"}}", literal(input));
332            assert!(header.contains(&row), "policy.h lacks {row}");
333        }
334        assert!(header.contains(&format!(
335            "std::array<Mapping, {}> enumeration_comma",
336            ENUMERATION_COMMA.len()
337        )));
338        for (input, output) in ENUMERATION_COMMA {
339            let row = format!("{{{}, \"{output}\"}}", literal(input));
340            assert!(header.contains(&row), "policy.h lacks {row}");
341        }
342        for (name, value) in [
343            ("nested_opening_input", literal(NESTED_OPENING_INPUT)),
344            ("nested_closing_input", literal(NESTED_CLOSING_INPUT)),
345            ("nested_opening[]", format!("\"{NESTED_OPENING}\"")),
346            (
347                "nested_opening_inner[]",
348                format!("\"{NESTED_OPENING_INNER}\""),
349            ),
350            ("nested_closing[]", format!("\"{NESTED_CLOSING}\"")),
351            (
352                "nested_closing_inner[]",
353                format!("\"{NESTED_CLOSING_INNER}\""),
354            ),
355        ] {
356            let line = format!("{name} = {value};");
357            assert!(header.contains(&line), "policy.h lacks {line}");
358        }
359    }
360
361    /// contracts/tests/punctuation_contract.cpp.
362    #[test]
363    fn contract_table() {
364        assert_eq!(SIMPLE.len(), 17);
365        assert_eq!(ENUMERATION_COMMA.len(), 2);
366        let mut policy = PunctuationPolicy::default();
367        assert_eq!(policy.translate(b'$'), Some("¥"));
368        assert_eq!(policy.translate(b'_'), Some("——"));
369        assert_eq!(policy.translate(b'"'), Some("“"));
370        assert_eq!(policy.translate(b'"'), Some("”"));
371        assert_eq!(policy.translate(b'<'), Some("《"));
372        assert_eq!(policy.translate(b'<'), Some("〈"));
373        for key in *b"a@-1 /" {
374            assert_eq!(policy.translate(key), None);
375        }
376    }
377
378    /// 契约第 4 版:`enumerationComma` 的键由各自的偏好开关决定,没打开的键返回 `None`(宿主按普通字符输出)。缺省是「只有 `\`」,`/` 因此仍是命令模式触发键。
379    #[test]
380    fn enumeration_comma_keys_follow_their_switches() {
381        let mut policy = PunctuationPolicy::default();
382        assert_eq!(policy.translate(b'\\'), Some("、"));
383        assert_eq!(policy.translate(b'/'), None);
384
385        policy.set_marks(PunctuationMarks {
386            enumeration_comma_slash: true,
387            ..PunctuationMarks::default()
388        });
389        assert_eq!(policy.translate(b'/'), Some("、"));
390
391        policy.set_marks(PunctuationMarks {
392            enumeration_comma_backslash: false,
393            enumeration_comma_slash: true,
394            ..PunctuationMarks::default()
395        });
396        assert_eq!(policy.translate(b'\\'), None);
397        assert_eq!(policy.translate(b'/'), Some("、"));
398
399        // 两个都关时谁出顿号是偏好解析处的归一职责;引擎只照开关执行,两个都关就都按普通字符。
400        policy.set_marks(PunctuationMarks {
401            enumeration_comma_backslash: false,
402            enumeration_comma_slash: false,
403            ..PunctuationMarks::default()
404        });
405        assert_eq!(policy.translate(b'\\'), None);
406        assert_eq!(policy.translate(b'/'), None);
407        assert_eq!(policy.translate(b','), Some(","));
408    }
409
410    /// The idle map of test_input_session.cpp:850-885, as recorded in the qp_idle_chinese_punctuation golden.
411    #[test]
412    fn idle_map_in_recorded_order() {
413        let mut policy = PunctuationPolicy::default();
414        let expected = [
415            "。", "“", "”", "‘", "’", "(", ")", "【", "】", "·", "¥", "……", "——", "《", "〈",
416            "〉", "》", "、",
417        ];
418        let actual = translate_all(&mut policy, ".\"\"''()[]`$^_<<>>\\");
419        assert_eq!(actual, expected.map(Some));
420        let rest = translate_all(&mut policy, ",?!;:");
421        assert_eq!(rest, [",", "?", "!", ";", ":"].map(Some));
422    }
423
424    #[test]
425    fn quotes_toggle_independently() {
426        let mut policy = PunctuationPolicy::default();
427        assert_eq!(
428            translate_all(&mut policy, "\"'\"'"),
429            ["“", "‘", "”", "’"].map(Some)
430        );
431    }
432
433    #[test]
434    fn deleting_the_last_quote_restores_its_direction() {
435        let mut policy = PunctuationPolicy::default();
436        assert_eq!(policy.translate(b'"'), Some("“"));
437        policy.undo_last_quote();
438        assert_eq!(policy.translate(b'"'), Some("“"));
439
440        // 其他标点会结束撤销窗口;这是正常的引号交替,删除后面的字符不能重新打开它。
441        assert_eq!(policy.translate(b':'), Some(":"));
442        policy.undo_last_quote();
443        assert_eq!(policy.translate(b'"'), Some("”"));
444    }
445
446    /// qp_paired_punctuation_disabled: alternation and nesting run with pairing off, and toggling pairing resets neither.
447    #[test]
448    fn pairing_off_still_alternates_and_nests() {
449        let mut policy = PunctuationPolicy::default();
450        policy.set_paired_enabled(false);
451        assert_eq!(
452            translate_all(&mut policy, "\"\"''<<>>"),
453            ["“", "”", "‘", "’", "《", "〈", "〉", "》"].map(Some)
454        );
455        policy.translate(b'"');
456        policy.set_paired_enabled(true);
457        assert_eq!(policy.translate(b'"'), Some("”"));
458    }
459
460    #[test]
461    fn unmatched_closing_never_goes_negative() {
462        let mut policy = PunctuationPolicy::default();
463        assert_eq!(
464            translate_all(&mut policy, ">><"),
465            ["》", "》", "《"].map(Some)
466        );
467        assert_eq!(policy.book_title_nesting, 1);
468    }
469
470    #[test]
471    fn auto_close_balances_only_with_pairing() {
472        let mut policy = PunctuationPolicy::default();
473        assert_eq!(policy.translate(b'<'), Some("《"));
474        policy.balance_after_auto_close(b'<');
475        assert_eq!(policy.book_title_nesting, 0);
476        assert_eq!(policy.translate(b'<'), Some("《"));
477
478        policy.balance_after_auto_close(b'(');
479        assert_eq!(policy.book_title_nesting, 1);
480
481        policy.set_paired_enabled(false);
482        policy.balance_after_auto_close(b'<');
483        assert_eq!(policy.book_title_nesting, 1);
484        assert_eq!(policy.translate(b'<'), Some("〈"));
485
486        policy.set_paired_enabled(true);
487        let mut empty = PunctuationPolicy::default();
488        empty.balance_after_auto_close(b'<');
489        assert_eq!(empty.book_title_nesting, 0);
490    }
491
492    fn styled(quotes: QuoteMarks, brackets: BracketMarks) -> PunctuationPolicy {
493        let mut policy = PunctuationPolicy::default();
494        policy.set_marks(PunctuationMarks {
495            quotes,
496            brackets,
497            ..PunctuationMarks::default()
498        });
499        policy
500    }
501
502    #[test]
503    fn corner_quotes_alternate_per_key() {
504        let mut policy = styled(QuoteMarks::Corner, BracketMarks::Solid);
505        assert_eq!(
506            translate_all(&mut policy, "'\"'\""),
507            ["「", "『", "」", "』"].map(Some)
508        );
509        assert_eq!(translate_all(&mut policy, "[]"), ["【", "】"].map(Some));
510    }
511
512    #[test]
513    fn hollow_brackets_replace_only_square_keys() {
514        let mut hollow = styled(QuoteMarks::Curly, BracketMarks::Hollow);
515        let mut solid = PunctuationPolicy::default();
516        assert_eq!(translate_all(&mut hollow, "[]"), ["〖", "〗"].map(Some));
517        for (key, _) in SIMPLE.iter().filter(|(key, _)| !matches!(key, b'[' | b']')) {
518            assert_eq!(hollow.translate(*key), solid.translate(*key));
519        }
520        assert_eq!(
521            translate_all(&mut hollow, "\"'<>"),
522            ["“", "‘", "《", "》"].map(Some)
523        );
524    }
525
526    /// 契约第 5 版(#10047):`Shift+[` `Shift+]`(`{` `}`)固定出左右直角引号「」,不交替,不随引号样式和成对开关变,也不动 `'` `"` 的交替状态。
527    #[test]
528    fn shifted_brackets_give_corner_quotes() {
529        let mut policy = PunctuationPolicy::default();
530        assert_eq!(
531            translate_all(&mut policy, "{}{{}}"),
532            ["「", "」", "「", "「", "」", "」"].map(Some)
533        );
534        // 两个弯引号键从没按过,仍出前半个。
535        assert_eq!(
536            translate_all(&mut policy, "\"'}{"),
537            ["“", "‘", "」", "「"].map(Some)
538        );
539        // 直角引号样式下 `{` `}` 出的还是这两个字:样式只换 `'` `"` 的字,`'` 的交替也不受 `{` `}` 影响。
540        let mut corner = styled(QuoteMarks::Corner, BracketMarks::Hollow);
541        assert_eq!(
542            translate_all(&mut corner, "{'}'"),
543            ["「", "「", "」", "」"].map(Some)
544        );
545        // 成对补全关着也一样。
546        policy.set_paired_enabled(false);
547        assert_eq!(translate_all(&mut policy, "}{"), ["」", "「"].map(Some));
548        // 固定映射不是交替引号:退格撤销不碰它,也不撤销更早的引号。
549        assert_eq!(policy.translate(b'"'), Some("”"));
550        assert_eq!(policy.translate(b'{'), Some("「"));
551        policy.undo_last_quote();
552        assert_eq!(policy.translate(b'"'), Some("“"));
553    }
554
555    /// 缺省样式就是契约第 1 版那张表,逐键相同。
556    #[test]
557    fn default_marks_match_the_contract() {
558        assert_eq!(
559            PunctuationMarks::default(),
560            PunctuationMarks {
561                quotes: QuoteMarks::Curly,
562                brackets: BracketMarks::Solid,
563                enumeration_comma_backslash: true,
564                enumeration_comma_slash: false,
565            }
566        );
567        let mut styled = styled(QuoteMarks::Curly, BracketMarks::Solid);
568        let mut default = PunctuationPolicy::default();
569        let keys = ",.?!;:()[]\\`$^_\"\"''<<>>";
570        assert_eq!(
571            translate_all(&mut styled, keys),
572            translate_all(&mut default, keys)
573        );
574    }
575
576    #[test]
577    fn marks_do_not_reset_alternation() {
578        let mut policy = PunctuationPolicy::default();
579        assert_eq!(policy.translate(b'\''), Some("‘"));
580        policy.set_marks(PunctuationMarks {
581            quotes: QuoteMarks::Corner,
582            brackets: BracketMarks::Solid,
583            ..PunctuationMarks::default()
584        });
585        assert_eq!(policy.translate(b'\''), Some("」"));
586        assert_eq!(policy.translate(b'\''), Some("「"));
587    }
588
589    #[test]
590    fn pairing_off_still_alternates_corner_quotes() {
591        let mut policy = styled(QuoteMarks::Corner, BracketMarks::Hollow);
592        policy.set_paired_enabled(false);
593        assert_eq!(
594            translate_all(&mut policy, "''\"\"[]"),
595            ["「", "」", "『", "』", "〖", "〗"].map(Some)
596        );
597    }
598
599    #[test]
600    fn balance_after_auto_close_ignores_styles() {
601        let mut policy = styled(QuoteMarks::Corner, BracketMarks::Hollow);
602        assert_eq!(policy.translate(b'<'), Some("《"));
603        policy.balance_after_auto_close(b'[');
604        policy.balance_after_auto_close(b'\'');
605        assert_eq!(policy.book_title_nesting, 1);
606        policy.balance_after_auto_close(b'<');
607        assert_eq!(policy.book_title_nesting, 0);
608        assert_eq!(policy.translate(b'\''), Some("「"));
609    }
610}