hishidaの開発blog

EBシリーズ(EBPocket,EBWin,EBMac,EBStudio),KWIC Finder,xdoc2txt,読書尚友の開発者ブログ

読書尚友 Text-to-speech対応

家電量販店で現在発売中のAndroidスマホ最新機種を調べたところ、どうやら現行の機種は全て標準で日本語読み上げエンジンに対応しているようだ。
(設定→「言語と入力」→「テキスト読み上げの出力」→「優先するエンジン」→「Googleテキスト読み上げエンジン」→「言語」 に日本語があり、音声データがインストール済みになっている。)
そこで安心して読書尚友もText-to-speech対応することにした。

  • 青空文庫形式のルビ《》に対応。
  • 。、の句読点の単位で読み上げを行い、ポーズボタンで中断/再開 ができる。現在読み上げ中の位置は下線で表示する。
  • 頁をまたがると自動的に頁送りを行う。
  • CJKなら日本語読み上げエンジン、英語なら英語のエンジンを使う。青空文庫の「アーサー王物語」のように英語と日本語が混じる文章でもそれぞれのエンジンで読み上げを行う。

機能的にはほぼこれでいいと思うが、肝心の日本語読み上げエンジンの抑揚のない機械音が不自然で、とても長く聞いていられない。日本語読み上げエンジンが進化しないと、実用性は今ひとつかもしれない。

EBPocket for Android pro Text-to-speech サポート

Text-to-speechはテキストの読み上げ機能で、Androidでは1.6から早くもAPIが提供されていた。だがAndroidの標準のテキスト読み上げエンジンは、英語、イタリア語、スペイン語、ドイツ語、フランス語しかサポートしておらず、日本語の読み上げを行うためにはサードパーティの日本語読み上げエンジンを導入する必要があった。このため、EBPocketも読書尚友も、Text-to-speechをサポートしてこなかった。
ところが、昨年購入したASUS Zenfone 3 laserは、標準で日本語読み上げエンジンを搭載していることがわかり、Text-to-speechをサポートする意欲が湧いてきた。
とりあえずEBPocket for Android proで対応してみた。

  • テキストの範囲を選択してコンテキストメニューでTTSを選択すると、範囲指定したテキストを読み上げる
  • 範囲指定せずにメニューからTTSを選択すると、本文全てを読み上げる
  • 画面にタッチすると読み上げを止める
  • 文字列中にCJK統合漢字、ひらがな、カタカナを含んでいた場合は、日本語読み上げエンジンを使う。含まれていない場合は英語の読み上げエンジンを使う。
  • CJK統合漢字に使用する読み上げエンジンは、日本語と中国語から選択できる。

日本語読み上げエンジンが使えない機種でも、少なくとも英語の読み上げはできるので、英和や和英で発音を確かめたい場合には、それなりに有用だと思う。

次は当然、読書尚友でサポートしなければいけないと思っている。

EBPocket for iOS のApp Extension対応について

iOSのアプリケーション間でテキストを受け渡したい場合、以前はURL Schemeクリップボード、OpenInぐらいしか方法がなかった。
iOS8からは App Extension という新たなアプリケーション連携の方法が提供されており、Androidのintentのように、任意のアプリケーション間でテキストやイメージが受け渡せるようになった。
iOS8の登場からもう3年も経っているが、遅ればせながらEBPocket Pro for iOSにもApp Extensionを実装してみた。
App Extensionには種類があるが、EBPocketではShare Extensionに対応した。

共有の画面例

Safariやメモで文字列を範囲選択し、[共有...]を実行すると、App Extensionに対応したアプリの一覧が表示される。


ここでEBPocketを選択し、Postを押すとEBPoketが起動して受け渡された文字列で検索を実行する。


左上の戻るボタンを押すと呼び出し元のアプリに戻る。
スプリットビューが使用できるiPadでは、SafariとEBPocketを両方開いたままにして、Safariで文字選択した文字列でEBPocketで検索できる。

実装の方法

App Extensionのプログラミングについては、Appleから日本語訳が出ている.
日本語ドキュメント - Apple Developer
App Extensionは単独で配布することはできず、必ず収容アプリと同時に配布しないといけない。今回はEBPocketが収容アプリということになる。
App Exensionと収容アプリとは別のバイナリであり、開発言語が異なっていてもいい。今回は勉強のため、Swift3で書くことにした。
ただし、実行できるiOSのバージョンの条件がiOS7.0以上になったが、現在では困る人はほとんどいないと思う。

