blob: b9103379e57561e74def880d15a2dcd2a3707617 [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;
29import java.util.Comparator;
30import java.util.List;
31import java.util.Locale;
32import java.util.Map;
33import java.util.TreeMap;
34import java.util.StringTokenizer;
35
36import javax.imageio.ImageIO;
37import javax.xml.parsers.DocumentBuilder;
38import javax.xml.parsers.DocumentBuilderFactory;
39import javax.xml.parsers.ParserConfigurationException;
40
Tianjie Xuedfeb972018-10-23 12:40:14 -070041import org.apache.commons.cli.CommandLine;
42import org.apache.commons.cli.GnuParser;
43import org.apache.commons.cli.HelpFormatter;
44import org.apache.commons.cli.OptionBuilder;
45import org.apache.commons.cli.Options;
46import org.apache.commons.cli.ParseException;
47
Tianjie Xu721f6792018-10-08 17:04:54 -070048import org.w3c.dom.Document;
49import org.w3c.dom.Node;
50import org.w3c.dom.NodeList;
51
52/**
53 * Command line tool to generate the localized image for recovery mode.
54 */
55public class ImageGenerator {
56 // Initial height of the image to draw.
57 private static final int INITIAL_HEIGHT = 20000;
58
59 private static final float DEFAULT_FONT_SIZE = 40;
60
61 // This is the canvas we used to draw texts.
62 private BufferedImage mBufferedImage;
63
64 // The width in pixels of our image. Once set, its value won't change.
65 private final int mImageWidth;
66
67 // The current height in pixels of our image. We will adjust the value when drawing more texts.
68 private int mImageHeight;
69
70 // The current vertical offset in pixels to draw the top edge of new text strings.
71 private int mVerticalOffset;
72
73 // The font size to draw the texts.
74 private final float mFontSize;
75
76 // The name description of the text to localize. It's used to find the translated strings in the
77 // resource file.
78 private final String mTextName;
79
80 // The directory that contains all the needed font files (e.g. ttf, otf, ttc files).
81 private final String mFontDirPath;
82
83 // An explicit map from language to the font name to use.
84 // The map is extracted from frameworks/base/data/fonts/fonts.xml.
85 // And the language-subtag-registry is found in:
86 // https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
87 private static final String DEFAULT_FONT_NAME = "Roboto-Regular";
88 private static final Map<String, String> LANGUAGE_TO_FONT_MAP = new TreeMap<String, String>() {{
89 put("am", "NotoSansEthiopic-Regular");
90 put("ar", "NotoNaskhArabicUI-Regular");
91 put("as", "NotoSansBengaliUI-Regular");
92 put("bn", "NotoSansBengaliUI-Regular");
93 put("fa", "NotoNaskhArabicUI-Regular");
94 put("gu", "NotoSansGujaratiUI-Regular");
95 put("hi", "NotoSansDevanagariUI-Regular");
96 put("hy", "NotoSansArmenian-Regular");
97 put("iw", "NotoSansHebrew-Regular");
98 put("ja", "NotoSansCJK-Regular");
99 put("ka", "NotoSansGeorgian-Regular");
100 put("ko", "NotoSansCJK-Regular");
101 put("km", "NotoSansKhmerUI-Regular");
102 put("kn", "NotoSansKannadaUI-Regular");
103 put("lo", "NotoSansLaoUI-Regular");
104 put("ml", "NotoSansMalayalamUI-Regular");
105 put("mr", "NotoSansDevanagariUI-Regular");
106 put("my", "NotoSansMyanmarUI-Regular");
107 put("ne", "NotoSansDevanagariUI-Regular");
108 put("or", "NotoSansOriya-Regular");
109 put("pa", "NotoSansGurmukhiUI-Regular");
110 put("si", "NotoSansSinhala-Regular");
111 put("ta", "NotoSansTamilUI-Regular");
112 put("te", "NotoSansTeluguUI-Regular");
113 put("th", "NotoSansThaiUI-Regular");
114 put("ur", "NotoNaskhArabicUI-Regular");
115 put("zh", "NotoSansCJK-Regular");
116 }};
117
118 /**
119 * Exception to indicate the failure to find the translated text strings.
120 */
121 public static class LocalizedStringNotFoundException extends Exception {
122 public LocalizedStringNotFoundException(String message) {
123 super(message);
124 }
125
126 public LocalizedStringNotFoundException(String message, Throwable cause) {
127 super(message, cause);
128 }
129 }
130
131 /**
132 * Initailizes the fields of the image image.
133 */
134 public ImageGenerator(int imageWidth, String textName, float fontSize, String fontDirPath) {
135 mImageWidth = imageWidth;
136 mImageHeight = INITIAL_HEIGHT;
137 mVerticalOffset = 0;
138
139 // Initialize the canvas with the default height.
140 mBufferedImage = new BufferedImage(mImageWidth, mImageHeight, BufferedImage.TYPE_BYTE_GRAY);
141
142 mTextName = textName;
143 mFontSize = fontSize;
144 mFontDirPath = fontDirPath;
145 }
146
147 /**
148 * Finds the translated text string for the given textName by parsing the resourceFile.
149 * Example of the xml fields:
150 * <resources xmlns:android="http://schemas.android.com/apk/res/android">
151 * <string name="recovery_installing_security" msgid="9184031299717114342">
152 * "Sicherheitsupdate wird installiert"</string>
153 * </resources>
154 *
155 * @param resourceFile the input resource file in xml format.
156 * @param textName the name description of the text.
157 *
158 * @return the string representation of the translated text.
159 */
160 private String getTextString(File resourceFile, String textName) throws IOException,
161 ParserConfigurationException, org.xml.sax.SAXException, LocalizedStringNotFoundException {
162 DocumentBuilderFactory builder = DocumentBuilderFactory.newInstance();
163 DocumentBuilder db = builder.newDocumentBuilder();
164
165 Document doc = db.parse(resourceFile);
166 doc.getDocumentElement().normalize();
167
168 NodeList nodeList = doc.getElementsByTagName("string");
169 for (int i = 0; i < nodeList.getLength(); i++) {
170 Node node = nodeList.item(i);
171 String name = node.getAttributes().getNamedItem("name").getNodeValue();
172 if (name.equals(textName)) {
173 return node.getTextContent();
174 }
175 }
176
177 throw new LocalizedStringNotFoundException(textName + " not found in "
178 + resourceFile.getName());
179 }
180
181 /**
182 * Constructs the locale from the name of the resource file.
183 */
184 private Locale getLocaleFromFilename(String filename) throws IOException {
185 // Gets the locale string by trimming the top "values-".
186 String localeString = filename.substring(7);
187 if (localeString.matches("[A-Za-z]+")) {
188 return Locale.forLanguageTag(localeString);
189 }
190 if (localeString.matches("[A-Za-z]+-r[A-Za-z]+")) {
191 // "${Language}-r${Region}". e.g. en-rGB
192 String[] tokens = localeString.split("-r");
193 return Locale.forLanguageTag(String.join("-", tokens));
194 }
195 if (localeString.startsWith("b+")) {
196 // The special case of b+sr+Latn, which has the form "b+${Language}+${ScriptName}"
197 String[] tokens = localeString.substring(2).split("\\+");
198 return Locale.forLanguageTag(String.join("-", tokens));
199 }
200
201 throw new IOException("Unrecognized locale string " + localeString);
202 }
203
204 /**
205 * Iterates over the xml files in the format of values-$LOCALE/strings.xml under the resource
206 * directory and collect the translated text.
207 *
208 * @param resourcePath the path to the resource directory
209 *
210 * @return a map with the locale as key, and translated text as value
211 *
212 * @throws LocalizedStringNotFoundException if we cannot find the translated text for the given
213 * locale
214 **/
215 public Map<Locale, String> readLocalizedStringFromXmls(String resourcePath) throws
216 IOException, LocalizedStringNotFoundException {
217 File resourceDir = new File(resourcePath);
218 if (!resourceDir.isDirectory()) {
219 throw new LocalizedStringNotFoundException(resourcePath + " is not a directory.");
220 }
221
222 Map<Locale, String> result =
223 new TreeMap<Locale, String>(Comparator.comparing(Locale::toLanguageTag));
224
225 // Find all the localized resource subdirectories in the format of values-$LOCALE
226 String[] nameList = resourceDir.list(
227 (File file, String name) -> name.startsWith("values-"));
228 for (String name : nameList) {
229 File textFile = new File(resourcePath, name + "/strings.xml");
230 String localizedText;
231 try {
232 localizedText = getTextString(textFile, mTextName);
233 } catch (IOException | ParserConfigurationException | org.xml.sax.SAXException e) {
234 throw new LocalizedStringNotFoundException(
235 "Failed to read the translated text for locale " + name, e);
236 }
237
238 Locale locale = getLocaleFromFilename(name);
239 // Removes the double quotation mark from the text.
240 result.put(locale, localizedText.substring(1, localizedText.length() - 1));
241 }
242
243 return result;
244 }
245
246 /**
247 * Returns a font object associated given the given locale
248 *
249 * @throws IOException if the font file fails to open
250 * @throws FontFormatException if the font file doesn't have the expected format
251 */
252 private Font loadFontsByLocale(String language) throws IOException, FontFormatException {
253 String fontName = LANGUAGE_TO_FONT_MAP.getOrDefault(language, DEFAULT_FONT_NAME);
254 String[] suffixes = {".otf", ".ttf", ".ttc"};
255 for (String suffix : suffixes ) {
256 File fontFile = new File(mFontDirPath, fontName + suffix);
257 if (fontFile.isFile()) {
258 return Font.createFont(Font.TRUETYPE_FONT, fontFile).deriveFont(mFontSize);
259 }
260 }
261
262 throw new IOException("Can not find the font file " + fontName + " for language " + language);
263 }
264
265 /**
266 * Separates the text string by spaces and wraps it by words.
267 **/
268 private List<String> wrapTextByWords(String text, FontMetrics metrics) {
269 List<String> wrappedText = new ArrayList<>();
270 StringTokenizer st = new StringTokenizer(text, " \n");
271
272 StringBuilder line = new StringBuilder();
273 while (st.hasMoreTokens()) {
274 String token = st.nextToken();
275 if (metrics.stringWidth(line + token + " ") > mImageWidth) {
276 wrappedText.add(line.toString());
277 line = new StringBuilder();
278 }
279 line.append(token).append(" ");
280 }
281 wrappedText.add(line.toString());
282
283 return wrappedText;
284 }
285
286 /**
287 * Wraps the text with a maximum of mImageWidth pixels per line.
288 *
289 * @param text the string representation of text to wrap
290 * @param metrics the metrics of the Font used to draw the text; it gives the width in pixels of
291 * the text given its string representation
292 *
293 * @return a list of strings with their width smaller than mImageWidth pixels
294 */
295 private List<String> wrapText(String text, FontMetrics metrics) {
296 // TODO(xunchang) handle other cases of text wrapping
297 // 1. RTL languages: "ar"(Arabic), "fa"(Persian), "he"(Hebrew), "iw"(Hebrew), "ur"(Urdu)
298 // 2. Language uses characters: CJK, "lo"(lao), "km"(khmer)
299
300 return wrapTextByWords(text, metrics);
301 }
302
303 /**
304 * Draws the text string on the canvas for given locale.
305 *
306 * @param text the string to draw on canvas
307 * @param locale the current locale tag of the string to draw
308 *
309 * @throws IOException if we cannot find the corresponding font file for the given locale.
310 * @throws FontFormatException if we failed to load the font file for the given locale.
311 */
312 private void drawText(String text, Locale locale) throws IOException, FontFormatException {
313 Graphics2D graphics = mBufferedImage.createGraphics();
314 graphics.setColor(Color.WHITE);
315 graphics.setRenderingHint(
316 RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_GASP);
317 graphics.setFont(loadFontsByLocale(locale.getLanguage()));
318
319 System.out.println("Drawing text for locale " + locale + " text " + text);
320
321 FontMetrics fontMetrics = graphics.getFontMetrics();
322 List<String> wrappedText = wrapTextByWords(text, fontMetrics);
323 for (String line : wrappedText) {
324 int lineHeight = fontMetrics.getHeight();
325 // Doubles the height of the image if we are short of space.
326 if (mVerticalOffset + lineHeight >= mImageHeight) {
327 resizeHeight(mImageHeight * 2);
328 }
329
330 // Draws the text at mVerticalOffset and increments the offset with line space.
331 int baseLine = mVerticalOffset + lineHeight - fontMetrics.getDescent();
332 graphics.drawString(line, 0, baseLine);
333 mVerticalOffset += lineHeight;
334 }
335 }
336
337 /**
338 * Redraws the image with the new height.
339 *
340 * @param height the new height of the image in pixels.
341 */
342 private void resizeHeight(int height) {
343 BufferedImage resizedImage =
344 new BufferedImage(mImageWidth, height, BufferedImage.TYPE_BYTE_GRAY);
345 Graphics2D graphic = resizedImage.createGraphics();
346 graphic.drawImage(mBufferedImage, 0, 0, null);
347 graphic.dispose();
348
349 mBufferedImage = resizedImage;
350 mImageHeight = height;
351 }
352
353 /**
354 * This function draws the font characters and saves the result to outputPath.
355 *
356 * @param localizedTextMap a map from locale to its translated text string
357 * @param outputPath the path to write the generated image file.
358 *
359 * @throws FontFormatException if there's a format error in one of the font file
360 * @throws IOException if we cannot find the font file for one of the locale, or we failed to
361 * write the image file.
362 */
363 public void generateImage(Map<Locale, String> localizedTextMap, String outputPath) throws
364 FontFormatException, IOException {
365 for (Locale locale : localizedTextMap.keySet()) {
366 // TODO(xunchang) reprocess the locales for the same language and make the last locale the
367 // catch-all type. e.g. "zh-CN, zh-HK, zh-TW" will become "zh-CN, zh-HK, zh"
368 // Or maybe we don't need to support these variants?
369 drawText(localizedTextMap.get(locale), locale);
370 }
371
372 // TODO(xunchang) adjust the width to save some space if all texts are smaller than imageWidth.
373 resizeHeight(mVerticalOffset);
374 ImageIO.write(mBufferedImage, "png", new File(outputPath));
375 }
376
Tianjie Xuedfeb972018-10-23 12:40:14 -0700377 public static void printUsage(Options options) {
378 new HelpFormatter().printHelp("java -jar path_to_jar [required_options]", options);
379
380 }
381
382 public static Options createOptions() {
383 Options options = new Options();
384 options.addOption(OptionBuilder
385 .withLongOpt("image_width")
386 .withDescription("The initial width of the image in pixels.")
387 .hasArgs(1)
388 .isRequired()
389 .create());
390
391 options.addOption(OptionBuilder
392 .withLongOpt("text_name")
393 .withDescription("The description of the text string, e.g. recovery_erasing")
394 .hasArgs(1)
395 .isRequired()
396 .create());
397
398 options.addOption(OptionBuilder
399 .withLongOpt("font_dir")
400 .withDescription("The directory that contains all the support font format files, e.g."
401 + " $OUT/system/fonts/")
402 .hasArgs(1)
403 .isRequired()
404 .create());
405
406 options.addOption(OptionBuilder
407 .withLongOpt("resource_dir")
408 .withDescription("The resource directory that contains all the translated strings in xml"
409 + " format, e.g. bootable/recovery/tools/recovery_l10n/res/")
410 .hasArgs(1)
411 .isRequired()
412 .create());
413
414 options.addOption(OptionBuilder
415 .withLongOpt("output_file")
416 .withDescription("Path to the generated image")
417 .hasArgs(1)
418 .isRequired()
419 .create());
420
421 return options;
Tianjie Xu721f6792018-10-08 17:04:54 -0700422 }
423
424 public static void main(String[] args) throws NumberFormatException, IOException,
425 FontFormatException, LocalizedStringNotFoundException {
Tianjie Xuedfeb972018-10-23 12:40:14 -0700426 Options options = createOptions();
427 CommandLine cmd;
428 try {
429 cmd = new GnuParser().parse(options, args);
430 } catch (ParseException e) {
431 System.err.println(e.getMessage());
432 printUsage(options);
433 return;
Tianjie Xu721f6792018-10-08 17:04:54 -0700434 }
435
Tianjie Xuedfeb972018-10-23 12:40:14 -0700436 int imageWidth = Integer.parseUnsignedInt(cmd.getOptionValue("image_width"));
Tianjie Xu721f6792018-10-08 17:04:54 -0700437
Tianjie Xuedfeb972018-10-23 12:40:14 -0700438 ImageGenerator imageGenerator = new ImageGenerator(imageWidth, cmd.getOptionValue("text_name"),
439 DEFAULT_FONT_SIZE, cmd.getOptionValue("font_dir"));
Tianjie Xu721f6792018-10-08 17:04:54 -0700440
441 Map<Locale, String> localizedStringMap =
Tianjie Xuedfeb972018-10-23 12:40:14 -0700442 imageGenerator.readLocalizedStringFromXmls(cmd.getOptionValue("resource_dir"));
443 imageGenerator.generateImage(localizedStringMap, cmd.getOptionValue("output_file"));
Tianjie Xu721f6792018-10-08 17:04:54 -0700444 }
445}
446