├── .gitignore ├── LICENSE ├── README.md ├── code.js ├── code.ts ├── manifest.json ├── package-lock.json ├── package.json ├── screenshots ├── currency-conversion-after.webp ├── currency-conversion-before.webp ├── font-substitution-after.webp ├── font-substitution-before.webp ├── mirroring-after.webp ├── mirroring-before.webp ├── translation-after.webp └── translation-before.webp ├── tsconfig.json └── ui.html /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Iskander Sitdikov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 4 | associated documentation files (the "Software"), to deal in the Software without restriction, 5 | including without limitation the rights to use, copy, modify, merge, publish, distribute, 6 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 7 | furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or 10 | substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 13 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 14 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 15 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT 16 | OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Static Localizer 2 | 3 | A Figma plugin that allows you to localize your content using a static dictionary. 4 | 5 | Unlike many other localization plugins, it 6 | - gracefully handles mixed text formatting 7 | - correctly renders RTL texts 8 | - can mirror interfaces for RTL languages 9 | - can perform currency conversion 10 | - can perform font substitution 11 | 12 | ## Installation 13 | 14 | [Install](https://www.figma.com/community/plugin/876934931929982678/Static-Localizer) this plugin from Figma Community. 15 | 16 | ## Usage 17 | 18 | The plugin includes several modules: 19 | - [Translation](#translation) 20 | - [Currency conversion](#currency-conversion) 21 | - [Mirroring](#mirroring) 22 | - [Font substitution](#font-substitution) 23 | 24 | Note that the plugin will always remember the last used settings. 25 | 26 | ### Translation 27 | 28 | - Select nodes to translate 29 | - Invoke this plugin 30 | - Specify a [dictionary](#dictionary) explicitly or load it from a file 31 | - Specify [exceptions](#exceptions) explicitly or load them from a file 32 | - Specify source and target languages 33 | - Mark the target language as RTL if needed 34 | - Hit `Translate` 35 | 36 | ![](screenshots/translation-before.webp) 37 | ![](screenshots/translation-after.webp) 38 | 39 | #### Dictionary 40 | 41 | Should be in the [TSV](https://en.wikipedia.org/wiki/Tab-separated_values) format. 42 | The first row is a header containing language codes. 43 | Each of the following rows contains translations of some phrase into corresponding languages. 44 | 45 | For instance, 46 | ``` 47 | RU EN DE 48 | Привет! Hello! Hallo! 49 | день day Tag 50 | ``` 51 | 52 | #### Exceptions 53 | 54 | Define patterns to ignore during translation. 55 | There should be one regular expression per line. 56 | 57 | For instance, 58 | ``` 59 | ^$ 60 | ^-?[0-9. ]+%?$ 61 | ``` 62 | 63 | Here are some commonly used patterns: 64 | 65 | | Pattern | Description | 66 | | - | - | 67 | | `^$` | an empty text | 68 | | `^\s*$` | whitespaces | 69 | | `^[+-]?[0-9.,]+%?` | decimal numbers and percentages | 70 | | `^Joom$` | some brand name | 71 | 72 | Also, check out this [tutorial on regular expressions](https://medium.com/factory-mind/regex-tutorial-a-simple-cheatsheet-by-examples-649dc1c3f285). 73 | 74 | #### Troubleshooting 75 | 76 | If translation fails, you will see the list of untranslatable nodes right in the UI. 77 | For each untranslatable node you will get 78 | - a hyperlink to it 79 | - a full error description 80 | 81 | The plugin will then also suggest a list of phrases that should be translated in order to complete the translation. 82 | 83 | You might get a `... does not fit into the box` error while translating into an RTL language 84 | if your font doesn't have the required symbols. 85 | Try [font substitution](#font-substitution) in this case. 86 | 87 | ### Currency conversion 88 | 89 | - Select nodes to convert 90 | - Invoke this plugin 91 | - Go to the `Conversion` tab 92 | - Specify a configuration for known currencies or load it from a file 93 | - Specify source and target currency codes 94 | - Hit `Convert currency` 95 | 96 | It supports numeric ranges, e.g. `12.3 - 456.7`. 97 | 98 | ![](screenshots/currency-conversion-before.webp) 99 | ![](screenshots/currency-conversion-after.webp) 100 | 101 | Here is a sample configuration: 102 | 103 | ```json 104 | [ 105 | { 106 | "code": "RU", 107 | "schema": "123 ₽", 108 | "digitGroupSeparator": " ", 109 | "decimalSeparator": "", 110 | "precision": 0, 111 | "rate": 1 112 | }, 113 | { 114 | "code": "US", 115 | "schema": "$123", 116 | "digitGroupSeparator": ",", 117 | "decimalSeparator": ".", 118 | "precision": 2, 119 | "rate": 0.013 120 | } 121 | ] 122 | ``` 123 | 124 | | Parameter | Example | Description | 125 | | - | - | - | 126 | | `code` | `US` | a unique identifier | 127 | | `schema` | `$123` | defines the appearance of a money value (`123` denotes the location of the numeric value/range) | 128 | | `digitGroupSeparator` | `,` | used to separate thousands | 129 | | `decimalSeparator` | `.` | used to separate the fraction | 130 | | `precision` | `2` | the size of the fraction in digits | 131 | | `rate` | `0.013` | the exchange rate to some fixed currency | 132 | 133 | ### Mirroring 134 | 135 | - Select nodes to mirror 136 | - Invoke this plugin 137 | - Go to the `Mirroring` tab 138 | - Hit `Mirror` 139 | 140 | Use this feature to adapt interfaces for RTL languages. 141 | 142 | First, top-level nodes get mirrored horizontally, then all their descendant "atomic" nodes get mirrored back. 143 | To treat a descendant frame/group as "atomic", simply lock it. 144 | If you want some node to stay reflected after mirroring, do the following: 145 | - reframe it to the boundaries of the host frame 146 | - clone it 147 | - reflect the clone to the left with respect to the left boundary of the host frame 148 | - group the clone with the original node 149 | - lock the resulting group 150 | 151 | By altering the clone you can change the appearance of the node after mirroring in any way you need. 152 | 153 | ![](screenshots/mirroring-before.webp) 154 | ![](screenshots/mirroring-after.webp) 155 | 156 | ### Font substitution 157 | 158 | - Select nodes to transform 159 | - Invoke this plugin 160 | - Go to the `Fonts` tab 161 | - Configure a font mapping by picking font pairs and clicking the tick 162 | - Hit `Substitute fonts` 163 | 164 | ![](screenshots/font-substitution-before.webp) 165 | ![](screenshots/font-substitution-after.webp) 166 | 167 | You can download configured mappings and then load them later from a file. 168 | 169 | ## Development 170 | 171 | Just follow this guide: https://www.figma.com/plugin-docs/setup/. 172 | 173 | # License 174 | 175 | **Static Localizer** is released under the MIT license. 176 | 177 | # FAQ 178 | 179 | #### Can I translate *from* an RTL language? 180 | 181 | No, currently this option is not supported. Moreover, it doesn't seem feasible due to error-prone word wrapping. 182 | 183 | #### How do I edit the dictionary? 184 | 185 | The dictionary field is not meant to be edited in-place but rather used as an upload point. 186 | Hence there is no download button. 187 | For now, we suggest you to create a table in, say, Google Sheets, and then export it as a TSV. 188 | 189 | #### I got an error but no text got translated at all. Why? 190 | 191 | The plugin makes sure the translation can be correctly performed first. 192 | So, if it finds any problems, no actual transformation is applied to the document. 193 | 194 | #### How do I translate back quickly? 195 | 196 | We recommend to use `Cmd+Z` to revert changes made by the plugin. 197 | As simple as that. 198 | One plugin invocation counts as a single action in Figma, which makes such rollbacks pretty safe and reliable. 199 | 200 | #### Why a text node containing spaces only cannot be translated? 201 | 202 | We trim spaces when we load a dictionary, so your phrase consisting of spaces only will degenerate into a blank line. 203 | Besides, we pre-process text node contents before translation: join lines and collapse repeating spaces. 204 | This may also cause deviations from what is in the dictionary. 205 | 206 | #### How do I treat multi-line texts? 207 | 208 | If you have line breaks within a sentence that you'd like to preserve - there is simply no way to make it work universally. 209 | In other languages the order of words may change significantly. 210 | But if you have separate paragraph, we highly recommend you to split it into a couple of independent text nodes and group them into an auto-layout. 211 | First, this approach will give you more flexibility in general. 212 | And second, you will be able to translate each paragraph as a separate line of text. 213 | 214 | #### What if some phrase has different translations depending on the context? 215 | 216 | Currently, the solution is cumbersome. 217 | You can put several [word joiners](https://unicode-table.com/en/#2060) after the phrase in both the document and the dictionary. 218 | These characters are hidden, so you'll end up with a phrase that differs from the original one, but looks exactly the same. 219 | Just add a different translation for the new phrase. 220 | -------------------------------------------------------------------------------- /code.js: -------------------------------------------------------------------------------- 1 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 2 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 3 | return new (P || (P = Promise))(function (resolve, reject) { 4 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 5 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 6 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 7 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 8 | }); 9 | }; 10 | var SettingsManager; 11 | (function (SettingsManager) { 12 | const DEFAULT = { 13 | serializedDictionary: 'RU\tEN\nПривет!\tHello!', 14 | serializedExceptions: '', 15 | serializedCurrencies: '[\n\t{\n\t\t"code": "RU",\n\t\t"schema": "123 \\u20bd",\n\t\t"digitGroupSeparator": " ",\n\t\t"decimalSeparator": "",\n\t\t"precision": 0,\n\t\t"rate": 1},\n\t{\n\t\t"code": "US",\n\t\t"schema": "$123",\n\t\t"digitGroupSeparator": ",",\n\t\t"decimalSeparator": ".",\n\t\t"precision": 2,\n\t\t"rate": 0.013\n\t}\n]', 16 | sourceLanguage: 'RU', 17 | targetLanguage: 'EN', 18 | targetLanguageIsRTL: false, 19 | sourceCurrencyCode: 'RU', 20 | targetCurrencyCode: 'US', 21 | serializedFontSubstitutions: '[]', 22 | }; 23 | const FIELDS = Object.keys(DEFAULT); 24 | const CLIENT_STORAGE_PREFIX = 'StaticLocalizer.'; 25 | function load() { 26 | return __awaiter(this, void 0, void 0, function* () { 27 | const result = {}; 28 | const promises = FIELDS.map(field => figma.clientStorage.getAsync(CLIENT_STORAGE_PREFIX + field).then(value => ({ field, value: value === undefined ? DEFAULT[field] : value }))); 29 | (yield Promise.all(promises)).forEach(({ field, value }) => { 30 | result[field] = value; 31 | }); 32 | return result; 33 | }); 34 | } 35 | SettingsManager.load = load; 36 | function save(settings) { 37 | return __awaiter(this, void 0, void 0, function* () { 38 | yield Promise.all(FIELDS.map(field => figma.clientStorage.setAsync(CLIENT_STORAGE_PREFIX + field, settings[field]))); 39 | }); 40 | } 41 | SettingsManager.save = save; 42 | })(SettingsManager || (SettingsManager = {})); 43 | ; 44 | function translateSelection(settings) { 45 | return __awaiter(this, void 0, void 0, function* () { 46 | const dictionary = yield parseDictionary(settings.serializedDictionary); 47 | const mapping = yield getMapping(dictionary, settings.sourceLanguage, settings.targetLanguage); 48 | const exceptions = yield parseExceptions(settings.serializedExceptions); 49 | yield replaceAllTexts(mapping, exceptions, settings.targetLanguageIsRTL); 50 | }); 51 | } 52 | function parseDictionary(serializedDictionary) { 53 | return __awaiter(this, void 0, void 0, function* () { 54 | const table = serializedDictionary.split('\n').map(line => line.split('\t').map(field => field.trim())); 55 | if (table.length === 0) { 56 | throw { error: 'no header in the dictionary' }; 57 | } 58 | const header = table[0]; 59 | const expectedColumnCount = header.length; 60 | const rows = table.slice(1, table.length); 61 | console.log('Dictionary:', { header, rows }); 62 | rows.forEach((row, index) => { 63 | if (row.length != expectedColumnCount) { 64 | throw { error: 'row ' + (index + 2) + ' of the dictionary has ' + row.length + ' (not ' + expectedColumnCount + ') columns' }; 65 | } 66 | }); 67 | return { header, rows }; 68 | }); 69 | } 70 | function getMapping(dictionary, sourceLanguage, targetLanguage) { 71 | return __awaiter(this, void 0, void 0, function* () { 72 | const sourceColumnIndex = dictionary.header.indexOf(sourceLanguage); 73 | if (sourceColumnIndex == -1) { 74 | throw { error: sourceLanguage + ' not listed in [' + dictionary.header + ']' }; 75 | } 76 | const targetColumnIndex = dictionary.header.indexOf(targetLanguage); 77 | if (targetColumnIndex == -1) { 78 | throw { error: targetLanguage + ' not listed in [' + dictionary.header + ']' }; 79 | } 80 | const result = {}; 81 | dictionary.rows.forEach(row => { 82 | const sourceString = row[sourceColumnIndex]; 83 | const targetString = row[targetColumnIndex]; 84 | if (targetString.trim() !== '') { 85 | if (sourceString in result) { 86 | throw { error: 'multiple translations for `' + sourceString + '` in the dictionary' }; 87 | } 88 | result[sourceString] = targetString; 89 | } 90 | }); 91 | console.log('Extracted mapping:', result); 92 | return result; 93 | }); 94 | } 95 | function parseExceptions(serializedExceptions) { 96 | return __awaiter(this, void 0, void 0, function* () { 97 | return serializedExceptions.split('\n').filter(pattern => pattern !== '').map(pattern => { 98 | try { 99 | return new RegExp(pattern); 100 | } 101 | catch (_) { 102 | throw { error: 'invalid regular expression `' + pattern + '`' }; 103 | } 104 | }); 105 | }); 106 | } 107 | function replaceAllTexts(mapping, exceptions, targetLanguageIsRTL) { 108 | return __awaiter(this, void 0, void 0, function* () { 109 | const textNodes = yield findSelectedTextNodes(); 110 | let replacements = (yield mapWithRateLimit(textNodes, 200, node => computeReplacement(node, mapping, exceptions))).filter(r => r !== null); 111 | let failures = replacements.filter(r => 'error' in r); 112 | if (failures.length == 0 && targetLanguageIsRTL) { 113 | replacements = yield mapWithRateLimit(replacements, 100, reverseAndWrapReplacement); 114 | failures = replacements.filter(r => 'error' in r); 115 | } 116 | if (failures.length > 0) { 117 | console.log('Failures:', failures); 118 | throw { error: 'found some untranslatable nodes', failures }; 119 | } 120 | if (targetLanguageIsRTL) { 121 | yield reverseNodeAlignments(textNodes); 122 | } 123 | yield mapWithRateLimit(replacements, 50, replaceText); 124 | }); 125 | } 126 | function computeReplacement(node, mapping, exceptions) { 127 | return __awaiter(this, void 0, void 0, function* () { 128 | const content = normalizeContent(node.characters); 129 | if (keepAsIs(content, exceptions)) { 130 | return null; 131 | } 132 | const sections = sliceIntoSections(node); 133 | const suggestions = suggest(node, content, sections, mapping, exceptions); 134 | if (!(content in mapping)) { 135 | return { nodeId: node.id, error: 'No translation for `' + content + '`', suggestions }; 136 | } 137 | const result = { 138 | node, 139 | translation: mapping[content], 140 | baseStyle: null, 141 | sections: [], 142 | }; 143 | const errorLog = [ 144 | 'Cannot determine a base style for `' + content + '`', 145 | 'Split into ' + sections.length + ' sections', 146 | ]; 147 | const styles = []; 148 | const styleIds = new Set(); 149 | sections.forEach(({ from, to, style }) => { 150 | if (!styleIds.has(style.id)) { 151 | styleIds.add(style.id); 152 | styles.push(Object.assign({ humanId: from + '-' + to }, style)); 153 | } 154 | }); 155 | for (let baseStyleCandidate of styles) { 156 | const prelude = 'Style ' + baseStyleCandidate.humanId + ' is not base: '; 157 | let ok = true; 158 | result.sections.length = 0; 159 | for (let { from, to, style } of sections) { 160 | if (style.id === baseStyleCandidate.id) { 161 | continue; 162 | } 163 | const sectionContent = normalizeContent(node.characters.slice(from, to)); 164 | let sectionTranslation = sectionContent; 165 | if (sectionContent in mapping) { 166 | sectionTranslation = mapping[sectionContent]; 167 | } 168 | else if (!keepAsIs(sectionContent, exceptions)) { 169 | errorLog.push(prelude + 'no translation for `' + sectionContent + '`'); 170 | ok = false; 171 | break; 172 | } 173 | const index = result.translation.indexOf(sectionTranslation); 174 | if (index == -1) { 175 | errorLog.push(prelude + '`' + sectionTranslation + '` not found within `' + result.translation + '`'); 176 | ok = false; 177 | break; 178 | } 179 | if (result.translation.indexOf(sectionTranslation, index + 1) != -1) { 180 | errorLog.push(prelude + 'found multiple occurrencies of `' + sectionTranslation + '` within `' + result.translation + '`'); 181 | ok = false; 182 | break; 183 | } 184 | result.sections.push({ from: index, to: index + sectionTranslation.length, style }); 185 | } 186 | if (ok) { 187 | result.baseStyle = baseStyleCandidate; 188 | break; 189 | } 190 | } 191 | if (result.baseStyle === null) { 192 | return { nodeId: node.id, error: errorLog.join('. '), suggestions }; 193 | } 194 | console.log('Replacement:', result); 195 | return result; 196 | }); 197 | } 198 | function normalizeContent(content) { 199 | return content.replace(/[\u000A\u00A0\u2028\u202F]/g, ' ').replace(/ +/g, ' '); 200 | } 201 | function keepAsIs(content, exceptions) { 202 | for (let regex of exceptions) { 203 | if (content.match(regex)) { 204 | return true; 205 | } 206 | } 207 | return false; 208 | } 209 | ; 210 | function suggest(node, content, sections, mapping, exceptions) { 211 | const n = content.length; 212 | const styleScores = new Map(); 213 | for (let { from, to, style } of sections) { 214 | styleScores.set(style.id, n + to - from + (styleScores.get(style.id) || 0)); 215 | } 216 | let suggestedBaseStyleId = null; 217 | let suggestedBaseStyleScore = 0; 218 | for (let [styleId, styleScore] of styleScores) { 219 | if (styleScore > suggestedBaseStyleScore) { 220 | suggestedBaseStyleId = styleId; 221 | suggestedBaseStyleScore = styleScore; 222 | } 223 | } 224 | const result = []; 225 | if (!(content in mapping)) { 226 | result.push(content); 227 | } 228 | for (let { from, to, style } of sections) { 229 | if (style.id === suggestedBaseStyleId) { 230 | continue; 231 | } 232 | const sectionContent = normalizeContent(node.characters.slice(from, to)); 233 | if (!keepAsIs(sectionContent, exceptions) && !(sectionContent in mapping)) { 234 | result.push(sectionContent); 235 | } 236 | } 237 | return result; 238 | } 239 | function reverseAndWrapReplacement(replacement) { 240 | return __awaiter(this, void 0, void 0, function* () { 241 | const reversedReplacement = yield reverseReplacement(replacement); 242 | if (replacement.node.textAutoResize === 'WIDTH_AND_HEIGHT') { 243 | return reversedReplacement; 244 | } 245 | return wrapReplacement(reversedReplacement); 246 | }); 247 | } 248 | function reverseReplacement(replacement) { 249 | return __awaiter(this, void 0, void 0, function* () { 250 | const { reversedText: reversedTranslation, nonReversibleRanges } = reverseText(replacement.translation); 251 | const n = replacement.translation.length; 252 | const reversedSections = replacement.sections.map(({ from, to, style }) => ({ from: n - to, to: n - from, style })); 253 | const overridingSections = []; 254 | for (let range of nonReversibleRanges) { 255 | overridingSections.push(Object.assign(Object.assign({}, range), { style: replacement.baseStyle })); 256 | for (let { from, to, style } of reversedSections) { 257 | if (from < range.to && to > range.from) { 258 | overridingSections.push({ 259 | from: range.from + range.to - Math.min(to, range.to), 260 | to: range.from + range.to - Math.max(from, range.from), 261 | style, 262 | }); 263 | } 264 | } 265 | } 266 | const result = { 267 | node: replacement.node, 268 | translation: reversedTranslation, 269 | baseStyle: replacement.baseStyle, 270 | sections: reversedSections.concat(overridingSections), 271 | }; 272 | console.log('Reversed replacement:', result); 273 | return result; 274 | }); 275 | } 276 | function reverseText(text) { 277 | // TODO: replace with a proper implementation for RTL languages 278 | const words = []; 279 | const nonReversibleWordStack = []; 280 | const nonReversibleRanges = []; 281 | const dumpNonReversibleWordStack = (to) => { 282 | if (nonReversibleWordStack.length > 0) { 283 | const phrase = nonReversibleWordStack.reverse().join(' '); 284 | words.push(phrase); 285 | nonReversibleRanges.push({ from: to - phrase.length, to }); 286 | nonReversibleWordStack.length = 0; 287 | } 288 | }; 289 | let offset = -1; 290 | for (let word of text.split(' ').reverse()) { 291 | if (isReversible(word)) { 292 | dumpNonReversibleWordStack(offset); 293 | words.push(reverseSpecialSymbols(word.split('').reverse().join(''))); 294 | } 295 | else { 296 | nonReversibleWordStack.push(word); 297 | } 298 | offset += word.length + 1; 299 | } 300 | ; 301 | dumpNonReversibleWordStack(offset); 302 | return { reversedText: words.join(' '), nonReversibleRanges }; 303 | } 304 | function isReversible(word) { 305 | return /[\u0500-\u0700]|^$/.test(word); 306 | } 307 | function reverseSpecialSymbols(word) { 308 | const reversalTable = new Map([ 309 | ['(', ')'], [')', '('], 310 | ['[', ']'], [']', '['], 311 | ['{', '}'], ['}', '{'], 312 | ]); 313 | return word.split('').map(c => reversalTable.get(c) || c).join(''); 314 | } 315 | function wrapReplacement(replacement) { 316 | return __awaiter(this, void 0, void 0, function* () { 317 | yield loadFontsForReplacement(replacement); 318 | const bufferNode = replacement.node.clone(); 319 | bufferNode.opacity = 0; 320 | bufferNode.characters = ''; 321 | bufferNode.textAutoResize = 'HEIGHT'; 322 | let wrappedTranslationLines = []; 323 | let wrappedSections = []; 324 | const words = replacement.translation.split(' '); 325 | let wordIndex = words.length - 1; 326 | let lineStart = replacement.translation.length; 327 | let lineEnd = lineStart; 328 | let currentLineOffset = 0; 329 | while (wordIndex >= 0) { 330 | let currentLine = ''; 331 | let lineBreakStyle = replacement.baseStyle; 332 | while (wordIndex >= 0) { 333 | const word = words[wordIndex]; 334 | const insertion = wordIndex > 0 ? (' ' + word) : word; 335 | const originalBufferHeight = bufferNode.height; 336 | bufferNode.insertCharacters(currentLineOffset, insertion, 'AFTER'); 337 | lineStart -= insertion.length; 338 | for (let { from, to, style } of replacement.sections) { 339 | if (from < lineStart + insertion.length && to > lineStart) { 340 | setSectionStyle(bufferNode, currentLineOffset + Math.max(0, from - lineStart), currentLineOffset + Math.min(to - lineStart, insertion.length), style); 341 | } 342 | } 343 | const newBufferHeight = bufferNode.height; 344 | if (newBufferHeight > originalBufferHeight) { 345 | bufferNode.deleteCharacters(currentLineOffset, currentLineOffset + insertion.length); 346 | lineStart += insertion.length; 347 | if (lineStart == lineEnd) { 348 | bufferNode.remove(); 349 | return { nodeId: replacement.node.id, error: 'Word `' + reverseText(insertion).reversedText + '` does not fit into the box', suggestions: [] }; 350 | } 351 | const lineBreakOffset = currentLineOffset + currentLine.length; 352 | bufferNode.insertCharacters(lineBreakOffset, '\u2028', 'BEFORE'); 353 | for (let { from, to, style } of replacement.sections.reverse()) { 354 | if (from <= lineStart - 1 && lineStart - 1 < to) { 355 | lineBreakStyle = style; 356 | break; 357 | } 358 | } 359 | setSectionStyle(bufferNode, lineBreakOffset, lineBreakOffset + 1, lineBreakStyle); 360 | break; 361 | } 362 | currentLine = insertion + currentLine; 363 | wordIndex--; 364 | } 365 | wrappedTranslationLines.push(currentLine); 366 | for (let { from, to, style } of replacement.sections) { 367 | if (from < lineEnd && to > lineStart) { 368 | wrappedSections.push({ from: currentLineOffset + Math.max(0, from - lineStart), to: currentLineOffset + Math.min(to, lineEnd) - lineStart, style }); 369 | } 370 | } 371 | if (wordIndex >= 0) { 372 | wrappedSections.push({ from: currentLineOffset + currentLine.length, to: currentLineOffset + currentLine.length + 1, style: lineBreakStyle }); 373 | } 374 | lineEnd = lineStart; 375 | currentLineOffset += currentLine.length + 1; 376 | } 377 | const result = { 378 | node: replacement.node, 379 | translation: wrappedTranslationLines.join('\u2028'), 380 | baseStyle: replacement.baseStyle, 381 | sections: wrappedSections, 382 | }; 383 | bufferNode.remove(); 384 | console.log('Wrapped replacement:', result); 385 | return result; 386 | }); 387 | } 388 | function reverseNodeAlignments(nodes) { 389 | return __awaiter(this, void 0, void 0, function* () { 390 | const alignments = nodes.map(node => ({ node, alignment: node.textAlignHorizontal })); 391 | yield mapWithRateLimit(alignments, 500, ({ node, alignment }) => __awaiter(this, void 0, void 0, function* () { 392 | if (alignment !== 'LEFT' && alignment !== 'RIGHT') { 393 | return; 394 | } 395 | yield loadFontsForNode(node); 396 | if (alignment === 'LEFT') { 397 | node.textAlignHorizontal = 'RIGHT'; 398 | } 399 | else if (alignment === 'RIGHT') { 400 | node.textAlignHorizontal = 'LEFT'; 401 | } 402 | })); 403 | }); 404 | } 405 | function replaceText(replacement) { 406 | return __awaiter(this, void 0, void 0, function* () { 407 | yield loadFontsForReplacement(replacement); 408 | const { node, translation, baseStyle, sections } = replacement; 409 | node.characters = translation; 410 | if (sections.length > 0) { 411 | setSectionStyle(node, 0, translation.length, baseStyle); 412 | for (let { from, to, style } of sections) { 413 | setSectionStyle(node, from, to, style); 414 | } 415 | } 416 | }); 417 | } 418 | function loadFontsForReplacement(replacement) { 419 | return __awaiter(this, void 0, void 0, function* () { 420 | yield figma.loadFontAsync(replacement.baseStyle.fontName); 421 | yield Promise.all(replacement.sections.map(({ style }) => figma.loadFontAsync(style.fontName))); 422 | }); 423 | } 424 | function loadFontsForNode(node) { 425 | return __awaiter(this, void 0, void 0, function* () { 426 | yield Promise.all(Array.from({ length: node.characters.length }, (_, k) => k).map(i => { 427 | return figma.loadFontAsync(node.getRangeFontName(i, i + 1)); 428 | })); 429 | }); 430 | } 431 | function convertCurrencyInSelection(settings) { 432 | return __awaiter(this, void 0, void 0, function* () { 433 | const currencies = parseCurrencies(settings.serializedCurrencies); 434 | console.log('Currencies:', currencies); 435 | const sourceCurrency = currencies.filter(currency => currency.code === settings.sourceCurrencyCode)[0]; 436 | if (sourceCurrency === undefined) { 437 | throw { error: 'unknown currency code `' + settings.sourceCurrencyCode + '`' }; 438 | } 439 | const targetCurrency = currencies.filter(currency => currency.code === settings.targetCurrencyCode)[0]; 440 | if (targetCurrency === undefined) { 441 | throw { error: 'unknown currency code `' + settings.targetCurrencyCode + '`' }; 442 | } 443 | yield replaceCurrencyInAllTexts(sourceCurrency, targetCurrency); 444 | }); 445 | } 446 | function parseCurrencies(serializedCurrencies) { 447 | const codeSet = new Set(); 448 | return JSON.parse(serializedCurrencies).map((x, index) => { 449 | const currency = { 450 | code: null, 451 | schema: null, 452 | digitGroupSeparator: null, 453 | decimalSeparator: null, 454 | precision: null, 455 | rate: null, 456 | }; 457 | Object.keys(currency).forEach(key => { 458 | if (x[key] === undefined || x[key] === null) { 459 | throw { error: 'invalid currency definition: no `' + key + '` in entry #' + (index + 1) }; 460 | } 461 | if (key === 'schema' && x[key].indexOf('123') === -1) { 462 | throw { error: 'schema in entry #' + (index + 1) + ' should contain `123`' }; 463 | } 464 | if (key === 'rate' && x[key] <= 0) { 465 | throw { error: 'non-positive rate in entry #' + (index + 1) }; 466 | } 467 | currency[key] = x[key]; 468 | }); 469 | if (currency.precision > 0 && currency.decimalSeparator === '') { 470 | throw { error: 'entry #' + (index + 1) + ' must have a non-empty decimal separator' }; 471 | } 472 | if (codeSet.has(currency.code)) { 473 | throw { error: 'multiple entries for `' + currency.code + '`' }; 474 | } 475 | codeSet.add(currency.code); 476 | return currency; 477 | }); 478 | } 479 | function replaceCurrencyInAllTexts(sourceCurrency, targetCurrency) { 480 | return __awaiter(this, void 0, void 0, function* () { 481 | const textNodes = yield findSelectedTextNodes(); 482 | const escapedSchema = escapeForRegExp(sourceCurrency.schema); 483 | const escapedDigitGroupSeparator = escapeForRegExp(sourceCurrency.digitGroupSeparator); 484 | const escapedDecimalSeparator = escapeForRegExp(sourceCurrency.decimalSeparator); 485 | const sourceValueRegExpString = '((?:\\d|\\d' + escapedDigitGroupSeparator + '\\d)+' + escapedDecimalSeparator + '\\d{' + sourceCurrency.precision + '})'; 486 | const sourceRegExpString = '\\b' + escapedSchema.replace('123', sourceValueRegExpString + '(?:(\\s*[-\u2010-\u2015]\\s*)' + sourceValueRegExpString + ')?'); 487 | const sourceRegExp = new RegExp(sourceRegExpString, 'g'); 488 | console.log('Source regular expression:', sourceRegExpString); 489 | yield mapWithRateLimit(textNodes, 250, (node) => __awaiter(this, void 0, void 0, function* () { 490 | const content = node.characters; 491 | const regexp = new RegExp(sourceRegExp); 492 | const conversions = []; 493 | while (true) { 494 | const match = regexp.exec(content); 495 | if (match === null) { 496 | break; 497 | } 498 | const style = getSectionStyle(node, match.index, match.index + match[0].length); 499 | if (style === figma.mixed) { 500 | throw { error: 'node `' + content + '` has a mixed-styled money value' }; 501 | } 502 | yield figma.loadFontAsync(style.fontName); 503 | let targetValueString = convertMoneyValue(match[1], sourceCurrency, targetCurrency); 504 | if (match[3] !== null && match[3] !== undefined) { 505 | targetValueString += match[2] + convertMoneyValue(match[3], sourceCurrency, targetCurrency); 506 | } 507 | conversions.push({ 508 | from: match.index, 509 | to: match.index + match[0].length, 510 | target: targetCurrency.schema.replace('123', targetValueString), 511 | }); 512 | } 513 | conversions.reverse().forEach(({ from, to, target }) => { 514 | node.insertCharacters(to, target, 'BEFORE'); 515 | node.deleteCharacters(from, to); 516 | }); 517 | })); 518 | }); 519 | } 520 | function convertMoneyValue(value, sourceCurrency, targetCurrency) { 521 | return renderMoneyValue(extractMoneyValue(value, sourceCurrency) * targetCurrency.rate / sourceCurrency.rate, targetCurrency); 522 | } 523 | function extractMoneyValue(value, currency) { 524 | let resultAsString = value.replace(new RegExp(escapeForRegExp(currency.digitGroupSeparator), 'g'), ''); 525 | if (currency.decimalSeparator !== '') { 526 | resultAsString = resultAsString.replace(currency.decimalSeparator, '.'); 527 | } 528 | return parseFloat(resultAsString); 529 | } 530 | function renderMoneyValue(value, currency) { 531 | const truncatedValue = Math.trunc(value); 532 | const valueFraction = value - truncatedValue; 533 | return (truncatedValue.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,').replace(/,/g, currency.digitGroupSeparator) + 534 | currency.decimalSeparator + 535 | valueFraction.toFixed(currency.precision).slice(2)); 536 | } 537 | function escapeForRegExp(s) { 538 | return s.replace(/([[\^$.|?*+()])/g, '\\$1'); 539 | } 540 | // Font substitution 541 | function sendAvailableFonts() { 542 | return __awaiter(this, void 0, void 0, function* () { 543 | const availableFonts = (yield figma.listAvailableFontsAsync()).map(f => f.fontName); 544 | figma.ui.postMessage({ type: 'available-fonts', availableFonts }); 545 | }); 546 | } 547 | function sendSelectionFonts() { 548 | return __awaiter(this, void 0, void 0, function* () { 549 | const textNodes = yield findSelectedTextNodes(); 550 | const selectionFontIds = new Set(); 551 | const selectionFonts = []; 552 | yield mapWithRateLimit(textNodes, 250, (node) => __awaiter(this, void 0, void 0, function* () { 553 | if (node.characters === '') { 554 | return; 555 | } 556 | const sections = sliceIntoSections(node); 557 | for (let { style } of sections) { 558 | const fontId = JSON.stringify(style.fontName); 559 | if (!selectionFontIds.has(fontId)) { 560 | selectionFontIds.add(fontId); 561 | selectionFonts.push(style.fontName); 562 | } 563 | } 564 | })); 565 | figma.ui.postMessage({ type: 'selection-fonts', selectionFonts }); 566 | }); 567 | } 568 | function substituteFontsInSelection(settings) { 569 | return __awaiter(this, void 0, void 0, function* () { 570 | const substitutions = JSON.parse(settings.serializedFontSubstitutions); 571 | const fontMapping = new Map(); 572 | for (let substitution of substitutions) { 573 | const sourceFontId = JSON.stringify(substitution.sourceFont); 574 | fontMapping.set(sourceFontId, substitution.targetFont); 575 | yield figma.loadFontAsync(substitution.targetFont); 576 | } 577 | const textNodes = yield findSelectedTextNodes(); 578 | yield mapWithRateLimit(textNodes, 250, (node) => __awaiter(this, void 0, void 0, function* () { 579 | if (node.characters === '') { 580 | return; 581 | } 582 | const sections = sliceIntoSections(node); 583 | for (let { style } of sections) { 584 | yield figma.loadFontAsync(style.fontName); 585 | } 586 | for (let { from, to, style } of sections) { 587 | const fontId = JSON.stringify(style.fontName); 588 | if (fontMapping.has(fontId)) { 589 | const newStyle = Object.assign({}, style); 590 | newStyle.fontName = fontMapping.get(fontId); 591 | setSectionStyle(node, from, to, newStyle); 592 | } 593 | } 594 | })); 595 | }); 596 | } 597 | function mirrorSelection(settings) { 598 | return __awaiter(this, void 0, void 0, function* () { 599 | const selection = figma.currentPage.selection; 600 | const nodesToMirror = selection.concat(findMirrorableNodes(selection)); 601 | const componentIds = new Set(); 602 | findNodesOfType(selection, 'COMPONENT').forEach(node => { 603 | componentIds.add(node.id); 604 | }); 605 | const failures = []; 606 | findNodesOfType(selection, 'INSTANCE').forEach((node) => { 607 | const componentId = node.mainComponent.id; 608 | if (node.locked) { 609 | if (componentIds.has(componentId) && !node.mainComponent.locked) { 610 | failures.push({ nodeId: node.id, error: 'Locked, but its main component is not' }); 611 | return; 612 | } 613 | } 614 | else { 615 | if (!componentIds.has(componentId)) { 616 | failures.push({ nodeId: node.id, error: 'Unlocked, but its main component is not selected' }); 617 | return; 618 | } 619 | } 620 | }); 621 | if (failures.length > 0) { 622 | throw { error: 'found some unmirrorable nodes', failures }; 623 | } 624 | yield mapWithRateLimit(nodesToMirror, 300, mirrorNode); 625 | }); 626 | } 627 | function findMirrorableNodes(roots) { 628 | const result = []; 629 | roots.forEach(root => { 630 | if ('findAll' in root && !root.locked) { 631 | if (root.type !== 'INSTANCE') { 632 | findMirrorableNodes(root.children).forEach(node => result.push(node)); 633 | } 634 | } 635 | else { 636 | result.push(root); 637 | } 638 | }); 639 | return result; 640 | } 641 | function findNodesOfType(roots, nodeType) { 642 | const result = []; 643 | roots.forEach(root => { 644 | if (root.type === nodeType) { 645 | result.push(root); 646 | } 647 | else if ('findAll' in root && !root.locked) { 648 | findNodesOfType(root.children, nodeType).forEach(node => result.push(node)); 649 | } 650 | }); 651 | return result; 652 | } 653 | function mirrorNode(node) { 654 | return __awaiter(this, void 0, void 0, function* () { 655 | const w = node.width; 656 | const t = node.relativeTransform; 657 | node.relativeTransform = [ 658 | [-t[0][0], t[0][1], w * t[0][0] + t[0][2]], 659 | [-t[1][0], t[1][1], w * t[1][0] + t[1][2]], 660 | ]; 661 | }); 662 | } 663 | function clipSelection(settings) { 664 | return __awaiter(this, void 0, void 0, function* () { 665 | if (figma.currentPage.selection.length > 1) { 666 | throw { error: 'more than 1 node selected (group your nodes first)' }; 667 | } 668 | const selectedNode = figma.currentPage.selection[0]; 669 | if (selectedNode.parent == null) { 670 | throw { error: 'the selected node has no parent' }; 671 | } 672 | if (!('width' in selectedNode.parent)) { 673 | throw { error: 'the parent node size is undefined' }; 674 | } 675 | const host = selectedNode.parent; 676 | const nodeIndex = host.children.indexOf(selectedNode); 677 | const clipper = figma.createFrame(); 678 | clipper.backgrounds = []; 679 | clipper.resizeWithoutConstraints(host.width, host.height); 680 | clipper.clipsContent = true; 681 | clipper.appendChild(selectedNode); 682 | host.insertChild(nodeIndex, clipper); 683 | }); 684 | } 685 | // Utilities 686 | function findSelectedTextNodes() { 687 | return __awaiter(this, void 0, void 0, function* () { 688 | const result = []; 689 | figma.currentPage.selection.forEach(root => { 690 | if (root.type === 'TEXT') { 691 | result.push(root); 692 | } 693 | else if ('findAll' in root) { 694 | root.findAll(node => node.type === 'TEXT').forEach(node => result.push(node)); 695 | } 696 | }); 697 | return result; 698 | }); 699 | } 700 | function sliceIntoSections(node, from = 0, to = node.characters.length) { 701 | if (to === from) { 702 | return []; 703 | } 704 | const style = getSectionStyle(node, from, to); 705 | if (style !== figma.mixed) { 706 | return [{ from, to, style }]; 707 | } 708 | if (to - from === 1) { 709 | console.log('WARNING! Unexpected problem at node `' + node.characters + '`: a single character has "mixed" style'); 710 | return []; // TODO: fix the problem 711 | } 712 | const center = Math.floor((from + to) / 2); 713 | const leftSections = sliceIntoSections(node, from, center); 714 | if (leftSections.length === 0) { 715 | return []; // TODO: fix the problem 716 | } 717 | const rightSections = sliceIntoSections(node, center, to); 718 | if (rightSections.length === 0) { 719 | return []; // TODO: fix the problem 720 | } 721 | const lastLeftSection = leftSections[leftSections.length - 1]; 722 | const firstRightSection = rightSections[0]; 723 | if (lastLeftSection.style.id === firstRightSection.style.id) { 724 | firstRightSection.from = lastLeftSection.from; 725 | leftSections.pop(); 726 | } 727 | return leftSections.concat(rightSections); 728 | } 729 | function getSectionStyle(node, from, to) { 730 | const fills = node.getRangeFills(from, to); 731 | if (fills === figma.mixed) { 732 | return figma.mixed; 733 | } 734 | const fillStyleId = node.getRangeFillStyleId(from, to); 735 | if (fillStyleId === figma.mixed) { 736 | return figma.mixed; 737 | } 738 | const fontName = node.getRangeFontName(from, to); 739 | if (fontName === figma.mixed) { 740 | return figma.mixed; 741 | } 742 | const fontSize = node.getRangeFontSize(from, to); 743 | if (fontSize === figma.mixed) { 744 | return figma.mixed; 745 | } 746 | const letterSpacing = node.getRangeLetterSpacing(from, to); 747 | if (letterSpacing === figma.mixed) { 748 | return figma.mixed; 749 | } 750 | const lineHeight = node.getRangeLineHeight(from, to); 751 | if (lineHeight === figma.mixed) { 752 | return figma.mixed; 753 | } 754 | const textDecoration = node.getRangeTextDecoration(from, to); 755 | if (textDecoration === figma.mixed) { 756 | return figma.mixed; 757 | } 758 | const textStyleId = node.getRangeTextStyleId(from, to); 759 | if (textStyleId === figma.mixed) { 760 | return figma.mixed; 761 | } 762 | const parameters = { 763 | fills, 764 | fillStyleId, 765 | fontName, 766 | fontSize, 767 | letterSpacing, 768 | lineHeight, 769 | textDecoration, 770 | textStyleId, 771 | }; 772 | return Object.assign({ id: JSON.stringify(parameters) }, parameters); 773 | } 774 | function setSectionStyle(node, from, to, style) { 775 | node.setRangeTextStyleId(from, to, style.textStyleId); 776 | node.setRangeFills(from, to, style.fills); 777 | node.setRangeFillStyleId(from, to, style.fillStyleId); 778 | node.setRangeFontName(from, to, style.fontName); 779 | node.setRangeFontSize(from, to, style.fontSize); 780 | node.setRangeLetterSpacing(from, to, style.letterSpacing); 781 | node.setRangeLineHeight(from, to, style.lineHeight); 782 | node.setRangeTextDecoration(from, to, style.textDecoration); 783 | } 784 | function mapWithRateLimit(array, rateLimit, mapper) { 785 | return new Promise((resolve, reject) => { 786 | const result = new Array(array.length); 787 | let index = 0; 788 | let done = 0; 789 | var startTime = Date.now(); 790 | const computeDelay = () => startTime + index * 1000.0 / rateLimit - Date.now(); 791 | const schedule = () => { 792 | while (index < array.length && computeDelay() < 0) { 793 | (i => { 794 | mapper(array[i]).then(y => { 795 | result[i] = y; 796 | ++done; 797 | schedule(); 798 | }, reject); 799 | })(index); 800 | ++index; 801 | } 802 | if (done === array.length) { 803 | resolve(result); 804 | } 805 | else { 806 | const delay = computeDelay(); 807 | if (delay >= 0) { 808 | setTimeout(schedule, delay); 809 | } 810 | } 811 | }; 812 | schedule(); 813 | }); 814 | } 815 | figma.showUI(__html__, { width: 400, height: 400 }); 816 | figma.ui.onmessage = (message) => __awaiter(this, void 0, void 0, function* () { 817 | if (message.type === 'load-settings') { 818 | const settings = yield SettingsManager.load(); 819 | console.log('Loaded settings:', settings); 820 | figma.ui.postMessage({ type: 'settings', settings }); 821 | figma.ui.postMessage({ type: 'ready' }); 822 | } 823 | else if (message.type === 'load-available-fonts') { 824 | yield sendAvailableFonts(); 825 | } 826 | else if (message.type === 'load-selection-fonts') { 827 | yield sendSelectionFonts(); 828 | } 829 | else if (message.type === 'translate') { 830 | yield SettingsManager.save(message.settings); 831 | yield translateSelection(message.settings) 832 | .then(() => { 833 | figma.notify('Done'); 834 | figma.ui.postMessage({ type: 'translation-failures', failures: [] }); 835 | figma.ui.postMessage({ type: 'ready' }); 836 | }) 837 | .catch(reason => { 838 | if ('error' in reason) { 839 | figma.notify('Translation failed: ' + reason.error); 840 | if ('failures' in reason) { 841 | figma.ui.postMessage({ type: 'translation-failures', failures: reason.failures }); 842 | } 843 | } 844 | else { 845 | figma.notify(reason.toString()); 846 | } 847 | figma.ui.postMessage({ type: 'ready' }); 848 | }); 849 | } 850 | else if (message.type === 'convert-currency') { 851 | yield SettingsManager.save(message.settings); 852 | yield convertCurrencyInSelection(message.settings) 853 | .then(() => { 854 | figma.notify('Done'); 855 | figma.ui.postMessage({ type: 'ready' }); 856 | }) 857 | .catch(reason => { 858 | if ('error' in reason) { 859 | figma.notify('Currency conversion failed: ' + reason.error); 860 | } 861 | else { 862 | figma.notify(reason.toString()); 863 | } 864 | figma.ui.postMessage({ type: 'ready' }); 865 | }); 866 | } 867 | else if (message.type === 'mirror') { 868 | yield SettingsManager.save(message.settings); 869 | yield mirrorSelection(message.settings) 870 | .then(() => { 871 | figma.notify('Done'); 872 | figma.ui.postMessage({ type: 'mirroring-failures', failures: [] }); 873 | figma.ui.postMessage({ type: 'ready' }); 874 | }) 875 | .catch(reason => { 876 | if ('error' in reason) { 877 | figma.notify('Mirroring failed: ' + reason.error); 878 | if ('failures' in reason) { 879 | figma.ui.postMessage({ type: 'mirroring-failures', failures: reason.failures }); 880 | } 881 | } 882 | else { 883 | figma.notify(reason.toString()); 884 | } 885 | figma.ui.postMessage({ type: 'ready' }); 886 | }); 887 | } 888 | else if (message.type === 'clip') { 889 | yield SettingsManager.save(message.settings); 890 | yield clipSelection(message.settings) 891 | .then(() => { 892 | figma.notify('Done'); 893 | figma.ui.postMessage({ type: 'mirroring-failures', failures: [] }); 894 | figma.ui.postMessage({ type: 'ready' }); 895 | }) 896 | .catch(reason => { 897 | if ('error' in reason) { 898 | figma.notify('Clipping failed: ' + reason.error); 899 | } 900 | else { 901 | figma.notify(reason.toString()); 902 | } 903 | figma.ui.postMessage({ type: 'ready' }); 904 | }); 905 | } 906 | else if (message.type === 'substitute-fonts') { 907 | yield SettingsManager.save(message.settings); 908 | yield substituteFontsInSelection(message.settings) 909 | .then(() => sendSelectionFonts().then(() => { 910 | figma.notify('Done'); 911 | figma.ui.postMessage({ type: 'ready' }); 912 | })) 913 | .catch(reason => { 914 | if ('error' in reason) { 915 | figma.notify('Font substitution failed: ' + reason.error); 916 | } 917 | else { 918 | figma.notify(reason.toString()); 919 | } 920 | figma.ui.postMessage({ type: 'ready' }); 921 | }); 922 | } 923 | else if (message.type === 'focus-node') { 924 | figma.viewport.zoom = 1000.0; 925 | figma.viewport.scrollAndZoomIntoView([figma.getNodeById(message.id)]); 926 | figma.viewport.zoom = 0.75 * figma.viewport.zoom; 927 | } 928 | }); 929 | figma.on("selectionchange", sendSelectionFonts); 930 | -------------------------------------------------------------------------------- /code.ts: -------------------------------------------------------------------------------- 1 | type Settings = { 2 | serializedDictionary: string; 3 | serializedExceptions: string; 4 | serializedCurrencies: string; 5 | sourceLanguage: string; 6 | targetLanguage: string; 7 | targetLanguageIsRTL: boolean; 8 | sourceCurrencyCode: string; 9 | targetCurrencyCode: string; 10 | serializedFontSubstitutions: string; 11 | }; 12 | 13 | namespace SettingsManager { 14 | const DEFAULT: Settings = { 15 | serializedDictionary: 'RU\tEN\nПривет!\tHello!', 16 | serializedExceptions: '', 17 | serializedCurrencies: '[\n\t{\n\t\t"code": "RU",\n\t\t"schema": "123 \\u20bd",\n\t\t"digitGroupSeparator": " ",\n\t\t"decimalSeparator": "",\n\t\t"precision": 0,\n\t\t"rate": 1},\n\t{\n\t\t"code": "US",\n\t\t"schema": "$123",\n\t\t"digitGroupSeparator": ",",\n\t\t"decimalSeparator": ".",\n\t\t"precision": 2,\n\t\t"rate": 0.013\n\t}\n]', 18 | sourceLanguage: 'RU', 19 | targetLanguage: 'EN', 20 | targetLanguageIsRTL: false, 21 | sourceCurrencyCode: 'RU', 22 | targetCurrencyCode: 'US', 23 | serializedFontSubstitutions: '[]', 24 | }; 25 | const FIELDS = Object.keys(DEFAULT); 26 | const CLIENT_STORAGE_PREFIX = 'StaticLocalizer.'; 27 | 28 | export async function load(): Promise { 29 | const result = {}; 30 | const promises = FIELDS.map(field => figma.clientStorage.getAsync(CLIENT_STORAGE_PREFIX + field).then(value => ({field, value: value === undefined ? DEFAULT[field] : value}))); 31 | (await Promise.all(promises)).forEach(({field, value}) => { 32 | result[field] = value; 33 | }); 34 | return result; 35 | } 36 | 37 | export async function save(settings: Settings): Promise { 38 | await Promise.all(FIELDS.map(field => figma.clientStorage.setAsync(CLIENT_STORAGE_PREFIX + field, settings[field]))); 39 | } 40 | }; 41 | 42 | 43 | // * 44 | 45 | type Style = { 46 | id: string; 47 | fills: Paint[]; 48 | fillStyleId: string; 49 | fontName: FontName; 50 | fontSize: number; 51 | letterSpacing: LetterSpacing; 52 | lineHeight: LineHeight; 53 | textDecoration: TextDecoration; 54 | textStyleId: string; 55 | }; 56 | 57 | 58 | // Translation 59 | 60 | type Dictionary = { 61 | header: string[]; 62 | rows: string[][]; 63 | }; 64 | 65 | type Mapping = { 66 | [source: string]: string; 67 | }; 68 | 69 | type Section = { 70 | from: number; 71 | to: number; 72 | style: Style; 73 | }; 74 | 75 | type Replacement = null | { 76 | node: TextNode; 77 | translation: string; 78 | baseStyle: Style; 79 | sections: Section[]; 80 | }; 81 | 82 | type ReplacementFailure = { 83 | nodeId: string; 84 | error: string; 85 | suggestions: string[]; 86 | }; 87 | 88 | type ReplacementAttempt = Replacement | ReplacementFailure; 89 | 90 | 91 | async function translateSelection(settings: Settings): Promise { 92 | const dictionary = await parseDictionary(settings.serializedDictionary); 93 | const mapping = await getMapping(dictionary, settings.sourceLanguage, settings.targetLanguage); 94 | const exceptions = await parseExceptions(settings.serializedExceptions); 95 | await replaceAllTexts(mapping, exceptions, settings.targetLanguageIsRTL); 96 | } 97 | 98 | async function parseDictionary(serializedDictionary: string): Promise { 99 | const table = serializedDictionary.split('\n').map(line => line.split('\t').map(field => field.trim())); 100 | if (table.length === 0) { 101 | throw {error: 'no header in the dictionary'}; 102 | } 103 | const header = table[0]; 104 | const expectedColumnCount = header.length; 105 | const rows = table.slice(1, table.length); 106 | console.log('Dictionary:', {header, rows}); 107 | rows.forEach((row, index) => { 108 | if (row.length != expectedColumnCount) { 109 | throw {error: 'row ' + (index + 2) + ' of the dictionary has ' + row.length + ' (not ' + expectedColumnCount + ') columns'}; 110 | } 111 | }); 112 | return {header, rows}; 113 | } 114 | 115 | async function getMapping(dictionary: Dictionary, sourceLanguage: string, targetLanguage: string): Promise { 116 | const sourceColumnIndex = dictionary.header.indexOf(sourceLanguage); 117 | if (sourceColumnIndex == -1) { 118 | throw {error: sourceLanguage + ' not listed in [' + dictionary.header + ']'}; 119 | } 120 | const targetColumnIndex = dictionary.header.indexOf(targetLanguage); 121 | if (targetColumnIndex == -1) { 122 | throw {error: targetLanguage + ' not listed in [' + dictionary.header + ']'}; 123 | } 124 | const result: Mapping = {}; 125 | dictionary.rows.forEach(row => { 126 | const sourceString = row[sourceColumnIndex]; 127 | const targetString = row[targetColumnIndex]; 128 | if (targetString.trim() !== '') { 129 | if (sourceString in result) { 130 | throw {error: 'multiple translations for `' + sourceString + '` in the dictionary'}; 131 | } 132 | result[sourceString] = targetString; 133 | } 134 | }); 135 | console.log('Extracted mapping:', result); 136 | return result; 137 | } 138 | 139 | async function parseExceptions(serializedExceptions: string): Promise { 140 | return serializedExceptions.split('\n').filter(pattern => pattern !== '').map(pattern => { 141 | try { 142 | return new RegExp(pattern); 143 | } catch (_) { 144 | throw {error: 'invalid regular expression `' + pattern + '`'}; 145 | } 146 | }); 147 | } 148 | 149 | async function replaceAllTexts(mapping: Mapping, exceptions: RegExp[], targetLanguageIsRTL: boolean): Promise { 150 | const textNodes = await findSelectedTextNodes(); 151 | 152 | let replacements = (await mapWithRateLimit(textNodes, 200, node => computeReplacement(node, mapping, exceptions))).filter(r => r !== null); 153 | let failures = replacements.filter(r => 'error' in r) as ReplacementFailure[]; 154 | if (failures.length == 0 && targetLanguageIsRTL) { 155 | replacements = await mapWithRateLimit(replacements, 100, reverseAndWrapReplacement); 156 | failures = replacements.filter(r => 'error' in r) as ReplacementFailure[]; 157 | } 158 | if (failures.length > 0) { 159 | console.log('Failures:', failures); 160 | throw {error: 'found some untranslatable nodes', failures}; 161 | } 162 | 163 | if (targetLanguageIsRTL) { 164 | await reverseNodeAlignments(textNodes); 165 | } 166 | 167 | await mapWithRateLimit(replacements, 50, replaceText); 168 | } 169 | 170 | async function computeReplacement(node: TextNode, mapping: Mapping, exceptions: RegExp[]): Promise { 171 | const content = normalizeContent(node.characters); 172 | if (keepAsIs(content, exceptions)) { 173 | return null; 174 | } 175 | 176 | const sections = sliceIntoSections(node); 177 | const suggestions = suggest(node, content, sections, mapping, exceptions); 178 | 179 | if (!(content in mapping)) { 180 | return {nodeId: node.id, error: 'No translation for `' + content + '`', suggestions}; 181 | } 182 | 183 | const result: Replacement = { 184 | node, 185 | translation: mapping[content], 186 | baseStyle: null, 187 | sections: [], 188 | }; 189 | 190 | const errorLog = [ 191 | 'Cannot determine a base style for `' + content + '`', 192 | 'Split into ' + sections.length + ' sections', 193 | ]; 194 | 195 | const styles = []; 196 | const styleIds = new Set(); 197 | sections.forEach(({from, to, style}) => { 198 | if (!styleIds.has(style.id)) { 199 | styleIds.add(style.id); 200 | styles.push({humanId: from + '-' + to, ...style}); 201 | } 202 | }); 203 | 204 | for (let baseStyleCandidate of styles) { 205 | const prelude = 'Style ' + baseStyleCandidate.humanId + ' is not base: '; 206 | let ok = true; 207 | result.sections.length = 0; 208 | for (let {from, to, style} of sections) { 209 | if (style.id === baseStyleCandidate.id) { 210 | continue; 211 | } 212 | const sectionContent = normalizeContent(node.characters.slice(from, to)); 213 | let sectionTranslation = sectionContent; 214 | if (sectionContent in mapping) { 215 | sectionTranslation = mapping[sectionContent]; 216 | } else if (!keepAsIs(sectionContent, exceptions)) { 217 | errorLog.push(prelude + 'no translation for `' + sectionContent + '`'); 218 | ok = false; 219 | break; 220 | } 221 | const index = result.translation.indexOf(sectionTranslation); 222 | if (index == -1) { 223 | errorLog.push(prelude + '`' + sectionTranslation + '` not found within `' + result.translation + '`'); 224 | ok = false; 225 | break; 226 | } 227 | if (result.translation.indexOf(sectionTranslation, index + 1) != -1) { 228 | errorLog.push(prelude + 'found multiple occurrencies of `' + sectionTranslation + '` within `' + result.translation + '`'); 229 | ok = false; 230 | break; 231 | } 232 | result.sections.push({from: index, to: index + sectionTranslation.length, style}); 233 | } 234 | if (ok) { 235 | result.baseStyle = baseStyleCandidate; 236 | break; 237 | } 238 | } 239 | 240 | if (result.baseStyle === null) { 241 | return {nodeId: node.id, error: errorLog.join('. '), suggestions}; 242 | } 243 | 244 | console.log('Replacement:', result); 245 | 246 | return result; 247 | } 248 | 249 | function normalizeContent(content: string): string { 250 | return content.replace(/[\u000A\u00A0\u2028\u202F]/g, ' ').replace(/ +/g, ' '); 251 | } 252 | 253 | function keepAsIs(content: string, exceptions: RegExp[]): boolean { 254 | for (let regex of exceptions) { 255 | if (content.match(regex)) { 256 | return true; 257 | } 258 | } 259 | return false; 260 | }; 261 | 262 | function suggest(node: TextNode, content: string, sections: Section[], mapping: Mapping, exceptions: RegExp[]): string[] { 263 | const n = content.length; 264 | const styleScores = new Map(); 265 | for (let {from, to, style} of sections) { 266 | styleScores.set(style.id, n + to - from + (styleScores.get(style.id) || 0)); 267 | } 268 | let suggestedBaseStyleId: string = null; 269 | let suggestedBaseStyleScore = 0; 270 | for (let [styleId, styleScore] of styleScores) { 271 | if (styleScore > suggestedBaseStyleScore) { 272 | suggestedBaseStyleId = styleId; 273 | suggestedBaseStyleScore = styleScore; 274 | } 275 | } 276 | 277 | const result: string[] = []; 278 | if (!(content in mapping)) { 279 | result.push(content); 280 | } 281 | for (let {from, to, style} of sections) { 282 | if (style.id === suggestedBaseStyleId) { 283 | continue; 284 | } 285 | const sectionContent = normalizeContent(node.characters.slice(from, to)); 286 | if (!keepAsIs(sectionContent, exceptions) && !(sectionContent in mapping)) { 287 | result.push(sectionContent); 288 | } 289 | } 290 | return result; 291 | } 292 | 293 | async function reverseAndWrapReplacement(replacement: Replacement): Promise { 294 | const reversedReplacement = await reverseReplacement(replacement); 295 | if (replacement.node.textAutoResize === 'WIDTH_AND_HEIGHT') { 296 | return reversedReplacement; 297 | } 298 | return wrapReplacement(reversedReplacement); 299 | } 300 | 301 | async function reverseReplacement(replacement: Replacement): Promise { 302 | const {reversedText: reversedTranslation, nonReversibleRanges} = reverseText(replacement.translation); 303 | const n = replacement.translation.length; 304 | const reversedSections = replacement.sections.map(({from, to, style}) => ({from: n - to, to: n - from, style})); 305 | const overridingSections: Section[] = []; 306 | 307 | for (let range of nonReversibleRanges) { 308 | overridingSections.push({...range, style: replacement.baseStyle}); 309 | for (let {from, to, style} of reversedSections) { 310 | if (from < range.to && to > range.from) { 311 | overridingSections.push({ 312 | from: range.from + range.to - Math.min(to, range.to), 313 | to: range.from + range.to - Math.max(from, range.from), 314 | style, 315 | }); 316 | } 317 | } 318 | } 319 | 320 | const result = { 321 | node: replacement.node, 322 | translation: reversedTranslation, 323 | baseStyle: replacement.baseStyle, 324 | sections: reversedSections.concat(overridingSections), 325 | }; 326 | 327 | console.log('Reversed replacement:', result); 328 | 329 | return result; 330 | } 331 | 332 | function reverseText(text: string): {reversedText: string, nonReversibleRanges: {from: number, to: number}[]} { 333 | // TODO: replace with a proper implementation for RTL languages 334 | const words: string[] = []; 335 | const nonReversibleWordStack: string[] = []; 336 | const nonReversibleRanges: {from: number, to: number}[] = []; 337 | const dumpNonReversibleWordStack = (to: number) => { 338 | if (nonReversibleWordStack.length > 0) { 339 | const phrase = nonReversibleWordStack.reverse().join(' '); 340 | words.push(phrase); 341 | nonReversibleRanges.push({from: to - phrase.length, to}); 342 | nonReversibleWordStack.length = 0; 343 | } 344 | }; 345 | let offset = -1; 346 | for (let word of text.split(' ').reverse()) { 347 | if (isReversible(word)) { 348 | dumpNonReversibleWordStack(offset); 349 | words.push(reverseSpecialSymbols(word.split('').reverse().join(''))); 350 | } else { 351 | nonReversibleWordStack.push(word); 352 | } 353 | offset += word.length + 1; 354 | }; 355 | dumpNonReversibleWordStack(offset); 356 | return {reversedText: words.join(' '), nonReversibleRanges}; 357 | } 358 | 359 | function isReversible(word: string): boolean { 360 | return /[\u0500-\u0700]|^$/.test(word); 361 | } 362 | 363 | function reverseSpecialSymbols(word: string): string { 364 | const reversalTable = new Map([ 365 | ['(', ')'], [')', '('], 366 | ['[', ']'], [']', '['], 367 | ['{', '}'], ['}', '{'], 368 | ]); 369 | return word.split('').map(c => reversalTable.get(c) || c).join(''); 370 | } 371 | 372 | async function wrapReplacement(replacement: Replacement): Promise { 373 | await loadFontsForReplacement(replacement); 374 | 375 | const bufferNode = replacement.node.clone(); 376 | bufferNode.opacity = 0; 377 | bufferNode.characters = ''; 378 | bufferNode.textAutoResize = 'HEIGHT'; 379 | 380 | let wrappedTranslationLines: string[] = []; 381 | let wrappedSections: Section[] = []; 382 | 383 | const words = replacement.translation.split(' '); 384 | let wordIndex = words.length - 1; 385 | let lineStart = replacement.translation.length; 386 | let lineEnd = lineStart; 387 | let currentLineOffset = 0; 388 | while (wordIndex >= 0) { 389 | let currentLine = ''; 390 | let lineBreakStyle = replacement.baseStyle; 391 | while (wordIndex >= 0) { 392 | const word = words[wordIndex]; 393 | const insertion = wordIndex > 0 ? (' ' + word) : word; 394 | const originalBufferHeight = bufferNode.height; 395 | bufferNode.insertCharacters(currentLineOffset, insertion, 'AFTER'); 396 | lineStart -= insertion.length; 397 | for (let {from, to, style} of replacement.sections) { 398 | if (from < lineStart + insertion.length && to > lineStart) { 399 | setSectionStyle(bufferNode, currentLineOffset + Math.max(0, from - lineStart), currentLineOffset + Math.min(to - lineStart, insertion.length), style); 400 | } 401 | } 402 | const newBufferHeight = bufferNode.height; 403 | if (newBufferHeight > originalBufferHeight) { 404 | bufferNode.deleteCharacters(currentLineOffset, currentLineOffset + insertion.length); 405 | lineStart += insertion.length; 406 | if (lineStart == lineEnd) { 407 | bufferNode.remove(); 408 | return {nodeId: replacement.node.id, error: 'Word `' + reverseText(insertion).reversedText + '` does not fit into the box', suggestions: []}; 409 | } 410 | const lineBreakOffset = currentLineOffset + currentLine.length; 411 | bufferNode.insertCharacters(lineBreakOffset, '\u2028', 'BEFORE'); 412 | for (let {from, to, style} of replacement.sections.reverse()) { 413 | if (from <= lineStart - 1 && lineStart - 1 < to) { 414 | lineBreakStyle = style; 415 | break; 416 | } 417 | } 418 | setSectionStyle(bufferNode, lineBreakOffset, lineBreakOffset + 1, lineBreakStyle); 419 | break; 420 | } 421 | currentLine = insertion + currentLine; 422 | wordIndex --; 423 | } 424 | 425 | wrappedTranslationLines.push(currentLine); 426 | for (let {from, to, style} of replacement.sections) { 427 | if (from < lineEnd && to > lineStart) { 428 | wrappedSections.push({from: currentLineOffset + Math.max(0, from - lineStart), to: currentLineOffset + Math.min(to, lineEnd) - lineStart, style}); 429 | } 430 | } 431 | if (wordIndex >= 0) { 432 | wrappedSections.push({from: currentLineOffset + currentLine.length, to: currentLineOffset + currentLine.length + 1, style: lineBreakStyle}); 433 | } 434 | lineEnd = lineStart; 435 | currentLineOffset += currentLine.length + 1; 436 | } 437 | 438 | const result = { 439 | node: replacement.node, 440 | translation: wrappedTranslationLines.join('\u2028'), 441 | baseStyle: replacement.baseStyle, 442 | sections: wrappedSections, 443 | }; 444 | 445 | bufferNode.remove(); 446 | 447 | console.log('Wrapped replacement:', result); 448 | 449 | return result; 450 | } 451 | 452 | async function reverseNodeAlignments(nodes: TextNode[]): Promise { 453 | const alignments = nodes.map(node => ({node, alignment: node.textAlignHorizontal})); 454 | await mapWithRateLimit(alignments, 500, async ({node, alignment}) => { 455 | if (alignment !== 'LEFT' && alignment !== 'RIGHT') { 456 | return; 457 | } 458 | await loadFontsForNode(node); 459 | if (alignment === 'LEFT') { 460 | node.textAlignHorizontal = 'RIGHT'; 461 | } else if (alignment === 'RIGHT') { 462 | node.textAlignHorizontal = 'LEFT'; 463 | } 464 | }); 465 | } 466 | 467 | async function replaceText(replacement: Replacement): Promise { 468 | await loadFontsForReplacement(replacement); 469 | 470 | const {node, translation, baseStyle, sections} = replacement; 471 | node.characters = translation; 472 | if (sections.length > 0) { 473 | setSectionStyle(node, 0, translation.length, baseStyle); 474 | for (let {from, to, style} of sections) { 475 | setSectionStyle(node, from, to, style); 476 | } 477 | } 478 | } 479 | 480 | async function loadFontsForReplacement(replacement: Replacement): Promise { 481 | await figma.loadFontAsync(replacement.baseStyle.fontName); 482 | await Promise.all(replacement.sections.map(({style}) => figma.loadFontAsync(style.fontName))); 483 | } 484 | 485 | async function loadFontsForNode(node: TextNode): Promise { 486 | await Promise.all(Array.from({length: node.characters.length}, (_, k) => k).map(i => { 487 | return figma.loadFontAsync(node.getRangeFontName(i, i + 1) as FontName); 488 | })); 489 | } 490 | 491 | 492 | // Currency conversion 493 | 494 | type Currency = { 495 | code: string; 496 | schema: string; 497 | digitGroupSeparator: string; 498 | decimalSeparator: string; 499 | precision: number; 500 | rate: number; 501 | }; 502 | 503 | async function convertCurrencyInSelection(settings: Settings): Promise { 504 | const currencies = parseCurrencies(settings.serializedCurrencies); 505 | console.log('Currencies:', currencies); 506 | const sourceCurrency = currencies.filter(currency => currency.code === settings.sourceCurrencyCode)[0]; 507 | if (sourceCurrency === undefined) { 508 | throw {error: 'unknown currency code `' + settings.sourceCurrencyCode + '`'}; 509 | } 510 | const targetCurrency = currencies.filter(currency => currency.code === settings.targetCurrencyCode)[0]; 511 | if (targetCurrency === undefined) { 512 | throw {error: 'unknown currency code `' + settings.targetCurrencyCode + '`'}; 513 | } 514 | await replaceCurrencyInAllTexts(sourceCurrency, targetCurrency); 515 | } 516 | 517 | function parseCurrencies(serializedCurrencies: string): Currency[] { 518 | const codeSet = new Set(); 519 | return JSON.parse(serializedCurrencies).map((x: any, index: number) => { 520 | const currency: Currency = { 521 | code: null, 522 | schema: null, 523 | digitGroupSeparator: null, 524 | decimalSeparator: null, 525 | precision: null, 526 | rate: null, 527 | }; 528 | Object.keys(currency).forEach(key => { 529 | if (x[key] === undefined || x[key] === null) { 530 | throw {error: 'invalid currency definition: no `' + key + '` in entry #' + (index + 1)}; 531 | } 532 | if (key === 'schema' && x[key].indexOf('123') === -1) { 533 | throw {error: 'schema in entry #' + (index + 1) + ' should contain `123`'}; 534 | } 535 | if (key === 'rate' && x[key] <= 0) { 536 | throw {error: 'non-positive rate in entry #' + (index + 1)}; 537 | } 538 | currency[key] = x[key]; 539 | }); 540 | if (currency.precision > 0 && currency.decimalSeparator === '') { 541 | throw {error: 'entry #' + (index + 1) + ' must have a non-empty decimal separator'}; 542 | } 543 | if (codeSet.has(currency.code)) { 544 | throw {error: 'multiple entries for `' + currency.code + '`'}; 545 | } 546 | codeSet.add(currency.code); 547 | return currency; 548 | }); 549 | } 550 | 551 | async function replaceCurrencyInAllTexts(sourceCurrency: Currency, targetCurrency: Currency): Promise { 552 | const textNodes = await findSelectedTextNodes(); 553 | 554 | const escapedSchema = escapeForRegExp(sourceCurrency.schema); 555 | const escapedDigitGroupSeparator = escapeForRegExp(sourceCurrency.digitGroupSeparator); 556 | const escapedDecimalSeparator = escapeForRegExp(sourceCurrency.decimalSeparator); 557 | const sourceValueRegExpString = '((?:\\d|\\d' + escapedDigitGroupSeparator + '\\d)+' + escapedDecimalSeparator + '\\d{' + sourceCurrency.precision + '})'; 558 | const sourceRegExpString = '\\b' + escapedSchema.replace('123', sourceValueRegExpString + '(?:(\\s*[-\u2010-\u2015]\\s*)' + sourceValueRegExpString + ')?'); 559 | const sourceRegExp = new RegExp(sourceRegExpString, 'g'); 560 | console.log('Source regular expression:', sourceRegExpString); 561 | 562 | await mapWithRateLimit(textNodes, 250, async node => { 563 | const content = node.characters; 564 | const regexp = new RegExp(sourceRegExp); 565 | const conversions = []; 566 | while (true) { 567 | const match = regexp.exec(content); 568 | if (match === null) { 569 | break; 570 | } 571 | 572 | const style = getSectionStyle(node, match.index, match.index + match[0].length); 573 | if (style === figma.mixed) { 574 | throw {error: 'node `' + content + '` has a mixed-styled money value'}; 575 | } 576 | await figma.loadFontAsync(style.fontName); 577 | 578 | let targetValueString = convertMoneyValue(match[1], sourceCurrency, targetCurrency); 579 | if (match[3] !== null && match[3] !== undefined) { 580 | targetValueString += match[2] + convertMoneyValue(match[3], sourceCurrency, targetCurrency); 581 | } 582 | 583 | conversions.push({ 584 | from: match.index, 585 | to: match.index + match[0].length, 586 | target: targetCurrency.schema.replace('123', targetValueString), 587 | }); 588 | } 589 | 590 | conversions.reverse().forEach(({from, to, target}) => { 591 | node.insertCharacters(to, target, 'BEFORE'); 592 | node.deleteCharacters(from, to); 593 | }); 594 | }); 595 | } 596 | 597 | function convertMoneyValue(value: string, sourceCurrency: Currency, targetCurrency: Currency): string { 598 | return renderMoneyValue(extractMoneyValue(value, sourceCurrency) * targetCurrency.rate / sourceCurrency.rate, targetCurrency); 599 | } 600 | 601 | function extractMoneyValue(value: string, currency: Currency): number { 602 | let resultAsString = value.replace(new RegExp(escapeForRegExp(currency.digitGroupSeparator), 'g'), ''); 603 | if (currency.decimalSeparator !== '') { 604 | resultAsString = resultAsString.replace(currency.decimalSeparator, '.'); 605 | } 606 | return parseFloat(resultAsString); 607 | } 608 | 609 | function renderMoneyValue(value: number, currency: Currency): string { 610 | const truncatedValue = Math.trunc(value); 611 | const valueFraction = value - truncatedValue; 612 | return ( 613 | truncatedValue.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,').replace(/,/g, currency.digitGroupSeparator) + 614 | currency.decimalSeparator + 615 | valueFraction.toFixed(currency.precision).slice(2) 616 | ); 617 | } 618 | 619 | function escapeForRegExp(s: string): string { 620 | return s.replace(/([[\^$.|?*+()])/g, '\\$1'); 621 | } 622 | 623 | 624 | // Font substitution 625 | 626 | async function sendAvailableFonts() { 627 | const availableFonts = (await figma.listAvailableFontsAsync()).map(f => f.fontName); 628 | figma.ui.postMessage({type: 'available-fonts', availableFonts}); 629 | } 630 | 631 | async function sendSelectionFonts() { 632 | const textNodes = await findSelectedTextNodes(); 633 | const selectionFontIds = new Set(); 634 | const selectionFonts = []; 635 | await mapWithRateLimit(textNodes, 250, async node => { 636 | if (node.characters === '') { 637 | return; 638 | } 639 | const sections = sliceIntoSections(node); 640 | for (let {style} of sections) { 641 | const fontId = JSON.stringify(style.fontName); 642 | if (!selectionFontIds.has(fontId)) { 643 | selectionFontIds.add(fontId); 644 | selectionFonts.push(style.fontName); 645 | } 646 | } 647 | }); 648 | figma.ui.postMessage({type: 'selection-fonts', selectionFonts}); 649 | } 650 | 651 | async function substituteFontsInSelection(settings: Settings): Promise { 652 | const substitutions = JSON.parse(settings.serializedFontSubstitutions); 653 | const fontMapping = new Map(); 654 | for (let substitution of substitutions) { 655 | const sourceFontId = JSON.stringify(substitution.sourceFont); 656 | fontMapping.set(sourceFontId, substitution.targetFont); 657 | await figma.loadFontAsync(substitution.targetFont); 658 | } 659 | 660 | const textNodes = await findSelectedTextNodes(); 661 | await mapWithRateLimit(textNodes, 250, async node => { 662 | if (node.characters === '') { 663 | return; 664 | } 665 | const sections = sliceIntoSections(node); 666 | for (let {style} of sections) { 667 | await figma.loadFontAsync(style.fontName); 668 | } 669 | for (let {from, to, style} of sections) { 670 | const fontId = JSON.stringify(style.fontName); 671 | if (fontMapping.has(fontId)) { 672 | const newStyle = {...style}; 673 | newStyle.fontName = fontMapping.get(fontId); 674 | setSectionStyle(node, from, to, newStyle); 675 | } 676 | } 677 | }); 678 | } 679 | 680 | 681 | // Mirroring 682 | 683 | type MirroringFailure = { 684 | nodeId: string; 685 | error: string; 686 | }; 687 | 688 | async function mirrorSelection(settings: Settings): Promise { 689 | const selection = figma.currentPage.selection; 690 | const nodesToMirror = selection.concat(findMirrorableNodes(selection)); 691 | 692 | const componentIds: Set = new Set(); 693 | findNodesOfType(selection, 'COMPONENT').forEach(node => { 694 | componentIds.add(node.id); 695 | }); 696 | 697 | const failures: MirroringFailure[] = []; 698 | findNodesOfType(selection, 'INSTANCE').forEach((node: InstanceNode) => { 699 | const componentId = node.mainComponent.id; 700 | if (node.locked) { 701 | if (componentIds.has(componentId) && !node.mainComponent.locked) { 702 | failures.push({nodeId: node.id, error: 'Locked, but its main component is not'}); 703 | return; 704 | } 705 | } else { 706 | if (!componentIds.has(componentId)) { 707 | failures.push({nodeId: node.id, error: 'Unlocked, but its main component is not selected'}); 708 | return; 709 | } 710 | } 711 | }); 712 | if (failures.length > 0) { 713 | throw {error: 'found some unmirrorable nodes', failures}; 714 | } 715 | 716 | await mapWithRateLimit(nodesToMirror, 300, mirrorNode); 717 | } 718 | 719 | function findMirrorableNodes(roots: readonly SceneNode[]): SceneNode[] { 720 | const result = []; 721 | roots.forEach(root => { 722 | if ('findAll' in root && !root.locked) { 723 | if (root.type !== 'INSTANCE') { 724 | findMirrorableNodes(root.children).forEach(node => result.push(node)); 725 | } 726 | } else { 727 | result.push(root); 728 | } 729 | }); 730 | return result; 731 | } 732 | 733 | function findNodesOfType(roots: readonly SceneNode[], nodeType: 'COMPONENT'|'INSTANCE'): SceneNode[] { 734 | const result = []; 735 | roots.forEach(root => { 736 | if (root.type === nodeType) { 737 | result.push(root); 738 | } else if ('findAll' in root && !root.locked) { 739 | findNodesOfType(root.children, nodeType).forEach(node => result.push(node)); 740 | } 741 | }); 742 | return result; 743 | } 744 | 745 | async function mirrorNode(node: SceneNode): Promise { 746 | const w = node.width; 747 | const t = node.relativeTransform; 748 | node.relativeTransform = [ 749 | [-t[0][0], t[0][1], w * t[0][0] + t[0][2]], 750 | [-t[1][0], t[1][1], w * t[1][0] + t[1][2]], 751 | ]; 752 | } 753 | 754 | async function clipSelection(settings: Settings): Promise { 755 | if (figma.currentPage.selection.length > 1) { 756 | throw {error: 'more than 1 node selected (group your nodes first)'} 757 | } 758 | const selectedNode = figma.currentPage.selection[0]; 759 | if (selectedNode.parent == null) { 760 | throw {error: 'the selected node has no parent'} 761 | } 762 | if (!('width' in selectedNode.parent)) { 763 | throw {error: 'the parent node size is undefined'} 764 | } 765 | const host = selectedNode.parent; 766 | const nodeIndex = host.children.indexOf(selectedNode); 767 | const clipper = figma.createFrame(); 768 | clipper.backgrounds = []; 769 | clipper.resizeWithoutConstraints(host.width, host.height); 770 | clipper.clipsContent = true; 771 | clipper.appendChild(selectedNode); 772 | host.insertChild(nodeIndex, clipper); 773 | } 774 | 775 | 776 | // Utilities 777 | 778 | async function findSelectedTextNodes(): Promise { 779 | const result: TextNode[] = []; 780 | figma.currentPage.selection.forEach(root => { 781 | if (root.type === 'TEXT') { 782 | result.push(root as TextNode); 783 | } else if ('findAll' in root) { 784 | (root as ChildrenMixin).findAll(node => node.type === 'TEXT').forEach(node => result.push(node as TextNode)); 785 | } 786 | }); 787 | return result; 788 | } 789 | 790 | function sliceIntoSections(node: TextNode, from: number = 0, to: number = node.characters.length): Section[] { 791 | if (to === from) { 792 | return []; 793 | } 794 | 795 | const style = getSectionStyle(node, from, to); 796 | if (style !== figma.mixed) { 797 | return [{from, to, style}]; 798 | } 799 | 800 | if (to - from === 1) { 801 | console.log('WARNING! Unexpected problem at node `' + node.characters + '`: a single character has "mixed" style'); 802 | return []; // TODO: fix the problem 803 | } 804 | 805 | const center = Math.floor((from + to) / 2); 806 | const leftSections = sliceIntoSections(node, from, center); 807 | if (leftSections.length === 0) { 808 | return []; // TODO: fix the problem 809 | } 810 | const rightSections = sliceIntoSections(node, center, to); 811 | if (rightSections.length === 0) { 812 | return []; // TODO: fix the problem 813 | } 814 | const lastLeftSection = leftSections[leftSections.length-1]; 815 | const firstRightSection = rightSections[0]; 816 | if (lastLeftSection.style.id === firstRightSection.style.id) { 817 | firstRightSection.from = lastLeftSection.from; 818 | leftSections.pop(); 819 | } 820 | return leftSections.concat(rightSections); 821 | } 822 | 823 | function getSectionStyle(node: TextNode, from: number, to: number): Style | PluginAPI['mixed'] { 824 | const fills = node.getRangeFills(from, to); 825 | if (fills === figma.mixed) { 826 | return figma.mixed; 827 | } 828 | const fillStyleId = node.getRangeFillStyleId(from, to); 829 | if (fillStyleId === figma.mixed) { 830 | return figma.mixed; 831 | } 832 | const fontName = node.getRangeFontName(from, to); 833 | if (fontName === figma.mixed) { 834 | return figma.mixed; 835 | } 836 | const fontSize = node.getRangeFontSize(from, to); 837 | if (fontSize === figma.mixed) { 838 | return figma.mixed; 839 | } 840 | const letterSpacing = node.getRangeLetterSpacing(from, to); 841 | if (letterSpacing === figma.mixed) { 842 | return figma.mixed; 843 | } 844 | const lineHeight = node.getRangeLineHeight(from, to); 845 | if (lineHeight === figma.mixed) { 846 | return figma.mixed; 847 | } 848 | const textDecoration = node.getRangeTextDecoration(from, to); 849 | if (textDecoration === figma.mixed) { 850 | return figma.mixed; 851 | } 852 | const textStyleId = node.getRangeTextStyleId(from, to); 853 | if (textStyleId === figma.mixed) { 854 | return figma.mixed; 855 | } 856 | const parameters = { 857 | fills, 858 | fillStyleId, 859 | fontName, 860 | fontSize, 861 | letterSpacing, 862 | lineHeight, 863 | textDecoration, 864 | textStyleId, 865 | }; 866 | return { 867 | id: JSON.stringify(parameters), 868 | ...parameters, 869 | }; 870 | } 871 | 872 | function setSectionStyle(node: TextNode, from: number, to: number, style: Style): void { 873 | node.setRangeTextStyleId(from, to, style.textStyleId); 874 | 875 | node.setRangeFills(from, to, style.fills); 876 | node.setRangeFillStyleId(from, to, style.fillStyleId); 877 | node.setRangeFontName(from, to, style.fontName); 878 | node.setRangeFontSize(from, to, style.fontSize); 879 | node.setRangeLetterSpacing(from, to, style.letterSpacing); 880 | node.setRangeLineHeight(from, to, style.lineHeight); 881 | node.setRangeTextDecoration(from, to, style.textDecoration); 882 | } 883 | 884 | function mapWithRateLimit(array: X[], rateLimit: number, mapper: (x: X) => Promise): Promise { 885 | return new Promise((resolve, reject) => { 886 | const result = new Array(array.length); 887 | let index = 0; 888 | let done = 0; 889 | var startTime = Date.now(); 890 | 891 | const computeDelay = () => startTime + index * 1000.0 / rateLimit - Date.now(); 892 | 893 | const schedule = () => { 894 | while (index < array.length && computeDelay() < 0) { 895 | (i => { 896 | mapper(array[i]).then(y => { 897 | result[i] = y; 898 | ++done; 899 | schedule(); 900 | }, reject); 901 | })(index); 902 | ++index; 903 | } 904 | if (done === array.length) { 905 | resolve(result); 906 | } else { 907 | const delay = computeDelay(); 908 | if (delay >= 0) { 909 | setTimeout(schedule, delay); 910 | } 911 | } 912 | } 913 | 914 | schedule(); 915 | }); 916 | } 917 | 918 | 919 | figma.showUI(__html__, {width: 400, height: 400}); 920 | 921 | figma.ui.onmessage = async message => { 922 | if (message.type === 'load-settings') { 923 | const settings = await SettingsManager.load(); 924 | console.log('Loaded settings:', settings); 925 | figma.ui.postMessage({type: 'settings', settings}); 926 | figma.ui.postMessage({type: 'ready'}); 927 | } else if (message.type === 'load-available-fonts') { 928 | await sendAvailableFonts(); 929 | } else if (message.type === 'load-selection-fonts') { 930 | await sendSelectionFonts(); 931 | } else if (message.type === 'translate') { 932 | await SettingsManager.save(message.settings); 933 | await translateSelection(message.settings) 934 | .then(() => { 935 | figma.notify('Done'); 936 | figma.ui.postMessage({type: 'translation-failures', failures: []}); 937 | figma.ui.postMessage({type: 'ready'}); 938 | }) 939 | .catch(reason => { 940 | if ('error' in reason) { 941 | figma.notify('Translation failed: ' + reason.error); 942 | if ('failures' in reason) { 943 | figma.ui.postMessage({type: 'translation-failures', failures: reason.failures}); 944 | } 945 | } else { 946 | figma.notify(reason.toString()); 947 | } 948 | figma.ui.postMessage({type: 'ready'}); 949 | }); 950 | } else if (message.type === 'convert-currency') { 951 | await SettingsManager.save(message.settings); 952 | await convertCurrencyInSelection(message.settings) 953 | .then(() => { 954 | figma.notify('Done'); 955 | figma.ui.postMessage({type: 'ready'}); 956 | }) 957 | .catch(reason => { 958 | if ('error' in reason) { 959 | figma.notify('Currency conversion failed: ' + reason.error); 960 | } else { 961 | figma.notify(reason.toString()); 962 | } 963 | figma.ui.postMessage({type: 'ready'}); 964 | }); 965 | } else if (message.type === 'mirror') { 966 | await SettingsManager.save(message.settings); 967 | await mirrorSelection(message.settings) 968 | .then(() => { 969 | figma.notify('Done'); 970 | figma.ui.postMessage({type: 'mirroring-failures', failures: []}); 971 | figma.ui.postMessage({type: 'ready'}); 972 | }) 973 | .catch(reason => { 974 | if ('error' in reason) { 975 | figma.notify('Mirroring failed: ' + reason.error); 976 | if ('failures' in reason) { 977 | figma.ui.postMessage({type: 'mirroring-failures', failures: reason.failures}); 978 | } 979 | } else { 980 | figma.notify(reason.toString()); 981 | } 982 | figma.ui.postMessage({type: 'ready'}); 983 | }); 984 | } else if (message.type === 'clip') { 985 | await SettingsManager.save(message.settings); 986 | await clipSelection(message.settings) 987 | .then(() => { 988 | figma.notify('Done'); 989 | figma.ui.postMessage({type: 'mirroring-failures', failures: []}); 990 | figma.ui.postMessage({type: 'ready'}); 991 | }) 992 | .catch(reason => { 993 | if ('error' in reason) { 994 | figma.notify('Clipping failed: ' + reason.error); 995 | } else { 996 | figma.notify(reason.toString()); 997 | } 998 | figma.ui.postMessage({type: 'ready'}); 999 | }); 1000 | } else if (message.type === 'substitute-fonts') { 1001 | await SettingsManager.save(message.settings); 1002 | await substituteFontsInSelection(message.settings) 1003 | .then(() => sendSelectionFonts().then(() => { 1004 | figma.notify('Done'); 1005 | figma.ui.postMessage({type: 'ready'}); 1006 | })) 1007 | .catch(reason => { 1008 | if ('error' in reason) { 1009 | figma.notify('Font substitution failed: ' + reason.error); 1010 | } else { 1011 | figma.notify(reason.toString()); 1012 | } 1013 | figma.ui.postMessage({type: 'ready'}); 1014 | }); 1015 | } else if (message.type === 'focus-node') { 1016 | figma.viewport.zoom = 1000.0; 1017 | figma.viewport.scrollAndZoomIntoView([figma.getNodeById(message.id)]); 1018 | figma.viewport.zoom = 0.75 * figma.viewport.zoom; 1019 | } 1020 | }; 1021 | 1022 | figma.on("selectionchange", sendSelectionFonts); 1023 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Static Localizer", 3 | "id": "876934931929982678", 4 | "api": "1.0.0", 5 | "main": "code.js", 6 | "ui": "ui.html" 7 | } -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-static-localizer", 3 | "version": "1.2.2", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@figma/plugin-typings": { 8 | "version": "1.16.1", 9 | "resolved": "https://registry.npmjs.org/@figma/plugin-typings/-/plugin-typings-1.16.1.tgz", 10 | "integrity": "sha512-8zJHr0UASf2eDBGW/jNV1PLB1FqNyMpy1Z7v5XODHUEDkoXO/UBfeRohx0SqIz44z/0eSG4youSzYUZoZbdeqg==", 11 | "dev": true 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-static-localizer", 3 | "version": "1.2.2", 4 | "description": "Figma Static Localizer", 5 | "main": "code.js", 6 | "scripts": { 7 | "build": "tsc -p tsconfig.json" 8 | }, 9 | "author": "Iskander Sitdikov", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@figma/plugin-typings": "^1.16.1" 13 | }, 14 | "dependencies": {} 15 | } 16 | -------------------------------------------------------------------------------- /screenshots/currency-conversion-after.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughteer/figma-static-localizer/5a3ff5f91e3885e89ba03f0631e1e367e86faec6/screenshots/currency-conversion-after.webp -------------------------------------------------------------------------------- /screenshots/currency-conversion-before.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughteer/figma-static-localizer/5a3ff5f91e3885e89ba03f0631e1e367e86faec6/screenshots/currency-conversion-before.webp -------------------------------------------------------------------------------- /screenshots/font-substitution-after.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughteer/figma-static-localizer/5a3ff5f91e3885e89ba03f0631e1e367e86faec6/screenshots/font-substitution-after.webp -------------------------------------------------------------------------------- /screenshots/font-substitution-before.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughteer/figma-static-localizer/5a3ff5f91e3885e89ba03f0631e1e367e86faec6/screenshots/font-substitution-before.webp -------------------------------------------------------------------------------- /screenshots/mirroring-after.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughteer/figma-static-localizer/5a3ff5f91e3885e89ba03f0631e1e367e86faec6/screenshots/mirroring-after.webp -------------------------------------------------------------------------------- /screenshots/mirroring-before.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughteer/figma-static-localizer/5a3ff5f91e3885e89ba03f0631e1e367e86faec6/screenshots/mirroring-before.webp -------------------------------------------------------------------------------- /screenshots/translation-after.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughteer/figma-static-localizer/5a3ff5f91e3885e89ba03f0631e1e367e86faec6/screenshots/translation-after.webp -------------------------------------------------------------------------------- /screenshots/translation-before.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughteer/figma-static-localizer/5a3ff5f91e3885e89ba03f0631e1e367e86faec6/screenshots/translation-before.webp -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "typeRoots": [ 5 | "./node_modules/@types", 6 | "./node_modules/@figma" 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ui.html: -------------------------------------------------------------------------------- 1 | 370 | 371 | 372 |
373 |
Translation
374 |
Conversion
375 |
Mirroring
376 |
Fonts
377 |
378 | 379 |
380 |
381 |
382 |
Dictionary
383 | 386 | 387 |
388 |
389 | 390 |
391 |
392 |
393 |
394 |
Exceptions
395 | 398 | 399 |
400 |
401 | 402 |
403 | 406 |
407 |
408 |
409 | 410 |
 → 
411 | 412 |
413 |
 
414 | 415 |
416 |
417 |
418 |
419 |
420 | 421 |
422 |
423 |
424 |
Currencies
425 | 428 | 429 |
430 |
431 | 432 |
433 |
434 |
435 |
436 | 437 |
 → 
438 | 439 |
 
440 | 441 |
442 |
443 |
444 | 445 |
446 |
447 |
448 | 449 |
450 |
451 |
452 |
453 |
454 | 455 |
Clip the node to the boundaries of the parent frame
456 |
457 |
458 |
459 |
460 |
461 | 462 |
463 |
464 |
Substitutions
465 |
466 | 467 |
468 | 471 | 472 | 473 |
474 |
475 | 476 |
477 | 478 |
 
479 | 480 |
481 |
482 |
483 | 484 |
485 |
486 | 487 | 750 | 751 | --------------------------------------------------------------------------------