1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.recovery.tools;
18 
19 import static java.util.Map.entry;
20 
21 import com.ibm.icu.text.BreakIterator;
22 
23 import org.apache.commons.cli.CommandLine;
24 import org.apache.commons.cli.GnuParser;
25 import org.apache.commons.cli.HelpFormatter;
26 import org.apache.commons.cli.OptionBuilder;
27 import org.apache.commons.cli.Options;
28 import org.apache.commons.cli.ParseException;
29 import org.w3c.dom.Document;
30 import org.w3c.dom.Node;
31 import org.w3c.dom.NodeList;
32 
33 import java.awt.Color;
34 import java.awt.Font;
35 import java.awt.FontFormatException;
36 import java.awt.FontMetrics;
37 import java.awt.Graphics2D;
38 import java.awt.RenderingHints;
39 import java.awt.font.TextAttribute;
40 import java.awt.image.BufferedImage;
41 import java.io.File;
42 import java.io.IOException;
43 import java.text.AttributedString;
44 import java.util.ArrayList;
45 import java.util.Arrays;
46 import java.util.HashMap;
47 import java.util.HashSet;
48 import java.util.List;
49 import java.util.Locale;
50 import java.util.Map;
51 import java.util.Set;
52 import java.util.TreeMap;
53 import java.util.logging.Level;
54 import java.util.logging.Logger;
55 
56 import javax.imageio.ImageIO;
57 import javax.xml.parsers.DocumentBuilder;
58 import javax.xml.parsers.DocumentBuilderFactory;
59 import javax.xml.parsers.ParserConfigurationException;
60 
61 /** Command line tool to generate the localized image for recovery mode. */
62 public class ImageGenerator {
63     // Initial height of the image to draw.
64     private static final int INITIAL_HEIGHT = 20000;
65 
66     private static final float DEFAULT_FONT_SIZE = 40;
67 
68     private static final Logger LOGGER = Logger.getLogger(ImageGenerator.class.getName());
69 
70     // This is the canvas we used to draw texts.
71     private BufferedImage mBufferedImage;
72 
73     // The width in pixels of our image. The value will be adjusted once when we calculate the
74     // maximum width to fit the wrapped text strings.
75     private int mImageWidth;
76 
77     // The current height in pixels of our image. We will adjust the value when drawing more texts.
78     private int mImageHeight;
79 
80     // The current vertical offset in pixels to draw the top edge of new text strings.
81     private int mVerticalOffset;
82 
83     // The font size to draw the texts.
84     private final float mFontSize;
85 
86     // The name description of the text to localize. It's used to find the translated strings in the
87     // resource file.
88     private final String mTextName;
89 
90     // The directory that contains all the needed font files (e.g. ttf, otf, ttc files).
91     private final String mFontDirPath;
92 
93     // Align the text in the center of the image.
94     private final boolean mCenterAlignment;
95 
96     // Some localized font cannot draw the word "Android" and some PUNCTUATIONS; we need to fall
97     // back to use our default latin font instead.
98     private static final char[] PUNCTUATIONS = {',', ';', '.', '!', '?'};
99 
100     private static final String ANDROID_STRING = "Android";
101 
102     // The width of the word "Android" when drawing with the default font.
103     private int mAndroidStringWidth;
104 
105     // The default Font to draw latin characters. It's loaded from DEFAULT_FONT_NAME.
106     private Font mDefaultFont;
107     // Cache of the loaded fonts for all languages.
108     private Map<String, Font> mLoadedFontMap;
109 
110     // An explicit map from language to the font name to use.
111     // The map is extracted from frameworks/base/data/fonts/fonts.xml.
112     // And the language-subtag-registry is found in:
113     // https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
114     private static final String DEFAULT_FONT_NAME = "Roboto-Regular";
115     private static final Map<String, String> LANGUAGE_TO_FONT_MAP =
116             Map.ofEntries(
117                 entry("am", "NotoSansEthiopic-VF"),
118                 entry("ar", "NotoNaskhArabicUI-Regular"),
119                 entry("as", "NotoSansBengaliUI-VF"),
120                 entry("bn", "NotoSansBengaliUI-VF"),
121                 entry("fa", "NotoNaskhArabicUI-Regular"),
122                 entry("gu", "NotoSansGujaratiUI-Regular"),
123                 entry("hi", "NotoSansDevanagariUI-VF"),
124                 entry("hy", "NotoSansArmenian-VF"),
125                 entry("iw", "NotoSansHebrew-Regular"),
126                 entry("ja", "NotoSansCJK-Regular"),
127                 entry("ka", "NotoSansGeorgian-VF"),
128                 entry("ko", "NotoSansCJK-Regular"),
129                 entry("km", "NotoSansKhmerUI-Regular"),
130                 entry("kn", "NotoSansKannadaUI-VF"),
131                 entry("lo", "NotoSansLaoUI-Regular"),
132                 entry("ml", "NotoSansMalayalamUI-VF"),
133                 entry("mr", "NotoSansDevanagariUI-VF"),
134                 entry("my", "NotoSansMyanmarUI-Regular"),
135                 entry("ne", "NotoSansDevanagariUI-VF"),
136                 entry("or", "NotoSansOriya-Regular"),
137                 entry("pa", "NotoSansGurmukhiUI-VF"),
138                 entry("si", "NotoSansSinhalaUI-VF"),
139                 entry("ta", "NotoSansTamilUI-VF"),
140                 entry("te", "NotoSansTeluguUI-VF"),
141                 entry("th", "NotoSansThaiUI-Regular"),
142                 entry("ur", "NotoNaskhArabicUI-Regular"),
143                 entry("zh", "NotoSansCJK-Regular"));
144 
145     // Languages that write from right to left.
146     private static final Set<String> RTL_LANGUAGE =
147             Set.of(
148                 "ar",  // Arabic
149                 "fa",  // Persian
150                 "he",  // Hebrew
151                 "iw",  // Hebrew
152                 "ur"); // Urdu
153 
154     /** Exception to indicate the failure to find the translated text strings. */
155     public static class LocalizedStringNotFoundException extends Exception {
LocalizedStringNotFoundException(String message)156         public LocalizedStringNotFoundException(String message) {
157             super(message);
158         }
159 
LocalizedStringNotFoundException(String message, Throwable cause)160         public LocalizedStringNotFoundException(String message, Throwable cause) {
161             super(message, cause);
162         }
163     }
164 
165     /**
166      *  This class maintains the content of wrapped text, the attributes to draw these text, and
167      *  the width of each wrapped lines.
168      */
169     private class WrappedTextInfo {
170         /** LineInfo holds the AttributedString and width of each wrapped line. */
171         private class LineInfo {
172             public AttributedString mLineContent;
173             public int mLineWidth;
174 
LineInfo(AttributedString text, int width)175             LineInfo(AttributedString text, int width) {
176                 mLineContent = text;
177                 mLineWidth = width;
178             }
179         }
180 
181         // Maintains the content of each line, as well as the width needed to draw these lines for
182         // a given language.
183         public List<LineInfo> mWrappedLines;
184 
WrappedTextInfo()185         WrappedTextInfo() {
186             mWrappedLines = new ArrayList<>();
187         }
188 
189         /**
190          * Checks if the given text has words "Android" and some PUNCTUATIONS. If it does, and its
191          * associated textFont cannot display them correctly (e.g. for persian and hebrew); sets the
192          * attributes of these substrings to use our default font instead.
193          *
194          * @param text the input string to perform the check on
195          * @param width the pre-calculated width for the given text
196          * @param textFont the localized font to draw the input string
197          * @param fallbackFont our default font to draw latin characters
198          */
addLine(String text, int width, Font textFont, Font fallbackFont)199         public void addLine(String text, int width, Font textFont, Font fallbackFont) {
200             AttributedString attributedText = new AttributedString(text);
201             attributedText.addAttribute(TextAttribute.FONT, textFont);
202             attributedText.addAttribute(TextAttribute.SIZE, mFontSize);
203 
204             // Skips the check if we don't specify a fallbackFont.
205             if (fallbackFont != null) {
206                 // Adds the attribute to use default font to draw the word "Android".
207                 if (text.contains(ANDROID_STRING)
208                         && textFont.canDisplayUpTo(ANDROID_STRING) != -1) {
209                     int index = text.indexOf(ANDROID_STRING);
210                     attributedText.addAttribute(TextAttribute.FONT, fallbackFont, index,
211                             index + ANDROID_STRING.length());
212                 }
213 
214                 // Adds the attribute to use default font to draw the PUNCTUATIONS ", . ; ! ?"
215                 for (char punctuation : PUNCTUATIONS) {
216                     // TODO (xunchang) handle the RTL language that has different directions for '?'
217                     if (text.indexOf(punctuation) != -1 && !textFont.canDisplay(punctuation)) {
218                         int index = 0;
219                         while ((index = text.indexOf(punctuation, index)) != -1) {
220                             attributedText.addAttribute(TextAttribute.FONT, fallbackFont, index,
221                                     index + 1);
222                             index += 1;
223                         }
224                     }
225                 }
226             }
227 
228             mWrappedLines.add(new LineInfo(attributedText, width));
229         }
230 
231         /** Merges two WrappedTextInfo. */
addLines(WrappedTextInfo other)232         public void addLines(WrappedTextInfo other) {
233             mWrappedLines.addAll(other.mWrappedLines);
234         }
235     }
236 
237     /** Initailizes the fields of the image image. */
ImageGenerator( int initialImageWidth, String textName, float fontSize, String fontDirPath, boolean centerAlignment)238     public ImageGenerator(
239             int initialImageWidth,
240             String textName,
241             float fontSize,
242             String fontDirPath,
243             boolean centerAlignment) {
244         mImageWidth = initialImageWidth;
245         mImageHeight = INITIAL_HEIGHT;
246         mVerticalOffset = 0;
247 
248         // Initialize the canvas with the default height.
249         mBufferedImage = new BufferedImage(mImageWidth, mImageHeight, BufferedImage.TYPE_BYTE_GRAY);
250 
251         mTextName = textName;
252         mFontSize = fontSize;
253         mFontDirPath = fontDirPath;
254         mLoadedFontMap = new TreeMap<>();
255 
256         mCenterAlignment = centerAlignment;
257     }
258 
259     /**
260      * Finds the translated text string for the given textName by parsing the resourceFile. Example
261      * of the xml fields: <resources xmlns:android="http://schemas.android.com/apk/res/android">
262      * <string name="recovery_installing_security" msgid="9184031299717114342"> "Sicherheitsupdate
263      * wird installiert"</string> </resources>
264      *
265      * @param resourceFile the input resource file in xml format.
266      * @param textName the name description of the text.
267      * @return the string representation of the translated text.
268      */
getTextString(File resourceFile, String textName)269     private String getTextString(File resourceFile, String textName)
270             throws IOException, ParserConfigurationException, org.xml.sax.SAXException,
271                     LocalizedStringNotFoundException {
272         DocumentBuilderFactory builder = DocumentBuilderFactory.newInstance();
273         DocumentBuilder db = builder.newDocumentBuilder();
274 
275         Document doc = db.parse(resourceFile);
276         doc.getDocumentElement().normalize();
277 
278         NodeList nodeList = doc.getElementsByTagName("string");
279         for (int i = 0; i < nodeList.getLength(); i++) {
280             Node node = nodeList.item(i);
281             String name = node.getAttributes().getNamedItem("name").getNodeValue();
282             if (name.equals(textName)) {
283                 return node.getTextContent();
284             }
285         }
286 
287         throw new LocalizedStringNotFoundException(
288                 textName + " not found in " + resourceFile.getName());
289     }
290 
291     /** Constructs the locale from the name of the resource file. */
getLocaleFromFilename(String filename)292     private Locale getLocaleFromFilename(String filename) throws IOException {
293         // Gets the locale string by trimming the top "values-".
294         String localeString = filename.substring(7);
295         if (localeString.matches("[A-Za-z]+")) {
296             return Locale.forLanguageTag(localeString);
297         }
298         if (localeString.matches("[A-Za-z]+-r[A-Za-z]+")) {
299             // "${Language}-r${Region}". e.g. en-rGB
300             String[] tokens = localeString.split("-r");
301             return Locale.forLanguageTag(String.join("-", tokens));
302         }
303         if (localeString.startsWith("b+")) {
304             // The special case of b+sr+Latn, which has the form "b+${Language}+${ScriptName}"
305             String[] tokens = localeString.substring(2).split("\\+");
306             return Locale.forLanguageTag(String.join("-", tokens));
307         }
308 
309         throw new IOException("Unrecognized locale string " + localeString);
310     }
311 
312     /**
313      * Iterates over the xml files in the format of values-$LOCALE/strings.xml under the resource
314      * directory and collect the translated text.
315      *
316      * @param resourcePath the path to the resource directory
317      * @param localesSet a list of supported locales; resources of other locales will be omitted.
318      * @return a map with the locale as key, and translated text as value
319      * @throws LocalizedStringNotFoundException if we cannot find the translated text for the given
320      *     locale
321      */
readLocalizedStringFromXmls(String resourcePath, Set<String> localesSet)322     public Map<Locale, String> readLocalizedStringFromXmls(String resourcePath,
323             Set<String> localesSet) throws IOException, LocalizedStringNotFoundException {
324         File resourceDir = new File(resourcePath);
325         if (!resourceDir.isDirectory()) {
326             throw new LocalizedStringNotFoundException(resourcePath + " is not a directory.");
327         }
328 
329         Map<Locale, String> result =
330                 // Overrides the string comparator so that sr is sorted behind sr-Latn. And thus
331                 // recovery can find the most relevant locale when going down the list.
332                 new TreeMap<>(
333                         (Locale l1, Locale l2) -> {
334                             if (l1.toLanguageTag().equals(l2.toLanguageTag())) {
335                                 return 0;
336                             }
337                             if (l1.getLanguage().equals(l2.toLanguageTag())) {
338                                 return -1;
339                             }
340                             if (l2.getLanguage().equals(l1.toLanguageTag())) {
341                                 return 1;
342                             }
343                             return l1.toLanguageTag().compareTo(l2.toLanguageTag());
344                         });
345 
346         // Find all the localized resource subdirectories in the format of values-$LOCALE
347         String[] nameList =
348                 resourceDir.list((File file, String name) -> name.startsWith("values-"));
349         for (String name : nameList) {
350             String localeString = name.substring(7);
351             if (localesSet != null && !localesSet.contains(localeString)) {
352                 LOGGER.info("Skip parsing text for locale " + localeString);
353                 continue;
354             }
355 
356             File textFile = new File(resourcePath, name + "/strings.xml");
357             String localizedText;
358             try {
359                 localizedText = getTextString(textFile, mTextName);
360             } catch (IOException | ParserConfigurationException | org.xml.sax.SAXException e) {
361                 throw new LocalizedStringNotFoundException(
362                         "Failed to read the translated text for locale " + name, e);
363             }
364 
365             Locale locale = getLocaleFromFilename(name);
366             // Removes the double quotation mark from the text.
367             result.put(locale, localizedText.substring(1, localizedText.length() - 1));
368         }
369 
370         return result;
371     }
372 
373     /**
374      * Returns a font object associated given the given locale
375      *
376      * @throws IOException if the font file fails to open
377      * @throws FontFormatException if the font file doesn't have the expected format
378      */
loadFontsByLocale(String language)379     private Font loadFontsByLocale(String language) throws IOException, FontFormatException {
380         if (mLoadedFontMap.containsKey(language)) {
381             return mLoadedFontMap.get(language);
382         }
383 
384         String fontName = LANGUAGE_TO_FONT_MAP.getOrDefault(language, DEFAULT_FONT_NAME);
385         String[] suffixes = {".otf", ".ttf", ".ttc"};
386         for (String suffix : suffixes) {
387             File fontFile = new File(mFontDirPath, fontName + suffix);
388             if (fontFile.isFile()) {
389                 Font result = Font.createFont(Font.TRUETYPE_FONT, fontFile).deriveFont(mFontSize);
390                 mLoadedFontMap.put(language, result);
391                 return result;
392             }
393         }
394 
395         throw new IOException(
396                 "Can not find the font file " + fontName + " for language " + language);
397     }
398 
399     /** Wraps the text with a maximum of mImageWidth pixels per line. */
wrapText(String text, FontMetrics metrics)400     private WrappedTextInfo wrapText(String text, FontMetrics metrics) {
401         WrappedTextInfo info = new WrappedTextInfo();
402 
403         BreakIterator lineBoundary = BreakIterator.getLineInstance();
404         lineBoundary.setText(text);
405 
406         int lineWidth = 0;  // Width of the processed words of the current line.
407         int start = lineBoundary.first();
408         StringBuilder line = new StringBuilder();
409         for (int end = lineBoundary.next(); end != BreakIterator.DONE;
410                 start = end, end = lineBoundary.next()) {
411             String token = text.substring(start, end);
412             int tokenWidth = metrics.stringWidth(token);
413             // Handles the width mismatch of the word "Android" between different fonts.
414             if (token.contains(ANDROID_STRING)
415                     && metrics.getFont().canDisplayUpTo(ANDROID_STRING) != -1) {
416                 tokenWidth = tokenWidth - metrics.stringWidth(ANDROID_STRING) + mAndroidStringWidth;
417             }
418 
419             if (lineWidth + tokenWidth > mImageWidth) {
420                 info.addLine(line.toString(), lineWidth, metrics.getFont(), mDefaultFont);
421 
422                 line = new StringBuilder();
423                 lineWidth = 0;
424             }
425             line.append(token);
426             lineWidth += tokenWidth;
427         }
428 
429         info.addLine(line.toString(), lineWidth, metrics.getFont(), mDefaultFont);
430 
431         return info;
432     }
433 
434     /**
435      * Handles the special characters of the raw text embedded in the xml file; and wraps the text
436      * with a maximum of mImageWidth pixels per line.
437      *
438      * @param text the string representation of text to wrap
439      * @param metrics the metrics of the Font used to draw the text; it gives the width in pixels of
440      *     the text given its string representation
441      * @return a WrappedTextInfo class with the width of each AttributedString smaller than
442      *     mImageWidth pixels
443      */
processAndWrapText(String text, FontMetrics metrics)444     private WrappedTextInfo processAndWrapText(String text, FontMetrics metrics) {
445         // Apostrophe is escaped in the xml file.
446         String processed = text.replace("\\'", "'");
447         // The separator "\n\n" indicates a new line in the text.
448         String[] lines = processed.split("\\\\n\\\\n");
449         WrappedTextInfo result = new WrappedTextInfo();
450         for (String line : lines) {
451             result.addLines(wrapText(line, metrics));
452         }
453 
454         return result;
455     }
456 
457     /**
458      * Encodes the information of the text image for |locale|. According to minui/resources.cpp, the
459      * width, height and locale of the image is decoded as: int w = (row[1] << 8) | row[0]; int h =
460      * (row[3] << 8) | row[2]; __unused int len = row[4]; char* loc =
461      * reinterpret_cast<char*>(&row[5]);
462      */
encodeTextInfo(int width, int height, String locale)463     private List<Integer> encodeTextInfo(int width, int height, String locale) {
464         List<Integer> info =
465                 new ArrayList<>(
466                         Arrays.asList(
467                                 width & 0xff,
468                                 width >> 8,
469                                 height & 0xff,
470                                 height >> 8,
471                                 locale.length()));
472 
473         byte[] localeBytes = locale.getBytes();
474         for (byte b : localeBytes) {
475             info.add((int) b);
476         }
477         info.add(0);
478 
479         return info;
480     }
481 
482     /** Returns Graphics2D object that uses the given locale. */
createGraphics(Locale locale)483     private Graphics2D createGraphics(Locale locale) throws IOException, FontFormatException {
484         Graphics2D graphics = mBufferedImage.createGraphics();
485         graphics.setColor(Color.WHITE);
486         graphics.setRenderingHint(
487                 RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_GASP);
488         graphics.setFont(loadFontsByLocale(locale.getLanguage()));
489 
490         return graphics;
491     }
492 
493     /** Returns the maximum screen width needed to fit the given text after wrapping. */
measureTextWidth(String text, Locale locale)494     private int measureTextWidth(String text, Locale locale)
495             throws IOException, FontFormatException {
496         Graphics2D graphics = createGraphics(locale);
497         FontMetrics fontMetrics = graphics.getFontMetrics();
498         WrappedTextInfo wrappedTextInfo = processAndWrapText(text, fontMetrics);
499 
500         int textWidth = 0;
501         for (WrappedTextInfo.LineInfo lineInfo : wrappedTextInfo.mWrappedLines) {
502             textWidth = Math.max(textWidth, lineInfo.mLineWidth);
503         }
504 
505         // This may happen if one single word is larger than the image width.
506         if (textWidth > mImageWidth) {
507             throw new IllegalStateException(
508                     "Wrapped text width "
509                             + textWidth
510                             + " is larger than image width "
511                             + mImageWidth
512                             + " for locale: "
513                             + locale);
514         }
515 
516         return textWidth;
517     }
518 
519     /**
520      * Draws the text string on the canvas for given locale.
521      *
522      * @param text the string to draw on canvas
523      * @param locale the current locale tag of the string to draw
524      * @throws IOException if we cannot find the corresponding font file for the given locale.
525      * @throws FontFormatException if we failed to load the font file for the given locale.
526      */
drawText(String text, Locale locale, String languageTag)527     private void drawText(String text, Locale locale, String languageTag)
528             throws IOException, FontFormatException {
529         LOGGER.info("Encoding \"" + locale + "\" as \"" + languageTag + "\": " + text);
530 
531         Graphics2D graphics = createGraphics(locale);
532         FontMetrics fontMetrics = graphics.getFontMetrics();
533         WrappedTextInfo wrappedTextInfo = processAndWrapText(text, fontMetrics);
534 
535         // Marks the start y offset for the text image of current locale; and reserves one line to
536         // encode the image metadata.
537         int currentImageStart = mVerticalOffset;
538         mVerticalOffset += 1;
539         for (WrappedTextInfo.LineInfo lineInfo : wrappedTextInfo.mWrappedLines) {
540             int lineHeight = fontMetrics.getHeight();
541             // Doubles the height of the image if we are short of space.
542             if (mVerticalOffset + lineHeight >= mImageHeight) {
543                 resize(mImageWidth, mImageHeight * 2);
544                 // Recreates the graphics since it's attached to the buffered image.
545                 graphics = createGraphics(locale);
546             }
547 
548             // Draws the text at mVerticalOffset and increments the offset with line space.
549             int baseLine = mVerticalOffset + lineHeight - fontMetrics.getDescent();
550 
551             // Draws from right if it's an RTL language.
552             int x =
553                     mCenterAlignment
554                             ? (mImageWidth - lineInfo.mLineWidth) / 2
555                             : RTL_LANGUAGE.contains(languageTag)
556                                     ? mImageWidth - lineInfo.mLineWidth
557                                     : 0;
558             graphics.drawString(lineInfo.mLineContent.getIterator(), x, baseLine);
559 
560             mVerticalOffset += lineHeight;
561         }
562 
563         // Encodes the metadata of the current localized image as pixels.
564         int currentImageHeight = mVerticalOffset - currentImageStart - 1;
565         List<Integer> info = encodeTextInfo(mImageWidth, currentImageHeight, languageTag);
566         for (int i = 0; i < info.size(); i++) {
567             int[] pixel = {info.get(i)};
568             mBufferedImage.getRaster().setPixel(i, currentImageStart, pixel);
569         }
570     }
571 
572     /**
573      * Redraws the image with the new width and new height.
574      *
575      * @param width the new width of the image in pixels.
576      * @param height the new height of the image in pixels.
577      */
resize(int width, int height)578     private void resize(int width, int height) {
579         BufferedImage resizedImage = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);
580         Graphics2D graphic = resizedImage.createGraphics();
581         graphic.drawImage(mBufferedImage, 0, 0, null);
582         graphic.dispose();
583 
584         mBufferedImage = resizedImage;
585         mImageWidth = width;
586         mImageHeight = height;
587     }
588 
589     /**
590      * This function draws the font characters and saves the result to outputPath.
591      *
592      * @param localizedTextMap a map from locale to its translated text string
593      * @param outputPath the path to write the generated image file.
594      * @throws FontFormatException if there's a format error in one of the font file
595      * @throws IOException if we cannot find the font file for one of the locale, or we failed to
596      *     write the image file.
597      */
generateImage(Map<Locale, String> localizedTextMap, String outputPath)598     public void generateImage(Map<Locale, String> localizedTextMap, String outputPath)
599             throws FontFormatException, IOException {
600         FontMetrics defaultFontMetrics =
601                 createGraphics(Locale.forLanguageTag("en")).getFontMetrics();
602         mDefaultFont = defaultFontMetrics.getFont();
603         mAndroidStringWidth = defaultFontMetrics.stringWidth(ANDROID_STRING);
604 
605         // The last country variant should be the fallback locale for a given language.
606         Map<String, Locale> fallbackLocaleMap = new HashMap<>();
607         int textWidth = 0;
608         for (Locale locale : localizedTextMap.keySet()) {
609             // Updates the fallback locale if we have a new language variant. Don't do it for en-XC
610             // as it's a pseudo-locale.
611             if (!locale.toLanguageTag().equals("en-XC")) {
612                 fallbackLocaleMap.put(locale.getLanguage(), locale);
613             }
614             textWidth = Math.max(textWidth, measureTextWidth(localizedTextMap.get(locale), locale));
615         }
616 
617         // Removes the black margins to reduce the size of the image.
618         resize(textWidth, mImageHeight);
619 
620         for (Locale locale : localizedTextMap.keySet()) {
621             // Recovery expects en-US instead of en_US.
622             String languageTag = locale.toLanguageTag();
623             Locale fallbackLocale = fallbackLocaleMap.get(locale.getLanguage());
624             if (locale.equals(fallbackLocale)) {
625                 // Makes the last country variant for a given language be the catch-all for that
626                 // language.
627                 languageTag = locale.getLanguage();
628             } else if (localizedTextMap.get(locale).equals(localizedTextMap.get(fallbackLocale))) {
629                 LOGGER.info("Skip parsing text for duplicate locale " + locale);
630                 continue;
631             }
632 
633             drawText(localizedTextMap.get(locale), locale, languageTag);
634         }
635 
636         resize(mImageWidth, mVerticalOffset);
637         ImageIO.write(mBufferedImage, "png", new File(outputPath));
638     }
639 
640     /** Prints the helper message. */
printUsage(Options options)641     public static void printUsage(Options options) {
642         new HelpFormatter().printHelp("java -jar path_to_jar [required_options]", options);
643     }
644 
645     /** Creates the command line options. */
createOptions()646     public static Options createOptions() {
647         Options options = new Options();
648         options.addOption(
649                 OptionBuilder.withLongOpt("image_width")
650                         .withDescription("The initial width of the image in pixels.")
651                         .hasArgs(1)
652                         .isRequired()
653                         .create());
654 
655         options.addOption(
656                 OptionBuilder.withLongOpt("text_name")
657                         .withDescription(
658                                 "The description of the text string, e.g. recovery_erasing")
659                         .hasArgs(1)
660                         .isRequired()
661                         .create());
662 
663         options.addOption(
664                 OptionBuilder.withLongOpt("font_dir")
665                         .withDescription(
666                                 "The directory that contains all the support font format files, "
667                                         + "e.g. $OUT/system/fonts/")
668                         .hasArgs(1)
669                         .isRequired()
670                         .create());
671 
672         options.addOption(
673                 OptionBuilder.withLongOpt("resource_dir")
674                         .withDescription(
675                                 "The resource directory that contains all the translated strings in"
676                                         + " xml format, e.g."
677                                         + " bootable/recovery/tools/recovery_l10n/res/")
678                         .hasArgs(1)
679                         .isRequired()
680                         .create());
681 
682         options.addOption(
683                 OptionBuilder.withLongOpt("output_file")
684                         .withDescription("Path to the generated image.")
685                         .hasArgs(1)
686                         .isRequired()
687                         .create());
688 
689         options.addOption(
690                 OptionBuilder.withLongOpt("center_alignment")
691                         .withDescription("Align the text in the center of the screen.")
692                         .hasArg(false)
693                         .create());
694 
695         options.addOption(
696                 OptionBuilder.withLongOpt("verbose")
697                         .withDescription("Output the logging above info level.")
698                         .hasArg(false)
699                         .create());
700 
701         options.addOption(
702                 OptionBuilder.withLongOpt("locales")
703                         .withDescription("A list of android locales separated by ',' e.g."
704                                 + " 'af,en,zh-rTW'")
705                         .hasArg(true)
706                         .create());
707 
708         return options;
709     }
710 
711     /** The main function parses the command line options and generates the desired text image. */
main(String[] args)712     public static void main(String[] args)
713             throws NumberFormatException, IOException, FontFormatException,
714                     LocalizedStringNotFoundException {
715         Options options = createOptions();
716         CommandLine cmd;
717         try {
718             cmd = new GnuParser().parse(options, args);
719         } catch (ParseException e) {
720             System.err.println(e.getMessage());
721             printUsage(options);
722             return;
723         }
724 
725         int imageWidth = Integer.parseUnsignedInt(cmd.getOptionValue("image_width"));
726 
727         if (cmd.hasOption("verbose")) {
728             LOGGER.setLevel(Level.INFO);
729         } else {
730             LOGGER.setLevel(Level.WARNING);
731         }
732 
733         ImageGenerator imageGenerator =
734                 new ImageGenerator(
735                         imageWidth,
736                         cmd.getOptionValue("text_name"),
737                         DEFAULT_FONT_SIZE,
738                         cmd.getOptionValue("font_dir"),
739                         cmd.hasOption("center_alignment"));
740 
741         Set<String> localesSet = null;
742         if (cmd.hasOption("locales")) {
743             String[] localesList = cmd.getOptionValue("locales").split(",");
744             localesSet = new HashSet<>(Arrays.asList(localesList));
745             // Ensures that we have the default locale, all english translations are identical.
746             localesSet.add("en-rAU");
747         }
748         Map<Locale, String> localizedStringMap =
749                 imageGenerator.readLocalizedStringFromXmls(cmd.getOptionValue("resource_dir"),
750                         localesSet);
751         imageGenerator.generateImage(localizedStringMap, cmd.getOptionValue("output_file"));
752     }
753 }
754