Xcodeで 既存のアプリにApp Extensionを追加するのは簡単で、
[File]->[New]->[Target...]を押してApp Extensionの種類からShare Extensionを選択し、Product Nameを入力する。
収容アプリの Bundle Identifire にProduct Nameを連結したものが、App ExtensionのBundle Identifireになる。

収容アプリのBundle Identifire : 
        info.ebstudio.EBPocketPro
App ExtensionのBundle Identifire : 
        info.ebstudio.EBPocketPro.shareExtension

ここで次の3つのファイルが生成されるので、これを雛形としてコードを追加していく。

  • ShareViewController.swift
  • Maininterface.storyboard
  • info.plist

info.plistでは:

  1. Bundle display name にextensionの表示名を設定(この場合EBPocket)
  2. 共有でテキストのみ選択できるようにNSExtensionActivationRuleを修正する
    NSExtension
        NSExtensionAttributes
            NSExtensionActivationRule
                NSExtensionActivationSupportsText	Boolean	YES

収容アプリと同じアイコンを使用するには、Targetに収容アプリの .xcassets のアイコン名を設定する:

Build Phases
    Copy Bundle Resources
        + 収容アプリの .xcassets   を追加する
Build Settings
    Asset Catalog App Icon Set Name     ->  AppIcon (.xcassetsのアイコン名)

ここで一つ困ったことは、Share Extension から収容アプリを起動する方法が URL Scheme しかないのだが、公式の方法である openURL:completionHandler: を実行できるExtensionの種類がToday Extensionだけという問題である。
これは世界中でみんな困っていて、ネットで検索するとさまざまな回避方法が出ている。
最終的に、iOS10でも実行できるSwift3の最終的なソースは次のようになった。

//
//  ShareViewController.swift
//  shareExtension
//
//  Created by hishida on 2017/06/14.
//
//

import UIKit
import Social

class ShareViewController: SLComposeServiceViewController {
    
    override func isContentValid() -> Bool {
        // Do validation of contentText and/or NSExtensionContext attachments here

        let canPost: Bool = self.contentText.characters.count > 0
        if canPost {
            return true
        }
        
        return false
    }

    override func didSelectPost() {
        // This is called after the user selects Post. Do the upload of contentText and/or NSExtensionContext attachments.
        
        let inputItem: NSExtensionItem = self.extensionContext?.inputItems[0] as! NSExtensionItem
        let itemProvider = inputItem.attachments![0] as! NSItemProvider
        
        if (itemProvider.hasItemConformingToTypeIdentifier("public.plain-text")) {
            itemProvider.loadItem(forTypeIdentifier: "public.plain-text", options: nil, completionHandler: {
                (item, error) in
                
                let URLSCHEME:String = "ebpocket://search?text="

                let encodedString:String = self.contentText.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)!
                
                let myUrlStr:String = URLSCHEME + encodedString

                let url = NSURL( string:myUrlStr )
                let context = NSExtensionContext()
                context.open(url! as URL, completionHandler: nil)
                
                var responder = self as UIResponder?
                
                while (responder != nil){
                    if responder?.responds(to: Selector("openURL:")) == true{
                        responder?.perform(Selector("openURL:"), with: url)
                    }
                    responder = responder!.next
                }
                
            })
        }

        // Inform the host that we're done, so it un-blocks its UI. Note: Alternatively you could call super's -didSelectPost, which will similarly complete the extension context.
        self.extensionContext!.completeRequest(returningItems: , completionHandler: nil)
    }

    override func configurationItems() -> [Any]! {
        // To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here.
        return 
    }

}


開発作業以上に手間取ったのが、App Storeに提出するための証明書の問題だった。
わかってみるとなんでもないが、App Extensionと本体の収容アプリは別のバイナリのため、Application Identifier、Provisioning profileは全て収容アプリとは別にする必要がある。

  1. iOS Dev Centerで Extension用の Application IdentifierとProvisioning profileを作成する
  2. 収容アプリのEntitlements.plistとは別に、App Extension用のEntitlements.plistを作成し、ここでApp ExtensionのApplication Identifierを定義する

ちなみApplication Identifierとは、開発者を表す10桁のprefix + Bundle Identifierのこと:

 XXXXXXXXXX.info.ebstudio.EBPocketPro.shareExtension 

あと、収容アプリと App Extensionのバージョン番号が食い違っていると提出時にワーニングが出るが、これは無視していいらしい。

iOS 11 Beta

