Skip to main content

ferroui_protranslate_formats/
lib.rs

1//! Loss-aware translation catalog conversion, independent of any UI framework.
2//!
3//! Ported from the MIT-licensed ProTranslate.Formats library. Runtime translation
4//! lookup belongs to `ferroui-protranslate-core`; this crate is optional tooling.
5//! Parsers report malformed input as diagnostics rather than panicking. Exports
6//! are deterministic and report metadata or cultures that cannot be represented.
7
8mod json;
9mod model;
10mod text;
11mod xcstrings;
12mod xml;
13pub use model::*;
14
15/// Stateless conversion entry points matching the source library's organization.
16pub struct TranslationCatalogConverter;
17
18impl TranslationCatalogConverter {
19    pub fn detect_format(path: &str) -> TranslationFileFormat {
20        detect_format(path)
21    }
22    pub fn import(
23        content: &str,
24        format: TranslationFileFormat,
25        options: &TranslationFormatOptions,
26        path: Option<&str>,
27    ) -> TranslationFormatResult {
28        import(content, format, options, path)
29    }
30    pub fn export(
31        catalog: &TranslationCatalog,
32        format: TranslationFileFormat,
33        options: &TranslationFormatOptions,
34    ) -> TranslationExportResult {
35        export(catalog, format, options)
36    }
37}
38
39/// Detect by file name. XLIFF version is read from its root during import.
40pub fn detect_format(path: &str) -> TranslationFileFormat {
41    use TranslationFileFormat::*;
42    let path = path.to_ascii_lowercase();
43    for (suffix, format) in [
44        (".xcstrings", AppleXcstrings),
45        (".stringsdict", AppleStringsdict),
46        (".strings", AppleStrings),
47        (".arb", FlutterArb),
48        (".resx", Resx),
49        (".po", GettextPo),
50        (".pot", GettextPo),
51        (".xlf", Xliff12),
52        (".xliff", Xliff12),
53        ("strings.xml", AndroidResources),
54        (".tsv", Tsv),
55        (".csv", Csv),
56        (".i18next.json", I18NextJson),
57        (".i18n.json", I18NextJson),
58    ] {
59        if path.ends_with(suffix) {
60            return format;
61        }
62    }
63    ProTranslateJson
64}
65
66pub fn import(
67    content: &str,
68    format: TranslationFileFormat,
69    options: &TranslationFormatOptions,
70    path: Option<&str>,
71) -> TranslationFormatResult {
72    let mut result = TranslationFormatResult {
73        catalog: TranslationCatalog {
74            name: options.name.clone().or_else(|| {
75                path.and_then(|p| std::path::Path::new(p).file_stem())
76                    .map(|p| p.to_string_lossy().into_owned())
77            }),
78            source_culture: options.source_culture.clone(),
79            entries: Vec::new(),
80            metadata: Default::default(),
81        },
82        diagnostics: Vec::new(),
83    };
84    use TranslationFileFormat::*;
85    let content = content.strip_prefix('\u{feff}').unwrap_or(content);
86    let parsed = match format {
87        ProTranslateJson | I18NextJson | FlutterArb | AppleXcstrings => {
88            json::import(content, format, options, &mut result)
89        }
90        Resx | AndroidResources | AppleStringsdict | Xliff12 | Xliff20 => {
91            xml::import(content, format, options, path, &mut result)
92        }
93        AppleStrings | GettextPo | Csv | Tsv => {
94            text::import(content, format, options, path, &mut result)
95        }
96    };
97    if let Err(message) = parsed {
98        result.catalog.entries.clear();
99        result.diagnostics.push(TranslationFormatDiagnostic::new(
100            Severity::Error,
101            "PTF001",
102            message,
103        ));
104    }
105    result.diagnostics.extend(result.catalog.validate());
106    result
107}
108
109pub fn export(
110    catalog: &TranslationCatalog,
111    format: TranslationFileFormat,
112    options: &TranslationFormatOptions,
113) -> TranslationExportResult {
114    use TranslationFileFormat::*;
115    let mut result = TranslationExportResult {
116        content: String::new(),
117        diagnostics: catalog.validate(),
118    };
119    if result.has_errors() {
120        return result;
121    }
122    diagnose_losses(catalog, format, options, &mut result.diagnostics);
123    let output = match format {
124        ProTranslateJson | I18NextJson | FlutterArb | AppleXcstrings => {
125            json::export(catalog, format, options)
126        }
127        Resx | AndroidResources | AppleStringsdict | Xliff12 | Xliff20 => {
128            xml::export(catalog, format, options)
129        }
130        AppleStrings | GettextPo | Csv | Tsv => text::export(catalog, format, options),
131    };
132    match output {
133        Ok(content) => result.content = content,
134        Err(message) => result.diagnostics.push(TranslationFormatDiagnostic::new(
135            Severity::Error,
136            "PTF201",
137            message,
138        )),
139    }
140    result
141}
142
143fn diagnose_losses(
144    catalog: &TranslationCatalog,
145    format: TranslationFileFormat,
146    options: &TranslationFormatOptions,
147    diagnostics: &mut Vec<TranslationFormatDiagnostic>,
148) {
149    use TranslationFileFormat::*;
150    let multi = matches!(format, AppleXcstrings | Csv | Tsv | Xliff12);
151    if !multi && options.culture.is_none() && catalog.cultures().len() > 1 {
152        diagnostics.push(TranslationFormatDiagnostic::new(Severity::Warning,"PTF200","The target has one language per file; pass an explicit culture to avoid ambiguous selection."));
153    }
154    let lost_document_metadata: Vec<_> = catalog
155        .metadata
156        .keys()
157        .filter(|key| match format {
158            Csv | Tsv => false,
159            FlutterArb => !matches!(key.as_str(), "arb.globals" | "arb.orphan_metadata"),
160            GettextPo => !matches!(key.as_str(), "po.header_entry" | "po.obsolete"),
161            AppleXcstrings => !matches!(key.as_str(), "xcstrings.root" | "xcstrings.untranslated"),
162            _ => true,
163        })
164        .map(String::as_str)
165        .collect();
166    if !lost_document_metadata.is_empty() {
167        diagnostics.push(TranslationFormatDiagnostic::new(
168            Severity::Warning,
169            "PTF204",
170            format!(
171                "Target cannot preserve document metadata: {}.",
172                lost_document_metadata.join(", ")
173            ),
174        ));
175    }
176    let selected = options.culture.clone().or_else(|| {
177        (!multi)
178            .then(|| catalog.cultures().into_iter().next())
179            .flatten()
180    });
181    let entries: Vec<_> = if multi {
182        catalog
183            .entries
184            .iter()
185            .filter(|entry| {
186                selected.as_deref().is_none_or(|culture| {
187                    entry
188                        .culture
189                        .as_deref()
190                        .is_none_or(|tag| tag.eq_ignore_ascii_case(culture))
191                })
192            })
193            .collect()
194    } else {
195        model::select_entries(catalog, selected.as_deref())
196    };
197    for entry in entries {
198        let mut losses = Vec::new();
199        if entry.comment.is_some()
200            && (!options.include_comments
201                || matches!(
202                    format,
203                    ProTranslateJson | I18NextJson | AndroidResources | AppleStringsdict
204                ))
205        {
206            losses.push("comment");
207        }
208        if entry.source.is_some() && !matches!(format, GettextPo | Xliff12 | Xliff20 | Csv | Tsv) {
209            losses.push("source");
210        }
211        if entry.context.is_some() && !matches!(format, GettextPo | Csv | Tsv | Xliff12 | Xliff20) {
212            losses.push("context");
213        }
214        if entry.state.is_some()
215            && (!matches!(
216                format,
217                GettextPo | AppleXcstrings | Csv | Tsv | Xliff12 | Xliff20
218            ) || (format == GettextPo && entry.state.as_deref() != Some("fuzzy")))
219        {
220            losses.push("state");
221        }
222        if entry.plural_category.is_some()
223            && matches!(
224                format,
225                ProTranslateJson
226                    | I18NextJson
227                    | FlutterArb
228                    | Resx
229                    | AppleStrings
230                    | Xliff12
231                    | Xliff20
232            )
233        {
234            losses.push("plural category (value retained under its flattened key)");
235        }
236        if entry.format.is_some() && !matches!(format, Csv | Tsv) {
237            losses.push("format");
238        }
239        let unknown_metadata = entry.metadata.keys().any(|key| match format {
240            Csv | Tsv => false,
241            FlutterArb => key != "arb",
242            AppleXcstrings => !matches!(
243                key.as_str(),
244                "xcstrings.path" | "xcstrings.localization" | "xcstrings.record" | "xcstrings.unit"
245            ),
246            Xliff12 | Xliff20 => {
247                let compatible = entry
248                    .metadata
249                    .get("xliff.version")
250                    .is_some_and(|version| version == if format == Xliff12 { "1" } else { "2" });
251                !compatible
252                    || !matches!(
253                        key.as_str(),
254                        "xliff.version"
255                            | "xliff.unit"
256                            | "xliff.unit_id"
257                            | "xliff.unit_attributes"
258                            | "xliff.segment"
259                            | "xliff.segment_id"
260                            | "xliff.segment_order"
261                            | "xliff.segment_attributes"
262                            | "xliff.source_xml"
263                            | "xliff.target_xml"
264                            | "xliff.notes"
265                            | "xliff.extras"
266                            | "xliff.extra_positions"
267                            | "xliff.untranslated"
268                    )
269            }
270            GettextPo => !matches!(
271                key.as_str(),
272                "po.index"
273                    | "po.msgid_plural"
274                    | "po.plural_forms"
275                    | "po.flags"
276                    | "po.flags_prefix"
277                    | "po.previous"
278                    | "po.references"
279                    | "po.translator_comment"
280                    | "po.untranslated"
281            ),
282            AppleStringsdict => !matches!(
283                key.as_str(),
284                "apple.key"
285                    | "apple.variable"
286                    | "apple.localized_format"
287                    | "apple.value_type"
288                    | "apple.rule"
289                    | "apple.variant"
290            ),
291            AndroidResources => !matches!(
292                key.as_str(),
293                "android.array" | "android.index" | "android.translatable" | "android.formatted"
294            ),
295            _ => true,
296        });
297        if unknown_metadata {
298            losses.push("metadata");
299        }
300        if format == AppleXcstrings && entry.culture.is_none() {
301            diagnostics.push(TranslationFormatDiagnostic::entry(
302                Severity::Warning,
303                "PTF203",
304                "A .xcstrings localization requires a culture; this neutral record is omitted.",
305                entry,
306            ));
307        }
308        if matches!(format, Csv | Tsv)
309            && [
310                entry.key.as_str(),
311                entry.value.as_str(),
312                entry.comment.as_deref().unwrap_or(""),
313            ]
314            .iter()
315            .any(|value| value.starts_with(['=', '+', '-', '@', '\t', '\r']))
316        {
317            diagnostics.push(TranslationFormatDiagnostic::entry(Severity::Warning,"PTF192","Formula-looking spreadsheet cell retained as text; review spreadsheet import settings.",entry));
318        }
319        if !losses.is_empty() {
320            diagnostics.push(TranslationFormatDiagnostic::entry(
321                Severity::Warning,
322                "PTF202",
323                format!("Target cannot preserve: {}.", losses.join(", ")),
324                entry,
325            ));
326        }
327    }
328}
329
330pub(crate) fn infer_culture(path: Option<&str>, suffix: &str) -> Option<String> {
331    let file = path?.rsplit(['/', '\\']).next()?;
332    let split = file.len().checked_sub(suffix.len())?;
333    if !file.get(split..)?.eq_ignore_ascii_case(suffix) {
334        return None;
335    }
336    let stem = &file[..split];
337    let candidate = stem.rsplit('.').next()?;
338    if candidate == stem
339        && !candidate.contains('-')
340        && !candidate.contains('_')
341        && candidate.len() > 3
342    {
343        return None;
344    }
345    if candidate.split(['-', '_']).next()?.len() > 3 {
346        return None;
347    }
348    Some(TranslationCatalog::normalize_culture_name(Some(candidate)))
349}
350
351pub(crate) fn infer_apple_culture(path: Option<&str>) -> Option<String> {
352    let path = path?;
353    path.split(['/', '\\'])
354        .find_map(|part| {
355            let split = part.len().checked_sub(6)?;
356            part.get(split..)?
357                .eq_ignore_ascii_case(".lproj")
358                .then(|| &part[..split])
359        })
360        .map(|tag| TranslationCatalog::normalize_culture_name(Some(tag)))
361        .or_else(|| infer_culture(Some(path), ".strings"))
362        .or_else(|| infer_culture(Some(path), ".stringsdict"))
363}
364
365#[cfg(test)]
366mod tests;