blob: 8ed696a450c2c744a9b8c58be90642d6b80c0c2e [file] [log] [blame]
Tianjie Xu721f6792018-10-08 17:04:54 -07001/*
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
17package com.android.recovery.tools;
18
19import java.awt.Color;
20import java.awt.Font;
21import java.awt.FontFormatException;
22import java.awt.FontMetrics;
23import java.awt.Graphics2D;
24import java.awt.RenderingHints;
25import java.awt.image.BufferedImage;
26import java.io.File;
27import java.io.IOException;
28import java.util.ArrayList;
Tianjie Xu22dd0192018-10-17 15:39:00 -070029import java.util.Arrays;
30import java.util.HashSet;
Tianjie Xu721f6792018-10-08 17:04:54 -070031import java.util.List;
32import java.util.Locale;
33import java.util.Map;
Tianjie Xu22dd0192018-10-17 15:39:00 -070034import java.util.Set;
Tianjie Xu721f6792018-10-08 17:04:54 -070035import java.util.TreeMap;
36import java.util.StringTokenizer;
37
38import javax.imageio.ImageIO;
39import javax.xml.parsers.DocumentBuilder;
40import javax.xml.parsers.DocumentBuilderFactory;
41import javax.xml.parsers.ParserConfigurationException;
42
Tianjie Xuedfeb972018-10-23 12:40:14 -070043import org.apache.commons.cli.CommandLine;
44import org.apache.commons.cli.GnuParser;
45import org.apache.commons.cli.HelpFormatter;
46import org.apache.commons.cli.OptionBuilder;
47import org.apache.commons.cli.Options;
48import org.apache.commons.cli.ParseException;
49
Tianjie Xu721f6792018-10-08 17:04:54 -070050import org.w3c.dom.Document;
51import org.w3c.dom.Node;
52import org.w3c.dom.NodeList;
53
54/**
55 * Command line tool to generate the localized image for recovery mode.
56 */
57public class ImageGenerator {
58 // Initial height of the image to draw.
59 private static final int INITIAL_HEIGHT = 20000;
60
61 private static final float DEFAULT_FONT_SIZE = 40;
62
63 // This is the canvas we used to draw texts.
64 private BufferedImage mBufferedImage;
65
66 // The width in pixels of our image. Once set, its value won't change.
67 private final int mImageWidth;
68
69 // The current height in pixels of our image. We will adjust the value when drawing more texts.
70 private int mImageHeight;
71
72 // The current vertical offset in pixels to draw the top edge of new text strings.
73 private int mVerticalOffset;
74
75 // The font size to draw the texts.
76 private final float mFontSize;
77
78 // The name description of the text to localize. It's used to find the translated strings in the
79 // resource file.
80 private final String mTextName;
81
82 // The directory that contains all the needed font files (e.g. ttf, otf, ttc files).
83 private final String mFontDirPath;
84
85 // An explicit map from language to the font name to use.
86 // The map is extracted from frameworks/base/data/fonts/fonts.xml.
87 // And the language-subtag-registry is found in:
88 // https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
89 private static final String DEFAULT_FONT_NAME = "Roboto-Regular";
90 private static final Map<String, String> LANGUAGE_TO_FONT_MAP = new TreeMap<String, String>() {{
91 put("am", "NotoSansEthiopic-Regular");
92 put("ar", "NotoNaskhArabicUI-Regular");
93 put("as", "NotoSansBengaliUI-Regular");
94 put("bn", "NotoSansBengaliUI-Regular");
95 put("fa", "NotoNaskhArabicUI-Regular");
96 put("gu", "NotoSansGujaratiUI-Regular");
97 put("hi", "NotoSansDevanagariUI-Regular");
98 put("hy", "NotoSansArmenian-Regular");
99 put("iw", "NotoSansHebrew-Regular");
100 put("ja", "NotoSansCJK-Regular");
101 put("ka", "NotoSansGeorgian-Regular");
102 put("ko", "NotoSansCJK-Regular");
103 put("km", "NotoSansKhmerUI-Regular");
104 put("kn", "NotoSansKannadaUI-Regular");
105 put("lo", "NotoSansLaoUI-Regular");
106 put("ml", "NotoSansMalayalamUI-Regular");
107 put("mr", "NotoSansDevanagariUI-Regular");
108 put("my", "NotoSansMyanmarUI-Regular");
109 put("ne", "NotoSansDevanagariUI-Regular");
110 put("or", "NotoSansOriya-Regular");
111 put("pa", "NotoSansGurmukhiUI-Regular");
112 put("si", "NotoSansSinhala-Regular");
113 put("ta", "NotoSansTamilUI-Regular");
114 put("te", "NotoSansTeluguUI-Regular");
115 put("th", "NotoSansThaiUI-Regular");
116 put("ur", "NotoNaskhArabicUI-Regular");
117 put("zh", "NotoSansCJK-Regular");
118 }};
119
Tianjie Xu22dd0192018-10-17 15:39:00 -0700120 // Languages that write from right to left.
121 private static final Set<String> RTL_LANGUAGE = new HashSet<String>() {{
122 add("ar"); // Arabic
123 add("fa"); // Persian
124 add("he"); // Hebrew
125 add("iw"); // Hebrew
126 add("ur"); // Urdu
127 }};
128
129 // Languages that breaks on arbitrary characters.
130 // TODO(xunchang) switch to icu library if possible.
131 private static final Set<String> LOGOGRAM_LANGUAGE = new HashSet<String>() {{
132 add("ja"); // Japanese
133 add("km"); // Khmer
134 add("ko"); // Korean
135 add("lo"); // Lao
136 add("zh"); // Chinese
137 }};
138
Tianjie Xu721f6792018-10-08 17:04:54 -0700139 /**
140 * Exception to indicate the failure to find the translated text strings.
141 */
142 public static class LocalizedStringNotFoundException extends Exception {
143 public LocalizedStringNotFoundException(String message) {
144 super(message);
145 }
146
147 public LocalizedStringNotFoundException(String message, Throwable cause) {
148 super(message, cause);
149 }
150 }
151
152 /**
153 * Initailizes the fields of the image image.
154 */
155 public ImageGenerator(int imageWidth, String textName, float fontSize, String fontDirPath) {
156 mImageWidth = imageWidth;
157 mImageHeight = INITIAL_HEIGHT;
158 mVerticalOffset = 0;
159
160 // Initialize the canvas with the default height.
161 mBufferedImage = new BufferedImage(mImageWidth, mImageHeight, BufferedImage.TYPE_BYTE_GRAY);
162
163 mTextName = textName;
164 mFontSize = fontSize;
165 mFontDirPath = fontDirPath;
166 }
167
168 /**
169 * Finds the translated text string for the given textName by parsing the resourceFile.
170 * Example of the xml fields:
171 * <resources xmlns:android="http://schemas.android.com/apk/res/android">
172 * <string name="recovery_installing_security" msgid="9184031299717114342">
173 * "Sicherheitsupdate wird installiert"</string>
174 * </resources>
175 *
176 * @param resourceFile the input resource file in xml format.
177 * @param textName the name description of the text.
178 *
179 * @return the string representation of the translated text.
180 */
181 private String getTextString(File resourceFile, String textName) throws IOException,
182 ParserConfigurationException, org.xml.sax.SAXException, LocalizedStringNotFoundException {
183 DocumentBuilderFactory builder = DocumentBuilderFactory.newInstance();
184 DocumentBuilder db = builder.newDocumentBuilder();
185
186 Document doc = db.parse(resourceFile);
187 doc.getDocumentElement().normalize();
188
189 NodeList nodeList = doc.getElementsByTagName("string");
190 for (int i = 0; i < nodeList.getLength(); i++) {
191 Node node = nodeList.item(i);
192 String name = node.getAttributes().getNamedItem("name").getNodeValue();
193 if (name.equals(textName)) {
194 return node.getTextContent();
195 }
196 }
197
198 throw new LocalizedStringNotFoundException(textName + " not found in "
199 + resourceFile.getName());
200 }
201
202 /**
203 * Constructs the locale from the name of the resource file.
204 */
205 private Locale getLocaleFromFilename(String filename) throws IOException {
206 // Gets the locale string by trimming the top "values-".
207 String localeString = filename.substring(7);
208 if (localeString.matches("[A-Za-z]+")) {
209 return Locale.forLanguageTag(localeString);
210 }
211 if (localeString.matches("[A-Za-z]+-r[A-Za-z]+")) {
212 // "${Language}-r${Region}". e.g. en-rGB
213 String[] tokens = localeString.split("-r");
214 return Locale.forLanguageTag(String.join("-", tokens));
215 }
216 if (localeString.startsWith("b+")) {
217 // The special case of b+sr+Latn, which has the form "b+${Language}+${ScriptName}"
218 String[] tokens = localeString.substring(2).split("\\+");
219 return Locale.forLanguageTag(String.join("-", tokens));
220 }
221
222 throw new IOException("Unrecognized locale string " + localeString);
223 }
224
225 /**
226 * Iterates over the xml files in the format of values-$LOCALE/strings.xml under the resource
227 * directory and collect the translated text.
228 *
229 * @param resourcePath the path to the resource directory
230 *
231 * @return a map with the locale as key, and translated text as value
232 *
233 * @throws LocalizedStringNotFoundException if we cannot find the translated text for the given
234 * locale
235 **/
236 public Map<Locale, String> readLocalizedStringFromXmls(String resourcePath) throws
237 IOException, LocalizedStringNotFoundException {
238 File resourceDir = new File(resourcePath);
239 if (!resourceDir.isDirectory()) {
240 throw new LocalizedStringNotFoundException(resourcePath + " is not a directory.");
241 }
242
243 Map<Locale, String> result =
Tianjie Xu22dd0192018-10-17 15:39:00 -0700244 // Overrides the string comparator so that sr is sorted behind sr-Latn. And thus recovery
245 // can find the most relevant locale when going down the list.
246 new TreeMap<>((Locale l1, Locale l2) -> {
247 if (l1.toLanguageTag().equals(l2.toLanguageTag())) {
248 return 0;
249 }
250 if (l1.getLanguage().equals(l2.toLanguageTag())) {
251 return -1;
252 }
253 if (l2.getLanguage().equals(l1.toLanguageTag())) {
254 return 1;
255 }
256 return l1.toLanguageTag().compareTo(l2.toLanguageTag());
257 });
Tianjie Xu721f6792018-10-08 17:04:54 -0700258
259 // Find all the localized resource subdirectories in the format of values-$LOCALE
260 String[] nameList = resourceDir.list(
261 (File file, String name) -> name.startsWith("values-"));
262 for (String name : nameList) {
263 File textFile = new File(resourcePath, name + "/strings.xml");
264 String localizedText;
265 try {
266 localizedText = getTextString(textFile, mTextName);
267 } catch (IOException | ParserConfigurationException | org.xml.sax.SAXException e) {
268 throw new LocalizedStringNotFoundException(
269 "Failed to read the translated text for locale " + name, e);
270 }
271
272 Locale locale = getLocaleFromFilename(name);
273 // Removes the double quotation mark from the text.
274 result.put(locale, localizedText.substring(1, localizedText.length() - 1));
275 }
276
277 return result;
278 }
279
280 /**
281 * Returns a font object associated given the given locale
282 *
283 * @throws IOException if the font file fails to open
284 * @throws FontFormatException if the font file doesn't have the expected format
285 */
286 private Font loadFontsByLocale(String language) throws IOException, FontFormatException {
287 String fontName = LANGUAGE_TO_FONT_MAP.getOrDefault(language, DEFAULT_FONT_NAME);
288 String[] suffixes = {".otf", ".ttf", ".ttc"};
289 for (String suffix : suffixes ) {
290 File fontFile = new File(mFontDirPath, fontName + suffix);
291 if (fontFile.isFile()) {
292 return Font.createFont(Font.TRUETYPE_FONT, fontFile).deriveFont(mFontSize);
293 }
294 }
295
296 throw new IOException("Can not find the font file " + fontName + " for language " + language);
297 }
298
299 /**
300 * Separates the text string by spaces and wraps it by words.
301 **/
302 private List<String> wrapTextByWords(String text, FontMetrics metrics) {
303 List<String> wrappedText = new ArrayList<>();
304 StringTokenizer st = new StringTokenizer(text, " \n");
305
Tianjie Xu22dd0192018-10-17 15:39:00 -0700306 // TODO(xunchang). We assume that all words can fit on the screen. Raise an
307 // IllegalStateException if the word is wider than the image width.
Tianjie Xu721f6792018-10-08 17:04:54 -0700308 StringBuilder line = new StringBuilder();
309 while (st.hasMoreTokens()) {
310 String token = st.nextToken();
311 if (metrics.stringWidth(line + token + " ") > mImageWidth) {
312 wrappedText.add(line.toString());
313 line = new StringBuilder();
314 }
315 line.append(token).append(" ");
316 }
317 wrappedText.add(line.toString());
318
319 return wrappedText;
320 }
321
322 /**
Tianjie Xu22dd0192018-10-17 15:39:00 -0700323 * One character is a word for CJK.
324 */
325 private List<String> wrapTextByCharacters(String text, FontMetrics metrics) {
326 List<String> wrappedText = new ArrayList<>();
327
328 StringBuilder line = new StringBuilder();
329 for (char token : text.toCharArray()) {
330 if (metrics.stringWidth(line + Character.toString(token)) > mImageWidth) {
331 wrappedText.add(line.toString());
332 line = new StringBuilder();
333 }
334 line.append(token);
335 }
336 wrappedText.add(line.toString());
337
338 return wrappedText;
339 }
340
341 /**
Tianjie Xu721f6792018-10-08 17:04:54 -0700342 * Wraps the text with a maximum of mImageWidth pixels per line.
343 *
344 * @param text the string representation of text to wrap
345 * @param metrics the metrics of the Font used to draw the text; it gives the width in pixels of
346 * the text given its string representation
347 *
348 * @return a list of strings with their width smaller than mImageWidth pixels
349 */
Tianjie Xu22dd0192018-10-17 15:39:00 -0700350 private List<String> wrapText(String text, FontMetrics metrics, String language) {
351 if (LOGOGRAM_LANGUAGE.contains(language)) {
352 return wrapTextByCharacters(text, metrics);
353 }
Tianjie Xu721f6792018-10-08 17:04:54 -0700354
355 return wrapTextByWords(text, metrics);
356 }
357
358 /**
Tianjie Xu22dd0192018-10-17 15:39:00 -0700359 * Encodes the information of the text image for |locale|.
360 * According to minui/resources.cpp, the width, height and locale of the image is decoded as:
361 * int w = (row[1] << 8) | row[0];
362 * int h = (row[3] << 8) | row[2];
363 * __unused int len = row[4];
364 * char* loc = reinterpret_cast<char*>(&row[5]);
365 */
366 private List<Integer> encodeTextInfo(int width, int height, String locale) {
367 List<Integer> info = new ArrayList<>(Arrays.asList(width & 0xff, width >> 8,
368 height & 0xff, height >> 8, locale.length()));
369
370 byte[] localeBytes = locale.getBytes();
371 for (byte b: localeBytes) {
372 info.add((int)b);
373 }
374 info.add(0);
375
376 return info;
377 }
378
379 /**
Tianjie Xu721f6792018-10-08 17:04:54 -0700380 * Draws the text string on the canvas for given locale.
381 *
382 * @param text the string to draw on canvas
383 * @param locale the current locale tag of the string to draw
384 *
385 * @throws IOException if we cannot find the corresponding font file for the given locale.
386 * @throws FontFormatException if we failed to load the font file for the given locale.
387 */
Tianjie Xu22dd0192018-10-17 15:39:00 -0700388 private void drawText(String text, Locale locale, String languageTag, boolean centralAlignment)
389 throws IOException, FontFormatException {
Tianjie Xu721f6792018-10-08 17:04:54 -0700390 Graphics2D graphics = mBufferedImage.createGraphics();
391 graphics.setColor(Color.WHITE);
392 graphics.setRenderingHint(
393 RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_GASP);
394 graphics.setFont(loadFontsByLocale(locale.getLanguage()));
395
Tianjie Xu22dd0192018-10-17 15:39:00 -0700396 System.out.println("Encoding \"" + locale + "\" as \"" + languageTag + "\": " + text);
Tianjie Xu721f6792018-10-08 17:04:54 -0700397
398 FontMetrics fontMetrics = graphics.getFontMetrics();
Tianjie Xu22dd0192018-10-17 15:39:00 -0700399 List<String> wrappedText = wrapText(text, fontMetrics, locale.getLanguage());
400
401 // Marks the start y offset for the text image of current locale; and reserves one line to
402 // encode the image metadata.
403 int currentImageStart = mVerticalOffset;
404 mVerticalOffset += 1;
Tianjie Xu721f6792018-10-08 17:04:54 -0700405 for (String line : wrappedText) {
406 int lineHeight = fontMetrics.getHeight();
407 // Doubles the height of the image if we are short of space.
408 if (mVerticalOffset + lineHeight >= mImageHeight) {
409 resizeHeight(mImageHeight * 2);
410 }
411
412 // Draws the text at mVerticalOffset and increments the offset with line space.
413 int baseLine = mVerticalOffset + lineHeight - fontMetrics.getDescent();
Tianjie Xu22dd0192018-10-17 15:39:00 -0700414
415 // Draws from right if it's an RTL language.
416 int x = centralAlignment ? (mImageWidth - fontMetrics.stringWidth(line)) / 2 :
417 RTL_LANGUAGE.contains(languageTag) ? mImageWidth - fontMetrics.stringWidth(line) : 0;
418
419 graphics.drawString(line, x, baseLine);
420
Tianjie Xu721f6792018-10-08 17:04:54 -0700421 mVerticalOffset += lineHeight;
422 }
Tianjie Xu22dd0192018-10-17 15:39:00 -0700423
424 // Encodes the metadata of the current localized image as pixels.
425 int currentImageHeight = mVerticalOffset - currentImageStart - 1;
426 List<Integer> info = encodeTextInfo(mImageWidth, currentImageHeight, languageTag);
427 for (int i = 0; i < info.size(); i++) {
428 int pixel[] = { info.get(i) };
429 mBufferedImage.getRaster().setPixel(i, currentImageStart, pixel);
430 }
Tianjie Xu721f6792018-10-08 17:04:54 -0700431 }
432
433 /**
434 * Redraws the image with the new height.
435 *
436 * @param height the new height of the image in pixels.
437 */
438 private void resizeHeight(int height) {
439 BufferedImage resizedImage =
440 new BufferedImage(mImageWidth, height, BufferedImage.TYPE_BYTE_GRAY);
441 Graphics2D graphic = resizedImage.createGraphics();
442 graphic.drawImage(mBufferedImage, 0, 0, null);
443 graphic.dispose();
444
445 mBufferedImage = resizedImage;
446 mImageHeight = height;
447 }
448
449 /**
450 * This function draws the font characters and saves the result to outputPath.
451 *
452 * @param localizedTextMap a map from locale to its translated text string
453 * @param outputPath the path to write the generated image file.
454 *
455 * @throws FontFormatException if there's a format error in one of the font file
456 * @throws IOException if we cannot find the font file for one of the locale, or we failed to
457 * write the image file.
458 */
459 public void generateImage(Map<Locale, String> localizedTextMap, String outputPath) throws
460 FontFormatException, IOException {
Tianjie Xu22dd0192018-10-17 15:39:00 -0700461 Map<String, Integer> languageCount = new TreeMap<>();
Tianjie Xu721f6792018-10-08 17:04:54 -0700462 for (Locale locale : localizedTextMap.keySet()) {
Tianjie Xu22dd0192018-10-17 15:39:00 -0700463 String language = locale.getLanguage();
464 languageCount.put(language, languageCount.getOrDefault(language, 0) + 1 );
465 }
466
467 for (Locale locale : localizedTextMap.keySet()) {
468 Integer count = languageCount.get(locale.getLanguage());
469 // Recovery expects en-US instead of en_US.
470 String languageTag = locale.toLanguageTag();
471 if (count == 1) {
472 // Make the last country variant for a given language be the catch-all for that language.
473 languageTag = locale.getLanguage();
474 } else {
475 languageCount.put(locale.getLanguage(), count - 1);
476 }
477
478 drawText(localizedTextMap.get(locale), locale, languageTag, false);
Tianjie Xu721f6792018-10-08 17:04:54 -0700479 }
480
481 // TODO(xunchang) adjust the width to save some space if all texts are smaller than imageWidth.
482 resizeHeight(mVerticalOffset);
483 ImageIO.write(mBufferedImage, "png", new File(outputPath));
484 }
485
Tianjie Xuedfeb972018-10-23 12:40:14 -0700486 public static void printUsage(Options options) {
487 new HelpFormatter().printHelp("java -jar path_to_jar [required_options]", options);
488
489 }
490
491 public static Options createOptions() {
492 Options options = new Options();
493 options.addOption(OptionBuilder
494 .withLongOpt("image_width")
495 .withDescription("The initial width of the image in pixels.")
496 .hasArgs(1)
497 .isRequired()
498 .create());
499
500 options.addOption(OptionBuilder
501 .withLongOpt("text_name")
502 .withDescription("The description of the text string, e.g. recovery_erasing")
503 .hasArgs(1)
504 .isRequired()
505 .create());
506
507 options.addOption(OptionBuilder
508 .withLongOpt("font_dir")
509 .withDescription("The directory that contains all the support font format files, e.g."
510 + " $OUT/system/fonts/")
511 .hasArgs(1)
512 .isRequired()
513 .create());
514
515 options.addOption(OptionBuilder
516 .withLongOpt("resource_dir")
517 .withDescription("The resource directory that contains all the translated strings in xml"
518 + " format, e.g. bootable/recovery/tools/recovery_l10n/res/")
519 .hasArgs(1)
520 .isRequired()
521 .create());
522
523 options.addOption(OptionBuilder
524 .withLongOpt("output_file")
525 .withDescription("Path to the generated image")
526 .hasArgs(1)
527 .isRequired()
528 .create());
529
530 return options;
Tianjie Xu721f6792018-10-08 17:04:54 -0700531 }
532
533 public static void main(String[] args) throws NumberFormatException, IOException,
534 FontFormatException, LocalizedStringNotFoundException {
Tianjie Xuedfeb972018-10-23 12:40:14 -0700535 Options options = createOptions();
536 CommandLine cmd;
537 try {
538 cmd = new GnuParser().parse(options, args);
539 } catch (ParseException e) {
540 System.err.println(e.getMessage());
541 printUsage(options);
542 return;
Tianjie Xu721f6792018-10-08 17:04:54 -0700543 }
544
Tianjie Xuedfeb972018-10-23 12:40:14 -0700545 int imageWidth = Integer.parseUnsignedInt(cmd.getOptionValue("image_width"));
Tianjie Xu721f6792018-10-08 17:04:54 -0700546
Tianjie Xuedfeb972018-10-23 12:40:14 -0700547 ImageGenerator imageGenerator = new ImageGenerator(imageWidth, cmd.getOptionValue("text_name"),
548 DEFAULT_FONT_SIZE, cmd.getOptionValue("font_dir"));
Tianjie Xu721f6792018-10-08 17:04:54 -0700549
550 Map<Locale, String> localizedStringMap =
Tianjie Xuedfeb972018-10-23 12:40:14 -0700551 imageGenerator.readLocalizedStringFromXmls(cmd.getOptionValue("resource_dir"));
552 imageGenerator.generateImage(localizedStringMap, cmd.getOptionValue("output_file"));
Tianjie Xu721f6792018-10-08 17:04:54 -0700553 }
554}
555