blob: 68f351721f02330ac5fb26200974e858742f72b9 [file] [log] [blame]
Yu-Ping Wud71b4452020-06-16 11:00:26 +08001#!/usr/bin/env python
Hung-Te Lin707e2ef2013-08-06 10:20:04 +08002# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +08005"""Script to generate bitmaps for firmware screens."""
Hung-Te Lin707e2ef2013-08-06 10:20:04 +08006
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +08007import argparse
Yu-Ping Wuf946dd42021-02-08 16:32:28 +08008from collections import namedtuple
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +08009import copy
Yu-Ping Wue445e042020-11-19 15:53:42 +080010import fractions
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080011import glob
Jes Klinke1687a992020-06-16 13:47:17 -070012import json
Hung-Te Lin04addcc2015-03-23 18:43:30 +080013import multiprocessing
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080014import os
15import re
Jes Klinke1687a992020-06-16 13:47:17 -070016import shutil
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080017import signal
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080018import subprocess
Jes Klinke1687a992020-06-16 13:47:17 -070019import tempfile
Hung-Te Lin04addcc2015-03-23 18:43:30 +080020
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080021import yaml
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080022from PIL import Image
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080023
24SCRIPT_BASE = os.path.dirname(os.path.abspath(__file__))
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080025
26STRINGS_GRD_FILE = 'firmware_strings.grd'
27STRINGS_JSON_FILE_TMPL = '{}.json'
28FORMAT_FILE = 'format.yaml'
29BOARDS_CONFIG_FILE = 'boards.yaml'
30
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080031OUTPUT_DIR = os.getenv('OUTPUT', os.path.join(SCRIPT_BASE, 'build'))
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080032
33ONE_LINE_DIR = 'one_line'
34SVG_FILES = '*.svg'
35PNG_FILES = '*.png'
36
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +080037DIAGNOSTIC_UI = os.getenv('DIAGNOSTIC_UI') == '1'
38
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080039# String format YAML key names.
Yu-Ping Wu177f12c2020-11-04 15:55:37 +080040KEY_DEFAULT = '_DEFAULT_'
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080041KEY_LOCALES = 'locales'
Yu-Ping Wu338f0832020-10-23 16:14:40 +080042KEY_GENERIC_FILES = 'generic_files'
43KEY_LOCALIZED_FILES = 'localized_files'
Yu-Ping Wu177f12c2020-11-04 15:55:37 +080044KEY_DIAGNOSTIC_FILES = 'diagnostic_files'
45KEY_SPRITE_FILES = 'sprite_files'
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080046KEY_STYLES = 'styles'
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +080047KEY_BGCOLOR = 'bgcolor'
48KEY_FGCOLOR = 'fgcolor'
49KEY_HEIGHT = 'height'
Yu-Ping Wued95df32020-11-04 17:08:15 +080050KEY_MAX_WIDTH = 'max_width'
Yu-Ping Wu177f12c2020-11-04 15:55:37 +080051KEY_FONTS = 'fonts'
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080052
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080053# Board config YAML key names.
54SCREEN_KEY = 'screen'
55PANEL_KEY = 'panel'
56SDCARD_KEY = 'sdcard'
57BAD_USB3_KEY = 'bad_usb3'
Yu-Ping Wue66a7b02020-11-19 15:18:08 +080058DPI_KEY = 'dpi'
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080059LOCALES_KEY = 'locales'
60RTL_KEY = 'rtl'
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080061RW_OVERRIDE_KEY = 'rw_override'
62
63BMP_HEADER_OFFSET_NUM_LINES = 6
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080064
Jes Klinke1687a992020-06-16 13:47:17 -070065# Regular expressions used to eliminate spurious spaces and newlines in
66# translation strings.
67NEWLINE_PATTERN = re.compile(r'([^\n])\n([^\n])')
68NEWLINE_REPLACEMENT = r'\1 \2'
69CRLF_PATTERN = re.compile(r'\r\n')
70MULTIBLANK_PATTERN = re.compile(r' *')
71
Yu-Ping Wu3d07a062021-01-26 18:10:32 +080072# The base for bitmap scales, same as UI_SCALE in depthcharge. For example, if
73# `SCALE_BASE` is 1000, then height = 200 means 20% of the screen height. Also
74# see the 'styles' section in format.yaml.
75SCALE_BASE = 1000
76DEFAULT_GLYPH_HEIGHT = 20
77
Yu-Ping Wucc86d6a2020-11-27 12:48:19 +080078GLYPH_FONT = 'Cousine'
Yu-Ping Wu11027f02020-10-14 17:35:42 +080079
Yu-Ping Wuabb9afb2020-10-27 17:15:22 +080080LocaleInfo = namedtuple('LocaleInfo', ['code', 'rtl'])
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080081
Yu-Ping Wu6b282c52020-03-19 12:54:15 +080082
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080083class DataError(Exception):
84 pass
85
86
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080087class BuildImageError(Exception):
88 """The exception class for all errors generated during build image process."""
89
90
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +080091def get_config_with_defaults(configs, key):
92 """Gets config of `key` from `configs`.
93
94 If `key` is not present in `configs`, the default config will be returned.
95 Similarly, if some config values are missing for `key`, the default ones will
96 be used.
97 """
Yu-Ping Wu177f12c2020-11-04 15:55:37 +080098 config = configs[KEY_DEFAULT].copy()
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +080099 config.update(configs.get(key, {}))
100 return config
101
102
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800103def load_boards_config(filename):
104 """Loads the configuration of all boards from `filename`.
105
106 Args:
107 filename: File name of a YAML config file.
108
109 Returns:
110 A dictionary mapping each board name to its config.
111 """
112 with open(filename, 'rb') as file:
113 raw = yaml.load(file)
114
115 configs = {}
116 default = raw[KEY_DEFAULT]
117 if not default:
118 raise BuildImageError('Default configuration is not found')
119 for boards, params in raw.items():
120 if boards == KEY_DEFAULT:
121 continue
122 config = copy.deepcopy(default)
123 if params:
124 config.update(params)
125 for board in boards.replace(',', ' ').split():
126 configs[board] = config
127
128 return configs
129
130
131def check_fonts(fonts):
132 """Check if all fonts are available."""
133 for locale, font in fonts.items():
134 if subprocess.run(['fc-list', '-q', font]).returncode != 0:
135 raise BuildImageError('Font %r not found for locale %r'
136 % (font, locale))
137
138
Yu-Ping Wu97046932021-01-25 17:38:56 +0800139def run_pango_view(input_file, output_file, locale, font, height, max_width,
140 dpi, bgcolor, fgcolor, hinting='full'):
141 """Run pango-view."""
142 command = ['pango-view', '-q']
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800143 if locale:
Yu-Ping Wu97046932021-01-25 17:38:56 +0800144 command += ['--language', locale]
145
146 # Font size should be proportional to the height. Here we use 2 as the
147 # divisor so that setting dpi to 96 (pango-view's default) in boards.yaml
148 # will be roughly equivalent to setting the screen resolution to 1366x768.
149 font_size = height / 2
150 font_spec = '%s %r' % (font, font_size)
151 command += ['--font', font_spec]
152
Yu-Ping Wued95df32020-11-04 17:08:15 +0800153 if max_width:
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800154 # When converting text to PNG by pango-view, the ratio of image height to
155 # the font size is usually no more than 1.1875 (with Roboto). Therefore,
156 # set the `max_width_pt` as follows to prevent UI drawing from exceeding
157 # the canvas boundary in depthcharge runtime. The divisor 2 is the same in
158 # the calculation of `font_size` above.
159 max_width_pt = int(max_width / 2 * 1.1875)
Yu-Ping Wued95df32020-11-04 17:08:15 +0800160 command.append('--width=%d' % max_width_pt)
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800161 if dpi:
162 command.append('--dpi=%d' % dpi)
Yu-Ping Wucc86d6a2020-11-27 12:48:19 +0800163 command.append('--margin=0')
Yu-Ping Wu97046932021-01-25 17:38:56 +0800164 command += ['--background', bgcolor]
165 command += ['--foreground', fgcolor]
166 command += ['--hinting', hinting]
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +0800167
Yu-Ping Wu97046932021-01-25 17:38:56 +0800168 command += ['--output', output_file]
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800169 command.append(input_file)
170
Yu-Ping Wu97046932021-01-25 17:38:56 +0800171 subprocess.check_call(command, stdout=subprocess.PIPE)
172
173
Yu-Ping Wu703dcfd2021-01-08 10:52:10 +0800174def parse_locale_json_file(locale, json_dir):
175 """Parses given firmware string json file.
176
177 Args:
178 locale: The name of the locale, e.g. "da" or "pt-BR".
179 json_dir: Directory containing json output from grit.
180
181 Returns:
182 A dictionary for mapping of "name to content" for files to be generated.
183 """
Jes Klinke1687a992020-06-16 13:47:17 -0700184 result = {}
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800185 filename = os.path.join(json_dir, STRINGS_JSON_FILE_TMPL.format(locale))
Yu-Ping Wud71b4452020-06-16 11:00:26 +0800186 with open(filename, encoding='utf-8-sig') as input_file:
Jes Klinke1687a992020-06-16 13:47:17 -0700187 for tag, msgdict in json.load(input_file).items():
188 msgtext = msgdict['message']
189 msgtext = re.sub(CRLF_PATTERN, '\n', msgtext)
190 msgtext = re.sub(NEWLINE_PATTERN, NEWLINE_REPLACEMENT, msgtext)
191 msgtext = re.sub(MULTIBLANK_PATTERN, ' ', msgtext)
192 # Strip any trailing whitespace. A trailing newline appears to make
193 # Pango report a larger layout size than what's actually visible.
194 msgtext = msgtext.strip()
195 result[tag] = msgtext
196 return result
197
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800198
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800199class Converter(object):
200 """Converter from assets, texts, URLs, and fonts to bitmap images.
201
202 Attributes:
203 ASSET_DIR (str): Directory of image assets.
204 DEFAULT_OUTPUT_EXT (str): Default output file extension.
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800205 ASSET_MAX_COLORS (int): Maximum colors to use for converting image assets
206 to bitmaps.
207 DEFAULT_BACKGROUND (tuple): Default background color.
208 BACKGROUND_COLORS (dict): Background color of each image. Key is the image
209 name and value is a tuple of RGB values.
210 """
211
212 ASSET_DIR = 'assets'
213 DEFAULT_OUTPUT_EXT = '.bmp'
214
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800215 # background colors
216 DEFAULT_BACKGROUND = (0x20, 0x21, 0x24)
217 LANG_HEADER_BACKGROUND = (0x16, 0x17, 0x19)
218 LINK_SELECTED_BACKGROUND = (0x2a, 0x2f, 0x39)
219 ASSET_MAX_COLORS = 128
220
221 BACKGROUND_COLORS = {
222 'ic_dropdown': LANG_HEADER_BACKGROUND,
223 'ic_dropleft_focus': LINK_SELECTED_BACKGROUND,
224 'ic_dropright_focus': LINK_SELECTED_BACKGROUND,
225 'ic_globe': LANG_HEADER_BACKGROUND,
226 'ic_search_focus': LINK_SELECTED_BACKGROUND,
227 'ic_settings_focus': LINK_SELECTED_BACKGROUND,
228 'ic_power_focus': LINK_SELECTED_BACKGROUND,
229 }
230
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +0800231 def __init__(self, board, formats, board_config, output):
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800232 """Inits converter.
233
234 Args:
235 board: Board name.
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +0800236 formats: A dictionary of string formats.
237 board_config: A dictionary of board configurations.
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800238 output: Output directory.
239 """
240 self.board = board
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +0800241 self.formats = formats
242 self.config = board_config
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800243 self.set_dirs(output)
244 self.set_screen()
Yu-Ping Wu3d272e72021-03-01 12:01:55 +0800245 self.set_rename_map()
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800246 self.set_locales()
Yu-Ping Wu96cf0022021-01-07 15:55:49 +0800247 self.text_max_colors = self.get_text_colors(self.config[DPI_KEY])
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800248
249 def set_dirs(self, output):
250 """Sets board output directory and stage directory.
251
252 Args:
253 output: Output directory.
254 """
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800255 self.strings_dir = os.path.join(SCRIPT_BASE, 'strings')
256 self.locale_dir = os.path.join(self.strings_dir, 'locale')
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800257 self.output_dir = os.path.join(output, self.board)
258 self.output_ro_dir = os.path.join(self.output_dir, 'locale', 'ro')
259 self.output_rw_dir = os.path.join(self.output_dir, 'locale', 'rw')
260 self.stage_dir = os.path.join(output, '.stage')
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800261 self.stage_locale_dir = os.path.join(self.stage_dir, 'locale')
262 self.stage_font_dir = os.path.join(self.stage_dir, 'font')
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800263 self.temp_dir = os.path.join(self.stage_dir, 'tmp')
264
265 def set_screen(self):
266 """Sets screen width and height."""
267 self.screen_width, self.screen_height = self.config[SCREEN_KEY]
268
Yu-Ping Wue445e042020-11-19 15:53:42 +0800269 self.panel_stretch = fractions.Fraction(1)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800270 if self.config[PANEL_KEY]:
Yu-Ping Wue445e042020-11-19 15:53:42 +0800271 # Calculate `panel_stretch`. It's used to shrink images horizontally so
272 # that the resulting images will look proportional to the original image
273 # on the stretched display. If the display is not stretched, meaning the
274 # aspect ratio is same as the screen where images were rendered, no
275 # shrinking is performed.
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800276 panel_width, panel_height = self.config[PANEL_KEY]
Yu-Ping Wue445e042020-11-19 15:53:42 +0800277 self.panel_stretch = fractions.Fraction(self.screen_width * panel_height,
278 self.screen_height * panel_width)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800279
Yu-Ping Wue445e042020-11-19 15:53:42 +0800280 if self.panel_stretch > 1:
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800281 raise BuildImageError('Panel aspect ratio (%f) is smaller than screen '
282 'aspect ratio (%f). It indicates screen will be '
283 'shrunk horizontally. It is currently unsupported.'
284 % (panel_width / panel_height,
285 self.screen_width / self.screen_height))
286
287 # Set up square drawing area
288 self.canvas_px = min(self.screen_width, self.screen_height)
289
Yu-Ping Wu3d272e72021-03-01 12:01:55 +0800290 def set_rename_map(self):
291 """Initializes a dict `self.rename_map` for image renaming.
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800292
Yu-Ping Wu3d272e72021-03-01 12:01:55 +0800293 For each items in the dict, image `key` will be renamed to `value`.
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800294 """
Yu-Ping Wu3d272e72021-03-01 12:01:55 +0800295 is_detachable = os.getenv('DETACHABLE') == '1'
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800296 physical_presence = os.getenv('PHYSICAL_PRESENCE')
Yu-Ping Wu3d272e72021-03-01 12:01:55 +0800297 rename_map = {}
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800298
Yu-Ping Wu3d272e72021-03-01 12:01:55 +0800299 # Navigation instructions
300 if is_detachable:
301 rename_map.update({
302 'nav-button_power': 'nav-key_enter',
303 'nav-button_volume_up': 'nav-key_up',
304 'nav-button_volume_down': 'nav-key_down',
305 'navigate0_tablet': 'navigate0',
306 'navigate1_tablet': 'navigate1',
307 })
308 else:
309 rename_map.update({
310 'nav-button_power': None,
311 'nav-button_volume_up': None,
312 'nav-button_volume_down': None,
313 'navigate0_tablet': None,
314 'navigate1_tablet': None,
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800315 })
316
Yu-Ping Wu3d272e72021-03-01 12:01:55 +0800317 # Physical presence confirmation
318 if physical_presence == 'recovery':
319 rename_map['rec_to_dev_desc1_phyrec'] = 'rec_to_dev_desc1'
320 rename_map['rec_to_dev_desc1_power'] = None
321 elif physical_presence == 'power':
322 rename_map['rec_to_dev_desc1_phyrec'] = None
323 rename_map['rec_to_dev_desc1_power'] = 'rec_to_dev_desc1'
324 else:
325 rename_map['rec_to_dev_desc1_phyrec'] = None
326 rename_map['rec_to_dev_desc1_power'] = None
327 if physical_presence != 'keyboard':
328 raise BuildImageError('Invalid physical presence setting %s for board '
329 '%s' % (physical_presence, self.board))
330
331 # Broken screen
332 if physical_presence == 'recovery':
333 rename_map['broken_desc_phyrec'] = 'broken_desc'
334 rename_map['broken_desc_detach'] = None
335 elif is_detachable:
336 rename_map['broken_desc_phyrec'] = None
337 rename_map['broken_desc_detach'] = 'broken_desc'
338 else:
339 rename_map['broken_desc_phyrec'] = None
340 rename_map['broken_desc_detach'] = None
341
342 # SD card
343 if not self.config[SDCARD_KEY]:
344 rename_map.update({
345 'rec_sel_desc1_no_sd': 'rec_sel_desc1',
346 'rec_sel_desc1_no_phone_no_sd': 'rec_sel_desc1_no_phone',
347 'rec_disk_step1_desc0_no_sd': 'rec_disk_step1_desc0',
348 })
349 else:
350 rename_map.update({
351 'rec_sel_desc1_no_sd': None,
352 'rec_sel_desc1_no_phone_no_sd': None,
353 'rec_disk_step1_desc0_no_sd': None,
354 })
355
356 # Check for duplicate new names
357 new_names = list(new_name for new_name in rename_map.values() if new_name)
358 if len(set(new_names)) != len(new_names):
359 raise BuildImageError('Duplicate values found in rename_map')
360
361 # Map new_name to None to skip image generation for it
362 for new_name in new_names:
363 if new_name not in rename_map:
364 rename_map[new_name] = None
365
366 # Print mapping
367 print('Rename map:')
368 for name, new_name in sorted(rename_map.items()):
369 print(' %s => %s' % (name, new_name))
370
371 self.rename_map = rename_map
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800372
373 def set_locales(self):
374 """Sets a list of locales for which localized images are converted."""
375 # LOCALES environment variable can overwrite boards.yaml
376 env_locales = os.getenv('LOCALES')
377 rtl_locales = set(self.config[RTL_KEY])
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800378 if env_locales:
379 locales = env_locales.split()
380 else:
381 locales = self.config[LOCALES_KEY]
382 # Check rtl_locales are contained in locales.
383 unknown_rtl_locales = rtl_locales - set(locales)
384 if unknown_rtl_locales:
385 raise BuildImageError('Unknown locales %s in %s' %
386 (list(unknown_rtl_locales), RTL_KEY))
Yu-Ping Wuabb9afb2020-10-27 17:15:22 +0800387 self.locales = [LocaleInfo(code, code in rtl_locales)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800388 for code in locales]
389
Yu-Ping Wu96cf0022021-01-07 15:55:49 +0800390 @classmethod
391 def get_text_colors(cls, dpi):
392 """Derive maximum text colors from `dpi`."""
393 if dpi < 64:
394 return 2
395 elif dpi < 72:
396 return 3
397 elif dpi < 80:
398 return 4
399 elif dpi < 96:
400 return 5
401 elif dpi < 112:
402 return 6
403 else:
404 return 7
405
Yu-Ping Wu08defcc2020-05-07 16:21:03 +0800406 def _to_px(self, length, num_lines=1):
407 """Converts the relative coordinate to absolute one in pixels."""
Yu-Ping Wu3d07a062021-01-26 18:10:32 +0800408 return int(self.canvas_px * length / SCALE_BASE) * num_lines
Yu-Ping Wu08defcc2020-05-07 16:21:03 +0800409
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800410 def _get_png_height(self, png_file):
411 with Image.open(png_file) as image:
412 return image.size[1]
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800413
414 def get_num_lines(self, file, one_line_dir):
415 """Gets the number of lines of text in `file`."""
416 name, _ = os.path.splitext(os.path.basename(file))
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800417 png_name = name + '.png'
418 multi_line_file = os.path.join(os.path.dirname(file), png_name)
419 one_line_file = os.path.join(one_line_dir, png_name)
420 # The number of lines is determined by comparing the height of
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800421 # `multi_line_file` with `one_line_file`, where the latter is generated
422 # without the '--width' option passed to pango-view.
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800423 height = self._get_png_height(multi_line_file)
424 line_height = self._get_png_height(one_line_file)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800425 return int(round(height / line_height))
426
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +0800427 def convert_svg_to_png(self, svg_file, png_file, height, num_lines,
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800428 background):
429 """Converts .svg file to .png file."""
430 background_hex = ''.join(format(x, '02x') for x in background)
431 # If the width/height of the SVG file is specified in points, the
432 # rsvg-convert command with default 90DPI will potentially cause the pixels
433 # at the right/bottom border of the output image to be transparent (or
434 # filled with the specified background color). This seems like an
435 # rsvg-convert issue regarding image scaling. Therefore, use 72DPI here
436 # to avoid the scaling.
437 command = ['rsvg-convert',
438 '--background-color', "'#%s'" % background_hex,
439 '--dpi-x', '72',
440 '--dpi-y', '72',
441 '-o', png_file]
Yu-Ping Wu08defcc2020-05-07 16:21:03 +0800442 height_px = self._to_px(height, num_lines)
Yu-Ping Wue445e042020-11-19 15:53:42 +0800443 if height_px <= 0:
444 raise BuildImageError('Height of %r <= 0 (%dpx)' %
445 (os.path.basename(svg_file), height_px))
446 command.extend(['--height', '%d' % height_px])
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800447 command.append(svg_file)
448 subprocess.check_call(' '.join(command), shell=True)
449
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800450 def convert_to_bitmap(self, input_file, height, num_lines, background, output,
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800451 max_colors):
452 """Converts an image file `input_file` to a BMP file `output`."""
453 image = Image.open(input_file)
454
455 # Process alpha channel and transparency.
456 if image.mode == 'RGBA':
457 target = Image.new('RGB', image.size, background)
458 image.load() # required for image.split()
459 mask = image.split()[-1]
460 target.paste(image, mask=mask)
461 elif (image.mode == 'P') and ('transparency' in image.info):
462 exit('Sorry, PNG with RGBA palette is not supported.')
463 elif image.mode != 'RGB':
464 target = image.convert('RGB')
465 else:
466 target = image
467
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800468 width_px, height_px = image.size
Yu-Ping Wu08defcc2020-05-07 16:21:03 +0800469 max_height_px = self._to_px(height, num_lines)
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800470 # If the image size is larger than what will be displayed at runtime,
471 # downscale it.
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800472 effective_dpi = None
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800473 if height_px > max_height_px:
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800474 effective_dpi = self.config[DPI_KEY] * max_height_px // height_px
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800475 height_px = max_height_px
476 width_px = height_px * image.size[0] // image.size[1]
477 # Stretch image horizontally for stretched display.
Yu-Ping Wue445e042020-11-19 15:53:42 +0800478 if self.panel_stretch != 1:
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800479 width_px = int(width_px * self.panel_stretch)
480 new_size = width_px, height_px
481 if new_size != image.size:
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800482 target = target.resize(new_size, Image.BICUBIC)
483
484 # Export and downsample color space.
485 target.convert('P', dither=None, colors=max_colors, palette=Image.ADAPTIVE
486 ).save(output)
487
488 with open(output, 'rb+') as f:
489 f.seek(BMP_HEADER_OFFSET_NUM_LINES)
490 f.write(bytearray([num_lines]))
491
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800492 return effective_dpi
493
494 def convert(self, file, output_dir, height, max_width, max_colors,
Yu-Ping Wued95df32020-11-04 17:08:15 +0800495 one_line_dir=None):
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800496 """Converts image `file` to bitmap format."""
497 name, ext = os.path.splitext(os.path.basename(file))
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800498
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800499 if name in self.rename_map:
500 new_name = self.rename_map[name]
501 if not new_name:
502 return
503 else:
504 new_name = name
505 output = os.path.join(output_dir, new_name + self.DEFAULT_OUTPUT_EXT)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800506
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800507 background = self.BACKGROUND_COLORS.get(name, self.DEFAULT_BACKGROUND)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800508
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800509 # Determine num_lines in order to scale the image
510 if one_line_dir and max_width:
511 num_lines = self.get_num_lines(file, one_line_dir)
512 else:
513 num_lines = 1
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +0800514
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800515 if ext == '.svg':
516 png_file = os.path.join(self.temp_dir, name + '.png')
517 self.convert_svg_to_png(file, png_file, height, num_lines, background)
518 file = png_file
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800519
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800520 return self.convert_to_bitmap(file, height, num_lines, background, output,
521 max_colors)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800522
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800523 def convert_text_to_image(self, locale, input_file, font, stage_dir,
524 output_dir, height=None, max_width=None, dpi=None,
525 bgcolor='#000000', fgcolor='#ffffff',
526 use_svg=False):
527 """Converts text file `input_file` into image file.
528
529 Because pango-view does not support assigning output format options for
530 bitmap, we must create images in SVG/PNG format and then post-process them
531 (e.g. convert into BMP by ImageMagick).
532
533 Args:
534 locale: Locale (language) to select implicit rendering options. None for
535 locale-independent strings.
536 input_file: Path of input text file.
537 font: Font name.
538 stage_dir: Directory to store intermediate file(s).
539 output_dir: Directory to store output image file.
540 height: Image height relative to the screen resolution.
541 max_width: Maximum image width relative to the screen resolution.
542 dpi: DPI value passed to pango-view.
543 bgcolor: Background color (#rrggbb).
544 fgcolor: Foreground color (#rrggbb).
545 use_svg: If set to True, generate SVG file. Otherwise, generate PNG file.
546
547 Returns:
548 Effective DPI, or `None` when not applicable.
549 """
550 one_line_dir = os.path.join(stage_dir, ONE_LINE_DIR)
551 os.makedirs(one_line_dir, exist_ok=True)
552 name, _ = os.path.splitext(os.path.basename(input_file))
553 svg_file = os.path.join(stage_dir, name + '.svg')
554 png_file = os.path.join(stage_dir, name + '.png')
555 png_file_one_line = os.path.join(one_line_dir, name + '.png')
556
557 if use_svg:
558 run_pango_view(input_file, svg_file, locale, font, height, 0, dpi,
559 bgcolor, fgcolor, hinting='none')
560 return self.convert(svg_file, output_dir, height, max_width,
561 self.text_max_colors)
562 else:
563 run_pango_view(input_file, png_file, locale, font, height, max_width, dpi,
564 bgcolor, fgcolor)
565 if locale:
566 run_pango_view(input_file, png_file_one_line, locale, font, height, 0,
567 dpi, bgcolor, fgcolor)
568 return self.convert(png_file, output_dir, height, max_width,
569 self.text_max_colors,
570 one_line_dir=one_line_dir if locale else None)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800571
Yu-Ping Wu177f12c2020-11-04 15:55:37 +0800572 def convert_sprite_images(self):
573 """Converts sprite images."""
574 names = self.formats[KEY_SPRITE_FILES]
575 styles = self.formats[KEY_STYLES]
576 # Check redundant images
577 for filename in glob.glob(os.path.join(self.ASSET_DIR, SVG_FILES)):
578 name, _ = os.path.splitext(os.path.basename(filename))
579 if name not in names:
580 raise BuildImageError('Sprite image %r not specified in %s' %
581 (filename, FORMAT_FILE))
582 # Convert images
Yu-Ping Wu177f12c2020-11-04 15:55:37 +0800583 for name, category in names.items():
584 style = get_config_with_defaults(styles, category)
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800585 file = os.path.join(self.ASSET_DIR, name + '.svg')
586 height = style[KEY_HEIGHT]
587 self.convert(file, self.output_dir, height, None, self.ASSET_MAX_COLORS)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800588
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800589 def build_generic_strings(self):
590 """Builds images of generic (locale-independent) strings."""
591 dpi = self.config[DPI_KEY]
592
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +0800593 names = self.formats[KEY_GENERIC_FILES]
594 styles = self.formats[KEY_STYLES]
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800595 fonts = self.formats[KEY_FONTS]
596 default_font = fonts[KEY_DEFAULT]
597
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800598 for txt_file in glob.glob(os.path.join(self.strings_dir, '*.txt')):
599 name, _ = os.path.splitext(os.path.basename(txt_file))
600 category = names[name]
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +0800601 style = get_config_with_defaults(styles, category)
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800602 self.convert_text_to_image(None, txt_file, default_font, self.stage_dir,
603 self.output_dir,
604 height=style[KEY_HEIGHT],
605 max_width=style[KEY_MAX_WIDTH],
606 dpi=dpi,
607 bgcolor=style[KEY_BGCOLOR],
608 fgcolor=style[KEY_FGCOLOR])
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800609
Yu-Ping Wu08defcc2020-05-07 16:21:03 +0800610 def _check_text_width(self, output_dir, heights, max_widths):
611 """Check if the width of text image will exceed canvas boundary."""
612 for filename in glob.glob(os.path.join(output_dir,
613 '*' + self.DEFAULT_OUTPUT_EXT)):
614 name, _ = os.path.splitext(os.path.basename(filename))
615 max_width = max_widths[name]
616 if not max_width:
617 continue
618 max_width_px = self._to_px(max_width)
619 with open(filename, 'rb') as f:
620 f.seek(BMP_HEADER_OFFSET_NUM_LINES)
621 num_lines = f.read(1)[0]
622 height_px = self._to_px(heights[name] * num_lines)
623 with Image.open(filename) as image:
624 width_px = height_px * image.size[0] // image.size[1]
625 if width_px > max_width_px:
626 raise BuildImageError('%s: Image width %dpx greater than max width '
627 '%dpx' % (filename, width_px, max_width_px))
628
Yu-Ping Wu703dcfd2021-01-08 10:52:10 +0800629 def _copy_missing_bitmaps(self):
630 """Copy missing (not yet translated) strings from locale 'en'."""
631 en_files = glob.glob(os.path.join(self.output_ro_dir, 'en',
632 '*' + self.DEFAULT_OUTPUT_EXT))
633 for locale_info in self.locales:
634 locale = locale_info.code
635 if locale == 'en':
636 continue
637 ro_locale_dir = os.path.join(self.output_ro_dir, locale)
638 for en_file in en_files:
639 filename = os.path.basename(en_file)
640 locale_file = os.path.join(ro_locale_dir, filename)
641 if not os.path.isfile(locale_file):
642 print("WARNING: Locale '%s': copying '%s'" % (locale, filename))
643 shutil.copyfile(en_file, locale_file)
644
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800645 def build_localized_strings(self):
646 """Builds images of localized strings."""
647 # Sources are one .grd file with identifiers chosen by engineers and
648 # corresponding English texts, as well as a set of .xtb files (one for each
649 # language other than US English) with a mapping from hash to translation.
650 # Because the keys in the .xtb files are a hash of the English source text,
651 # rather than our identifiers, such as "btn_cancel", we use the "grit"
652 # command line tool to process the .grd and .xtb files, producing a set of
653 # .json files mapping our identifier to the translated string, one for every
654 # language including US English.
655
656 # Create a temporary directory to place the translation output from grit in.
657 json_dir = tempfile.mkdtemp()
658
659 # This invokes the grit build command to generate JSON files from the XTB
660 # files containing translations. The results are placed in `json_dir` as
661 # specified in firmware_strings.grd, i.e. one JSON file per locale.
662 subprocess.check_call([
663 'grit',
664 '-i', os.path.join(self.locale_dir, STRINGS_GRD_FILE),
665 'build',
666 '-o', os.path.join(json_dir),
667 ])
668
669 # Make a copy to avoid modifying `self.formats`
670 names = copy.deepcopy(self.formats[KEY_LOCALIZED_FILES])
671 if DIAGNOSTIC_UI:
672 names.update(self.formats[KEY_DIAGNOSTIC_FILES])
673
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +0800674 styles = self.formats[KEY_STYLES]
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800675 fonts = self.formats[KEY_FONTS]
676 default_font = fonts[KEY_DEFAULT]
677 dpi = self.config[DPI_KEY]
678
679 # Ignore SIGINT in child processes
680 sigint_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
681 pool = multiprocessing.Pool(multiprocessing.cpu_count())
682 signal.signal(signal.SIGINT, sigint_handler)
683
684 results = []
685 for locale_info in self.locales:
686 locale = locale_info.code
687 print(locale, end=' ', flush=True)
688 inputs = parse_locale_json_file(locale, json_dir)
689
690 # Walk locale directory to add pre-generated texts such as language names.
691 for txt_file in glob.glob(os.path.join(self.locale_dir, locale, '*.txt')):
692 name, _ = os.path.splitext(os.path.basename(txt_file))
693 with open(txt_file, 'r', encoding='utf-8-sig') as f:
694 inputs[name] = f.read().strip()
695
696 output_dir = os.path.join(self.stage_locale_dir, locale)
697 os.makedirs(output_dir, exist_ok=True)
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800698 ro_locale_dir = os.path.join(self.output_ro_dir, locale)
699 os.makedirs(ro_locale_dir, exist_ok=True)
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800700
701 for name, category in names.items():
702 # Ignore missing translation
703 if locale != 'en' and name not in inputs:
704 continue
705
706 # Write to text file
707 text_file = os.path.join(output_dir, name + '.txt')
708 with open(text_file, 'w', encoding='utf-8-sig') as f:
709 f.write(inputs[name] + '\n')
710
711 # Convert to PNG file
712 style = get_config_with_defaults(styles, category)
713 args = (
714 locale,
715 os.path.join(output_dir, '%s.txt' % name),
716 fonts.get(locale, default_font),
717 output_dir,
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800718 ro_locale_dir,
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800719 )
720 kwargs = {
721 'height': style[KEY_HEIGHT],
722 'max_width': style[KEY_MAX_WIDTH],
723 'dpi': dpi,
724 'bgcolor': style[KEY_BGCOLOR],
725 'fgcolor': style[KEY_FGCOLOR],
726 }
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800727 results.append(pool.apply_async(self.convert_text_to_image,
728 args, kwargs))
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800729
730 print()
731 pool.close()
732
733 try:
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800734 effective_dpi = [r.get() for r in results]
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800735 except KeyboardInterrupt:
736 pool.terminate()
737 pool.join()
738 exit('Aborted by user')
739 else:
740 pool.join()
741
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800742 effective_dpi = [dpi for dpi in effective_dpi if dpi]
743 if effective_dpi:
744 print('Reducing effective DPI to %d, limited by screen resolution' %
745 max(effective_dpi))
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800746
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800747 shutil.rmtree(json_dir)
Yu-Ping Wu703dcfd2021-01-08 10:52:10 +0800748 self._copy_missing_bitmaps()
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800749
750 def move_language_images(self):
751 """Renames language bitmaps and move to self.output_dir.
752
753 The directory self.output_dir contains locale-independent images, and is
754 used for creating vbgfx.bin by archive_images.py.
755 """
756 for locale_info in self.locales:
757 locale = locale_info.code
758 ro_locale_dir = os.path.join(self.output_ro_dir, locale)
759 old_file = os.path.join(ro_locale_dir, 'language.bmp')
760 new_file = os.path.join(self.output_dir, 'language_%s.bmp' % locale)
761 if os.path.exists(new_file):
762 raise BuildImageError('File already exists: %s' % new_file)
763 shutil.move(old_file, new_file)
764
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800765 def build_glyphs(self):
766 """Builds glyphs of ascii characters."""
767 os.makedirs(self.stage_font_dir, exist_ok=True)
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800768 font_output_dir = os.path.join(self.output_dir, 'font')
769 os.makedirs(font_output_dir)
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800770 # TODO(b/163109632): Parallelize the conversion of glyphs
771 for c in range(ord(' '), ord('~') + 1):
772 name = f'idx{c:03d}_{c:02x}'
773 txt_file = os.path.join(self.stage_font_dir, name + '.txt')
774 with open(txt_file, 'w', encoding='ascii') as f:
775 f.write(chr(c))
776 f.write('\n')
Yu-Ping Wuf946dd42021-02-08 16:32:28 +0800777 self.convert_text_to_image(None, txt_file, GLYPH_FONT,
778 self.stage_font_dir, font_output_dir,
779 height=DEFAULT_GLYPH_HEIGHT,
780 use_svg=True)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800781
782 def copy_images_to_rw(self):
783 """Copies localized images specified in boards.yaml for RW override."""
784 if not self.config[RW_OVERRIDE_KEY]:
785 print(' No localized images are specified for RW, skipping')
786 return
787
788 for locale_info in self.locales:
789 locale = locale_info.code
790 rw_locale_dir = os.path.join(self.output_ro_dir, locale)
791 ro_locale_dir = os.path.join(self.output_rw_dir, locale)
792 os.makedirs(rw_locale_dir)
793
794 for name in self.config[RW_OVERRIDE_KEY]:
795 ro_src = os.path.join(ro_locale_dir, name + self.DEFAULT_OUTPUT_EXT)
796 rw_dst = os.path.join(rw_locale_dir, name + self.DEFAULT_OUTPUT_EXT)
797 shutil.copyfile(ro_src, rw_dst)
798
799 def create_locale_list(self):
800 """Creates locale list as a CSV file.
801
802 Each line in the file is of format "code,rtl", where
803 - "code": language code of the locale
804 - "rtl": "1" for right-to-left language, "0" otherwise
805 """
806 with open(os.path.join(self.output_dir, 'locales'), 'w') as f:
807 for locale_info in self.locales:
808 f.write('{},{}\n'.format(locale_info.code,
809 int(locale_info.rtl)))
810
811 def build(self):
812 """Builds all images required by a board."""
813 # Clean up output directory
814 if os.path.exists(self.output_dir):
815 shutil.rmtree(self.output_dir)
816 os.makedirs(self.output_dir)
817
818 if not os.path.exists(self.stage_dir):
819 raise BuildImageError('Missing stage folder. Run make in strings dir.')
820
821 # Clean up temp directory
822 if os.path.exists(self.temp_dir):
823 shutil.rmtree(self.temp_dir)
824 os.makedirs(self.temp_dir)
825
Yu-Ping Wu177f12c2020-11-04 15:55:37 +0800826 print('Converting sprite images...')
827 self.convert_sprite_images()
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800828
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800829 print('Building generic strings...')
830 self.build_generic_strings()
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800831
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800832 print('Building localized strings...')
833 self.build_localized_strings()
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800834
835 print('Moving language images to locale-independent directory...')
836 self.move_language_images()
837
838 print('Creating locale list file...')
839 self.create_locale_list()
840
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800841 print('Building glyphs...')
842 self.build_glyphs()
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800843
844 print('Copying specified images to RW packing directory...')
845 self.copy_images_to_rw()
846
847
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800848def main():
849 """Builds bitmaps for firmware screens."""
850 parser = argparse.ArgumentParser()
851 parser.add_argument('board', help='Target board')
852 args = parser.parse_args()
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800853 board = args.board
Yu-Ping Wu8c8bfc72020-10-27 16:19:34 +0800854
855 with open(FORMAT_FILE, encoding='utf-8') as f:
856 formats = yaml.load(f)
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800857 board_config = load_boards_config(BOARDS_CONFIG_FILE)[board]
858
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800859 print('Building for ' + board)
Yu-Ping Wu675e7e82021-01-29 08:32:12 +0800860 check_fonts(formats[KEY_FONTS])
861 print('Output dir: ' + OUTPUT_DIR)
Yu-Ping Wue66a7b02020-11-19 15:18:08 +0800862 converter = Converter(board, formats, board_config, OUTPUT_DIR)
863 converter.build()
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800864
865
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800866if __name__ == '__main__':
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800867 main()