├── .gitignore ├── LICENSE ├── README.md ├── SmallStrings.podspec ├── Source ├── SSTSmallStrings.h └── SSTSmallStrings.m ├── compress ├── compress.m ├── localize.rb └── localize.sh /.gitignore: -------------------------------------------------------------------------------- 1 | compress # Ignore executable that gets compiled 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Emerge Tools 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Emerge Tools rounded](https://user-images.githubusercontent.com/6634452/205205728-19b3376a-e99a-4a74-916b-0519deeff08b.png) 2 | 3 | # SmallStrings | Reduce localized .strings file sizes by 80% 4 | #### Maintained by [Emerge Tools](https://emergetools.com?utm_source=smallstrings) 5 | 6 | > 🧪 **Note**: This repo is meant as a proof-of-concept on how to reduce localization size. Adjustments may be needed for your specific project. 7 | 8 | ### How does it work 9 | 10 | - Convert .strings files (of the form App.app/\*.lproj/Localizable.strings only) into a minified form 11 | - Eliminate key duplication ([read more about the strategy](https://eisel.me/localization)), this typically reduces the size by about 50% 12 | - Keep small files in a compressed form on disk, using LZFSE, to reduce the size further 13 | - Replace the original language.lproj/Localizable.strings with placeholders that have one key-value pair each. This shows Apple that the same languages are still supported, so that it can pick the correct one based on the user's settings. 14 | - Use the iOS library that replaces `NSLocalizedString` with a new version, `SSTStringForKey` that fetches values for keys from this minified format. 15 | 16 | ### Usage 17 | 18 | #### Cocoapods 19 | 20 | Add this to your Podfile: 21 | ``` 22 | pod 'SmallStrings' 23 | ``` 24 | 25 | Then add a Run Script build phase after the "Copy bundle resources" phase: 26 | ``` 27 | cd ${PODS_ROOT}/SmallStrings && ./localize.sh ${CODESIGNING_FOLDER_PATH} ${DERIVED_FILES_DIR}/SmallStrings.cache 28 | ``` 29 | 30 | Lastly, replace all usages of `NSLocalizedString(key, comment)` with `SSTStringForKey(key)`. 31 | 32 | #### Manual 33 | 34 | - Add `Source/SSTSmallStrings.{h,m}` to your project. 35 | - Create a `compress` binary via `clang -O3 compress.m -framework Foundation -lcompression -o compress` and put the executable in the same directory as `localize.{rb,sh}`. 36 | - Add a build step with `cd /path/to/SmallStrings && ./localize.sh ${CODESIGNING_FOLDER_PATH} ${DERIVED_FILES_DIR}/SmallStrings.cache`. 37 | - Lastly, replace all usages of `NSLocalizedString(key, comment)` with `SSTStringForKey(key)`. 38 | -------------------------------------------------------------------------------- /SmallStrings.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'SmallStrings' 3 | s.version = '0.1.3' 4 | s.summary = 'A minifier for localized .strings files' 5 | s.license = { :type => 'MIT', :file => 'LICENSE' } 6 | s.authors = 'Emerge Tools' 7 | s.source = {:git => 'https://github.com/EmergeTools/SmallStrings'} 8 | s.homepage = 'https://www.emergetools.com/' 9 | 10 | s.ios.deployment_target = '11.0' 11 | 12 | s.source_files = [ 13 | 'Source/*.{m,h}', 14 | ] 15 | 16 | s.preserve_paths = [ 17 | 'compress', 18 | 'localize.rb', 19 | 'localize.sh', 20 | ] 21 | 22 | # Ensure the run script and upload-symbols are callable via 23 | s.prepare_command = <<-PREPARE_COMMAND_END 24 | clang -O3 compress.m -framework Foundation -lcompression -o compress 25 | PREPARE_COMMAND_END 26 | 27 | s.libraries = 'compression' 28 | end 29 | -------------------------------------------------------------------------------- /Source/SSTSmallStrings.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | NSString *SSTStringForKey(NSString *key); 4 | -------------------------------------------------------------------------------- /Source/SSTSmallStrings.m: -------------------------------------------------------------------------------- 1 | #import "SSTSmallStrings.h" 2 | #import 3 | 4 | static NSDictionary *sKeyToString = nil; 5 | 6 | NSData * SSTDecompressedDataForFile(NSURL *file) 7 | { 8 | // The file format is: |-- 8 bytes for length of uncompressed data --|-- compressed LZFSE data --| 9 | NS_VALID_UNTIL_END_OF_SCOPE NSData *compressedData = [NSData dataWithContentsOfURL:file options:NSDataReadingMappedIfSafe error:nil]; 10 | uint8_t *buffer = (uint8_t *)compressedData.bytes; 11 | // Each compressed file is prefixed by a uint64_t indicating the size, in order to know how big a buffer to create 12 | uint64_t outSize = 0; 13 | memcpy(&outSize, buffer, sizeof(outSize)); 14 | uint8_t *outBuffer = (uint8_t *)malloc(outSize); 15 | // Although doing this compression may seem time-consuming, in reality it seems to only take a small fraction of overall time for this whole process 16 | size_t actualSize = compression_decode_buffer(outBuffer, outSize, buffer + sizeof(outSize), compressedData.length - sizeof(outSize), NULL, COMPRESSION_LZFSE); 17 | return [NSData dataWithBytesNoCopy:outBuffer length:actualSize freeWhenDone:YES]; 18 | } 19 | 20 | id SSTJsonForName(NSString *name) 21 | { 22 | NSURL *compressedFile = [[NSBundle mainBundle] URLForResource:name withExtension:nil subdirectory:@"localization"]; 23 | NSData *data = SSTDecompressedDataForFile(compressedFile); 24 | return [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; 25 | } 26 | 27 | NSDictionary *SSTCreateKeyToString() 28 | { 29 | // Note that the preferred list does seem to at least include the development region as a fallback if there aren't 30 | // any other languages 31 | NSString *bestLocalization = [[[NSBundle mainBundle] preferredLocalizations] firstObject] ?: [[NSBundle mainBundle] developmentLocalization]; 32 | if (!bestLocalization) { 33 | return @{}; 34 | } 35 | NSString *valuesPath = [NSString stringWithFormat:@"%@.values.json.lzfse", bestLocalization]; 36 | NSArray *values = SSTJsonForName(valuesPath); 37 | 38 | NSArray *keys = SSTJsonForName(@"keys.json.lzfse"); 39 | 40 | NSMutableDictionary *keyToString = [NSMutableDictionary dictionaryWithCapacity:keys.count]; 41 | NSInteger count = keys.count; 42 | for (NSInteger i = 0; i < count; i++) { 43 | id value = values[i]; 44 | if (value == [NSNull null]) { 45 | continue; 46 | } 47 | NSString *key = keys[i]; 48 | keyToString[key] = value; 49 | } 50 | return keyToString; // Avoid -copy to be a bit faster 51 | } 52 | 53 | NSString *SSTStringForKey(NSString *key) 54 | { 55 | static dispatch_once_t onceToken; 56 | dispatch_once(&onceToken, ^{ 57 | sKeyToString = SSTCreateKeyToString(); 58 | }); 59 | // Haven't tested with CFBundleAllowMixedLocalizations set to YES, although it seems like that'd be handled by the 60 | // NSLocalizedString fallback 61 | return sKeyToString[key] ?: NSLocalizedString(key, @""); 62 | } 63 | -------------------------------------------------------------------------------- /compress: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmergeTools/SmallStrings/b801b143235c20159431f72f900e7296091211f2/compress -------------------------------------------------------------------------------- /compress.m: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | int main(int argc, char *argv[]) { 5 | if (argc != 3) { 6 | return 1; 7 | } 8 | NSString *inFile = @(argv[1]); 9 | NSString *outFile = @(argv[2]); 10 | NSData *data = [NSData dataWithContentsOfFile:inFile options:NSDataReadingMappedIfSafe error:nil]; 11 | if (!data) { 12 | return 1; 13 | } 14 | size_t outBufferLength = data.length + sizeof(uint64_t) + 500 * 1024; 15 | uint8_t *outBuffer = (uint8_t *)malloc(outBufferLength); 16 | uint64_t outLength = compression_encode_buffer(outBuffer + sizeof(uint64_t), outBufferLength - sizeof(uint64_t), data.bytes, data.length, NULL, COMPRESSION_LZFSE); 17 | if (outLength == 0) { 18 | NSLog(@"Error occurred: either during compression, or because resulting file is much larger than original file"); 19 | return 1; 20 | } 21 | uint64_t inLength = data.length; 22 | memcpy(outBuffer, &inLength, sizeof(uint64_t)); 23 | NSData *outData = [NSData dataWithBytesNoCopy:outBuffer length:outLength + sizeof(uint64_t) freeWhenDone:YES]; 24 | [outData writeToFile:outFile atomically:YES]; 25 | return 0; 26 | } 27 | -------------------------------------------------------------------------------- /localize.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'json' 4 | require 'set' 5 | require 'tempfile' 6 | 7 | def sh(cmd) 8 | out = `#{cmd}` 9 | raise unless $?.success? 10 | out 11 | end 12 | 13 | bundle_dir = ARGV[0] 14 | 15 | Dir.chdir(bundle_dir) 16 | 17 | keys_set = Set.new 18 | lang_to_map = {} 19 | Dir["*.lproj/Localizable.strings"].each do |strings_file| 20 | json = sh("plutil -convert json -o - #{strings_file}") 21 | lang = strings_file.match(/(.*)\.lproj/)[1] 22 | map = JSON.parse(json) 23 | lang_to_map[lang] = map 24 | keys_set += map.keys 25 | end 26 | 27 | keys = keys_set.to_a 28 | keys.sort! 29 | 30 | out_dir = "#{bundle_dir}/localization" 31 | 32 | `rm -r #{out_dir} 2> /dev/null` 33 | 34 | sh("mkdir #{out_dir}") 35 | 36 | def write_compressed(out, str) 37 | Tempfile.create('localization_values') do |file| 38 | file.write(str) 39 | file.close 40 | sh("./compress #{file.path} #{out}") 41 | end 42 | end 43 | 44 | write_compressed("#{out_dir}/keys.json.lzfse", keys.to_json) 45 | 46 | lang_to_map.each do |lang, map| 47 | values = keys.map { |key| map[key] } 48 | write_compressed("#{out_dir}/#{lang}.values.json.lzfse", values.to_json) 49 | end 50 | -------------------------------------------------------------------------------- /localize.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | NEW_HASHES_FILE=`mktemp` 6 | shasum $1/*.lproj/Localizable.strings $1/localization/* | sort > $NEW_HASHES_FILE 7 | retVal=0 8 | cmp $NEW_HASHES_FILE $2 2> /dev/null || retVal=$? # The || prevents set -e from exiting early 9 | if [ $retVal -ne 0 ]; then 10 | # If this process is killed halfway through, make sure that there's not an old hashes file lying around that could mislead 11 | rm $2 2> /dev/null || true 12 | ruby localize.rb $1 13 | fi 14 | 15 | PLACEHOLDER="\"placeholder1234\" = \"foo\"; // This is just a placeholder so that Apple knows that this language still has a localization" 16 | for FILE in $1/*.lproj/Localizable.strings; do echo $PLACEHOLDER > $FILE; done 17 | 18 | mv $NEW_HASHES_FILE $2 19 | --------------------------------------------------------------------------------