blob: 22b534c66d5f9eb86cd4869813ad7088c9955dd4 [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
8from collections import defaultdict, namedtuple
9import copy
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080010import glob
Jes Klinke1687a992020-06-16 13:47:17 -070011import json
Hung-Te Lin04addcc2015-03-23 18:43:30 +080012import multiprocessing
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080013import os
14import re
Jes Klinke1687a992020-06-16 13:47:17 -070015import shutil
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080016import signal
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080017import subprocess
18import sys
Jes Klinke1687a992020-06-16 13:47:17 -070019import tempfile
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080020from xml.etree import ElementTree
Hung-Te Lin04addcc2015-03-23 18:43:30 +080021
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080022import yaml
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080023from PIL import Image
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080024
25SCRIPT_BASE = os.path.dirname(os.path.abspath(__file__))
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080026
27STRINGS_GRD_FILE = 'firmware_strings.grd'
28STRINGS_JSON_FILE_TMPL = '{}.json'
29FORMAT_FILE = 'format.yaml'
30BOARDS_CONFIG_FILE = 'boards.yaml'
31
32TXT_TO_PNG_SVG = os.path.join(SCRIPT_BASE, 'text_to_png_svg')
33STRINGS_DIR = os.path.join(SCRIPT_BASE, 'strings')
34LOCALE_DIR = os.path.join(STRINGS_DIR, 'locale')
35OUTPUT_DIR = os.getenv('OUTPUT', os.path.join(SCRIPT_BASE, 'build'))
36STAGE_DIR = os.path.join(OUTPUT_DIR, '.stage')
37STAGE_LOCALE_DIR = os.path.join(STAGE_DIR, 'locale')
38STAGE_FONT_DIR = os.path.join(STAGE_DIR, 'font')
39
40ONE_LINE_DIR = 'one_line'
41SVG_FILES = '*.svg'
42PNG_FILES = '*.png'
43
44# String format YAML key names.
Yu-Ping Wu10cf2892020-08-10 17:20:11 +080045DEFAULT_NAME = '_DEFAULT_'
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080046KEY_LOCALES = 'locales'
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080047KEY_FILES = 'files'
48KEY_FONTS = 'fonts'
49KEY_STYLES = 'styles'
Matt Delco4c5580d2019-03-07 14:00:28 -080050DIAGNOSTIC_FILES = 'diagnostic_files'
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080051
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080052# Board config YAML key names.
53SCREEN_KEY = 'screen'
54PANEL_KEY = 'panel'
55SDCARD_KEY = 'sdcard'
56BAD_USB3_KEY = 'bad_usb3'
57LOCALES_KEY = 'locales'
58RTL_KEY = 'rtl'
59HI_RES_KEY = 'hi_res'
60TEXT_COLORS_KEY = 'text_colors'
61RW_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 Wu11027f02020-10-14 17:35:42 +080072GLYPH_FONT = 'Noto Sans Mono'
73
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080074LocaleInfo = namedtuple('LocaleInfo', ['code', 'rtl', 'hi_res'])
75
Yu-Ping Wu6b282c52020-03-19 12:54:15 +080076
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080077class DataError(Exception):
78 pass
79
80
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080081class BuildImageError(Exception):
82 """The exception class for all errors generated during build image process."""
83
84
Hsuan Ting Chenf2917582020-10-21 13:50:08 +080085# Some of the numbers below are from depthcharge:
86# - 1000: UI_SCALE
87# - 50: UI_MARGIN_H
88# - 228: UI_REC_QR_SIZE
89# - 24: UI_REC_QR_MARGIN_H
90# - 24: UI_DESC_TEXT_HEIGHT
91# - 128: UI_FOOTER_HEIGHT
92# - 20: UI_FOOTER_COL1_MARGIN_RIGHT
93# - 282: width of rec_url.bmp
94# - 40: UI_FOOTER_COL2_MARGIN_RIGHT
95# - 40: UI_FOOTER_COL3_MARGIN_LEFT
96# - 20: UI_FOOTER_TEXT_HEIGHT
97
98DEFAULT_MAX_WIDTH = 1000 - 50 * 2
99
100MAX_WIDTH_LOOKUPS = [
101 (re.compile(r'rec_phone_step2_desc'), DEFAULT_MAX_WIDTH - 228 - 24 * 2, 24),
102 (re.compile(r'navigate\w*'), DEFAULT_MAX_WIDTH - 128 - 20 - 282 - 40 - 40,
103 20),
104 (re.compile(r'\w+_desc\w*'), DEFAULT_MAX_WIDTH, 24),
105]
106
107
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800108def convert_text_to_png(locale, input_file, font, margin, output_dir,
109 options=None):
110 """Converts text files into PNG image files.
111
112 Args:
113 locale: Locale (language) to select implicit rendering options. None for
114 locale-independent strings.
115 input_file: Path of input text file.
116 font: Font spec.
117 margin: CSS-style margin.
118 output_dir: Directory to generate image files.
119 options: List of other options to be added.
120 """
121 name, _ = os.path.splitext(os.path.basename(input_file))
122 command = [TXT_TO_PNG_SVG, '--outdir=%s' % output_dir]
123 if locale:
124 command.append('--lan=%s' % locale)
125 if font:
126 command.append("--font='%s'" % font)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800127 font_size = os.getenv('FONT_SIZE')
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800128 if font_size:
129 command.append('--point=%r' % font_size)
130 if margin:
131 command.append('--margin="%s"' % margin)
132 # TODO(b/159399377): Set different widths for titles and descriptions.
Hsuan Ting Chenf2917582020-10-21 13:50:08 +0800133 # Currently only wrap lines for descriptions and navigation instructions in
134 # the footer.
135 # Without the --width option set, the minimum height of the output SVG
136 # image is roughly 22px (for locale 'en'). With --width=WIDTH passed to
137 # pango-view, the width of the output seems to always be (WIDTH * 4 / 3),
138 # regardless of the font being used. Therefore, set the max_width in
139 # points as follows to prevent drawing from exceeding canvas boundary in
140 # depthcharge runtime.
141
142 for pattern, max_width, text_height in MAX_WIDTH_LOOKUPS:
143 if pattern.fullmatch(name):
144 max_width_pt = int(22 * max_width / text_height / (4 / 3))
145 command.append('--width=%d' % max_width_pt)
146 break
147
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800148 if options:
149 command.extend(options)
150 command.append(input_file)
151
152 return subprocess.call(' '.join(command), shell=True,
153 stdout=subprocess.PIPE) == 0
154
155
156def convert_glyphs():
157 """Converts glyphs of ascii characters."""
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800158 os.makedirs(STAGE_FONT_DIR, exist_ok=True)
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800159 # Remove the extra whitespace at the top/bottom within the glyphs
160 margin = '-3 0 -1 0'
161 options = ['--color="#ffffff"', '--bgcolor="#000000"']
162 for c in range(ord(' '), ord('~') + 1):
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800163 txt_file = os.path.join(STAGE_FONT_DIR, f'idx{c:03d}_{c:02x}.txt')
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800164 with open(txt_file, 'w', encoding='ascii') as f:
165 f.write(chr(c))
166 f.write('\n')
167 # TODO(b/163109632): Parallelize the conversion of glyphs
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800168 convert_text_to_png(None, txt_file, GLYPH_FONT, margin, STAGE_FONT_DIR,
169 options)
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800170
171
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800172def _load_locale_json_file(locale, json_dir):
Jes Klinke1687a992020-06-16 13:47:17 -0700173 result = {}
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800174 filename = os.path.join(json_dir, STRINGS_JSON_FILE_TMPL.format(locale))
Yu-Ping Wud71b4452020-06-16 11:00:26 +0800175 with open(filename, encoding='utf-8-sig') as input_file:
Jes Klinke1687a992020-06-16 13:47:17 -0700176 for tag, msgdict in json.load(input_file).items():
177 msgtext = msgdict['message']
178 msgtext = re.sub(CRLF_PATTERN, '\n', msgtext)
179 msgtext = re.sub(NEWLINE_PATTERN, NEWLINE_REPLACEMENT, msgtext)
180 msgtext = re.sub(MULTIBLANK_PATTERN, ' ', msgtext)
181 # Strip any trailing whitespace. A trailing newline appears to make
182 # Pango report a larger layout size than what's actually visible.
183 msgtext = msgtext.strip()
184 result[tag] = msgtext
185 return result
186
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800187
188def parse_locale_json_file(locale, json_dir):
189 """Parses given firmware string json file for build_text_files.
Mathew King89d48c62019-02-15 10:08:39 -0700190
191 Args:
Yu-Ping Wu8f633b82020-09-22 14:27:57 +0800192 locale: The name of the locale, e.g. "da" or "pt-BR".
Jes Klinke1687a992020-06-16 13:47:17 -0700193 json_dir: Directory containing json output from grit.
Mathew King89d48c62019-02-15 10:08:39 -0700194
195 Returns:
196 A dictionary for mapping of "name to content" for files to be generated.
197 """
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800198 result = _load_locale_json_file(locale, json_dir)
199 original = _load_locale_json_file('en', json_dir)
200 for tag in original:
201 if tag not in result:
202 # Use original English text, in case translation is not yet available
203 print('WARNING: locale "%s", missing entry %s' % (locale, tag))
204 result[tag] = original[tag]
Mathew King89d48c62019-02-15 10:08:39 -0700205
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800206 return result
Mathew King89d48c62019-02-15 10:08:39 -0700207
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800208
209def parse_locale_input_files(locale, json_dir):
210 """Parses all firmware string files for the given locale.
211
212 Args:
213 locale: The name of the locale, e.g. "da" or "pt-BR".
214 json_dir: Directory containing json output from grit.
215
216 Returns:
217 A dictionary for mapping of "name to content" for files to be generated.
218 """
219 result = parse_locale_json_file(locale, json_dir)
220
221 # Walk locale directory to add pre-generated texts such as language names.
Yu-Ping Wu8f633b82020-09-22 14:27:57 +0800222 for input_file in glob.glob(os.path.join(LOCALE_DIR, locale, "*.txt")):
Mathew King89d48c62019-02-15 10:08:39 -0700223 name, _ = os.path.splitext(os.path.basename(input_file))
Yu-Ping Wud71b4452020-06-16 11:00:26 +0800224 with open(input_file, 'r', encoding='utf-8-sig') as f:
Mathew King89d48c62019-02-15 10:08:39 -0700225 result[name] = f.read().strip()
Shelley Chen2f616ac2017-05-22 13:19:40 -0700226
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800227 return result
228
229
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800230def create_file(file_name, contents, output_dir):
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800231 """Creates a text file in output directory by given contents.
232
233 Args:
234 file_name: Output file name without extension.
235 contents: A list of strings for file content.
236 output_dir: The directory to store output file.
237 """
238 output_name = os.path.join(output_dir, file_name + '.txt')
Yu-Ping Wud71b4452020-06-16 11:00:26 +0800239 with open(output_name, 'w', encoding='utf-8-sig') as f:
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800240 f.write('\n'.join(contents) + '\n')
241
242
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800243def build_text_files(inputs, files, output_dir):
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800244 """Builds text files from given input data.
245
246 Args:
247 inputs: Dictionary of contents for given file name.
248 files: List of file records: [name, content].
249 output_dir: Directory to generate text files.
250 """
Yu-Ping Wud71b4452020-06-16 11:00:26 +0800251 for file_name, file_content in files.items():
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800252 if file_content is None:
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800253 create_file(file_name, [inputs[file_name]], output_dir)
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800254 else:
255 contents = []
256 for data in file_content:
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800257 contents.append(inputs[data])
258 create_file(file_name, contents, output_dir)
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800259
260
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800261def convert_localized_strings(formats):
262 """Converts localized strings."""
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800263 # Make a copy of formats to avoid modifying it
264 formats = copy.deepcopy(formats)
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800265
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800266 env_locales = os.getenv('LOCALES')
267 if env_locales:
268 locales = env_locales.split()
269 else:
270 locales = formats[KEY_LOCALES]
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800271
272 styles = formats[KEY_STYLES]
273 fonts = formats[KEY_FONTS]
274 default_font = fonts.get(DEFAULT_NAME)
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800275
Yu-Ping Wu51940352020-09-17 08:48:55 +0800276 # Sources are one .grd file with identifiers chosen by engineers and
277 # corresponding English texts, as well as a set of .xlt files (one for each
278 # language other than US english) with a mapping from hash to translation.
279 # Because the keys in the xlt files are a hash of the English source text,
280 # rather than our identifiers, such as "btn_cancel", we use the "grit"
281 # command line tool to process the .grd and .xlt files, producing a set of
282 # .json files mapping our identifier to the translated string, one for every
283 # language including US English.
Jes Klinke1687a992020-06-16 13:47:17 -0700284
Yu-Ping Wu51940352020-09-17 08:48:55 +0800285 # Create a temporary directory to place the translation output from grit in.
286 json_dir = tempfile.mkdtemp()
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800287
Yu-Ping Wu51940352020-09-17 08:48:55 +0800288 # This invokes the grit build command to generate JSON files from the XTB
289 # files containing translations. The results are placed in `json_dir` as
290 # specified in firmware_strings.grd, i.e. one JSON file per locale.
291 subprocess.check_call([
292 'grit',
Yu-Ping Wu8f633b82020-09-22 14:27:57 +0800293 '-i', os.path.join(LOCALE_DIR, STRINGS_GRD_FILE),
Yu-Ping Wu51940352020-09-17 08:48:55 +0800294 'build',
295 '-o', os.path.join(json_dir)
296 ])
Jes Klinke1687a992020-06-16 13:47:17 -0700297
Yu-Ping Wuc90a22f2020-04-24 11:17:15 +0800298 # Ignore SIGINT in child processes
299 sigint_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
Hung-Te Lin04addcc2015-03-23 18:43:30 +0800300 pool = multiprocessing.Pool(multiprocessing.cpu_count())
Yu-Ping Wuc90a22f2020-04-24 11:17:15 +0800301 signal.signal(signal.SIGINT, sigint_handler)
302
Hung-Te Lin04addcc2015-03-23 18:43:30 +0800303 results = []
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800304 for locale in locales:
Yu-Ping Wud71b4452020-06-16 11:00:26 +0800305 print(locale, end=' ', flush=True)
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800306 inputs = parse_locale_input_files(locale, json_dir)
307 output_dir = os.path.normpath(os.path.join(STAGE_DIR, 'locale', locale))
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800308 if not os.path.exists(output_dir):
309 os.makedirs(output_dir)
Julius Wernera77900c2018-02-01 17:39:07 -0800310 files = formats[KEY_FILES]
Matt Delco4c5580d2019-03-07 14:00:28 -0800311
312 # Now parse strings for optional features
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800313 if os.getenv('DIAGNOSTIC_UI') == '1' and DIAGNOSTIC_FILES in formats:
Matt Delco4c5580d2019-03-07 14:00:28 -0800314 files.update(formats[DIAGNOSTIC_FILES])
315
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800316 build_text_files(inputs, files, output_dir)
Shelley Chen2f616ac2017-05-22 13:19:40 -0700317
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800318 results += [pool.apply_async(convert_text_to_png,
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800319 (locale,
320 os.path.join(output_dir, '%s.txt' % name),
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800321 fonts.get(locale, default_font),
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800322 '0',
323 output_dir,
324 [styles.get(name)]))
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800325 for name in formats[KEY_FILES]]
Hung-Te Lin04addcc2015-03-23 18:43:30 +0800326 pool.close()
Jes Klinke1687a992020-06-16 13:47:17 -0700327 if json_dir is not None:
328 shutil.rmtree(json_dir)
Yu-Ping Wud71b4452020-06-16 11:00:26 +0800329 print()
Yu-Ping Wuc90a22f2020-04-24 11:17:15 +0800330
331 try:
332 success = [r.get() for r in results]
333 except KeyboardInterrupt:
334 pool.terminate()
335 pool.join()
336 exit('Aborted by user')
337 else:
338 pool.join()
339 if not all(success):
340 exit('Failed to render some locales')
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800341
342
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800343def build_strings():
344 """Builds text strings."""
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800345 with open(FORMAT_FILE, encoding='utf-8') as f:
346 formats = yaml.load(f)
347
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800348 # Convert glyphs
349 print('Converting glyphs...')
350 convert_glyphs()
351
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800352 # Convert generic (locale-independent) strings
353 styles = formats[KEY_STYLES]
354 fonts = formats[KEY_FONTS]
355 default_font = fonts.get(DEFAULT_NAME)
356
357 for input_file in glob.glob(os.path.join(STRINGS_DIR, '*.txt')):
358 name, _ = os.path.splitext(os.path.basename(input_file))
359 style = styles.get(name)
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800360 if not convert_text_to_png(None, input_file, default_font, '0',
361 STAGE_DIR, options=[style]):
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800362 exit('Failed to convert text %s' % input_file)
363
364 # Convert localized strings
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800365 convert_localized_strings(formats)
366
367
368def load_boards_config(filename):
369 """Loads the configuration of all boards from `filename`.
370
371 Args:
372 filename: File name of a YAML config file.
373
374 Returns:
375 A dictionary mapping each board name to its config.
376 """
377 with open(filename, 'rb') as file:
378 raw = yaml.load(file)
379
380 configs = {}
381 default = raw[DEFAULT_NAME]
382 if not default:
383 raise BuildImageError('Default configuration is not found')
384 for boards, params in raw.items():
385 if boards == DEFAULT_NAME:
386 continue
387 config = copy.deepcopy(default)
388 if params:
389 config.update(params)
390 for board in boards.replace(',', ' ').split():
391 configs[board] = config
392
393 return configs
394
395
396class Converter(object):
397 """Converter from assets, texts, URLs, and fonts to bitmap images.
398
399 Attributes:
400 ASSET_DIR (str): Directory of image assets.
401 DEFAULT_OUTPUT_EXT (str): Default output file extension.
402 DEFAULT_REPLACE_MAP (dict): Default mapping of file replacement. For
403 {'a': 'b'}, "a.*" will be converted to "b.*".
404 SCALE_BASE (int): See ASSET_SCALES below.
405 DEFAULT_FONT_SCALE (tuple): Scale (width and height) of the font images.
406 ASSET_SCALES (dict): Scale of each image asset. Key is the image name and
407 value is a tuple (w, h), which are the width and height relative to the
408 screen resolution. For example, if SCALE_BASE is 1000, (500, 100) means
409 the image will be scaled to 50% of the screen width and 10% of the screen
410 height.
411 TEXT_SCALES (dict): Scale of each localized text image. The meaning is
412 similar to ASSET_SCALES.
413 ASSET_MAX_COLORS (int): Maximum colors to use for converting image assets
414 to bitmaps.
415 DEFAULT_BACKGROUND (tuple): Default background color.
416 BACKGROUND_COLORS (dict): Background color of each image. Key is the image
417 name and value is a tuple of RGB values.
418 """
419
420 ASSET_DIR = 'assets'
421 DEFAULT_OUTPUT_EXT = '.bmp'
422
423 DEFAULT_REPLACE_MAP = {
424 'rec_sel_desc1_no_sd': '',
425 'rec_sel_desc1_no_phone_no_sd': '',
426 'rec_disk_step1_desc0_no_sd': '',
427 'rec_to_dev_desc1_phyrec': '',
428 'rec_to_dev_desc1_power': '',
429 'navigate0_tablet': '',
430 'navigate1_tablet': '',
431 'nav-button_power': '',
432 'nav-button_volume_up': '',
433 'nav-button_volume_down': '',
434 'broken_desc_phyrec': '',
435 'broken_desc_detach': '',
436 }
437
438 # scales
439 SCALE_BASE = 1000 # 100.0%
440
441 # These are supposed to be kept in sync with the numbers set in depthcharge
442 # to avoid runtime scaling, which makes images blurry.
443 DEFAULT_ASSET_SCALE = (0, 30)
444 DEFAULT_TEXT_SCALE = (0, 24)
445 DEFAULT_FONT_SCALE = (0, 20)
446 LANG_SCALE = (0, 26)
447 ICON_SCALE = (0, 45)
448 STEP_ICON_SCALE = (0, 28)
449 TITLE_SCALE = (0, 42)
450 BUTTON_SCALE = (0, 20)
451 BUTTON_ICON_SCALE = (0, 24)
452 BUTTON_ARROW_SCALE = (0, 20)
453 QR_FOOTER_SCALE = (0, 108)
454 QR_DESC_SCALE = (0, 228)
455 FOOTER_TEXT_SCALE = (0, 20)
456
457 ASSET_SCALES = {
458 'separator': None,
459 'ic_globe': (0, 20),
460 'ic_dropdown': (0, 24),
461 'ic_info': ICON_SCALE,
462 'ic_warning': ICON_SCALE,
463 'ic_error': ICON_SCALE,
464 'ic_dev_mode': ICON_SCALE,
465 'ic_restart': ICON_SCALE,
466 'ic_1': STEP_ICON_SCALE,
467 'ic_1-done': STEP_ICON_SCALE,
468 'ic_2': STEP_ICON_SCALE,
469 'ic_2-done': STEP_ICON_SCALE,
470 'ic_3': STEP_ICON_SCALE,
471 'ic_3-done': STEP_ICON_SCALE,
472 'ic_done': STEP_ICON_SCALE,
473 'ic_search': BUTTON_ICON_SCALE,
474 'ic_search_focus': BUTTON_ICON_SCALE,
475 'ic_settings': BUTTON_ICON_SCALE,
476 'ic_settings_focus': BUTTON_ICON_SCALE,
477 'ic_power': BUTTON_ICON_SCALE,
478 'ic_power_focus': BUTTON_ICON_SCALE,
479 'ic_dropleft': BUTTON_ARROW_SCALE,
480 'ic_dropleft_focus': BUTTON_ARROW_SCALE,
481 'ic_dropright': BUTTON_ARROW_SCALE,
482 'ic_dropright_focus': BUTTON_ARROW_SCALE,
483 'qr_rec': QR_FOOTER_SCALE,
484 'qr_rec_phone': QR_DESC_SCALE,
485 }
486
487 TEXT_SCALES = {
488 'language': LANG_SCALE,
489 'firmware_sync_title': TITLE_SCALE,
490 'broken_title': TITLE_SCALE,
491 'adv_options_title': TITLE_SCALE,
492 'debug_info_title': TITLE_SCALE,
493 'firmware_log_title': TITLE_SCALE,
494 'rec_sel_title': TITLE_SCALE,
495 'rec_step1_title': TITLE_SCALE,
496 'rec_phone_step2_title': TITLE_SCALE,
497 'rec_disk_step2_title': TITLE_SCALE,
498 'rec_disk_step3_title': TITLE_SCALE,
499 'rec_invalid_title': TITLE_SCALE,
500 'rec_to_dev_title': TITLE_SCALE,
501 'dev_title': TITLE_SCALE,
502 'dev_to_norm_title': TITLE_SCALE,
503 'dev_boot_ext_title': TITLE_SCALE,
504 'dev_invalid_disk_title': TITLE_SCALE,
505 'dev_select_bootloader_title': TITLE_SCALE,
506 'diag_menu_title': TITLE_SCALE,
507 'diag_storage_title': TITLE_SCALE,
508 'diag_memory_quick_title': TITLE_SCALE,
509 'diag_memory_full_title': TITLE_SCALE,
510 'btn_dev_mode': BUTTON_SCALE,
511 'btn_debug_info': BUTTON_SCALE,
512 'btn_firmware_log': BUTTON_SCALE,
513 'btn_page_up': BUTTON_SCALE,
514 'btn_page_down': BUTTON_SCALE,
515 'btn_rec_by_phone': BUTTON_SCALE,
516 'btn_rec_by_disk': BUTTON_SCALE,
517 'btn_adv_options': BUTTON_SCALE,
518 'btn_secure_mode': BUTTON_SCALE,
519 'btn_int_disk': BUTTON_SCALE,
520 'btn_ext_disk': BUTTON_SCALE,
521 'btn_alt_bootloader': BUTTON_SCALE,
522 'btn_launch_diag': BUTTON_SCALE,
523 'btn_diag_storage': BUTTON_SCALE,
524 'btn_diag_memory_quick': BUTTON_SCALE,
525 'btn_diag_memory_full': BUTTON_SCALE,
526 'btn_diag_cancel': BUTTON_SCALE,
527 'btn_next': BUTTON_SCALE,
528 'btn_back': BUTTON_SCALE,
529 'btn_confirm': BUTTON_SCALE,
530 'btn_cancel': BUTTON_SCALE,
531 'model': FOOTER_TEXT_SCALE,
532 'help_center': FOOTER_TEXT_SCALE,
533 'rec_url': FOOTER_TEXT_SCALE,
534 'navigate0': FOOTER_TEXT_SCALE,
535 'navigate1': FOOTER_TEXT_SCALE,
536 'navigate0_tablet': FOOTER_TEXT_SCALE,
537 'navigate1_tablet': FOOTER_TEXT_SCALE,
538 }
539
540 # background colors
541 DEFAULT_BACKGROUND = (0x20, 0x21, 0x24)
542 LANG_HEADER_BACKGROUND = (0x16, 0x17, 0x19)
543 LINK_SELECTED_BACKGROUND = (0x2a, 0x2f, 0x39)
544 ASSET_MAX_COLORS = 128
545
546 BACKGROUND_COLORS = {
547 'ic_dropdown': LANG_HEADER_BACKGROUND,
548 'ic_dropleft_focus': LINK_SELECTED_BACKGROUND,
549 'ic_dropright_focus': LINK_SELECTED_BACKGROUND,
550 'ic_globe': LANG_HEADER_BACKGROUND,
551 'ic_search_focus': LINK_SELECTED_BACKGROUND,
552 'ic_settings_focus': LINK_SELECTED_BACKGROUND,
553 'ic_power_focus': LINK_SELECTED_BACKGROUND,
554 }
555
556 def __init__(self, board, config, output):
557 """Inits converter.
558
559 Args:
560 board: Board name.
561 config: A dictionary of configuration parameters.
562 output: Output directory.
563 """
564 self.board = board
565 self.config = config
566 self.set_dirs(output)
567 self.set_screen()
568 self.set_replace_map()
569 self.set_locales()
570 self.text_max_colors = self.config[TEXT_COLORS_KEY]
571
572 def set_dirs(self, output):
573 """Sets board output directory and stage directory.
574
575 Args:
576 output: Output directory.
577 """
578 self.output_dir = os.path.join(output, self.board)
579 self.output_ro_dir = os.path.join(self.output_dir, 'locale', 'ro')
580 self.output_rw_dir = os.path.join(self.output_dir, 'locale', 'rw')
581 self.stage_dir = os.path.join(output, '.stage')
582 self.temp_dir = os.path.join(self.stage_dir, 'tmp')
583
584 def set_screen(self):
585 """Sets screen width and height."""
586 self.screen_width, self.screen_height = self.config[SCREEN_KEY]
587
588 self.stretch = (1, 1)
589 if self.config[PANEL_KEY]:
590 # Calculate 'stretch'. It's used to shrink images horizontally so that
591 # resulting images will look proportional to the original image on the
592 # stretched display. If the display is not stretched, meaning aspect
593 # ratio is same as the screen where images were rendered (1366x766),
594 # no shrinking is performed.
595 panel_width, panel_height = self.config[PANEL_KEY]
596 self.stretch = (self.screen_width * panel_height,
597 self.screen_height * panel_width)
598
599 if self.stretch[0] > self.stretch[1]:
600 raise BuildImageError('Panel aspect ratio (%f) is smaller than screen '
601 'aspect ratio (%f). It indicates screen will be '
602 'shrunk horizontally. It is currently unsupported.'
603 % (panel_width / panel_height,
604 self.screen_width / self.screen_height))
605
606 # Set up square drawing area
607 self.canvas_px = min(self.screen_width, self.screen_height)
608
609 def set_replace_map(self):
610 """Sets a map replacing images.
611
612 For each (key, value), image 'key' will be replaced by image 'value'.
613 """
614 replace_map = self.DEFAULT_REPLACE_MAP.copy()
615
616 if os.getenv('DETACHABLE') == '1':
617 replace_map.update({
618 'nav-key_enter': 'nav-button_power',
619 'nav-key_up': 'nav-button_volume_up',
620 'nav-key_down': 'nav-button_volume_down',
621 'navigate0': 'navigate0_tablet',
622 'navigate1': 'navigate1_tablet',
623 'broken_desc': 'broken_desc_detach',
624 })
625
626 physical_presence = os.getenv('PHYSICAL_PRESENCE')
627 if physical_presence == 'recovery':
628 replace_map['rec_to_dev_desc1'] = 'rec_to_dev_desc1_phyrec'
629 replace_map['broken_desc'] = 'broken_desc_phyrec'
630 elif physical_presence == 'power':
631 replace_map['rec_to_dev_desc1'] = 'rec_to_dev_desc1_power'
632 elif physical_presence != 'keyboard':
633 raise BuildImageError('Invalid physical presence setting %s for board %s'
634 % (physical_presence, self.board))
635
636 if not self.config[SDCARD_KEY]:
637 replace_map.update({
638 'rec_sel_desc1': 'rec_sel_desc1_no_sd',
639 'rec_sel_desc1_no_phone': 'rec_sel_desc1_no_phone_no_sd',
640 'rec_disk_step1_desc0': 'rec_disk_step1_desc0_no_sd',
641 })
642
643 self.replace_map = replace_map
644
645 def set_locales(self):
646 """Sets a list of locales for which localized images are converted."""
647 # LOCALES environment variable can overwrite boards.yaml
648 env_locales = os.getenv('LOCALES')
649 rtl_locales = set(self.config[RTL_KEY])
650 hi_res_locales = set(self.config[HI_RES_KEY])
651 if env_locales:
652 locales = env_locales.split()
653 else:
654 locales = self.config[LOCALES_KEY]
655 # Check rtl_locales are contained in locales.
656 unknown_rtl_locales = rtl_locales - set(locales)
657 if unknown_rtl_locales:
658 raise BuildImageError('Unknown locales %s in %s' %
659 (list(unknown_rtl_locales), RTL_KEY))
660 # Check hi_res_locales are contained in locales.
661 unknown_hi_res_locales = hi_res_locales - set(locales)
662 if unknown_hi_res_locales:
663 raise BuildImageError('Unknown locales %s in %s' %
664 (list(unknown_hi_res_locales), HI_RES_KEY))
665 self.locales = [LocaleInfo(code, code in rtl_locales,
666 code in hi_res_locales)
667 for code in locales]
668
669 def calculate_dimension(self, original, scale, num_lines):
670 """Calculates scaled width and height.
671
672 This imitates the function of Depthcharge with the same name.
673
674 Args:
675 original: (width, height) of the original image.
676 scale: (x, y) scale parameter relative to the canvas size using
677 SCALE_BASE as a base.
678 num_lines: multiplication factor for the y-dimension.
679
680 Returns:
681 (width, height) of the scaled image.
682 """
683 dim_width, dim_height = (0, 0)
684 scale_x, scale_y = scale
685 org_width, org_height = original
686
687 if scale_x == 0 and scale_y == 0:
688 raise BuildImageError('Invalid scale parameter: %s' % (scale))
689 if scale_x > 0:
690 dim_width = int(self.canvas_px * scale_x / self.SCALE_BASE)
691 if scale_y > 0:
692 dim_height = int(self.canvas_px * scale_y / self.SCALE_BASE) * num_lines
693 if scale_x == 0:
694 dim_width = org_width * dim_height // org_height
695 if scale_y == 0:
696 dim_height = org_height * dim_width // org_width
697
698 dim_width = int(dim_width * self.stretch[0] / self.stretch[1])
699
700 return dim_width, dim_height
701
702 def _get_svg_height(self, svg_file):
703 tree = ElementTree.parse(svg_file)
704 height = tree.getroot().attrib['height']
705 m = re.match('([0-9]+)pt', height)
706 if not m:
707 raise BuildImageError('Cannot get height from %s' % svg_file)
708 return int(m.group(1))
709
710 def get_num_lines(self, file, one_line_dir):
711 """Gets the number of lines of text in `file`."""
712 name, _ = os.path.splitext(os.path.basename(file))
713 svg_name = name + '.svg'
714 multi_line_file = os.path.join(os.path.dirname(file), svg_name)
715 one_line_file = os.path.join(one_line_dir, svg_name)
716 # The number of lines id determined by comparing the height of
717 # `multi_line_file` with `one_line_file`, where the latter is generated
718 # without the '--width' option passed to pango-view.
719 height = self._get_svg_height(multi_line_file)
720 line_height = self._get_svg_height(one_line_file)
721 return int(round(height / line_height))
722
723 def convert_svg_to_png(self, svg_file, png_file, scale, num_lines,
724 background):
725 """Converts .svg file to .png file."""
726 background_hex = ''.join(format(x, '02x') for x in background)
727 # If the width/height of the SVG file is specified in points, the
728 # rsvg-convert command with default 90DPI will potentially cause the pixels
729 # at the right/bottom border of the output image to be transparent (or
730 # filled with the specified background color). This seems like an
731 # rsvg-convert issue regarding image scaling. Therefore, use 72DPI here
732 # to avoid the scaling.
733 command = ['rsvg-convert',
734 '--background-color', "'#%s'" % background_hex,
735 '--dpi-x', '72',
736 '--dpi-y', '72',
737 '-o', png_file]
738 if scale:
739 width = int(self.canvas_px * scale[0] / self.SCALE_BASE)
740 height = int(self.canvas_px * scale[1] / self.SCALE_BASE) * num_lines
741 if width:
742 command.extend(['--width', '%d' % width])
743 if height:
744 command.extend(['--height', '%d' % height])
745 command.append(svg_file)
746 subprocess.check_call(' '.join(command), shell=True)
747
748 def convert_to_bitmap(self, input_file, scale, num_lines, background, output,
749 max_colors):
750 """Converts an image file `input_file` to a BMP file `output`."""
751 image = Image.open(input_file)
752
753 # Process alpha channel and transparency.
754 if image.mode == 'RGBA':
755 target = Image.new('RGB', image.size, background)
756 image.load() # required for image.split()
757 mask = image.split()[-1]
758 target.paste(image, mask=mask)
759 elif (image.mode == 'P') and ('transparency' in image.info):
760 exit('Sorry, PNG with RGBA palette is not supported.')
761 elif image.mode != 'RGB':
762 target = image.convert('RGB')
763 else:
764 target = image
765
766 # Process scaling
767 if scale:
768 new_size = self.calculate_dimension(image.size, scale, num_lines)
769 if new_size[0] == 0 or new_size[1] == 0:
770 print('Scaling', input_file)
771 print('Warning: width or height is 0 after resizing: '
772 'scale=%s size=%s stretch=%s new_size=%s' %
773 (scale, image.size, self.stretch, new_size))
774 return
775 target = target.resize(new_size, Image.BICUBIC)
776
777 # Export and downsample color space.
778 target.convert('P', dither=None, colors=max_colors, palette=Image.ADAPTIVE
779 ).save(output)
780
781 with open(output, 'rb+') as f:
782 f.seek(BMP_HEADER_OFFSET_NUM_LINES)
783 f.write(bytearray([num_lines]))
784
785 def convert(self, files, output_dir, scales, max_colors, one_line_dir=None):
786 """Converts file(s) to bitmap format."""
787 if not files:
788 raise BuildImageError('Unable to find file(s) to convert')
789
790 for file in files:
791 name, ext = os.path.splitext(os.path.basename(file))
792 output = os.path.join(output_dir, name + self.DEFAULT_OUTPUT_EXT)
793
794 background = self.BACKGROUND_COLORS.get(name, self.DEFAULT_BACKGROUND)
795 scale = scales[name]
796
797 if name in self.replace_map:
798 name = self.replace_map[name]
799 if not name:
800 continue
801 print('Replace: %s => %s' % (file, name))
802 file = os.path.join(os.path.dirname(file), name + ext)
803
804 # Determine num_lines in order to scale the image
805 # TODO(b/159399377): Wrap lines for texts other than descriptions.
Hsuan Ting Chenf2917582020-10-21 13:50:08 +0800806 if one_line_dir and ('_desc' in name or name.startswith('navigate')):
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800807 num_lines = self.get_num_lines(file, one_line_dir)
808 else:
809 num_lines = 1
810
811 if ext == '.svg':
812 png_file = os.path.join(self.temp_dir, name + '.png')
813 self.convert_svg_to_png(file, png_file, scale, num_lines, background)
814 file = png_file
815
816 self.convert_to_bitmap(
817 file, scale, num_lines, background, output, max_colors)
818
819 def convert_assets(self):
820 """Converts images in assets folder."""
821 files = []
822 files.extend(glob.glob(os.path.join(self.ASSET_DIR, SVG_FILES)))
823 files.extend(glob.glob(os.path.join(self.ASSET_DIR, PNG_FILES)))
824 scales = defaultdict(lambda: self.DEFAULT_ASSET_SCALE)
825 scales.update(self.ASSET_SCALES)
826 self.convert(files, self.output_dir, scales, self.ASSET_MAX_COLORS)
827
828 def convert_generic_strings(self):
829 """Converts generic (locale-independent) strings."""
830 scales = self.TEXT_SCALES.copy()
831 files = glob.glob(os.path.join(self.stage_dir, SVG_FILES))
832 self.convert(files, self.output_dir, scales, self.text_max_colors)
833
834 def convert_localized_strings(self):
835 """Converts localized strings."""
836 # Using stderr to report progress synchronously
837 print(' processing:', end='', file=sys.stderr, flush=True)
838 for locale_info in self.locales:
839 locale = locale_info.code
840 ro_locale_dir = os.path.join(self.output_ro_dir, locale)
841 stage_locale_dir = os.path.join(STAGE_LOCALE_DIR, locale)
842 if locale_info.hi_res:
843 scales = defaultdict(lambda: self.DEFAULT_TEXT_SCALE)
844 scales.update(self.TEXT_SCALES)
845 print(' ' + locale, end='', file=sys.stderr, flush=True)
846 else:
847 # We use low-res images for these locales and turn off scaling
848 # to make the files fit in a ROM. Note that these text images will
849 # be scaled by Depthcharge to be the same height as hi-res texts.
850 scales = defaultdict(lambda: None)
851 print(' ' + locale + '/lo', end='', file=sys.stderr, flush=True)
852 os.makedirs(ro_locale_dir)
853 self.convert(
854 glob.glob(os.path.join(stage_locale_dir, SVG_FILES)),
855 ro_locale_dir, scales, self.text_max_colors,
856 one_line_dir=os.path.join(stage_locale_dir, ONE_LINE_DIR))
857 print(file=sys.stderr)
858
859 def move_language_images(self):
860 """Renames language bitmaps and move to self.output_dir.
861
862 The directory self.output_dir contains locale-independent images, and is
863 used for creating vbgfx.bin by archive_images.py.
864 """
865 for locale_info in self.locales:
866 locale = locale_info.code
867 ro_locale_dir = os.path.join(self.output_ro_dir, locale)
868 old_file = os.path.join(ro_locale_dir, 'language.bmp')
869 new_file = os.path.join(self.output_dir, 'language_%s.bmp' % locale)
870 if os.path.exists(new_file):
871 raise BuildImageError('File already exists: %s' % new_file)
872 shutil.move(old_file, new_file)
873
874 def convert_fonts(self):
875 """Converts font images"""
876 scales = defaultdict(lambda: self.DEFAULT_FONT_SCALE)
877 files = glob.glob(os.path.join(STAGE_FONT_DIR, SVG_FILES))
878 font_output_dir = os.path.join(self.output_dir, 'font')
879 os.makedirs(font_output_dir)
880 self.convert(files, font_output_dir, scales, self.text_max_colors)
881
882 def copy_images_to_rw(self):
883 """Copies localized images specified in boards.yaml for RW override."""
884 if not self.config[RW_OVERRIDE_KEY]:
885 print(' No localized images are specified for RW, skipping')
886 return
887
888 for locale_info in self.locales:
889 locale = locale_info.code
890 rw_locale_dir = os.path.join(self.output_ro_dir, locale)
891 ro_locale_dir = os.path.join(self.output_rw_dir, locale)
892 os.makedirs(rw_locale_dir)
893
894 for name in self.config[RW_OVERRIDE_KEY]:
895 ro_src = os.path.join(ro_locale_dir, name + self.DEFAULT_OUTPUT_EXT)
896 rw_dst = os.path.join(rw_locale_dir, name + self.DEFAULT_OUTPUT_EXT)
897 shutil.copyfile(ro_src, rw_dst)
898
899 def create_locale_list(self):
900 """Creates locale list as a CSV file.
901
902 Each line in the file is of format "code,rtl", where
903 - "code": language code of the locale
904 - "rtl": "1" for right-to-left language, "0" otherwise
905 """
906 with open(os.path.join(self.output_dir, 'locales'), 'w') as f:
907 for locale_info in self.locales:
908 f.write('{},{}\n'.format(locale_info.code,
909 int(locale_info.rtl)))
910
911 def build(self):
912 """Builds all images required by a board."""
913 # Clean up output directory
914 if os.path.exists(self.output_dir):
915 shutil.rmtree(self.output_dir)
916 os.makedirs(self.output_dir)
917
918 if not os.path.exists(self.stage_dir):
919 raise BuildImageError('Missing stage folder. Run make in strings dir.')
920
921 # Clean up temp directory
922 if os.path.exists(self.temp_dir):
923 shutil.rmtree(self.temp_dir)
924 os.makedirs(self.temp_dir)
925
926 print('Converting asset images...')
927 self.convert_assets()
928
929 print('Converting generic strings...')
930 self.convert_generic_strings()
931
932 print('Converting localized strings...')
933 self.convert_localized_strings()
934
935 print('Moving language images to locale-independent directory...')
936 self.move_language_images()
937
938 print('Creating locale list file...')
939 self.create_locale_list()
940
941 print('Converting fonts...')
942 self.convert_fonts()
943
944 print('Copying specified images to RW packing directory...')
945 self.copy_images_to_rw()
946
947
948def build_images(board):
949 """Builds images for `board`."""
950 configs = load_boards_config(BOARDS_CONFIG_FILE)
951 print('Building for ' + board)
952 converter = Converter(board, configs[board], OUTPUT_DIR)
953 converter.build()
954
955
956def main():
957 """Builds bitmaps for firmware screens."""
958 parser = argparse.ArgumentParser()
959 parser.add_argument('board', help='Target board')
960 args = parser.parse_args()
961 build_strings()
962 build_images(args.board)
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800963
964
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800965if __name__ == '__main__':
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800966 main()