diff --git a/Ilion.xcodeproj/project.pbxproj b/Ilion.xcodeproj/project.pbxproj index 32dd22e..019f122 100644 --- a/Ilion.xcodeproj/project.pbxproj +++ b/Ilion.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ E85387D91EA6B080003184B4 /* BrowserWindow.xib in Resources */ = {isa = PBXBuildFile; fileRef = E85387D71EA6B080003184B4 /* BrowserWindow.xib */; }; E85387DA1EA6B080003184B4 /* BrowserWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85387D81EA6B080003184B4 /* BrowserWindowController.swift */; }; E85387DC1EA6B1AF003184B4 /* Ilion.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85387DB1EA6B1AF003184B4 /* Ilion.swift */; }; + E85E7BA0216E9CF400858F2B /* Translation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85E7B9F216E9CF400858F2B /* Translation.swift */; }; + E85E7BA2216E9F0900858F2B /* String+PseudoLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85E7BA1216E9F0900858F2B /* String+PseudoLanguage.swift */; }; E8802A251EAE73B5005DFAF0 /* Dictionary+Fmap.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8802A241EAE73B5005DFAF0 /* Dictionary+Fmap.swift */; }; E8802A271EAE8472005DFAF0 /* String+RelativePath.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8802A261EAE8472005DFAF0 /* String+RelativePath.swift */; }; E8AA66DE1EB638A100903163 /* FormatDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8AA66DD1EB638A100903163 /* FormatDescriptor.swift */; }; @@ -51,6 +53,8 @@ E85387D71EA6B080003184B4 /* BrowserWindow.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = BrowserWindow.xib; sourceTree = ""; }; E85387D81EA6B080003184B4 /* BrowserWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserWindowController.swift; sourceTree = ""; }; E85387DB1EA6B1AF003184B4 /* Ilion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Ilion.swift; sourceTree = ""; }; + E85E7B9F216E9CF400858F2B /* Translation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Translation.swift; sourceTree = ""; }; + E85E7BA1216E9F0900858F2B /* String+PseudoLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+PseudoLanguage.swift"; sourceTree = ""; }; E8802A241EAE73B5005DFAF0 /* Dictionary+Fmap.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Dictionary+Fmap.swift"; sourceTree = ""; }; E8802A261EAE8472005DFAF0 /* String+RelativePath.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+RelativePath.swift"; sourceTree = ""; }; E8AA66DD1EB638A100903163 /* FormatDescriptor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FormatDescriptor.swift; sourceTree = ""; }; @@ -111,9 +115,11 @@ E8B46F0C1EF5A46500A9C6F5 /* NSAttributedString+TokenMarkup.swift */, E8136A691EA7E155003D9877 /* NSBundle+Ilion.m */, E81AE5C61EC4FED30034EF07 /* String+Length.swift */, + E85E7BA1216E9F0900858F2B /* String+PseudoLanguage.swift */, E8802A261EAE8472005DFAF0 /* String+RelativePath.swift */, E8DCAA231ECF8E8E007F8915 /* StringsFileParser.swift */, E85387D11EA6AD2E003184B4 /* StringsManager.swift */, + E85E7B9F216E9CF400858F2B /* Translation.swift */, E8B46F081EF5986D00A9C6F5 /* TokenCell.swift */, E80F44AB216A96DF00EF273A /* ToolsPanel.xib */, E80F44A9216A96A700EF273A /* ToolsPanelController.swift */, @@ -209,12 +215,14 @@ E8DCAA241ECF8E8E007F8915 /* StringsFileParser.swift in Sources */, E8AA66DE1EB638A100903163 /* FormatDescriptor.swift in Sources */, E85387DC1EA6B1AF003184B4 /* Ilion.swift in Sources */, + E85E7BA2216E9F0900858F2B /* String+PseudoLanguage.swift in Sources */, E84EA94F1EB3C858001C6856 /* IlionLauncher.m in Sources */, E8B027D01EC1198B0006CA51 /* LocalizedFormat.swift in Sources */, E8802A271EAE8472005DFAF0 /* String+RelativePath.swift in Sources */, E81AE5CB1EC74F2D0034EF07 /* EditPanelViewModel.swift in Sources */, E85387D61EA6AF9B003184B4 /* EditPanelController.swift in Sources */, E8802A251EAE73B5005DFAF0 /* Dictionary+Fmap.swift in Sources */, + E85E7BA0216E9CF400858F2B /* Translation.swift in Sources */, E85387DA1EA6B080003184B4 /* BrowserWindowController.swift in Sources */, E80F44AA216A96A700EF273A /* ToolsPanelController.swift in Sources */, E8B46F091EF5986D00A9C6F5 /* TokenCell.swift in Sources */, diff --git a/Ilion/Ilion.swift b/Ilion/Ilion.swift index cc74315..630cdfd 100644 --- a/Ilion/Ilion.swift +++ b/Ilion/Ilion.swift @@ -108,6 +108,7 @@ extension Ilion: BrowserWindowControllerDelegate { toolsPanelController = ToolsPanelController() toolsPanelController?.shouldInsertStartEndMarkers = StringsManager.defaultManager.insertsStartEndMarkers + toolsPanelController?.shouldTransformCharacters = StringsManager.defaultManager.transformsCharacters toolsPanelController?.delegate = self browserWindowController?.window?.beginSheet(toolsPanelController!.window!) @@ -154,11 +155,13 @@ extension Ilion: ToolsPanelControllerDelegate { func toolsPanelControllerDidClose(_ sender: ToolsPanelController) { let markersFlag = toolsPanelController!.shouldInsertStartEndMarkers + let transformFlag = toolsPanelController!.shouldTransformCharacters browserWindowController?.window?.endSheet(sender.window!) toolsPanelController = nil StringsManager.defaultManager.insertsStartEndMarkers = markersFlag + StringsManager.defaultManager.transformsCharacters = transformFlag } } diff --git a/Ilion/LocalizedFormat.swift b/Ilion/LocalizedFormat.swift index 55e9875..7921f0a 100644 --- a/Ilion/LocalizedFormat.swift +++ b/Ilion/LocalizedFormat.swift @@ -88,10 +88,73 @@ struct LocalizedFormat { "format": VariableSpec(valueType: valueType, ruleSpecs: formats) ] } + + private init(baseFormat: String, variableSpecs: [String: VariableSpec]) { + self.baseFormat = baseFormat + self.variableSpecs = variableSpecs + } + + func appending(_ string: String) -> LocalizedFormat { + return LocalizedFormat(baseFormat: baseFormat.appending(string), variableSpecs: variableSpecs) + } + + func prepending(_ string: String) -> LocalizedFormat { + return LocalizedFormat(baseFormat: string.appending(baseFormat), variableSpecs: variableSpecs) + } + + func applyingTransform(_ transform: ([String]) -> [String]) -> LocalizedFormat { + var config = toStringsDictEntry() + let ruleNames = Set(PluralRule.allCases.map({ $0.rawValue })) + + func slice(_ str: String, by indices: IndexSet) -> [String] { + return indices.rangeView.map { range in + let sliceStart = str.index(str.startIndex, offsetBy: range.startIndex) + let sliceEnd = str.index(str.startIndex, offsetBy: range.endIndex) + return String(str[sliceStart.. [String: Any] { + func toStringsDictEntry() -> [String: Any] { var config: [String: Any] = [ - "NSStringLocalizedFormatKey": insertMarkers ?"[\(baseFormat)]" : baseFormat + "NSStringLocalizedFormatKey": baseFormat ] for (varName, varSpec) in variableSpecs { diff --git a/Ilion/String+PseudoLanguage.swift b/Ilion/String+PseudoLanguage.swift new file mode 100644 index 0000000..0b46be0 --- /dev/null +++ b/Ilion/String+PseudoLanguage.swift @@ -0,0 +1,45 @@ +// +// String+PseudoLanguage.swift +// Ilion +// +// Created by Tamas Lustyik on 2018. 10. 10.. +// Copyright © 2018. Tamas Lustyik. All rights reserved. +// + +import Foundation + +extension String { + func applyingPseudoLanguageTransformation() -> String { + let pseudoLatin = "αɓ¢ԃεƒϑჩℹ︎ʝҝƖოηфϸƪʀς†цγш×уƶΛßϚЀƑ₲ҤǀͿƘ£ℳИØ₱Ɋ®ʃƬЦƲШχ¥Ɀ" + let combiningDiacriticalRange: Range = 0x300..<0x370 + let isBasicLatinLower: (UInt32) -> Bool = { (0x61...0x7A).contains($0) } + let isBasicLatinUpper: (UInt32) -> Bool = { (0x41...0x5A).contains($0) } + let isBasicLatin: (UInt32) -> Bool = { isBasicLatinLower($0) || isBasicLatinUpper($0) } + + var scalars = decomposedStringWithCanonicalMapping + .flatMap { Array($0.unicodeScalars) } + .map { $0.value } + + var insertions: [(Int, UInt32)] = [] + for (idx, scalar) in scalars.enumerated() where isBasicLatin(scalar) { + // character replacement + if Bool.random() { + let offset = isBasicLatinLower(scalar) ? Int(scalar - 0x61) : Int(scalar - 0x41) + let ch = pseudoLatin[pseudoLatin.index(pseudoLatin.startIndex, offsetBy: offset)] + scalars[idx] = ch.unicodeScalars.first!.value + } + + // diacritic decoration + for _ in 0..<(1...4).randomElement()! { + let diacritic = combiningDiacriticalRange.randomElement()! + insertions.append((idx + 1, diacritic)) + } + } + + for insertion in insertions.reversed() { + scalars.insert(insertion.1, at: insertion.0) + } + + return String(String.UnicodeScalarView(scalars.map { Unicode.Scalar($0)! })) + } +} diff --git a/Ilion/StringsManager.swift b/Ilion/StringsManager.swift index bacd37b..a4a818e 100644 --- a/Ilion/StringsManager.swift +++ b/Ilion/StringsManager.swift @@ -9,11 +9,6 @@ import Foundation -enum Translation { - case `static`(String) - case `dynamic`(LocalizedFormat) -} - struct StringsEntry { var locKey: LocKey var comment: String? @@ -38,6 +33,7 @@ typealias StringsDB = [BundleURI: [ResourceURI: [LocKey: StringsEntry]]] private let storedOverridesKey = "Ilion.TranslationOverrides" private let markersKey = "Ilion.InsertsStartEndMarkers" + private let transformKey = "Ilion.TransformsCharacters" private let userDefaults: UserDefaults private let stringsFileParser: StringsFileParser @@ -51,7 +47,13 @@ typealias StringsDB = [BundleURI: [ResourceURI: [LocKey: StringsEntry]]] userDefaults.setValue(insertsStartEndMarkers, forKey: markersKey) } } - + + var transformsCharacters: Bool = false { + didSet { + userDefaults.setValue(transformsCharacters, forKey: transformKey) + } + } + @objc static let defaultManager = StringsManager(userDefaults: .standard, stringsFileParser: StringsFileParser()) private init(userDefaults: UserDefaults, stringsFileParser: StringsFileParser) { @@ -62,6 +64,7 @@ typealias StringsDB = [BundleURI: [ResourceURI: [LocKey: StringsEntry]]] db = [:] overriddenKeyPaths = [] insertsStartEndMarkers = userDefaults.value(forKey: markersKey) as? Bool ?? false + transformsCharacters = userDefaults.value(forKey: transformKey) as? Bool ?? false super.init() @@ -140,20 +143,18 @@ typealias StringsDB = [BundleURI: [ResourceURI: [LocKey: StringsEntry]]] let entry = db[bundleURI]?[stringsDictResourceURI]?[key] ?? db[bundleURI]?[stringsResourceURI]?[key] else { let baseCopy = (value?.isEmpty ?? true) ? key : value! - return insertsStartEndMarkers ? "[\(baseCopy)]" : baseCopy + return [Translation.static(baseCopy)] + .map { transformsCharacters ? $0.applyingPseudoLocalization() : $0 } + .map { insertsStartEndMarkers ? $0.addingStartEndMarkers() : $0 } + .first! + .toString() } - let translation = entry.override ?? entry.translation - - switch translation { - case .static(let text): return insertsStartEndMarkers ? "[\(text)]" : text - case .dynamic(let format): - let config = format.toStringsDictEntry(insertingStartEndMarkers: insertsStartEndMarkers) - let nsFormat = format.baseFormat as NSString - let locFormat = nsFormat.perform(NSSelectorFromString("_copyFormatStringWithConfiguration:"), with: config) - .takeUnretainedValue() - return locFormat as! String - } + return [entry.override ?? entry.translation] + .map { transformsCharacters ? $0.applyingPseudoLocalization() : $0 } + .map { insertsStartEndMarkers ? $0.addingStartEndMarkers() : $0 } + .first! + .toString() } // MARK: - internal Swift API diff --git a/Ilion/ToolsPanel.xib b/Ilion/ToolsPanel.xib index 297df9d..305326b 100644 --- a/Ilion/ToolsPanel.xib +++ b/Ilion/ToolsPanel.xib @@ -9,6 +9,7 @@ + @@ -17,14 +18,14 @@ - + - + + + + - + diff --git a/Ilion/ToolsPanelController.swift b/Ilion/ToolsPanelController.swift index f2d11c7..9ab2604 100644 --- a/Ilion/ToolsPanelController.swift +++ b/Ilion/ToolsPanelController.swift @@ -14,6 +14,7 @@ protocol ToolsPanelControllerDelegate: class { final class ToolsPanelController: NSWindowController { @IBOutlet private weak var markersCheckbox: NSButton! + @IBOutlet private weak var transformCheckbox: NSButton! weak var delegate: ToolsPanelControllerDelegate? @@ -23,7 +24,14 @@ final class ToolsPanelController: NSWindowController { markersCheckbox?.state = shouldInsertStartEndMarkers ? .on : .off } } - + + var shouldTransformCharacters: Bool = false { + didSet { + guard isWindowLoaded else { return } + transformCheckbox?.state = shouldTransformCharacters ? .on : .off + } + } + override var windowNibName: NSNib.Name? { return "ToolsPanel" } @@ -31,10 +39,12 @@ final class ToolsPanelController: NSWindowController { override func windowDidLoad() { super.windowDidLoad() markersCheckbox.state = shouldInsertStartEndMarkers ? .on : .off + transformCheckbox.state = shouldTransformCharacters ? .on : .off } @IBAction private func checkboxToggled(_ sender: Any) { shouldInsertStartEndMarkers = markersCheckbox.state == .on + shouldTransformCharacters = transformCheckbox.state == .on } @IBAction private func doneClicked(_ sender: Any) { diff --git a/Ilion/Translation.swift b/Ilion/Translation.swift new file mode 100644 index 0000000..9e68b87 --- /dev/null +++ b/Ilion/Translation.swift @@ -0,0 +1,48 @@ +// +// Translation.swift +// Ilion +// +// Created by Tamas Lustyik on 2018. 10. 10.. +// Copyright © 2018. Tamas Lustyik. All rights reserved. +// + +import Foundation + +enum Translation { + case `static`(String) + case `dynamic`(LocalizedFormat) + + func toString() -> String { + switch self { + case .static(let text): return text + case .dynamic(let format): + let config = format.toStringsDictEntry() + let nsFormat = format.baseFormat as NSString + let locFormat = nsFormat.perform(NSSelectorFromString("_copyFormatStringWithConfiguration:"), with: config) + .takeUnretainedValue() + return locFormat as! String + } + } +} + +extension Translation { + func addingStartEndMarkers() -> Translation { + switch self { + case .static(let text): return .static("[\(text)]") + case .dynamic(let format): return .dynamic(format.prepending("[").appending("]")) + } + } + + func applyingPseudoLocalization() -> Translation { + switch self { + case .static(let text): + return .static(text.applyingPseudoLanguageTransformation()) + + case .dynamic(let format): + let transformedFormat = format.applyingTransform { slices in + slices.map { $0.applyingPseudoLanguageTransformation() } + } + return .dynamic(transformedFormat) + } + } +} diff --git a/README.md b/README.md index c1955c1..fcf4121 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ or in ObjC: macOS 10.10 +### Usage + +For a detailed description of Ilion's features, see the [Getting Started](doc/GettingStarted.md) guide. + ### Limitations - only `.strings`/`.stringsdict` files and UI base localization are supported (that is, one XIB + many strings files) diff --git a/doc/GettingStarted.md b/doc/GettingStarted.md index 61f838b..05e66df 100644 --- a/doc/GettingStarted.md +++ b/doc/GettingStarted.md @@ -21,7 +21,7 @@ But before we dive deeper, let me drag you back into reality: Ilion is not (yet) a replacement for the user interface of cloud-based localization services (e.g. Transifex or Smartling). The strings you change while running the app will not get synced back to the server or saved into the application. If you copy the application to another machine, the modified strings won't follow. So imagine it as an isolated sandbox where you can play around but cannot alter anything in the outside world. --- +--- ### Basic usage @@ -51,7 +51,7 @@ Note: to revert to the original copy, it is not sufficient to clear the text fie **IMPORTANT:** Changes made to copies are never immediately reflected on the app UI. E.g. if you are customizing texts in a dialog, you'll have to close and reopen the dialog to see the updated texts. Customizing other parts of the application (e.g. the main menu) may even require restarting the app for changes to take effect. This is a known limitation. --- +--- ### Localization tools @@ -63,7 +63,16 @@ Ilion comes with a limited but possibly growing set of localization tools that a Checking the _Insert start/end markers_ checkbox will make all localized strings appear surrounded by square brackets throughout the host application. This is useful to visually detect whether copy dimensions are respected by the UI (it's easy to spot a missing `]` closing bracket). Marker brackets will not appear in the localization editor. -- +#### Fuzzy character transform (aka. pseudo-localization) + +If the _Fuzzy transform Latin characters_ checkbox is checked, any original or overridden translation appearing in the host app will be subject to a transformation that: + +- replaces base characters of the Latin alphabet with similar-looking unicode characters (e.g. `o` --> `ф`), and +- sprinkles random diacritics onto each character. + +The result is a somewhat noisy but more or less legible copy (e.g. `localization` --> ![localization](pseudo_localization.png). This tool can come handy to detect hardcoded/unlocalized strings in the host application (they will stick out from the accented mass), but you can also use it to verify that your labels are high enough to accomodate letters that extend farther above or below the baseline that the ones in your development language (glyph clipping); and, finally, to see if the chosen font can cope with the shadier parts of the Unicode plane. + +--- ### Exporting changes @@ -73,7 +82,7 @@ Ilion now supports exporting the app's string resources with all the changes tha You are then presented with a file dialog where you can select the directory to save the resources to. When exporting is finished, the exported files will be revealed in the Finder. --- +--- ### Advanced topics diff --git a/doc/pseudo_localization.png b/doc/pseudo_localization.png new file mode 100644 index 0000000..9f92df5 Binary files /dev/null and b/doc/pseudo_localization.png differ diff --git a/doc/tools_button.png b/doc/tools_button.png index c9017d5..1f28797 100644 Binary files a/doc/tools_button.png and b/doc/tools_button.png differ