WWDCAppleからiPad proやMacbookなどの新製品とiOS11 が発表され、即日iOS Dev Center から、Xcode 9 betaと iOS11 betaがダウンロードできるようになった。早速iOS11 betaをダウンロードして検証した。iPad mini2 と、最近中古で購入したiPhone 5sにiOS11 betaを入れてEBPocketの動作確認をしたところ、特に問題もなく正常動作した。予想通り32ビットアプリは動作しなくなっており、古い小学館大辞泉はついにアウト。
Xcode 9 のほうは一筋縄でいかず、コンパイルを通すのにかなり苦労しそうだ。当面はXcode 8.xでAppStoreに提出できるはずだが、1年以内にはXcode 9 対応も行わないと、将来アプリの更新ができなくなってしまう。

読書尚友 2.0.0

読書尚友に大きめの改良を行なったので、メジャーバージョンアップとみなして2.0.0とした。

  • OPDSカタログライブラリ
  • PDF(目次対応)
  • Unicodeテキストのサロゲートペア
  • 内蔵フォントを源ノ明朝と花園明朝B(Unicode拡張領域用)に変更


フォントをIPA明朝から源ノ明朝に変えたので、電子書籍の文字が非常に美しくなった。またLatin文字もNoto Serifベースなので、英文でも同じフォントで対応できるのもいい。花園明朝Bを搭載したのは、サロゲートペアに対応したのでUnicode CJK Ext.B,C,D,Eを表示させるためである。ただし、フォントを2つ内蔵したので、apkのサイズが肥大化してしまい(約60MB)、残り領域が少ないとインストールできない可能性がある。Unicode拡張領域を使用する文書は滅多にないので、オプションでダウンロードさせる仕組みにしたほうがいいかもしれない。(追記:2.0.1で花園明朝Bはオプションでダウンロードにした)
PDFは目次に対応し、正式版とした。ただ階層目次の開閉には対応していないので、フラットに全目次が表示される。このあたりはまだ工夫の余地がある。

android用OPDSクライアント公開

前回のブログ( 読書尚友のPDFサポート 他 - hishidaのblog )で検討中と書いたandroid用OPDSクライアントがそこそこ動くようになったので、Google Playで公開した。とりあえずカタログのナビゲーション機能を実装してみたが、次の段階ではOpenSearchに対応してみたい。
https://play.google.com/store/apps/details?id=info.ebstudio.opdsviewer

初期状態では次のOPDSカタログを登録している。

Project Gutenberg http://m.gutenberg.org/?format=opds
Feedbooks http://www.feedbooks.com/publicdomain/catalog.atom
BookServer(Internet Archive) http://bookserver.archive.org/catalog/
Manybooks http://manybooks.net/opds/
Smashwords https://smashwords.com/atom
青空文庫 OPDS http://aozora.textlive.net/catalog.opds
台湾 中華電子佛典協會 漢文大蔵経 http://www.cbeta.org/opds/
達人出版会 http://tatsu-zine.com/catalogs.opds
O'Reilly Japan https://www.oreilly.co.jp/ebook/catalogs.opds

これ以外に、有名どころで O’Reilly Media(https://opds.oreilly.com/opds/)も動作を確認したが、リンク先が購買しかないので、初期登録からは外した。
日本の出版サイトでは技術評論社がOPDSを提供しているが(http://gihyo.jp/dp/catalogs.opds)、いろいろと技術的な問題があって初期登録からは外すことにした。何が問題かというと、feedの作りが悪いのか、Abdroidの標準のXmlPullParserではパースで文法エラーになる。SAX Parserだと読み込みに成功した。パーサーによる相性問題が他にもあるかもしれないので、設定でXmlPullParserとSAX Parserを選択できるようにしてみた。
また、技術評論社のOPDSには新刊(http://gihyo.jp/dp/new.opds)と既刊書(http://gihyo.jp/dp/all.opds)があり、既刊書の方のOPDSが、1600冊以上のentryを一度に返してくる作りになっている。スマートフォンでは長考状態になって全く実用に耐えない。件数が多い場合には100件ずつぐらいページ単位で返すような設計にすべきだと思う。

海外の有名な読書リーダー(Moon+ Reader、Aldiko等)では大抵はOPDSブラウズ機能を内蔵している。日本ではOPDS自体が普及していないこともあり、青空文庫専用ビューアが普及している。
正直OPDSクライアントのニーズがあるかどうかわからないが、個人的にはOPDSの勉強にはなった。
読書尚友にもライブラリ機能としてOPDSブラウズを組み込むことを予定している。