From 866c9571df2e5696af201348ffafc9ec54d8d463 Mon Sep 17 00:00:00 2001 From: poire-z Date: Fri, 6 Dec 2019 22:55:33 +0100 Subject: [PATCH] [RTL UI] adds bidi.lua, bootstrap UI mirroring with RTL languages Set default language (for Harfbuzz to pick up localized glyphs in a font), default text direction, and UI element mirroring depending on the UI language. --- frontend/apps/filemanager/filemanagermenu.lua | 31 +++ frontend/ui/bidi.lua | 189 ++++++++++++++++++ frontend/ui/language.lua | 35 +++- reader.lua | 9 + 4 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 frontend/ui/bidi.lua diff --git a/frontend/apps/filemanager/filemanagermenu.lua b/frontend/apps/filemanager/filemanagermenu.lua index 5ccbad444..3d76d5195 100644 --- a/frontend/apps/filemanager/filemanagermenu.lua +++ b/frontend/apps/filemanager/filemanagermenu.lua @@ -399,6 +399,37 @@ function FileManagerMenu:setUpdateItemTable() }) end, }) + table.insert(self.menu_items.developer_options.sub_item_table, { + text = "UI layout mirroring and text direction", + sub_item_table = { + { + text = _("Reverse UI layout mirroring"), + checked_func = function() + return G_reader_settings:isTrue("dev_reverse_ui_layout_mirroring") + end, + callback = function() + G_reader_settings:flipNilOrFalse("dev_reverse_ui_layout_mirroring") + local InfoMessage = require("ui/widget/infomessage") + UIManager:show(InfoMessage:new{ + text = _("This will take effect on next restart."), + }) + end + }, + { + text = _("Reverse UI text direction"), + checked_func = function() + return G_reader_settings:isTrue("dev_reverse_ui_text_direction") + end, + callback = function() + G_reader_settings:flipNilOrFalse("dev_reverse_ui_text_direction") + local InfoMessage = require("ui/widget/infomessage") + UIManager:show(InfoMessage:new{ + text = _("This will take effect on next restart."), + }) + end + } + } + }) self.menu_items.cloud_storage = { text = _("Cloud storage"), diff --git a/frontend/ui/bidi.lua b/frontend/ui/bidi.lua new file mode 100644 index 000000000..29b4557ac --- /dev/null +++ b/frontend/ui/bidi.lua @@ -0,0 +1,189 @@ +--[[-- +Bidirectional text and UI mirroring setup and helpers. + +There are 2 concepts we attempt to handle: +- Text direction: Left-To-Right (LTR) or Right-To-Left (RTL) +- UI elements mirroring: not-mirrored, or mirrored + +These 2 concepts are somehow orthogonal to each other in +their implementation, even if in the real world there are +only 2 valid combinations: +- LTR and not-mirrored: for western languages, CJK, Indic... +- RTL and mirrored: for Arabic, Hebrew, Farsi and a few others. + +Text direction is handled by the libkoreader-xtext.so C module, +and the TextWidget and TextBoxWidget widgets that handle text +aligment. We just need here to set the default global paragraph +direction (that widgets can override if needed). + +UI mirroring is to be handled by our widget themselves, with the +help of a few functions defined here. + +Fortunately, low level widgets like LeftContainer, RightContainer, +FrameContainer, HorizontalGroup, OverlapGroup... will do most of +the work. +But some care must be taken in other widgets and apps when: +- some arrow symbols are used (for next, previous, first, last...): + they might need to be swapped, or some alternative symbols or + images can be used. +- some geometry arithmetic is done (e.g. detecting if a tap is on the + right part of screen, to go forward), which need to be adapted/reversed. +- handling left or right swipe, whose action might need to be reversed +- some TextBoxWidget/InputText might need to be forced to be LTR (when + showing HTML or CSS code, or entering URLs, path...) + +Some overview at: +https://material.io/design/usability/bidirectionality.html +]] + +local Language = require("ui/language") +local _ = require("gettext") + +local Bidi = { + _mirrored_ui_layout = false, + _rtl_ui_text = false, +} + +-- Setup UI mirroring and RTL text for UI language +function Bidi.setup(lang) + local is_rtl = Language:isLanguageRTL(lang) + -- Mirror UI if language is RTL + Bidi._mirrored_ui_layout = is_rtl + -- Unless requested not to (or requested mirroring with LTR language) + if G_reader_settings:isTrue("dev_reverse_ui_layout_mirroring") then + Bidi._mirrored_ui_layout = not Bidi._mirrored_ui_layout + end + -- Xtext default language and direction + if G_reader_settings:nilOrTrue("use_xtext") then + local xtext = require("libs/libkoreader-xtext") + + -- Text direction should normally not follow ui mirroring + -- lang override (so that Arabic is still right aligned + -- when one wants the UI layout LTR). But allow it to + -- be independantly reversed (for testing UI mirroring + -- with english text right aligned). + if G_reader_settings:isTrue("dev_reverse_ui_text_direction") then + is_rtl = not is_rtl + end + Bidi._rtl_ui_text = is_rtl + xtext.setDefaultParaDirection(is_rtl) + + -- Text language: this helps picking localized glyphs from the + -- font (eg. ideographs shaped differently for Japanese vs + -- Simplified Chinese vs Traditional Chinese). + -- Allow overriding xtext language rules from main UI language + -- (eg. English UI, with French line breaking rules) + local alt_lang = G_reader_settings:readSetting("xtext_alt_lang") or lang + if alt_lang then + xtext.setDefaultLang(alt_lang) + end + end + -- Optimise Bidi.default and Bidi.wrap by aliasing them to the right wrappers + if Bidi._rtl_ui_text then + Bidi.default = Bidi.rtl + Bidi.wrap = Bidi.rtl + else + Bidi.default = Bidi.ltr + Bidi.wrap = Bidi.noop + end +end + + +-- Use this function in widgets to check if UI elements mirroring +-- is to be done +function Bidi.mirroredUILayout() + return Bidi._mirrored_ui_layout +end + +-- This function might only be useful in some rare cases (RTL text +-- is handled directly by TextWidget and TextBoxWidget) +function Bidi.rtlUIText() + return Bidi._rtl_ui_text +end + +-- Small helper to mirror gesture directions +local mirrored_directions = { + east = "west", + west = "east", + northeast = "northwest", + northwest = "northeast", + southeast = "southwest", + southwest = "southeast", +} + +function Bidi.flipDirectionIfMirroredUILayout(direction) + if Bidi._mirrored_ui_layout then + return mirrored_directions[direction] or direction + end + return direction +end + +function Bidi.flipIfMirroredUILayout(bool) + if Bidi._mirrored_ui_layout then + return not bool + end + return bool +end + +-- Wrap provided text with bidirectionality control characters, see: +-- http://unicode.org/reports/tr9/#Markup_And_Formatting +-- https://www.w3.org/International/questions/qa-bidi-unicode-controls.en +-- https://www.w3.org/International/articles/inline-bidi-markup/ +-- This works only when use_xtext=true: these characters are used +-- by FriBidi for correct char visual ordering, and later stripped +-- by Harfbuzz. +-- When use_xtext=false, these characters are considered as normal +-- characters, and would be printed. Fortunately, most fonts know them +-- and provide an invisible glyph of zero-width - except FreeSans and +-- FreeSerif which provide a real glyph (a square with "LRI" inside) +-- which would be an issue and would need stripping. But as these +-- Free fonts are only used as fallback fonts, and the invisible glyphs +-- will have been found in the previous fonts, we don't need to. +local LRI = "\xE2\x81\xA6" -- U+2066 LRI / LEFT-TO-RIGHT ISOLATE +local RLI = "\xE2\x81\xA7" -- U+2067 RLI / RIGHT-TO-LEFT ISOLATE +local FSI = "\xE2\x81\xA8" -- U+2068 FSI / FIRST STRONG ISOLATE +local PDI = "\xE2\x81\xA9" -- U+2069 PDI / POP DIRECTIONAL ISOLATE + +function Bidi.ltr(text) + return string.format("%s%s%s", LRI, text, PDI) +end + +function Bidi.rtl(text) -- should hardly be needed + return string.format("%s%s%s", RLI, text, PDI) +end + +function Bidi.auto(text) -- from first strong character + return string.format("%s%s%s", FSI, text, PDI) +end + +function Bidi.default(text) -- default direction + return Bidi._rtl_ui_text and Bidi.rtl(text) or Bidi.ltr(text) +end + +function Bidi.noop(text) -- no wrap + return text +end + +-- Helper for concatenated string bits of numbers an symbols (like +-- our reader footer) to keep them ordered in RTL UI (to not have +-- a letter B for battery make the whole string considered LTR). +-- Note: it will be replaced and aliased to Bidi.noop or Bidi.rtl +-- by Bibi.setup() as an optimisation +function Bidi.wrap(text) + return Bidi._rtl_ui_text and Bidi.rtl(text) or text +end + +-- See at having GetText_mt.__call() wrap untranslated strings in Bidi.ltr() +-- so they are fully displayed LTR. + +-- Use these specific wrappers when the wrapped content type is known +-- (so we can easily switch to use rtl() if RTL readers prefer filenames +-- shown as real RTL). +-- Note: when the filename or path are standalone in a TextWidget, it's +-- better to use "para_direction_rtl = false" without any wrapping. +Bidi.filename = Bidi.ltr +Bidi.directory = Bidi.ltr +Bidi.path = Bidi.ltr +Bidi.url = Bidi.ltr + +return Bidi diff --git a/frontend/ui/language.lua b/frontend/ui/language.lua index ed5ccc169..919429915 100644 --- a/frontend/ui/language.lua +++ b/frontend/ui/language.lua @@ -1,7 +1,5 @@ -- high level wrapper module for gettext -local InfoMessage = require("ui/widget/infomessage") -local UIManager = require("ui/uimanager") local _ = require("gettext") local Language = { @@ -47,13 +45,46 @@ local Language = { zh_TW = "中文(台灣)", ["zh_TW.Big5"] = "中文(台灣)(Big5)", }, + -- Languages that are written RTL, and should have the UI mirrored. + -- Should match lang tags defined in harfbuzz/src/hb-ot-tag-table.hh. + -- https://meta.wikimedia.org/wiki/Template:List_of_language_names_ordered_by_code + -- Not included are those absent or commented out in hb-ot-tag-table.hh. + languages_rtl = { + ar = true, -- Arabic + arz = true, -- Egyptian Arabic + ckb = true, -- Sorani (Central Kurdish) + dv = true, -- Divehi + fa = true, -- Persian + he = true, -- Hebrew + ks = true, -- Kashmiri + ku = true, -- Kurdish + ps = true, -- Pashto + sd = true, -- Sindhi + ug = true, -- Uyghur + ur = true, -- Urdu + yi = true, -- Yiddish + } } function Language:getLanguageName(lang_locale) return self.language_names[lang_locale] or lang_locale end +function Language:isLanguageRTL(lang_locale) + if not lang_locale then + return false + end + local lang = lang_locale + local sep = lang:find("_") + if sep then + lang = lang:sub(1, sep-1) + end + return self.languages_rtl[lang] or false +end + function Language:changeLanguage(lang_locale) + local InfoMessage = require("ui/widget/infomessage") + local UIManager = require("ui/uimanager") _.changeLang(lang_locale) G_reader_settings:saveSetting("language", lang_locale) UIManager:show(InfoMessage:new{ diff --git a/reader.lua b/reader.lua index 565be66ef..5b81b01df 100755 --- a/reader.lua +++ b/reader.lua @@ -30,6 +30,8 @@ io.stdout:flush() G_reader_settings = require("luasettings"):open( DataStorage:getDataDir().."/settings.reader.lua") local lang_locale = G_reader_settings:readSetting("language") +-- Allow quick switching to Arabic for testing RTL/UI mirroring +if os.getenv("KO_RTL") then lang_locale = "ar_AA" end local _ = require("gettext") if lang_locale then _.changeLang(lang_locale) @@ -147,6 +149,13 @@ SettingsMigration:migrateSettings(G_reader_settings) local CanvasContext = require("document/canvascontext") CanvasContext:init(Device) +-- UI mirroring for RTL languages, and text shaping configuration +local Bidi = require("ui/bidi") +Bidi.setup(lang_locale) +-- Avoid loading UIManager and widgets before here, as they may +-- cache Bidi mirroring settings. Check that with: +-- for name, _ in pairs(package.loaded) do print(name) end + -- User fonts override local fontmap = G_reader_settings:readSetting("fontmap") if fontmap ~= nil then