Русский
Русский
English
Статистика
Реклама

Интернационализация

Автоматизируем локализацию макетов в Figma

29.12.2020 14:10:26 | Автор: admin
В один прекрасный момент наши дизайнеры решили, что пора обновить обложки
нашего приложения в Apple Store и Google Play. На всех 17 языках.

Это история про то, как нырнуть в незнакомый язык программирования, незнакомую платформу и незнакомую задачу, собрать много всего интересного, помочь коллегам и оставить след в open source community.

мемасик



обложки

После подготовки макетов в Figma пришло осознание страшного факта: мы должны поддержать 17 различных языков! Причем максимально качественно и согласованно, так что автоматические переводы через Google Translate (или даже сервис Crowdin) нам не подходили. Что ж, задача была поручена ответственным людям, и вот через некоторое время у нас появилась табличка Google Sheets с переводами всех фраз.

таблица с переводами

Первая попытка ручной замены текстов в макетах привела к следующему:

  • было потрачено около 40 минут;
  • были допущены мелкие ошибки форматирования;
  • заменяющий вносил свои правки в переводы (потому что знал язык) и нигде это не фиксировал;
  • пропало желание повторять эту процедуру, а значит, и вносить какие-либо изменения.

И задача резко обрела пугающий объем и сложность. Требовалась автоматизация.

Конечно же, хотелось бы воспользоваться готовым решением. Однако из рассмотренных (Crowdin, Localize, Yandex.Translate) ни одно нас не устроило, так как они не позволяют использовать кастомизированные переводы и полностью игнорируют форматирование (подсветку, выделения). А в идеале решение должно было давать практически готовый результат, не требующий вмешательства человека.

Как известно, подобные проблемы лучше всего решаются за чашечкой чая в компании коллег-разработчиков на уютной офисной кухне. Несмотря на то, что в нашей команде нет разработчиков, хорошо знакомых с Figma и ее возможностями, никто не брезгует TypeScript'ом, и все способны написать на нем плагин по документации. Даже Golang и Kotlin разработчики заднего конца. Более того, небольшой опыт у некоторых уже имелся.

Написание плагинов для Figma


Figma предоставляет достаточно подробную документацию по созданию плагинов. Код плагина делится на клиентскую и серверную части, при этом только у последней есть доступ к структуре документа (названия условные, потому что обе выполняются локально). Клиентская часть отвечает за взаимодействие с пользователем через HTML-интерфейс. Возможности API так же хорошо описаны в соответствующей документации.

Разработка плагина ведется исключительно в десктоп-версии Figma, которая по сути является обычным WebView, поэтому разработчику доступна консоль JavaScript и прочие привычные инструменты.

После беглого осмотра стало ясно, что API поддерживает поиск нод, редактирование текста и стилей, то есть в теории позволяет нам осуществить задуманное.

Автоматический перевод по словарю


Автозамена


Было решено за полчасика сделать PoC, который будет в состоянии просто подменить все тексты в соответствии с табличкой. Пока с максимально примитивным UI, конечно же.

Итак, для начала нам нужно загрузить словарь. Поскольку заморачиваться с подключением сторонних модулей на первом этапе не хотелось, решил выбрать самый простой формат, в который можно выгрузить имеющуюся табличку из Google Sheets. Между CSV и TSV выбор пал на TSV: в текстах могли присутствовать запятые, это усложнило бы парсинг.

