Line data Source code
1 : // Copyright 2026 The Authors.
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // https://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : import 'backend.dart';
16 : import 'guess.dart';
17 : import 'ngram/ngram_backend.dart';
18 : import 'ngram/profiles.g.dart' show ngramProfiles;
19 : import 'script/script_candidates.dart';
20 : import 'script/script_filter.dart';
21 :
22 : /// The default [LanguageDetectorBackend]: a Unicode script pre-filter that
23 : /// short-circuits for scripts exclusive to one of [allLanguages], falling
24 : /// back to [NgramBackend] for the rest.
25 : ///
26 : /// Orchestration:
27 : ///
28 : /// 1. A single [scanScript] pass over the input yields both the dominant
29 : /// script and whether any kana codepoint was present.
30 : /// 2. If kana was present and `'ja'` is a candidate, the result is Japanese
31 : /// — immediately, with no n-gram scoring.
32 : /// 3. Else if the dominant script is Han with no kana and `'zh'` is a
33 : /// candidate, the result is Chinese.
34 : /// 4. Else if the dominant script maps to exactly one candidate language via
35 : /// [scriptExclusiveLanguage], that language is the result.
36 : /// 5. Otherwise, the n-gram stage runs: the candidate set is narrowed via
37 : /// [scriptCandidates] if the dominant script identifies a relevant script
38 : /// family (Latin, Cyrillic, Arabic, Devanagari), then scored by
39 : /// [NgramBackend].
40 : ///
41 : /// Every input is handled. Script-less text, or a `restrictTo` that excludes
42 : /// every candidate a script suggests, narrows the n-gram stage down to zero
43 : /// profiles — [NgramBackend] returns `[]` for that, which
44 : /// [LanguageDetector.detect] turns into [Undetermined]. A `restrictTo` that
45 : /// narrows to exactly *one* plausible language instead reaches the n-gram
46 : /// stage with a single profile, which [NgramBackend] scores at confidence
47 : /// `1.0` by definition (there is nothing else to compare against) — this can
48 : /// be surprising: it says "the only candidate you allowed" regardless of how
49 : /// well the text actually fits that language.
50 : final class CompositeBackend implements LanguageDetectorBackend {
51 : /// Creates a backend limited to [restrictTo] (intersected with
52 : /// [allLanguages]), or all 58 languages if [restrictTo] is `null`.
53 2 : CompositeBackend({Set<String>? restrictTo})
54 : : _candidates = restrictTo == null
55 : ? allLanguages
56 2 : : restrictTo.intersection(allLanguages);
57 :
58 : final Set<String> _candidates;
59 :
60 1 : @override
61 1 : Set<String> get supportedLanguages => _candidates;
62 :
63 2 : @override
64 : List<LanguageGuess> score(String text) {
65 2 : final scan = scanScript(text);
66 2 : final scriptGuess = _scriptStage(scan);
67 1 : if (scriptGuess != null) return [scriptGuess];
68 4 : return _ngramStage(text, scan.dominantScript);
69 : }
70 :
71 2 : LanguageGuess? _scriptStage(ScriptScan scan) {
72 6 : if (scan.hasKana && _candidates.contains('ja')) {
73 : return const LanguageGuess('ja', 1.0);
74 : }
75 :
76 2 : final script = scan.dominantScript;
77 : if (script == null) return null;
78 :
79 4 : if (script == hanScript && _candidates.contains('zh')) {
80 : return const LanguageGuess('zh', 1.0);
81 : }
82 :
83 2 : final exclusive = scriptExclusiveLanguage[script];
84 2 : if (exclusive != null && _candidates.contains(exclusive)) {
85 1 : return LanguageGuess(exclusive, 1.0);
86 : }
87 :
88 : return null;
89 : }
90 :
91 2 : List<LanguageGuess> _ngramStage(String text, String? script) {
92 2 : final scriptCandidateSet = script == null ? null : scriptCandidates[script];
93 : final narrowed = scriptCandidateSet == null
94 2 : ? _candidates
95 4 : : _candidates.intersection(scriptCandidateSet);
96 :
97 : // No explicit "fewer than 2 candidates" guard here: NgramBackend already
98 : // handles both edge cases correctly on its own — an empty profile map
99 : // scores to `[]` (see NgramBackend.score's `_profiles.isEmpty` check),
100 : // and a single-entry map trivially scores that one candidate at
101 : // confidence 1.0 (there is nothing to compare against, so it "wins" by
102 : // definition — the same documented behaviour NgramBackend already
103 : // guarantees for any single-candidate set, restrictTo-narrowed or not).
104 2 : final profiles = {
105 2 : for (final code in narrowed)
106 6 : if (ngramProfiles.containsKey(code)) code: ngramProfiles[code]!,
107 : };
108 :
109 4 : return NgramBackend(profiles).score(text);
110 : }
111 : }
|