Автоматизируем локализацию макетов в Figma
- среда, 30 декабря 2020 г. в 00:31:36
Написание плагинов для Figma Автоматический перевод по словарю Автозамена Исключения Форматирование Сообщения об ошибках и отладка עם האוכל בא התיאבון Конвертирование валют Публикация В заключение |
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 получаются элементарно из UI
async 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;
};
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);
}
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;
}
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');
}