Парсим табличку
type Dictionary = {    header: string[];    rows: string[][];};async function parseDictionary(serializedDictionary: string): Promise<Dictionary> {    const table = serializedDictionary.split('\n').map(line => line.split('\t').map(field => field.trim()));    if (table.length === 0) {        throw {error: 'no header in the dictionary'};    }    // в заголовке будут языковые коды    const header = table[0];    const expectedColumnCount = header.length;    const rows = table.slice(1, table.length);    console.log('Dictionary:', {header, rows});    rows.forEach((row, index) => {        if (row.length != expectedColumnCount) {            throw {error: 'row ' + (index + 2) + ' of the dictionary has ' + row.length + ' (not ' + expectedColumnCount + ') columns'};        }    });    return {header, rows};}

Строим словарь для выбранной пары языков
type Mapping = {    [source: string]: string;};async function getMapping(dictionary: Dictionary, sourceLanguage: string, targetLanguage: string): Promise<Mapping> {    const sourceColumnIndex = dictionary.header.indexOf(sourceLanguage);    if (sourceColumnIndex == -1) {        throw {error: sourceLanguage + ' not listed in [' + dictionary.header + ']'};    }    const targetColumnIndex = dictionary.header.indexOf(targetLanguage);    if (targetColumnIndex == -1) {        throw {error: targetLanguage + ' not listed in [' + dictionary.header + ']'};    }    const result: Mapping = {};    dictionary.rows.forEach(row => {        const sourceString = row[sourceColumnIndex];        const targetString = row[targetColumnIndex];        if (targetString.trim() !== '') {            if (sourceString in result) {                throw {error: 'multiple translations for `' + sourceString + '` in the dictionary'};            }            result[sourceString] = targetString;        }    });    // крайне удобный способ отладки в случае с Figma    console.log('Extracted mapping:', result);    return result;}

Подставляем переводы во все текстовые ноды по прямому совпадению
type Replacement = null | {    // в какой ноде заменяем текст    node: TextNode;    // на что заменяем    translation: string;};type ReplacementFailure = {    // в какой ноде произошла ошибка    nodeId: string;    // описание самой ошибки    error: string;};type ReplacementAttempt = Replacement | ReplacementFailure;// Settings получаются элементарно из UIasync function translateSelection(settings: Settings): Promise<void> {    const dictionary = await parseDictionary(settings.serializedDictionary);    const mapping = await getMapping(dictionary, settings.sourceLanguage, settings.targetLanguage);    await replaceAllTexts(mapping);}async function replaceAllTexts(mapping: Mapping): Promise<void> {    const textNodes = await findSelectedTextNodes();    let replacements = (await Promise.all(textNodes.map(node => computeReplacement(node, mapping)))).filter(r => r !== null);    let failures = replacements.filter(r => 'error' in r) as ReplacementFailure[];    if (failures.length > 0) {        console.log('Failures:', failures);        throw {error: 'found some untranslatable nodes', failures};    }    replacements.forEach(replaceText);}async function findSelectedTextNodes(): Promise<TextNode[]> {    const result: TextNode[] = [];    figma.currentPage.selection.forEach(root => {        if (root.type === 'TEXT') {            // либо в выделение попала текстовая нода            result.push(root as TextNode);        } else if ('findAll' in root) {            // либо фрейм/группа,            // тогда в ней можно найти все текстовые подноды встроенной функцией findAll            (root as ChildrenMixin).findAll(node => node.type === 'TEXT').forEach(node => result.push(node as TextNode));        }    });    return result;}async function computeReplacement(node: TextNode, mapping: Mapping): Promise<ReplacementAttempt> {    // текст ноды может содержать лишние пробелы и переносы слов,    // и это не должно влиять на возможность перевода,    // поэтому предварительно нормализуем строки    const content = normalizeContent(node.characters);    if (!(content in mapping)) {        // не нашли перевод? жаль        return {nodeId: node.id, error: 'No translation for `' + content + '`'};    }    const result: Replacement = {        node,        translation: mapping[content],    };    console.log('Replacement:', result);    return result;}function normalizeContent(content: string): string {    // интересные факты из жизни Unicode:    //   \u2028  разделитель строк (но не \n)    //   \u202F  невидимый разделитель пробелов (?)    // по-хорошему, стоит добавить и прочие разделители,    // но они на практике пока не встречались    return content.replace(/[\u000A\u00A0\u2028\u202F]/g, ' ').replace(/ +/g, ' ');}async function replaceText(replacement: Replacement): Promise<void> {    const {node, translation} = replacement;    // интересная особенность Figma:    // перед тем, как менять что-либо в текстовой ноде, нужно предварительно    // загрузить все шрифты, которые в ней используются    await loadFontsForNode(node);    node.characters = translation;}async function loadFontsForNode(node: TextNode): Promise<void> {    await Promise.all(Array.from({length: node.characters.length}, (_, k) => k).map(i => {        // очень забавный момент, конечно:        // Figma позволяет узнать свойства любой секции текста в ноде,        // при этом если в секции оно неоднородно (например, используется несколько шрифтов),        // то возвращается специальный объект mixed        return figma.loadFontAsync(node.getRangeFontName(i, i + 1) as FontName);    }));}

Вся эта конструкция довольно быстро заработала, что порадовало и вселило уверенность.

базовая реализация

Исключения


При первом же запуске было обнаружено множество текстов, не требующих переводов. Потому они и не фигурировали в исходной табличке.

исключения

Имена собственные, числа, валюты, какие-то символы, эмодзи Не беда. Достаточно задать исключения в виде списка регулярок.

Учитываем исключения
async function translateSelection(settings: Settings): Promise<void> {    const dictionary = await parseDictionary(settings.serializedDictionary);    const mapping = await getMapping(dictionary, settings.sourceLanguage, settings.targetLanguage);    // дополнительно учитываем список исключений    const exceptions = await parseExceptions(settings.serializedExceptions);    await replaceAllTexts(mapping, exceptions);}async function parseExceptions(serializedExceptions: string): Promise<RegExp[]> {    return serializedExceptions.split('\n').filter(pattern => pattern !== '').map(pattern => {        try {            return new RegExp(pattern);        } catch (_) {            throw {error: 'invalid regular expression `' + pattern + '`'};        }    });}async function replaceAllTexts(mapping: Mapping, exceptions: RegExp[]): Promise<void> {    const textNodes = await findSelectedTextNodes();    // пробрасываем список в функцию вычисления подстановки    let replacements = (await Promise.all(textNodes.map(node => computeReplacement(node, mapping, exceptions)))).filter(r => r !== null);    let failures = replacements.filter(r => 'error' in r) as ReplacementFailure[];    if (failures.length > 0) {        console.log('Failures:', failures);        throw {error: 'found some untranslatable nodes', failures};    }    replacements.forEach(replaceText);}async function computeReplacement(node: TextNode, mapping: Mapping, exceptions: RegExp[]): Promise<ReplacementAttempt> {    const content = normalizeContent(node.characters);    // если содержимое подходит под одну из регулярок    if (keepAsIs(content, exceptions)) {        // то говорим, что заменять текст в этой ноде не нужно совсем        return null;    }    if (!(content in mapping)) {        // не нашли перевод? жаль        return {nodeId: node.id, error: 'No translation for `' + content + '`'};    }    const result: Replacement = {        node,        translation: mapping[content],    };    console.log('Replacement:', result);    return result;}function keepAsIs(content: string, exceptions: RegExp[]): boolean {    for (let regex of exceptions) {        if (content.match(regex)) {            return true;        }    }    return false;};

Форматирование


Теперь не хватало лишь вишенки на торте.

автоперевод без форматирования

Видно, что наивная подстановка текста переформатирует его в соответствии со стилем
самой первой буквы. Можем ли мы как-то сохранить сложное форматирование? Кажется, да. Можно дополнительно к основному тексту перевести отдельно выделенную часть. Затем в переводе основного текста найти перевод выделенной части и переформатировать его. Только вот далеко не всегда ясно, какая часть текста является выделенной. Но мы можем перебирать имеющиеся стили в поисках основного, мы же точно знаем, что он один.

Итого.

  1. Разбиваем исходный форматированный текст на непрерывные моностильные секции.
  2. Перебираем использующиеся в тексте стили, рассматривая каждый в качестве основного.

    • Пробуем перевести все секции других стилей.
    • Пробуем найти переводы секций других стилей внутри общего перевода (перевода всего текста).
    • Считаем, что нашли основной стиль, если предыдущие два пункта завершились успешно.

  3. Подставляем сначала общий перевод и применяем к нему основной стиль.
  4. Для каждого вычисленного выделения в переводе применяем соответствующий ему стиль.

алгоритм

При реализации пришлось учесть, что Figma не имеет какого-то общего объекта для описания стиля, только для отдельных его параметров, и дописать небольшую обертку для этого. При этом естественная посимвольная итеративная реализация пункта 1 становится крайне дорогой. Благо, существует довольно элегантный и эффективный рекурсивный алгоритм.

Разбиваем тексты на моностильные секции
type Style = {    // это поле будет хранить уникальный идентификатор    // так можно будет быстро сравнивать стили между собой    id: string;    fills: Paint[];    fillStyleId: string;    fontName: FontName;    fontSize: number;    letterSpacing: LetterSpacing;    lineHeight: LineHeight;    textDecoration: TextDecoration;    textStyleId: string;};type Section = {    // начало секции, включительно, индексация с 0    from: number;    // конец секции, не включительно, индексация с 0    to: number;    // стиль всей секции    style: Style;};function sliceIntoSections(node: TextNode, from: number = 0, to: number = node.characters.length): Section[] {    if (to == from) {        return [];    }    const style = getSectionStyle(node, from, to);    if (style !== figma.mixed) {        // это моностильная секция        return [{from, to, style}];    }    // разделяй и властвуй!    const center = Math.floor((from + to) / 2);    const leftSections = sliceIntoSections(node, from, center);    const rightSections = sliceIntoSections(node, center, to);    const lastLeftSection = leftSections[leftSections.length-1];    const firstRightSection = rightSections[0];    if (lastLeftSection.style.id === firstRightSection.style.id) {        firstRightSection.from = lastLeftSection.from;        leftSections.pop();    }    return leftSections.concat(rightSections);}function getSectionStyle(node: TextNode, from: number, to: number): Style | PluginAPI['mixed'] {    const fills = node.getRangeFills(from, to);    if (fills === figma.mixed) {        return figma.mixed;    }    const fillStyleId = node.getRangeFillStyleId(from, to);    if (fillStyleId === figma.mixed) {        return figma.mixed;    }    const fontName = node.getRangeFontName(from, to);    if (fontName === figma.mixed) {        return figma.mixed;    }    const fontSize = node.getRangeFontSize(from, to);    if (fontSize === figma.mixed) {        return figma.mixed;    }    const letterSpacing = node.getRangeLetterSpacing(from, to);    if (letterSpacing === figma.mixed) {        return figma.mixed;    }    const lineHeight = node.getRangeLineHeight(from, to);    if (lineHeight === figma.mixed) {        return figma.mixed;    }    const textDecoration = node.getRangeTextDecoration(from, to);    if (textDecoration === figma.mixed) {        return figma.mixed;    }    const textStyleId = node.getRangeTextStyleId(from, to);    if (textStyleId === figma.mixed) {        return figma.mixed;    }    const parameters = {        fills,        fillStyleId,        fontName,        fontSize,        letterSpacing,        lineHeight,        textDecoration,        textStyleId,    };    return {        id: JSON.stringify(parameters),        ...parameters,    };}

Сохраняем форматирование при подстановке текста
// придется немного расширить нашу структуру подстановкиtype Replacement = null | {    node: TextNode;    translation: string;    // тот самый "основной" стиль    baseStyle: Style;    // разметка translation на секции со стилями, отличными от основного    sections: Section[];};async function computeReplacement(node: TextNode, mapping: Mapping, exceptions: RegExp[]): Promise<ReplacementAttempt> {    const content = normalizeContent(node.characters);    if (keepAsIs(content, exceptions)) {        return null;    }    if (!(content in mapping)) {        return {nodeId: node.id, error: 'No translation for `' + content + '`'};    }    // режем на моностильные секции    const sections = sliceIntoSections(node);    // готовим результат    const result: Replacement = {        node,        translation: mapping[content],        baseStyle: null,        sections: [],    };    // формируем лог ошибок на случай безуспешных поисков    const errorLog = [        'Cannot determine a base style for `' + content + '`',        'Split into ' + sections.length + ' sections',    ];    // собираем множество задействованных стилей    const styles = [];    const styleIds = new Set<string>();    sections.forEach(({from, to, style}) => {        if (!styleIds.has(style.id)) {            styleIds.add(style.id);            styles.push({humanId: from + '-' + to, ...style});        }    });    for (let baseStyleCandidate of styles) {        const prelude = 'Style ' + baseStyleCandidate.humanId + ' is not base: ';        let ok = true;        // будем попутно собирать разметку для неосновных стилей        result.sections.length = 0;        for (let {from, to, style} of sections) {            if (style.id === baseStyleCandidate.id) {                continue;            }            const sectionContent = normalizeContent(node.characters.slice(from, to));            let sectionTranslation = sectionContent;            // либо мы должны уметь переводить секцию,            // либо она должна входить в список исключений            if (sectionContent in mapping) {                sectionTranslation = mapping[sectionContent];            } else if (!keepAsIs(sectionContent, exceptions)) {                errorLog.push(prelude + 'no translation for `' + sectionContent + '`');                ok = false;                break;            }            const index = result.translation.indexOf(sectionTranslation);            if (index == -1) {                errorLog.push(prelude + '`' + sectionTranslation + '` not found within `' + result.translation + '`');                ok = false;                break;            }            if (result.translation.indexOf(sectionTranslation, index + 1) != -1) {                errorLog.push(prelude + 'found multiple occurrencies of `' + sectionTranslation + '` within `' + result.translation + '`');                ok = false;                break;            }            result.sections.push({from: index, to: index + sectionTranslation.length, style});        }        if (ok) {            // нашли основной стиль!            result.baseStyle = baseStyleCandidate;            break;        }    }    if (result.baseStyle === null) {        return {nodeId: node.id, error: errorLog.join('. ')};    }    console.log('Replacement:', result);    return result;}async function replaceText(replacement: Replacement): Promise<void> {    // нет необходимости вызывать загрузку для каждого символа,    // когда мы уже получили разбиение по стилям    await loadFontsForReplacement(replacement);    const {node, translation, baseStyle, sections} = replacement;    node.characters = translation;    if (sections.length > 0) {        setSectionStyle(node, 0, translation.length, baseStyle);        for (let {from, to, style} of sections) {            setSectionStyle(node, from, to, style);        }    }}async function loadFontsForReplacement(replacement: Replacement): Promise<void> {    await figma.loadFontAsync(replacement.baseStyle.fontName);    await Promise.all(replacement.sections.map(({style}) => figma.loadFontAsync(style.fontName)));}function setSectionStyle(node: TextNode, from: number, to: number, style: Style): void {    node.setRangeTextStyleId(from, to, style.textStyleId);    node.setRangeFills(from, to, style.fills);    node.setRangeFillStyleId(from, to, style.fillStyleId);    node.setRangeFontName(from, to, style.fontName);    node.setRangeFontSize(from, to, style.fontSize);    node.setRangeLetterSpacing(from, to, style.letterSpacing);    node.setRangeLineHeight(from, to, style.lineHeight);    node.setRangeTextDecoration(from, to, style.textDecoration);}

Стоит сразу отметить, что много внимания теперь было уделено адекватным сообщениям
об ошибке. Алгоритм не совсем очевидный для непосвященного человека, и понятно сформулировать, что именно в алгоритме выше пошло не так крайне сложно.

Да и алгоритм при этом далеко не идеальный. Например, во фразе Отличное качество, все еще как новые еще слово еще встречается два раза, что при переводе на некоторые языки ведет к тому, что и в переводе оно встречается тоже в двух местах. В итоге наш алгоритм не в состоянии определить, какое из двух вхождений нужно выделить жирным. Такую ситуацию, к сожалению, невозможно исправить без прокачки формата входных данных. Еще может так произойти, что одно и то же слово используется в разных контекстах, при этом оно как-то выделено. В этом случае мы упадем еще на этапе обработки словаря, так как не поддерживаем несколько переводов для одного слова.

автоперевод с форматированием

На данном этапе мы уже сделали приятный интерфейс и могли переводить макеты на почти все языки одним нажатием на кнопку! Выглядело фантастически, честно говоря.

Сообщения об ошибках и отладка


На удивление сложно было донести до переводчиков, что переводы нужных нам частей фраз должны быть согласованы с тем, как переведена сама фраза. Так, например, регулярно слово еще в очередном комментарии пользователя переводилось по-новому.

Нас спас только развернутый список ошибок с интерактивными ссылками на ноды, в которых те возникли. В Figma это можно организовать посылкой специального сообщения в серверную часть плагина с таким обработчиком:

if (message.type === 'focus-node') {    // максимально приближаемся    figma.viewport.zoom = 1000.0;    // перемещаем viewport в положение, где нода видна целиком    figma.viewport.scrollAndZoomIntoView([figma.getNodeById(message.id)]);    // немного отдаляем для комфортного восприятия    figma.viewport.zoom = 0.75 * figma.viewport.zoom;}


В дальнейшем также оказалось полезным уметь формировать задания для переводчиков автоматически. Все, что необходимо для этого, мы, на самом деле, уже подготовили. Просто среди потенциальных основных стилей выберем тот, что охватывает наибольшее число символов.

Предлагаем доперевести некоторые фразы
function suggest(node: TextNode, content: string, sections: Section[], mapping: Mapping, exceptions: RegExp[]): string[] {    const n = content.length;    const styleScores = new Map<string, number>();    for (let {from, to, style} of sections) {        styleScores.set(style.id, n + to - from + (styleScores.get(style.id) || 0));    }    let suggestedBaseStyleId: string = null;    let suggestedBaseStyleScore = 0;    for (let [styleId, styleScore] of styleScores) {        if (styleScore > suggestedBaseStyleScore) {            suggestedBaseStyleId = styleId;            suggestedBaseStyleScore = styleScore;        }    }    const result: string[] = [];    if (!(content in mapping)) {        result.push(content);    }    for (let {from, to, style} of sections) {        if (style.id === suggestedBaseStyleId) {            continue;        }        const sectionContent = normalizeContent(node.characters.slice(from, to));        if (!keepAsIs(sectionContent, exceptions) && !(sectionContent in mapping)) {            result.push(sectionContent);        }    }    return result;}

ошибки


Казалось, все должно было прекрасно работать и с RTL языками, так как мы лишь выполнили замену вхождений по точному совпадению. Однако внезапно выяснилось, что Figma не умеет корректно отображать RTL тексты рендерит их слева направо. А лезть в дебри особенностей рендеринга RTL текстов последнее, чего бы хотелось.

Да, нашлось несколько плагинов для интерактивного ввода арабского текста и один разломанный плагин, пытающийся разворачивать RTL тексты, но все это не решило нашу проблему. Хотя бы потому, что при этом напрочь слетало форматирование.

Аппетит приходит во время еды. Пути назад уже не было. Была видна лишь возможность сделать из этого проекта действительно полнофункциональный плагин.

Что может быть сложного? Давайте просто развернем текст посимвольно.

наивный подход к RTL

Так. Теперь все рендерится справа налево, но Figma вставляет по привычке автопереносы строк так, словно это LTR текст. В итоге строки нужно читать снизу вверх. Беда. Факт расстановки автопереносов Figma не отражает в содержимом ноды, да и вообще, никак нигде не отражает Разве что Если нода имеет адаптивную высоту, то при возникновении автопереноса высота ноды изменяется!

Итак, план такой.

  1. Разворачиваем перевод, как раньше.
  2. Создаем невидимую текстовую ноду с адаптивной высотой и той же шириной (если, конечно, ширина исходной ноды тоже фиксирована).
  3. Разбиваем развернутый перевод на слова и начинаем добавлять их в скрытую ноду по одному (справа налево). Если вдруг высота скрытой ноды изменилась, откатываемся, вставляем разделитель строк и продолжаем.
  4. Получившееся разбиение на строки просто конкатенируем.
  5. Удаляем временную ноду.
  6. Инвертируем выравнивание текста (если было по левому краю, то делаем по правому, и наоборот).

Большая сложность заключается в том, что в процессе нужно внимательно следить за тем, как видоизменяются секции.

К тому же, на ровном месте возникло несколько подводных камней.

  • Большинство шрифтов из коробки не поддерживают арабский и иврит. Пришлось искать похожие по начертанию (а их не так много).
  • Нельзя просто брать и разворачивать текст, если он содержит, например, латиницу, цифры и т.п. Эти части должны писаться слева направо даже в RTL тексте.
  • От стиля зависит размер слов, так что внутри скрытой ноды текст нужно правильно форматировать.
  • Первое слово в очередной строке может не влезть по ширине, тогда это ошибка, о которой нужно сообщить.
  • Нашлись слова в арабском, которые в отсутствие пробела после них отображались в Figma некорректно.

Полный код поддержки RTL языков я здесь приводить не стану он доступен в репозитории.

Этот этап занял гораздо больше времени и сил, чем реализация базовой функциональности плагина, но результат был поистине впечатляющим!

автоперевод на арабский

На десерт.

  • Правильное обращение арабского текста куда более сложная задача, чем кажется, если учесть всевозможные примеси из других языков и специальные лигатуры.
  • Интерфейсы на RTL языках необходимо отразить по горизонтали, что пока приходится делать вручную, хотя потенциально и это автоматизируется.
  • Попутно нами был обнаружен и зарепорчен баг в Figma, из-за которого при добавлении латиницы в арабский текст лигатуры развязывались.

Конвертирование валют


Так уж случилось, что мы маркетплейс, и у нас есть товары. И товары имеют цену. Можно, конечно, отдать с десяток текстов вида 1 234 переводчикам и надеяться, что они правильно локализуют цены, использовав верные разделители, верные знаки валют и верное количество цифр после запятой Но зачем, если можно все это проделать автоматически, один раз настроив несложный конвертер?

Конвертируем валюты автоматически
type Currency = {    // уникальный код, у нас для удобства совпадает с языковым    code: string;    // схема, в которой 123 нужно заменить на нужную сумму в нужном формате, вроде "$123"    schema: string;    // разделитель тысяч    digitGroupSeparator: string;    // разделитель дробной части (обычно точка или запятая)    decimalSeparator: string;    // кол-во десятичных знаков в дробной части    precision: number;    // курс к некоторой базовой валюте (должна быть общей для всего конфига)    rate: number;};async function convertCurrencyInSelection(settings: Settings): Promise<void> {    const currencies = parseCurrencies(settings.serializedCurrencies);    console.log('Currencies:', currencies);    const sourceCurrency = currencies.filter(currency => currency.code === settings.sourceCurrencyCode)[0];    if (sourceCurrency === undefined) {        throw {error: 'unknown currency code `' + settings.sourceCurrencyCode + '`'};    }    const targetCurrency = currencies.filter(currency => currency.code === settings.targetCurrencyCode)[0];    if (targetCurrency === undefined) {        throw {error: 'unknown currency code `' + settings.targetCurrencyCode + '`'};    }    await replaceCurrencyInAllTexts(sourceCurrency, targetCurrency);}function parseCurrencies(serializedCurrencies: string): Currency[] {    const codeSet = new Set<string>();    return JSON.parse(serializedCurrencies).map((x: any, index: number) => {        const currency: Currency = {            code: null,            schema: null,            digitGroupSeparator: null,            decimalSeparator: null,            precision: null,            rate: null,        };        Object.keys(currency).forEach(key => {            if (x[key] === undefined || x[key] === null) {                throw {error: 'invalid currency definition: no `' + key + '` in entry #' + (index + 1)};            }            if (key === 'schema' && x[key].indexOf('123') === -1) {                throw {error: 'schema in entry #' + (index + 1) + ' should contain `123`'};            }            if (key === 'rate' && x[key] <= 0) {                throw {error: 'non-positive rate in entry #' + (index + 1)};            }            currency[key] = x[key];        });        if (currency.precision > 0 && currency.decimalSeparator === '') {            throw {error: 'entry #' + (index + 1) + ' must have a non-empty decimal separator'};        }        if (codeSet.has(currency.code)) {            throw {error: 'multiple entries for `' + currency.code + '`'};        }        codeSet.add(currency.code);        return currency;    });}async function replaceCurrencyInAllTexts(sourceCurrency: Currency, targetCurrency: Currency): Promise<void> {    const textNodes = await findSelectedTextNodes();    const escapedSchema = escapeForRegExp(sourceCurrency.schema);    const escapedDigitGroupSeparator = escapeForRegExp(sourceCurrency.digitGroupSeparator);    const escapedDecimalSeparator = escapeForRegExp(sourceCurrency.decimalSeparator);    const sourceValueRegExpString = '((?:[0-9]|' + escapedDigitGroupSeparator + ')+' + escapedDecimalSeparator + '[0-9]{' + sourceCurrency.precision + '})';    const sourceRegExp = new RegExp('^' + escapedSchema.replace('123', sourceValueRegExpString) + '$');    console.log('Source regular expression:', sourceRegExp.toString());    await Promise.all(textNodes.map(async node => {        const content = node.characters;        const match = content.match(sourceRegExp);        if (match !== null && match[1] !== null && match[1] !== undefined) {            const style = getSectionStyle(node, 0, node.characters.length);            if (style === figma.mixed) {                throw {error: 'node `' + content + '` has a mixed style'};            }            let sourceValueString = match[1].replace(new RegExp(escapedDigitGroupSeparator, 'g'), '');            if (sourceCurrency.decimalSeparator !== '') {                sourceValueString = sourceValueString.replace(sourceCurrency.decimalSeparator, '.');            }            const sourceValue = parseFloat(sourceValueString);            const targetValue = sourceValue * targetCurrency.rate / sourceCurrency.rate;            const truncatedTargetValue = Math.trunc(targetValue);            const targetValueFraction = targetValue - truncatedTargetValue;            const targetValueString = (                truncatedTargetValue.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,').replace(/,/g, targetCurrency.digitGroupSeparator) +                targetCurrency.decimalSeparator +                targetValueFraction.toFixed(targetCurrency.precision).slice(2)            );            await figma.loadFontAsync(style.fontName);            node.characters = targetCurrency.schema.replace('123', targetValueString);        }    }));}function escapeForRegExp(s: string): string {    return s.replace(/([[\^$.|?*+()])/g, '\\$1');}

конвертация валют

Публикация


Перед публикацией плагина был проделан ряд улучшений UX. Например, настройки в интерфейсе плагина сохранялись в localStorage (да, Figmа такое умеет!). Это позволяет не загружать словари каждый раз и быстро выполнять переводы на несколько языков. А чтобы интерфейс не зависал во время активных операций с документом, по рекомендации самой же Figma, была ограничена интенсивность асинхронных параллельных операций с помощью простой самописной функции mapWithRateLimit. На сдачу в плагин была добавлена возможность заменять шрифты.

Процедура публикации плагина заняла больше двух недель, но никаких проблем не возникло.

В заключение


Без плагина не обошлась и подготовка к Новому году.

новый год

Возможно, когда-нибудь мы расскажем, как в рамках той же задачи научились выгружать из Figma весь документ в виде одного структурированного архива из картинок.

Хочется поблагодарить Аню Ноджак, дизайн-директора Joom, за веру в мои силы. Очень рекомендую почитать ее взгляд на задачу.

Ссылки


Исходный код плагина Static Localizer на GitHub
Плагин Static Localizer на сайте Figma
Подробнее..

I18n в Angular

27.08.2020 14:07:16 | Автор: admin

Angular i18n


Цель статьи это описать детальные шаги интернационализации вашего приложения на Angular с помощью родного функционала.


На входе мы имеем коммерческое приложение. Как традиционно положено сначала сделали, запустили, а потом решили что ой, у нас же есть другие рынки, нужно срочно перевод сделать. Обычная история, пальцев рук не хватит посчитать сколько раз такое было. Но так же все знают что решения по интернационализации необходимо закладывать в архитектуру приложения с самого начала, потому что потом очень тяжело добавить i18n в готовое приложение. Но все же...


Angular i18n
Illustration by Thomas Renon


Есть два подхода использовать встроенный в angular i18n: генерация под каждую локаль своего бандла приложения, либо использовать библиотеки вроде transloco, которые предлагают хранить переводы во внешних json файлах или разных других форматах и динамически подставлять/менять локаль по запросу пользователя. Не мало холиваров было о вопросе как удобнее, но однозначно ясно одно если у нас уже написаное приложение расставлять в нем токены дело не сильно приятное. В то время как родные средства Angular более подходят, для того чтобы взять и готовое приложение сделать многоязычным.


Тут вы найдете ответы на вопросы:


  • Как вынести текущий язык в токены
  • Как добавить новый язык переводов
  • Как модифицировать языки
  • Как деплоить и собирать приложение
  • Как быть если есть токены в ts файле или они приходят по API

Вступление


Как это работает?


Помимо черной магии в angular есть специальный атрибут i18n для поддержи интернационализации. Работает он совсем не так как обычные атрибутивные компоненты в angular (как ngClass к примеру). Потому что на самом деле это не компонента, Это фактически директива препроцессора. Да, для интернационализации Angular предлагает не использовать Angular, а использовать хитрый препроцессор во время сборки проекта. Именно такой подход отчасти и позволяет нам локализовать приложение которое уже написанно с минимальными вложениями в этот процесс (оставим за кадром RTL языки, поддержка которых хромает на обе ноги везде). Соответственно разметив все строки в шаблонах мы говорим angular-cli извлеки все строки в проекте и сделай мне файл для переводов.


Итого Для создания мультиязычных интерфейсов Angular предлагает использовать механизм разметки HTML шаблонов при помощи специального атрибута i18n который после компиляции удаляется из финального кода.


1. Вопрос: "Как токенизировать текущий язык и не создать путаницу токенов"


  • Советую всегда привязываться к id
  • Для себя выберите правила токенизации приложения которым должны следовать все на проекте

Теперь вопрос: "как создать сам id"?
Какие правила придумать?


В проекте следует по возможности для маркера указывать дополнительные параметры которые отображаются в специализированных редакторах использующихся для перевода и дополняют переводимый текст служебной информацией призванной помочь переводчику. Это параметры передаются в формате Значение|Описание или только Описание. Обязательно следует указывать @@id это будет токен для перевода. Идентификатор пишется своеобразным синтаксисом используя префикс @@.


<div i18n="форма логина | поле @@login.email">Email</div><button i18n="форма логина | кнопка @@login.post">save</button>

Правила для именования можно выбрать такие:
Название компонента или роут используются как префикс токена это позволит переводчику понять где искать токен, грубо говоря не явное описание контекста. Кроме того при удалении компонента из проекта легко вычленить и удалить все связанные с ним переводы.
Слова метки: кнопка, переключатель, поле, колонка для переводчиков как дополнительный контекст


Пример соглашения по наименованию токенов


Находясь в компоненте <info-statuses> токен следует называть таким образом:


<th i18n="Статусы покупателя | колонка @@(селектор компонента и поле)info-statuses.date">Дата добавления</th>

Различные варианты использования токенов


Иногда у нас есть необходимость задать перевод для текста который является фрагментом внутри большего блока или текст вне тегов. Используем ng-container, который не рендерится в финальный код.


<ng-container     i18n="Генерация архива | поле @@generate-archive.title">I don't output any element</ng-container>

Возможно так же делать перевод для атрибутов тегов. Указывается i18n-attrName


<img     [src]="logo"     i18n-title="картинка @@company.logo"    title="Angular logo" />

Вот мы заполнили все шаблоны тегами i18n и что теперь? Теперь нужно создать файл переводов, Angular приходит на помощь и говорит, просто вызываем команду i18n-extract и генерируем файл с переводами. Глянуть описание аргументов можно тут https://angular.io/cli/xi18n


В моём случае команда выглядит таким образом (я указываю исходную локаль файлов перевода. "uk")


"extract-i18n": "ng xi18n projectName --i18n-format xlf --output-path i18n --i18n-locale uk

Теперь вы знаете ответ как локализовать приложение под один язык.


2. Как добавить новый язык для переводов


Сейчас мы поговорим о инструментах, что помогают работать с дефолтным форматом выгрузки ключей в angular i18n это xliffmerge


Проблема возникает при генерации нового языка. Необходимо синхронизировать токены дефолтного языка и нового. Также есть процесс обновления локалей, после редактирования переводчиком или после добавления новых токенов в проект. Инструмент xliffmerge решает эти проблемы одним конфигом и одной командой.


Вывод для настройки и удобной генерации новых языков xliffmerge наше спасение.


https://www.npmjs.com/package/@ngx-i18nsupport/ngx-i18nsupport
https://github.com/martinroob/ngx-i18nsupport/wiki/Tutorial-for-using-xliffmerge-with-angular-cli


"xliffmerge": {  "builder": "@ngx-i18nsupport/tooling:xliffmerge",  "options": {    "xliffmergeOptions": {      "i18nFormat": "xlf",      "srcDir": "projects/my-test/i18n",      "genDir": "projects/my-test/i18n",      "verbose": true,      "defaultLanguage": "uk",      "languages": [        "uk",        "en"      ]    }  }}

В настройках angular.json мы добавляем новую конфигурацию.
Эта конфигурация при запуске, принимает дефолтный исходный файл. B зависимости от настроек генерирует производные или дополняет уже существующие файлы с переводом новыми ключами. Важно! При добавлении ключа в базовую локаль он будет добавлен всем производным локалям. Это все делается автоматически, не нужно править XML руками.


Таким образом. Когда мне нужно добавить новую локаль. Я добавляю поле в блок languages с именем языка, к примеру en и запускаю ng run my-test:xliffmerge чтобы на выходе получить новый файл xlf с локалью en.
Теперь команда генерации файлов переводов выглядит таким образом


"extract-i18n": "ng xi18n crm --i18n-format xlf --output-path i18n --i18n-locale ru && ng run my-test:xliffmerge",

Было бы классно ещё пропускать переводы через google translate, чтобы сэкономить на переводах и иметь какой-то черновой вариант подумал я. Как выяснилось xliffmerge имеет и такую опцию.


Дополняем конфиг xliffmerge а angular.json:


"autotranslate": ["en"],"apikey": "yourAPIkey",

Хорошо, теперь при изменениях в нашем html запуск команды extract-i18n будет обновлять все локали.


Осталось последнее, как собирать бандл для деплоя.


"build-prod:my-test:en": "ng build my-test --configuration=productionEN --base-href /en/ --resources-output-path ../""build-prod:my-test:uk": "ng build my-test --configuration=productionUK --base-href /uk/ --resources-output-path ../","build-prod:locales": "npm run build-prod:my-test:en && npm run build-prod:my-test:uk",

Под каждую локаль своя команда, к сожалению, в А8 на то время нельзя было ставить аргументы через запятую --configuration=production,en, поэтому пришлось дублировать конфиги в angular.json


"productionEN": {  "outputPath": "dist/my-test/en",  "fileReplacements": [    {      "replace": "projects/my-test/src/environments/environment.ts",      "with": "projects/my-test/src/environments/environment.en.prod.ts"    }  ],  ... like in production},

Мы настроили билд так, чтобы assets были общими (resources-output-path ../), вы можете убрать resources path и максимально отделить разные версии между собой. Для большинства приложений ресурсы в разных языковых версиях не будут отличаться, поэтому такой ход оправдан. В случае общих ресурсов перезагрузка бандла при смене языка будет происходить существенно быстрее, потому что часть ресурсов уже будет в кеше браузера.


Теперь запоминаем самое главное: файлы .xlf никогда руками не правим, через специальные инструменты (weblate к примеру OpenSource инструмент) можно записывать верные переводы и пушить в ветку, а там ваш билд это всё подхватит и всё супер.


На выходе в нашем проекте мы разметили все шаблоны, дополнили список команд для работы с переводами, сняли с себя задачу актуализации json между несколькими языками, наняли несколько переводчиков в штат, чтобы они изучили CMS Weblate и поседели раньше чем разработчики. На самом деле для небольших проектов экономия на снятии сопровождения переводов с разработчиков дает весьма значимый эффект.


Таким образом мы теперь должны доработать свой CI/CD папйплайн чтобы генерировать несколько языковых версий вместо одной.


Всё работает, но оказывается у в проекте есть текст который зашит в ts файлах и как его переводить, если подстановка i18n атрибута работает только в шаблонах.


Переводы в коде


Есть список с текстом, который никак не завязан на бэкенд, поэтому и переводов у нас с бэканда этой сущности нет. Что делать? Ответ один локализируй через шаблон.


Вот пример как это будет


  list = [    {      token: 'login-info-1',      value: 1,    },    {      token: 'login-info-2',      value: 2,    },  ];

 <div style="display: none"       #el       i18n-login-info-1="поле @@login-form.first"       login-info-1="первое условие это..."       i18n-login-info-2="поле @@login-form.second"       i18n-login-info-2="второе условие это..."  >  </div>   <div *ngFor="let item of list">      <label>        {{ item.token | customPipeI18n: el }}      </label>  </div>

Таким образом мы сохраняем нужную локаль в атрибут элемента который скрываем на странице, а пайпом по названию атрибута вытаскиваем локализированый текст.
Да, выглядит не очень, но стойте, у нас же сначала не была заложена локализация в приложении


Пример пайпа


@Pipe({ name: 'customPipeI18n', pure: true })export class TranslatePipe {  transform(key: string, value: HTMLElement): any {    const lowerKey = key.toLowerCase();    if (value && value.hasAttribute(lowerKey)) {      return value.getAttribute(lowerKey);    }    console.log('key: ', lowerKey);    return '*not found key*';  }}

Хорошо это работает. А что делать если у меня схожие тексты повторяются на многих страницах? Добавлять на каждую по скрытому элементу с идентичной логикой?


Согласен, что вариант с таким пайпом не сильно удобен и было бы круто сделать так, чтобы все тексты, что в есть в ts файлах определенного модуля были вынесены куда-то в одном место где их легко смотреть редактировать и менять. Было принято решение написать реестр переводов.


Для этого создана комбинация:
сервис(ElementRegistry) для хранения элемента;
директива(ElementDirective) для регистрации шаблона с атрибутами и сохранения его в сервис;
пайп(ElementPipe) для получения перевода из сервиса;


Пример использования:


Имеем модуль auth
в корневом компоненте создаём элемент с атрибутами объявляем директиву и регистрируем имя шаблона auth


<div  i18nElement="auth"  le="display: none"  i18n-login-info-1="поле @@login-form.first"  login-info-1="первое условие это..."  i18n-login-info-2="поле @@login-form.second"  i18n-login-info-2="второе условие это..."></div>

Для перевода вызывается pipe i18nElement туда передаётся название шаблона в котором объявлены атрибуты с токенами.


   <div *ngFor="let item of list">      <label>        {{ item.token | i18nElement: 'auth' }}      </label>  </div>

Это решает следующие проблемы:


  • Eсли мы используем текст который приходит из сторонего API, а локализовать необходимо ответ
  • У вас в ts файле просто почему-то оказался текст который нужно локализовать

Итог


Simple-Made-Easy нативные средства i18n в Angular не смотря на меньшую популярность чем классический подход с кучей json файлов тоже работают и весьма удобны\продуманны в практическом применнии.
xliff как формат хранения переводов помимо непригодности для редактирования руками имеет много удобных интсрументов для переводчиков, позволяющих анотировать и групировать переводы. Отказ от использования json и переход на xliff позволяет упростить работу с переводами для команды локализации, особенно вместе с инструментами вроде weblate или аналогами.
Некоторые сложности вызывает использование переводов вне шаблонов, но все они в целом решаемые при помощи подходв описанных в статье.


P.S.


в 9-10м Ангуляре есть изменения в работе с локализацией. Ставьте палец вверх и будет ещё одна статья про облегчение с 9м


Пригласить автора на хабр: skochkobo
Метода опробована на проектах: nodeart.io

Подробнее..

Продолжаем интернационализацию поиска по адресам с помощью Sphinx или Manticore. Теперь Metaphone

05.04.2021 08:09:25 | Автор: admin

Это продолжение публикации Интернационализация поиска по городским адресам. Реализуем русскоязычный Soundex на Sphinx Search, в которой я разбирал, как реализовать поддержку фонетических алгоритмов Soundex в Sphinx Search, для текста написанного кириллицей. Для текста на латинице поддержка Soundex уже есть. С Metphone аналогично, для латиницы есть, для кириллицы не очень, но попытаемся исправить этот досадный факт с помощью транслитерации, регулярных выражений и напильника.

Это прямое продолжение, в котором разберём как реализовать оригинальный Metaphone, русскийMetaphone (в том смысле что транслитерация не понадобится), Caverphone, и не сможем сделать DoubleMetaphone.

Реализация подойдёт как для использования на платформе SphinxSearch, так и ManticoreSearch.

В конце, в качестве бонуса, посмотрим как Metaphone воспримет "ракомакофон".

Докер образ

Подготовил докер образ tkachenkoivan/searchfonetic, для того чтобы можно было "пощупать" результат. В образ добавлены все индексы и из этой публикации и из предыдущей, но, внимание, названия индексов из предыдущей публикации не соответствуют тому что хранится в образе. Почему? Потому что хорошая мысля приходит опосля.

Описание алгоритмов, всё так же, брал из публикации "фонетические алгоритмы". Постараюсь как можно меньше дублировать текст, написанный в ней.

Оригинальный Metaphone

Реализуется элементарно, создаются регулярные выражения для транслитерации:

regexp_filter = (А|а) => aregexp_filter = (Б|б) => bregexp_filter = (В|в) => v

И включаем metaphone:

morphology = metaphone

Всё, как и с оригинальным Soundex. В прошлый раз, мы пришли к выводу, что лучше всего, из всех Soundex алгоритмов, использовать именно оригинальный Soundex, единственный недостаток которого коллизии, разрешается вычислением расстояния Левенштейна.

В этот раз, забегая вперёд, скажу, что снова сделал бы свой выбор в пользу оригинального Metaphone + транслит. А вот причина небанальна.

Дело в том что у Sphinx есть в такой параметр blend_chars. Смысл следующий, Sphinx индексирует по словам, а слова он находит по разделителям, например, если между буквами есть пробел, значит буквы два разных слова, перенос строки, табуляция, знаки препинания и т.д., и т.п. Но могут быть символы, которые одновременно разделяют слова, а могут быть и частью одного слова, например, &. M&Ms это сколько слов? А Рога&Копыта? Для таких случаев и существует blend_chars.

И тут можно пойти на хитрость, и добавить в blend_chars пробел:

blend_chars = U+0020

Теперь, когда кто-то будет искать улицу Мориса Тореза, то найдёт её, даже если подумает, что это одно слово. Да что там слитное написание, он её найдёт даже если решит, что Мать Тереза и Морис Торез родственники.

mysql> select * from metaphone where match('Морисатереза');+------+--------------------------------------+-----------+---------------------------+| id   | aoguid                               | shortname | offname                   |+------+--------------------------------------+-----------+---------------------------+| 1130 | e21aec85-0f63-4367-b9bb-1943b2b5a8fb | ул        | Мориса Тореза             |+------+--------------------------------------+-----------+---------------------------+

Можем увидеть, как работает индекс для Мориса Тореза, вызвав call keywords:

mysql> call keywords ('Мориса Тореза', 'metaphone');+------+---------------+------------+| qpos | tokenized     | normalized |+------+---------------+------------+| 1    | morisa toreza | MRSTRS     || 1    | morisa        | MRS        || 2    | toreza        | TRS        |+------+---------------+------------+

Обратите внимание, что два слова было воспринято как три: morisa, toreza и morisa toreza, притом при создании кода Metaphone, пробел был съеден.

Это особенность реализации Metaphone в Sphinx Search. Самостоятельно, с помощью регулярных выражений её не реализовать. Нет, конечно мы можем добавить регулярное выражение, удаляющее пробелы:

regexp_filter = [ ] => 

но тогда Мориса Тореза, и другие, будут всегда восприниматься как одно слово, а нам этого не надо.

Давайте сравним как работают другие индексы, в том числе те, которые ещё разобрать не успели, но они будут ниже.

Caverphone пробел сохраняет, поэтому при слитном написании просто не находит.

mysql> call keywords ('Мориса Тореза', 'caverphone');+------+-----------+------------+| qpos | tokenized | normalized |+------+-----------+------------+| 1    | mrsa trza | mrsa trza  || 1    | mrsa      | mrsa       || 2    | trza      | trza       |+------+-----------+------------+mysql> select * from caverphone where match('Морисатереза');Empty set (0.00 sec)

Оригинальный Soundex (из предыдущей публикации), в котором используется базовая реализация Sphinx, просто сходит с ума, и не понимает, как кодировать слово, в котором встретился пробел, morisa и toreza закодирован, а morisa toreza нет:

mysql> call keywords ('Мориса Тореза', 'simple_soundex');+------+---------------+---------------+| qpos | tokenized     | normalized    |+------+---------------+---------------+| 1    | morisa toreza | morisa toreza || 1    | morisa        | m620          || 2    | toreza        | t620          |+------+---------------+---------------+

Потому не включайте пробел в blend_chars в большинстве случаем это не просто бесполезно, но и вредно. Единственно исключение metaphone. И это позволяет решить самую сложную для исправления опечатку (с машинной точки зрения) опечатку в пробелах: как наличие лишних, так и отсутствие нужных.

А это дорогого стоит.

Double Metaphone

Для двойного Metaphone планировал использовать два индекса, вместо одного, как обычно, а затем искать поочерёдно в обоих.

К сожалению, я не понял, как реализовать двойной Metaphone с помощью регулярных выражений. Когда я начал искать по нему описание, меня как будто в гугле забанили, зато нашёл много реализаций на различных языках программирования, например, DoubleMetaphone.java. Я даже попытался понять этот алгоритм и реализовать его в виде регулярок, но дошёл до буквы C, и понял, что рассудок покидает меня.

Если Вы гуру гуглапоиска, или просто знаете способ, как сделать реализацию с помощью регулярных выражений пишите, пишите мне, пишите публикацию на хабр, придумаем как прикрутить его к Sphinx и Manticore.

Но будет нечестно, если про двойной Metaphone совсем ничего не написать. Опишу как бы я его сделал, если было бы ну очень нужно. Sphinx не понадобится. Но придётся программировать.

Для начала я бы подключил уже готовую библиотеку в которой он реализован, благо их нашлось множество для разных языков, я использую Java, поэтому подключаю Commons Codec. Библиотека не работает с кириллицей требуется транслитерация, и кодирует только первое слово. Поэтому если вам надо реализовать кодирование большого текста, разделённого пробелами то самостоятельно придётся разбивать строку на слова, и в цикле их перебирать.

В таблице БД, где хранятся данные, я бы добавил две новые колонки, в которых бы хранил закодированное значение. Это один из недостатков данной реализации требуется допиливать таблицы в БД.

Затем, перед вставкой новых записей в таблицу, я бы вызывал:

DoubleMetaphone dm = new DoubleMetaphone();String metaphone1 = dm.doubleMetaphone("Text", false);String metaphone2 = dm.doubleMetaphone("Text", true);

И сохранял metaphone1 и metaphone2 вместе с данными.

Это вторая большая проблема вся вставка теперь должна проходить через эту процедуру.

При поиске значений в таблице, кодируем поисковой запрос с помощью CommonsCodec. И теперь ищем по столбцам, в которых код сохранялся. Особенность двойного Metaphone в том, что кода мы кодируем поисковый запрос двумя реализациями, то мы получившиеся оба результата ищем и в первом столбце и во втором. А не первый код в первом столбце, второй код во втором: и первый, и второй код в первом столбце и первый и второй код во втором, и все их комбинации.

Без Sphinx всё стало очень неудобно.

Русский Metaphone

Не подойдёт для целей интернационализации.

Зато подразумевает отсутствие транслитерации. А это может быть весьма удобно, ибо транслитерация сама по себе несёт небольшое искажение. Получает как в игре глухой телефон, только глухой Metaphone.

Если вы создаёте поиск исключительно для тех, кто печатает кириллицей, то разумно прибегнуть именно этой версии, а не городить транслитерации всякие.

Ну и ещё один момент, который нужно учесть при выборе, алгоритм не сжимает окончания. Значит, если улица Ленина, то надо так и писать Ленина, Ленин без окончания, не найдётся:

mysql> call keywords ('Ленина Ленин', 'rus_metaphone');+------+--------------+--------------+| qpos | tokenized    | normalized   |+------+--------------+--------------+| 1    | линина       | линина       || 2    | линин        | линин        |+------+--------------+--------------+

Реализуем регулярные выражения. Полный конфигурационный файл, как и ранее, лежит на GitHub Gist manticore.conf.

  • Переделываем гласные:

regexp_filter = (?i)(йо|ио|йе|ие) => иregexp_filter = (?i)(о|ы|я) => аregexp_filter = (?i)(е|ё|э) => иregexp_filter = (?i)(ю) => у
  • Для всех согласных букв, за которыми следует любая согласная, кроме Л, М, Н или Р, провести оглушение:

regexp_filter = (?i)(б)(б|в|г|д|ж|з|й|к|п|с|т|ф|х|ц|ч|ш|щ) => п\2regexp_filter = (?i)(г)(б|в|г|д|ж|з|й|к|п|с|т|ф|х|ц|ч|ш|щ) => к\2regexp_filter = (?i)(в)(б|в|г|д|ж|з|й|к|п|с|т|ф|х|ц|ч|ш|щ) => ф\2regexp_filter = (?i)(д)(б|в|г|д|ж|з|й|к|п|с|т|ф|х|ц|ч|ш|щ) => т\2regexp_filter = (?i)(ж)(б|в|г|д|ж|з|й|к|п|с|т|ф|х|ц|ч|ш|щ) => ш\2regexp_filter = (?i)(з)(б|в|г|д|ж|з|й|к|п|с|т|ф|х|ц|ч|ш|щ) => с\2
  • Для согласных на конце слова, провести оглушение

regexp_filter = (?i)б\b => пregexp_filter = (?i)г\b => кregexp_filter = (?i)в\b => фregexp_filter = (?i)д\b => тregexp_filter = (?i)ж\b => шregexp_filter = (?i)з\b => з
  • Склеиваем ТС и ДС в Ц

regexp_filter = (?i)(тс|дс|ц) => ц

Caverphone

Здесь сначала транслитерация.

  • Затем, нужно перевести транслитерированное в нижний регистр:

regexp_filter = (A|a) => aregexp_filter = (B|b) => b

Это необходимо, потому что алгоритм для некоторых букв делает различие между тем, в каком она регистре, если в верхнем, то она считается как бы замороженной для любых изменений.

  • Удалить e на конце

regexp_filter = e\b =>
  • Происходит преобразование начала слова, но это актуально для новозеландских фамилий, этот шаг можно и пропустить:

regexp_filter = \b(cough) => cou2fregexp_filter = \b(rough) => rou2f
  • Провести замены символов

regexp_filter = (cq) => 2qregexp_filter = (ci) => si
  • Заменить все гласные в начале слова на a, в остальных случаях на 3

regexp_filter = (?i)\b(a|e|i|o|u|y) => Aregexp_filter = (?i)(a|e|i|o|u|y) => 3
  • Провести очередные замены

regexp_filter = (j) => yregexp_filter = \b(y3) => Y3
  • Удалить все цифры 2

regexp_filter = 2 => 
  • Если на конце слова осталась цифра 3, то заменить её на A

regexp_filter = 3\b => A
  • Удалить все цифры 3

regexp_filter = 3 =>

До 10 символов не сокращаю и не дополняю.

Проверим:

mysql> select * from caverphone where match ('Ленина');+------+--------------------------------------+-----------+------------------+| id   | aoguid                               | shortname | offname          |+------+--------------------------------------+-----------+------------------+|    5 | 01339f2b-6907-4cb8-919b-b71dbed23f06 | ул        | Линейная         ||  387 | 4b919f60-7f5d-4b9e-99af-a7a02d344767 | ул        | Ленина           |+------+--------------------------------------+-----------+------------------+

Кроме Ленина находит и Линейная. Согласен, некоторое сходство есть, другие алгоритмы так не смогли, ну разве что Daitch Mokotoff Soundex из предыдущей публикации выкинул что-то подобное с Лунная:

mysql> select * from daitch_mokotoff_soundex where match ('Ленина');+------+--------------------------------------+-----------+--------------+| id   | aoguid                               | shortname | offname      |+------+--------------------------------------+-----------+--------------+|  387 | 4b919f60-7f5d-4b9e-99af-a7a02d344767 | ул        | Ленина       ||  541 | 69b8220e-a42d-4fec-a346-1df56370c363 | ул        | Лунная       |+------+--------------------------------------+-----------+--------------+

Можем посмотреть как это всё кодируется:

mysql> call keywords ('Ленина Линейная Лунная', 'caverphone');+------+-----------+------------+| qpos | tokenized | normalized |+------+-----------+------------+| 1    | lnna      | lnna       || 2    | lnna      | lnna       || 3    | lna       | lna        |+------+-----------+------------+mysql> call keywords ('Ленина Линейная Лунная', 'daitch_mokotoff_soundex');+------+-----------+------------+| qpos | tokenized | normalized |+------+-----------+------------+| 1    | 866       | 866        || 2    | 8616      | 8616       || 3    | 866       | 866        |+------+-----------+------------+

Теоретически это не лишено смысла, если Вы ожидаете именно такого поведения, то смело используете, - то что надо. А коллизии всегда поможет разрешить расстояние Левенштейна, при необходимости.

Бонус: ищем ракомакофон. Вместо заключения

Это лишено практического смысла, но наблюдение забавное, поэтому напишу. Just for fun.

Помните ракомакофон, который слышится вместо rock the microphone?! Было интересно, сможет ли Metaphone понять ракомакофон. И ведь почти!

Во-первых, добавляем пробел в blend_chars, ведь нам надо чтобы три слова rock the microphone, воспринимались как одно:

blend_chars = U+0020

Поскольку у нас только один алгоритм умеет адекватно работать в такой ситуации - оригинальный metaphone, то его и применим.

Проверим с помощью keywords как оно воспринимается Sphinx:

mysql> call keywords ('ракомакофон', 'metaphone');+------+-------------+------------+| qpos | tokenized   | normalized |+------+-------------+------------+| 1    | rakomakofon | RKMKFN     |+------+-------------+------------+

И rock the microphone:

mysql> call keywords ('rock the microphone', 'metaphone');+------+---------------------+------------+| qpos | tokenized           | normalized |+------+---------------------+------------+| 1    | rock the microphone | RK0MKRFN   || 1    | rock                | RK         || 2    | the                 | 0          || 3    | microphone          | MKRFN      |+------+---------------------+------------+

Получилось RK0MKRFN, и RKMKFN, расстояние Левенштейна между ними всего 2(!). А если найти способ исключить the из кодирования, то получится RKMKRFN:

mysql> call keywords ('rock microphone', 'metaphone');+------+-----------------+------------+| qpos | tokenized       | normalized |+------+-----------------+------------+| 1    | rock microphone | RKMKRFN    || 1    | rock            | RK         || 2    | microphone      | MKRFN      |+------+-----------------+------------+

Между RKMKRFN и RKMKFN, расстояние Левенштейна всего 1! Мы почти у цели.

Проблема убрать the, параметр stopwords здесь не поможет, ибо из-за blend_chars = U+0020 the не будет восприниматься самостоятельно. Но даже если удастся сделать предобработку, то всё равно расстояние в 1, не позволит обнаружить похожий.

Надежда на qsuggest не оправдалась, - не даст подсказок. Почему? Можно заметить, что при вызове keywords есть два столбца tokenized и normalized, qsuggest даёт подсказку по столбцу tokenized и измеряет расстояние Левенштейна относительно него, qsuggest всё равно, что там, в normalized, расстояние равно 1.

Поэтому наблюдение забавное, но не практичное.

Подробнее..

Перевод Почему интернационализация и локализация имеют значение

12.10.2020 16:11:28 | Автор: admin

Хабр, отличного всем времени суток! Скоро в OTUS стартует курс Python Web-Developer: мы приглашаем на бесплатный Demo-урок Паттерны Page Controller и Front Controller: реализация в Django и публикуем перевод статьи Nicolle Cysneiros Full Stack Developer (Labcodes).


Согласно всегда правдивой информации на Википедии, в мире насчитывается около 360 миллионов носителей английского языка. Мы, как разработчики, настолько привыкли писать код и документацию на английском языке, что не осознаем, что это число это всего. 4,67% населения всего мира. Единый язык общения между разработчиками это, конечно, хорошо, но это не значит, что пользователь должен чувствовать дискомфорт при использовании вашего продукта.

В этой статье мы начнем говорить о понятиях интернационализации и локализации, а также обозначим их важность для вашего приложения. Затем рассмотрим некоторые элементы интернационализации, доступные разработчикам для работы над проектами на Python и Django. Под конец расскажем о том, как мы изменяли свой процесс разработки, чтобы добавить интернационализацию.

Локализация или интернационализация

Локализация процесс адаптации приложения, продукта или документа для удобства пользователей разных стран и культур.

Тогда как интернационализация процесс обеспечения возможности локализации продукта. То есть реализация программного обеспечения таким образом чтобы оно знало, когда и как показывать различный контент в зависимости от культурной или языковой принадлежности (локали) пользователя.

Как гласит документация Django: локализацию делают переводчики, а интернационализацию разработчики.

Однако упрощенное определение интернационализации и локализации может создать ложное впечатление, будто речь идет только о переводе. Этот процесс также включает в себя несколько видов адаптации, которые позволяют пользователю чувствовать себя комфортнее при использовании вашего продукта, например:

  • Формат даты и валюты;

  • Конвертация валюты;

  • Преобразование единиц измерения;

  • Символы юникода и двунаправленны текст (см. ниже);

  • Часовые пояса, календарь и особые праздники.

Домашняя страница Википедии на английскомДомашняя страница Википедии на английскомДомашняя страница Википедии на арабскомДомашняя страница Википедии на арабском

С помощью таких адаптаций мы можем улучшить пользовательский опыт нашего приложения.

Как это делается в Python?

GNU gettext

Есть несколько инструментов, которые могут помочь локализовать ваше приложения на Python. Начнем с пакета GNU gettext, который является частью Translation Project. В этом пакете есть:

  • библиотека, которая в рантайме поддерживает извлечение переведенных сообщений;

  • набор соглашений о том, как нужно писать код для поддержки каталогов сообщений;

  • библиотека, поддерживающая синтаксический анализ и создание файлов, содержащих переведенные сообщения.

Следующий фрагмент кода это просто Hello World в файле app.py, где используется модуль gettext в Python для создания объекта перевода (gettext.translation) в домене приложения с указанием директории локали и языка, на который мы хотим перевести строки. Затем мы присваиваем функцию gettext символу нижнего подчеркивания (обычная практика для уменьшения накладных расходов на ввод gettext для каждой переводимой строки), и, наконец, ставим флаг строке Hello World!, чтобы она была переведена.

import gettextgettext.bindtextdomain("app", "/locale")gettext.textdomain("app")t = gettext.translation("app", localedir="locale", languages=['en_US'])t.install()_ = t.gettextgreeting = _("Hello, world!")print(greeting)

После пометки переводимых строк в коде, мы можем собрать их с помощью инструмента командной строки GNU xgettext. Этот инструмент сгенерирует PO-файл, который будет содержать все отмеченные нами строки.

xgettext -d app app.py

PO-файл (или файл Portable Object) содержит список записей, а структура записи выглядит следующим образом:

#  translator-comments#. extracted-comments#: reference#, flag#| msgid previous-untranslated-stringmsgid untranslated-stringmsgstr translated-string

Мы можем добавить для строки комментарий для переводчиков, ссылки и флаги. После этого мы обращаемся к ID записи (msgid), который представляет из себя непереведенную строку, помеченную в коде и строку записи (msgstr) переведенную версию этой строки.

Когда мы запускаем xgettext в командной строке, передавая app.py в качестве входного файла, получается такой PO-файл:

"Project-Id-Version: PACKAGE VERSION\n""Report-Msgid-Bugs-To: \n""POT-Creation-Date: 2019-05-03 13:23-0300\n""PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n""Last-Translator: FULL NAME <EMAIL@ADDRESS>\n""Language-Team: LANGUAGE <LL@li.org>\n""Language: \n""MIME-Version: 1.0\n""Content-Type: text/plain; charset=UTF-8\n""Content-Transfer-Encoding: 8bit\n"#: app.py:7msgid "Hello, world!"msgstr ""

В начале файла у нас есть метаданные о файле, проекте и процессе перевода. Потом стоит непереведенная строка Hello World! в качестве ID записи и пустая строка для строки записи. Если для записи не указан перевод, то при переводе будет использоваться ID записи.

После генерации PO-файла можно начинать переводить термины на разные языки. Важно отметить, что библиотека GNU gettext будет искать переведенные PO-файлы в пути к папке определенного вида (<localedir>/<languagecode>/LCMESSAGES/<domain>.po), то есть для каждого языка, который вы хотите поддерживать, должен быть один PO-файл.

|-- app.py|-- locale   |-- en_US   |   |-- LC_MESSAGES   |       |-- app.po   |-- pt_BR       |-- LC_MESSAGES       |   |-- app.po

Вот пример PO-файла с переводом на португальский:

"Project-Id-Version: PACKAGE VERSION\n""Report-Msgid-Bugs-To: \n""POT-Creation-Date: 2019-05-03 13:23-0300\n""PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n""Last-Translator: FULL NAME <EMAIL@ADDRESS>\n""Language-Team: LANGUAGE <LL@li.org>\n""Language: \n""MIME-Version: 1.0\n""Content-Type: text/plain; charset=UTF-8\n""Content-Transfer-Encoding: 8bit\n"#: app.py:7msgid "Hello, world!"msgstr "Ol, mundo!"

Чтобы использовать переведенные строки в коде, нужно скомпилировать PO-файл в MO-файл с помощью команды msgfmt.

msgfmt -o app.mo app.po

Когда MO-файл готов, можно изменить язык программы на португальский, подав его на вход функции перевода. Если мы запустим следующий код, отмеченная строка будет переведена как Ol, mundo!:

import gettextgettext.bindtextdomain("app", "/locale")gettext.textdomain("app")t = gettext.translation("app", localedir="locale", languages=['pt_BR'])t.install()_ = t.gettextgreeting = _("Hello, world!")print(greeting)

Модуль locale

У этого модуля есть доступ к базе данных локалей (locale) POSIX, и он особенно полезен для обработки форматов дат, чисел и валют. В примере ниже показано как использовать библиотеку locale:

import datetimeimport localelocale.setlocale(locale.LC_ALL, locale='en_US')local_conv = locale.localeconv()now = datetime.datetime.now()some_price = 1234567.89formatted_price = locale.format('%1.2f', some_price, grouping=True)currency_symbol = local_conv['currency_symbol']print(now.strftime('%x'))print(f'{currency_symbol}{formatted_price}')

В данном примере мы импортируем модуль, меняем все настройки локалей на US English и извлекаем соглашения локали. С помощью метода locale.format мы можем отформатировать число и не беспокоиться о разделителях в разрядах десятков и тысяч. С помощью директивы %x для форматирования даты день, месяц и год будут стоять в правильном порядке для текущей локали. Из соглашений локали мы получим и корректный символ для обозначения валюты.

Ниже вы видите выходные данные того кода на Python. Мы видим, что дата соответствует формату Month/Day/Year, десятичный разделитель это точка, а разделитель разряда тысяч запятая, а также есть знак доллара для валюты США.

$ python format_example.py05/03/2019$1,234,567.89

Теперь с тем же кодом, но изменив локаль на Portuguese Brazil, мы получим другой вывод, основанный на бразильских соглашениях форматирования: дата будет отображаться в формате Month/Day/Year, запятая будет разделителем для десятков, а точка для тысяч, символ R$ будет говорить о том, что сумма указана в бразильских реалах.

import datetimeimport localelocale.setlocale(locale.LC_ALL, locale='pt_BR')local_conv = locale.localeconv()now = datetime.datetime.now()some_price = 1234567.89formatted_price = locale.format('%1.2f', some_price, grouping=True)currency_symbol = local_conv['currency_symbol']print(now.strftime('%x'))print(f'{currency_symbol}{formatted_price}')

Легче ли дела обстоят в Django?

Переводы и форматирование

Интернационализация включается по умолчанию при создании проекта на Django. Модуль перевода инкапсулирует библиотеку GNU и предоставляет функционал gettext с настройками перевода на основе языка, полученного из заголовка Accept-Language, который браузер передает в объекте запроса. Итак, весь тот код на Python, который мы видели раньше, оказывается инкапсулирован в модуль перевода из django utils, так что мы можем перепрыгнуть далеко вперед и просто использовать функцию gettext:

from django.http import HttpResponsefrom django.utils.translation import gettext as _def my_view(request):    greetings = _('Hello, World!')    return HttpResponse(greetings)

Для переводов, мы можем помечать переводимые строки в коде Python и в шаблоне (после загрузки тегов интернационализации). Тег trans template переводит одну строку, тогда как тег blocktrans может пометить как переводимый целый блок строк, включая переменный контент.

<p>{% trans "Hello, World!" %}</p><p>{% blocktrans %}This string will have {{ value }} inside.{% endblocktrans %}</p>

Помимо стандартной функции gettext в Django есть ленивые переводы: помеченная строка будет переведена только тогда, когда значение используется в контексте строки, например, при рендеринге шаблона. Особенно полезно это бывает для перевода атрибутов help_text и verbose_name в моделях Django.

Аналогично интерфейсу командной строки GNU, django admin предоставляет команды эквивалентные тем, которые часто используются в процессе разработки. Чтобы собрать все строки, помеченные как переводимые в коде, вам просто нужно выполнить команды django admin makemessages для каждой локали, которую вы хотите поддерживать в своей системе. Как только вы создадите папку locale в рабочей области проекта, эта команда автоматически создаст правильную структуру папок для PO-файла для каждого языка.

Чтобы скомпилировать все PO-файлы, вам просто нужно выполнить django admin compilemessages. Если вам нужно скопировать PO-файл для конкретной локали, вы можете передать его в качестве аргумента django-admin compilemessages --locale=pt_BR. Чтобы получить более полное представление о том, как работают переводы в Django, вы можете ознакомиться с документацией.

Django также использует заголовок Accept-Language для определения локали пользователя и правильного форматирования дат, времени и чисел. В примере ниже мы видим простую форму с DateField и DecimalField. Чтобы указать, что мы хотим получить эти входные данные в формате, согласующимся с локалью пользователя, нам просто нужно передать параметр localize со значением True в экземпляр поля формы.

from django import formsclass DatePriceForm(forms.Form):    date = forms.DateField(localize=True)    price = forms.DecimalField(max_digits=10, decimal_places=2, localize=True)

Как меняется процесс разработки?

После интернационализации приложения процесс развертывания должен быть адаптирован к процессу перевода. В нашем проекте мы отправляем любые новые термины в перевод сразу после развертывания в промежуточной среде. Развертывание на продакшн будет утверждено сразу после перевода всех терминов и компиляции PO-файлов.

Еще одно важное изменение в потоке разработки в нашем случае затронуло добавление интеграционных тестов для различных локалей на этапе QA. Команда QA будет симулировать локали, поддерживаемые нашим приложением, и проверять, все ли тексты были корректно переведены, валюты и единицы измерения преобразованы.

Наш главный вывод из всего этого процесса интернационализации заключается в том, что все эти шаги должны были быть заложены на этапе проектирования в самом начале проекта. Остановить всю разработку, чтобы провести интернационализацию не лучшее решение. Если ваш проект уже не на ранней стадии, то я рекомендую следовать правилу бойскаута: начните помечать строки, которые необходимо перевести, когда внедряете новую функцию или исправляете некритический баг. Таким образом, мы все равно будете поставлять новые функции вместе с постепенным процессом интернационализации.


Интересно развиваться в данном направлении? Узнайте больше о курсе Python Web-Developer и записывайтесь на бесплатные Demo-уроки в OTUS!

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru