diff --git a/android/debug/org.coolreader.crengine.L.java b/android/debug/org.coolreader.crengine.L.java new file mode 100644 index 000000000..ab773b219 --- /dev/null +++ b/android/debug/org.coolreader.crengine.L.java @@ -0,0 +1,75 @@ +package org.coolreader.crengine; + +public class L { + public static class LoggerImpl implements Logger { + public void i(String msg) { + System.out.println(msg); + } + public void i(String msg, Exception e) { + System.out.println(msg); + } + public void w(String msg) { + System.out.println(msg); + } + public void w(String msg, Exception e) { + System.out.println(msg); + } + public void e(String msg) { + System.out.println(msg); + } + public void e(String msg, Exception e) { + System.out.println(msg); + } + public void d(String msg) { + System.out.println(msg); + } + public void d(String msg, Exception e) { + System.out.println(msg); + } + public void v(String msg) { + System.out.println(msg); + } + public void v(String msg, Exception e) { + System.out.println(msg); + } + public void setLevel(int level) { + } + } + + public static void i(String msg) { + System.out.println(msg); + } + public static void i(String msg, Exception e) { + System.out.println(msg); + } + public static void w(String msg) { + System.out.println(msg); + } + public static void w(String msg, Exception e) { + System.out.println(msg); + } + public static void e(String msg) { + System.out.println(msg); + } + public static void e(String msg, Exception e) { + System.out.println(msg); + } + public static void d(String msg) { + System.out.println(msg); + } + public static void d(String msg, Exception e) { + System.out.println(msg); + } + public static void v(String msg) { + System.out.println(msg); + } + public static void v(String msg, Exception e) { + System.out.println(msg); + } + public static Logger create(String name) { + return new LoggerImpl(); + } + public static Logger create(String name, int level) { + return new LoggerImpl(); + } +} diff --git a/android/jni/docview.cpp b/android/jni/docview.cpp index e7fabd934..1e2edf630 100644 --- a/android/jni/docview.cpp +++ b/android/jni/docview.cpp @@ -1808,6 +1808,64 @@ JNIEXPORT jobject JNICALL Java_org_coolreader_crengine_DocView_getCurrentPageBoo return obj; } +/* + * Class: org_coolreader_crengine_DocView + * Method: getAllSentencesInternal + * Signature: ()Ljava/util/ArrayList; + */ +JNIEXPORT jobject JNICALL Java_org_coolreader_crengine_DocView_getAllSentencesInternal + (JNIEnv * _env, jobject _this) +{ + CRJNIEnv env(_env); + DocViewNative * p = getNative(_env, _this); + if (!p) { + CRLog::error("Cannot get native view"); + return NULL; + } + + jclass arrListClass = _env->FindClass("java/util/ArrayList"); + jmethodID arrListCtor = _env->GetMethodID(arrListClass, "", "()V"); + jmethodID arrListAdd = env->GetMethodID(arrListClass, "add", "(Ljava/lang/Object;)Z"); + + jclass sentenceInfoClass = _env->FindClass("org/coolreader/crengine/SentenceInfo"); + jmethodID sentenceInfoCtor = _env->GetMethodID(sentenceInfoClass, "", "()V"); + jmethodID sentenceInfoSetText = _env->GetMethodID(sentenceInfoClass, "setText", "(Ljava/lang/String;)V"); + jmethodID sentenceInfoSetStartPos = _env->GetMethodID(sentenceInfoClass, "setStartPos", "(Ljava/lang/String;)V"); + + jobject arrList = env->NewObject(arrListClass, arrListCtor); + + + p->_docview->savePosition(); + p->_docview->clearSelection(); + p->_docview->goToPage(0); + p->_docview->SetPos(0, false); + while(p->_docview->nextSentence()){ + jobject sentenceInfo = _env->NewObject(sentenceInfoClass, sentenceInfoCtor); + jint startX = 0; + jint startY = 0; + + ldomXRangeList & sel = p->_docview->getDocument()->getSelections(); + ldomXRange currSel; + if ( sel.length()>0 ){ + currSel = *sel[0]; + } + lvPoint startPoint = currSel.getStart().toPoint(); + lvPoint endPoint = currSel.getEnd().toPoint(); + + env->CallVoidMethod(sentenceInfo, sentenceInfoSetText, env->NewStringUTF( + UnicodeToUtf8(currSel.getRangeText()).c_str() + )); + env->CallVoidMethod(sentenceInfo, sentenceInfoSetStartPos, env->NewStringUTF( + UnicodeToUtf8(currSel.getStart().toString()).c_str() + )); + + env->CallBooleanMethod(arrList, arrListAdd, sentenceInfo); + } + p->_docview->restorePosition(); + + return arrList; +} + /* * Class: org_coolreader_crengine_DocView * Method: updateBookInfoInternal diff --git a/android/jni/org_coolreader_crengine_DocView.h b/android/jni/org_coolreader_crengine_DocView.h index be32e551e..33cebdeb2 100644 --- a/android/jni/org_coolreader_crengine_DocView.h +++ b/android/jni/org_coolreader_crengine_DocView.h @@ -117,6 +117,14 @@ JNIEXPORT jboolean JNICALL Java_org_coolreader_crengine_DocView_doCommandInterna JNIEXPORT jobject JNICALL Java_org_coolreader_crengine_DocView_getCurrentPageBookmarkInternal (JNIEnv *, jobject); +/* + * Class: org_coolreader_crengine_DocView + * Method: getAllSentencesInternal + * Signature: ()Ljava/util/ArrayList; + */ +JNIEXPORT jobject JNICALL Java_org_coolreader_crengine_DocView_getAllSentencesInternal + (JNIEnv * _env, jobject _this); + /* * Class: org_coolreader_crengine_DocView * Method: goToPositionInternal diff --git a/android/res/values/strings.xml b/android/res/values/strings.xml index 79a857117..e02ef4f39 100644 --- a/android/res/values/strings.xml +++ b/android/res/values/strings.xml @@ -553,6 +553,7 @@ Very high quality Use a workaround to disable processing of abbreviations at the end of a sentence when using "Google Speech Services" + Use audiobook instead of TTS, if *.wordtiming file exists TTS engine \"%s\" init failure, disabling it… Open book Open containing folder diff --git a/android/run-wordtiming-main.pl b/android/run-wordtiming-main.pl new file mode 100755 index 000000000..4104c0ae6 --- /dev/null +++ b/android/run-wordtiming-main.pl @@ -0,0 +1,46 @@ +#!/usr/bin/perl +use strict; +use warnings; + +my @classes = qw( + org/coolreader/crengine/WordTimingAudiobookMatcher.java + org/coolreader/crengine/SentenceInfo.java + org/coolreader/crengine/L.java + org/coolreader/crengine/Logger.java + org/coolreader/crengine/SentenceInfoCache.java +); + +sub main(@){ + system "rm", "-rf", "run-main-tmp/"; + system "mkdir", "run-main-tmp/"; + my @restoreCommands; + for my $debugClassFile(glob "debug/*"){ + if($debugClassFile =~ /^(?:.*\/)?([^\/]+)\.(\w+)\.java$/){ + my ($pkg, $class) = ($1, $2); + my $targetDir = $pkg; + $targetDir =~ s/\./\//g; + $targetDir = "src/$targetDir"; + system "cp", "$targetDir/$class.java", "run-main-tmp/$pkg\.$class.java"; + push @restoreCommands, ["mv", "run-main-tmp/$pkg\.$class.java", "$targetDir/$class.java"]; + system "cp", $debugClassFile, "$targetDir/$class.java"; + }else{ + die "ERROR: malformed debug class $debugClassFile\n"; + } + } + system "javac", map {"src/$_"} @classes; + if($? != 0){ + die "java failed\n"; + } + system "java", + "-cp", "src", + "org.coolreader.crengine.WordTimingAudiobookMatcher", + @_; + + for my $cmd(@restoreCommands){ + system @$cmd; + } + system "rm", "-rf", "run-main-tmp/"; + system "find", "src/", "-name", "*.class", "-delete"; +} + +&main(@ARGV); diff --git a/android/src/org/coolreader/crengine/BaseActivity.java b/android/src/org/coolreader/crengine/BaseActivity.java index bb6f3fac9..afb35b549 100644 --- a/android/src/org/coolreader/crengine/BaseActivity.java +++ b/android/src/org/coolreader/crengine/BaseActivity.java @@ -1898,6 +1898,8 @@ public Properties loadSettings(BaseActivity activity, File file) { // By default enable workaround to disable processing of abbreviations at the end of a sentence when using "Google Speech Services". props.applyDefault(ReaderView.PROP_APP_TTS_GOOGLE_END_OF_SENTENCE_ABBR, "1"); + props.applyDefault(ReaderView.PROP_APP_TTS_USE_AUDIOBOOK, "1"); + props.applyDefault(ReaderView.PROP_APP_THEME, DeviceInfo.FORCE_HC_THEME ? "HICONTRAST1" : "LIGHT"); props.applyDefault(ReaderView.PROP_APP_THEME_DAY, DeviceInfo.FORCE_HC_THEME ? "HICONTRAST1" : "LIGHT"); props.applyDefault(ReaderView.PROP_APP_THEME_NIGHT, DeviceInfo.FORCE_HC_THEME ? "HICONTRAST2" : "DARK"); diff --git a/android/src/org/coolreader/crengine/DocView.java b/android/src/org/coolreader/crengine/DocView.java index 93469041f..801770cf1 100644 --- a/android/src/org/coolreader/crengine/DocView.java +++ b/android/src/org/coolreader/crengine/DocView.java @@ -27,6 +27,8 @@ import java.io.IOException; import java.io.InputStream; +import java.util.List; + public class DocView { public static final Logger log = L.create("dv"); @@ -281,6 +283,14 @@ public Bookmark getCurrentPageBookmarkNoRender() { } } + public List getAllSentences() { + List sentences; + synchronized(mutex) { + sentences = getAllSentencesInternal(); + } + return sentences; + } + /** * Check whether document is formatted/rendered. * @return true if document is rendered, and e.g. retrieving of page image will not cause long activity (formatting etc.) @@ -470,6 +480,8 @@ private native boolean applySettingsInternal( private native Bookmark getCurrentPageBookmarkInternal(); + private native List getAllSentencesInternal(); + private native boolean goToPositionInternal(String xPath, boolean saveToHistory); private native PositionProperties getPositionPropsInternal(String xPath, boolean precise); diff --git a/android/src/org/coolreader/crengine/InitAudiobookWordTimingsCallback.java b/android/src/org/coolreader/crengine/InitAudiobookWordTimingsCallback.java new file mode 100644 index 000000000..ecb0e6883 --- /dev/null +++ b/android/src/org/coolreader/crengine/InitAudiobookWordTimingsCallback.java @@ -0,0 +1,5 @@ +package org.coolreader.crengine; + +public interface InitAudiobookWordTimingsCallback { + public void onComplete(); +} diff --git a/android/src/org/coolreader/crengine/OptionsDialog.java b/android/src/org/coolreader/crengine/OptionsDialog.java index f3d3b8552..7c65e226c 100644 --- a/android/src/org/coolreader/crengine/OptionsDialog.java +++ b/android/src/org/coolreader/crengine/OptionsDialog.java @@ -2655,6 +2655,12 @@ public void onTimedOut() { mOptionsTTS.add(mTTSVoiceOption); } mOptionsTTS.add(new BoolOption(this, getString(R.string.options_tts_google_abbr_workaround), PROP_APP_TTS_GOOGLE_END_OF_SENTENCE_ABBR).setComment(getString(R.string.options_tts_google_abbr_workaround_comment)).setDefaultValue("1").noIcon()); + mOptionsTTS.add( + new BoolOption( + this, + getString(R.string.options_tts_use_audiobook), + PROP_APP_TTS_USE_AUDIOBOOK + ).setDefaultValue("1").noIcon()); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ECLAIR) mOptionsTTS.add(new ListOption(this, getString(R.string.options_app_tts_stop_motion_timeout), PROP_APP_MOTION_TIMEOUT).add(mMotionTimeouts, mMotionTimeoutsTitles).setDefaultValue(Integer.toString(mMotionTimeouts[0])).noIcon()); mOptionsTTS.refresh(); diff --git a/android/src/org/coolreader/crengine/ReaderView.java b/android/src/org/coolreader/crengine/ReaderView.java index 52ec2a0ae..d4afb47e4 100644 --- a/android/src/org/coolreader/crengine/ReaderView.java +++ b/android/src/org/coolreader/crengine/ReaderView.java @@ -2420,6 +2420,7 @@ public void onCommand(final ReaderCommand cmd, final int param, final Runnable o ttsToolbar = TTSToolbarDlg.showDialog(mActivity, ReaderView.this, ttsacc); ttsToolbar.setOnCloseListener(() -> ttsToolbar = null); ttsToolbar.setAppSettings(mSettings, null); + ttsToolbar.initAudiobookWordTimings(null); })); } break; @@ -3253,6 +3254,10 @@ public BookInfo getBookInfo() { return mBookInfo; } + public List getAllSentences() { + return doc.getAllSentences(); + } + private int mBatteryState = BATTERY_STATE_DISCHARGING; private int mBatteryChargingConn = BATTERY_CHARGER_NO; private int mBatteryChargeLevel = 0; diff --git a/android/src/org/coolreader/crengine/SentenceInfo.java b/android/src/org/coolreader/crengine/SentenceInfo.java new file mode 100644 index 000000000..8f904b339 --- /dev/null +++ b/android/src/org/coolreader/crengine/SentenceInfo.java @@ -0,0 +1,25 @@ +package org.coolreader.crengine; + +import java.io.File; +import java.util.List; + +public class SentenceInfo { + public String text; + public String startPos; + + public double startTime; + public boolean isFirstSentenceInAudioFile = false; + public File audioFile; + public List words; + public SentenceInfo nextSentence; + + public SentenceInfo() { + } + + public void setStartPos(String startPos){ + this.startPos = startPos; + } + public void setText(String text){ + this.text = text; + } +} diff --git a/android/src/org/coolreader/crengine/SentenceInfoCache.java b/android/src/org/coolreader/crengine/SentenceInfoCache.java new file mode 100644 index 000000000..4533d3af3 --- /dev/null +++ b/android/src/org/coolreader/crengine/SentenceInfoCache.java @@ -0,0 +1,80 @@ +package org.coolreader.crengine; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class SentenceInfoCache { + public static final Logger log = L.create("sentenceinfocache"); + + private final File sentenceInfoCacheFile; + + public static List maybeReadCache(File sentenceInfoCacheFile) { + try{ + return new SentenceInfoCache(sentenceInfoCacheFile).readCache(); + }catch(Exception e){ + log.e("ERROR: could not read sentence info cache file: " + sentenceInfoCacheFile + " " + e); + return null; + } + } + public static void maybeWriteCache(File sentenceInfoCacheFile, List allSentences) { + try{ + new SentenceInfoCache(sentenceInfoCacheFile).writeCache(allSentences); + }catch(Exception e){ + log.e("ERROR: could not write sentence info cache file: " + sentenceInfoCacheFile + " " + e); + } + } + + + public SentenceInfoCache(File sentenceInfoCacheFile) { + this.sentenceInfoCacheFile = sentenceInfoCacheFile; + } + + public List readCache() throws IOException { + List allSentences = new ArrayList<>(); + try ( + BufferedReader br = new BufferedReader(new FileReader(sentenceInfoCacheFile)); + ) { + String line; + while ((line = br.readLine()) != null) { + SentenceInfo sentenceInfo = parseSentenceInfoLine(line); + if(sentenceInfo == null){ + log.e("ERROR: could not parse sentence info cache line: " + line); + return null; + } + allSentences.add(sentenceInfo); + } + } + if(allSentences.isEmpty()){ + return null; + } + return allSentences; + } + + public void writeCache(List allSentences) throws IOException { + FileWriter fw = new FileWriter(sentenceInfoCacheFile); + for(SentenceInfo s : allSentences){ + fw.write(formatSentenceInfo(s)); + } + fw.close(); + } + + private SentenceInfo parseSentenceInfoLine(String line){ + int sep = line.indexOf(','); + if(sep < 0 || sep >= line.length()){ + return null; + } + SentenceInfo sentenceInfo = new SentenceInfo(); + sentenceInfo.startPos = line.substring(0, sep); + sentenceInfo.text = line.substring(sep+1); + return sentenceInfo; + } + + private String formatSentenceInfo(SentenceInfo sentenceInfo){ + return sentenceInfo.startPos + "," + sentenceInfo.text + "\n"; + } +} diff --git a/android/src/org/coolreader/crengine/Settings.java b/android/src/org/coolreader/crengine/Settings.java index 32c1a2650..3e48f8716 100644 --- a/android/src/org/coolreader/crengine/Settings.java +++ b/android/src/org/coolreader/crengine/Settings.java @@ -220,6 +220,7 @@ Commented until the appearance of free implementation of the binding to the Goog String PROP_APP_TTS_FORCE_LANGUAGE = "app.tts.force.lang"; // Force use specified language String PROP_APP_TTS_VOICE = "app.tts.voice"; String PROP_APP_TTS_GOOGLE_END_OF_SENTENCE_ABBR = "app.tts.google.end-of-sentence-abbreviation.workaround"; // Use a workaround to disable processing of abbreviations at the end of a sentence when using "Google Speech Services" + String PROP_APP_TTS_USE_AUDIOBOOK = "app.tts.use.audiobook"; //if *.wordtiming file exists for ebook String PROP_APP_VIEW_ANIM_DURATION ="app.view.anim.duration"; diff --git a/android/src/org/coolreader/crengine/TTSToolbarDlg.java b/android/src/org/coolreader/crengine/TTSToolbarDlg.java index 2f22c5b9e..b853814b0 100644 --- a/android/src/org/coolreader/crengine/TTSToolbarDlg.java +++ b/android/src/org/coolreader/crengine/TTSToolbarDlg.java @@ -31,6 +31,8 @@ import android.os.Build; import android.os.Bundle; import android.os.HandlerThread; +import android.os.Handler; +import android.os.Looper; import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; @@ -53,6 +55,8 @@ import org.coolreader.tts.TTSControlService; import org.coolreader.tts.TTSControlServiceAccessor; +import java.io.File; +import java.util.List; import java.util.Locale; import java.util.Map; @@ -67,10 +71,18 @@ public class TTSToolbarDlg implements Settings { private final ReaderView mReaderView; private final TTSControlServiceAccessor mTTSControl; private final ImageButton mPlayPauseButton; + private final ImageButton backButton; + private final ImageButton forwardButton; + private final ImageButton stopButton; + private final ImageButton optionsButton; private final TextView mVolumeTextView; private final TextView mSpeedTextView; private final SeekBar mSbSpeed; private final SeekBar mSbVolume; + private final ImageButton btnDecVolume; + private final ImageButton btnIncVolume; + private final ImageButton btnDecSpeed; + private final ImageButton btnIncSpeed; private HandlerThread mMotionWatchdog; private boolean changedPageMode; private Runnable mOnCloseListener; @@ -88,8 +100,38 @@ public class TTSToolbarDlg implements Settings { private String mCurrentLanguage; private String mCurrentVoiceName; private boolean mGoogleTTSAbbreviationWorkaround; + private boolean allowUseAudiobook; private int mTTSSpeedPercent = 50; // 50% (normal) + private File wordTimingFile; + private File sentenceInfoCacheFile; + private WordTimingAudiobookMatcher wordTimingAudiobookMatcher; + private SentenceInfo currentSentenceInfo; + + private HandlerThread wordTimingCalcHandlerThread; + private Handler wordTimingCalcHandler; + + private Handler audioBookPosHandler = new Handler(Looper.getMainLooper()); + private Runnable audioBookPosRunnable = new Runnable() { + @Override + public void run() { + try{ + SentenceInfo currentSentence = fetchSelectedSentenceInfo(); + if(currentSentence != null){ + mTTSControl.bind(ttsbinder -> ttsbinder.isAudioBookPlaybackAfterSentence( + currentSentence, + isAfter -> { + if(isAfter){ + moveSelection(ReaderCommand.DCMD_SELECT_NEXT_SENTENCE, null); + } + } + )); + } + } finally { + audioBookPosHandler.postDelayed(this, 500); + } + } + }; static public TTSToolbarDlg showDialog( CoolReader coolReader, ReaderView readerView, TTSControlServiceAccessor ttsacc) { TTSToolbarDlg dlg = new TTSToolbarDlg(coolReader, readerView, ttsacc); @@ -146,6 +188,13 @@ private void restoreReaderMode() { } } + private SentenceInfo fetchSelectedSentenceInfo() { + if(wordTimingAudiobookMatcher != null && mCurrentSelection != null){ + return wordTimingAudiobookMatcher.getSentence(mCurrentSelection.startPos); + } + return null; + } + /** * Select next or previous sentence. ONLY the selection changes and the specified callback is called! * Not affected to speech synthesis process. @@ -158,8 +207,16 @@ private void moveSelection( ReaderCommand cmd, ReaderView.MoveSelectionCallback @Override public void onNewSelection(Selection selection) { - log.d("onNewSelection: " + selection.text); + log.d("onNewSelection: " + selection.text + " : " + selection.startY + " x " + selection.startX); mCurrentSelection = selection; + if(allowUseAudiobook){ + SentenceInfo sentenceInfo = fetchSelectedSentenceInfo(); + if(sentenceInfo != null && sentenceInfo.audioFile != null){ + mTTSControl.bind(ttsbinder -> { + ttsbinder.setAudioFile(sentenceInfo.audioFile, sentenceInfo.startTime); + }); + } + } if (null != callback) callback.onNewSelection(mCurrentSelection); } @@ -238,9 +295,15 @@ private float speechRateFromPercent(int percent) { public void setAppSettings(Properties newSettings, Properties oldSettings) { log.v("setAppSettings()"); BackgroundThread.ensureGUI(); - if (oldSettings == null) + boolean initialSetup; + if (oldSettings == null){ oldSettings = new Properties(); + initialSetup = true; + }else{ + initialSetup = false; + } int oldTTSSpeed = mTTSSpeedPercent; + boolean oldAllowUseAudiobook = this.allowUseAudiobook; Properties changedSettings = newSettings.diff(oldSettings); for (Map.Entry entry : changedSettings.entrySet()) { String key = (String) entry.getKey(); @@ -257,6 +320,41 @@ public void setAppSettings(Properties newSettings, Properties oldSettings) { }); }); } + boolean newAllowUseAudiobook = allowUseAudiobook; + if (!initialSetup && oldAllowUseAudiobook && !newAllowUseAudiobook){ + mTTSControl.bind(ttsbinder -> { + ttsbinder.stop(null); + ttsbinder.setAudioFile(null, 0); + initAudiobookWordTimings(new InitAudiobookWordTimingsCallback(){ + public void onComplete(){ + moveSelection(ReaderCommand.DCMD_SELECT_FIRST_SENTENCE, new ReaderView.MoveSelectionCallback() { + @Override + public void onNewSelection(Selection selection) { + if (isSpeaking) { + ttsbinder.say(preprocessUtterance(selection.text), null); + } else { + ttsbinder.setCurrentUtterance(preprocessUtterance(selection.text)); + } + } + + @Override + public void onFail() { + } + }); + } + }); + }); + }else if(!initialSetup && !oldAllowUseAudiobook && newAllowUseAudiobook){ + mTTSControl.bind(ttsbinder -> { + ttsbinder.stop(null); + SentenceInfo sentenceInfo = fetchSelectedSentenceInfo(); + if(sentenceInfo != null){ + ttsbinder.setAudioFile(sentenceInfo.audioFile, sentenceInfo.startTime); + } + initAudiobookWordTimings(null); + moveSelection(ReaderCommand.DCMD_SELECT_FIRST_SENTENCE, null); + }); + } } private void processAppSetting(String key, String value) { @@ -285,6 +383,10 @@ private void processAppSetting(String key, String value) { break; case PROP_APP_TTS_GOOGLE_END_OF_SENTENCE_ABBR: mGoogleTTSAbbreviationWorkaround = flg; + break; + case PROP_APP_TTS_USE_AUDIOBOOK: + allowUseAudiobook = flg; + break; } } @@ -440,10 +542,10 @@ public TTSToolbarDlg(CoolReader coolReader, ReaderView readerView, TTSControlSer mPlayPauseButton = panel.findViewById(R.id.tts_play_pause); mPlayPauseButton.setImageResource(Utils.resolveResourceIdByAttr(mCoolReader, R.attr.ic_media_play_drawable, R.drawable.ic_media_play)); - ImageButton backButton = panel.findViewById(R.id.tts_back); - ImageButton forwardButton = panel.findViewById(R.id.tts_forward); - ImageButton stopButton = panel.findViewById(R.id.tts_stop); - ImageButton optionsButton = panel.findViewById(R.id.tts_options); + backButton = panel.findViewById(R.id.tts_back); + forwardButton = panel.findViewById(R.id.tts_forward); + stopButton = panel.findViewById(R.id.tts_stop); + optionsButton = panel.findViewById(R.id.tts_options); mWindow = new PopupWindow( context ); mWindow.setBackgroundDrawable(new BitmapDrawable()); @@ -489,18 +591,18 @@ public void onStopTrackingTouch(SeekBar seekBar) { mCoolReader.setSetting(PROP_APP_TTS_SPEED, String.valueOf(mProgress), true); } }); - ImageButton btnDecVolume = panel.findViewById(R.id.btn_dec_volume); + btnDecVolume = panel.findViewById(R.id.btn_dec_volume); btnDecVolume.setOnTouchListener(new RepeatOnTouchListener(500, 150, view -> mSbVolume.setProgress(mSbVolume.getProgress() - 1))); - ImageButton btnIncVolume = panel.findViewById(R.id.btn_inc_volume); + btnIncVolume = panel.findViewById(R.id.btn_inc_volume); btnIncVolume.setOnTouchListener(new RepeatOnTouchListener(500, 150, view -> mSbVolume.setProgress(mSbVolume.getProgress() + 1))); - ImageButton btnDecSpeed = panel.findViewById(R.id.btn_dec_speed); + btnDecSpeed = panel.findViewById(R.id.btn_dec_speed); btnDecSpeed.setOnTouchListener(new RepeatOnTouchListener(500, 150, view -> { mSbSpeed.setProgress(mSbSpeed.getProgress() - 1); mCoolReader.setSetting(PROP_APP_TTS_SPEED, String.valueOf(mSbSpeed.getProgress()), true); })); - ImageButton btnIncSpeed = panel.findViewById(R.id.btn_inc_speed); + btnIncSpeed = panel.findViewById(R.id.btn_inc_speed); btnIncSpeed.setOnTouchListener(new RepeatOnTouchListener(500, 150, view -> { mSbSpeed.setProgress(mSbSpeed.getProgress() + 1); mCoolReader.setSetting(PROP_APP_TTS_SPEED, String.valueOf(mSbSpeed.getProgress()), true); @@ -596,6 +698,8 @@ public void onStopTrackingTouch(SeekBar seekBar) { // All tasks bellow after service start // Fetch book's metadata BookInfo bookInfo = mReaderView.getBookInfo(); + wordTimingFile = null; + sentenceInfoCacheFile = null; if (null != bookInfo) { FileInfo fileInfo = bookInfo.getFileInfo(); if (null != fileInfo) { @@ -605,6 +709,13 @@ public void onStopTrackingTouch(SeekBar seekBar) { mBookCover = Bitmap.createBitmap(MEDIA_COVER_WIDTH, MEDIA_COVER_HEIGHT, Bitmap.Config.RGB_565); Services.getCoverpageManager().drawCoverpageFor(mCoolReader.getDB(), fileInfo, mBookCover, true, (file, bitmap) -> mTTSControl.bind(ttsbinder -> ttsbinder.setMediaItemInfo(mBookAuthors, mBookTitle, bitmap))); + String pathName = fileInfo.getPathName(); + String wordTimingPath = pathName.replaceAll("\\.\\w+$", ".wordtiming"); + String sentenceInfoPath = pathName.replaceAll("\\.\\w+$", ".sentenceinfo"); + if(wordTimingPath.matches(".*\\.wordtiming$")){ + wordTimingFile = new File(wordTimingPath); + sentenceInfoCacheFile = new File(sentenceInfoPath); + } } } // Show volume @@ -633,4 +744,60 @@ public void onStopTrackingTouch(SeekBar seekBar) { // And finally, setup status change handler setupSpeechStatusHandler(); } + + public void initAudiobookWordTimings(InitAudiobookWordTimingsCallback callback){ + audioBookPosHandler.removeCallbacks(audioBookPosRunnable); + + if(allowUseAudiobook && wordTimingFile != null && wordTimingFile.exists()){ + if(wordTimingCalcHandler == null){ + if(wordTimingCalcHandlerThread == null){ + wordTimingCalcHandlerThread = new HandlerThread("word-timing-calc-handler"); + wordTimingCalcHandlerThread.start(); + } + Looper wordTimingCalcLooper = wordTimingCalcHandlerThread.getLooper(); + wordTimingCalcHandler = new Handler(wordTimingCalcLooper); + } + + mPlayPauseButton.setVisibility(View.GONE); + backButton.setVisibility(View.GONE); + forwardButton.setVisibility(View.GONE); + stopButton.setVisibility(View.GONE); + optionsButton.setVisibility(View.GONE); + + wordTimingCalcHandler.removeCallbacksAndMessages(null); + mCoolReader.showToast("matching audiobook word timings"); + wordTimingCalcHandler.post( + new Runnable() { + public void run() { + List allSentences = SentenceInfoCache.maybeReadCache(sentenceInfoCacheFile); + if(allSentences == null){ + allSentences = mReaderView.getAllSentences(); + SentenceInfoCache.maybeWriteCache(sentenceInfoCacheFile, allSentences); + } + wordTimingAudiobookMatcher = new WordTimingAudiobookMatcher(wordTimingFile, allSentences); + + //can be very long + wordTimingAudiobookMatcher.parseWordTimingsFile(); + + moveSelection(ReaderCommand.DCMD_SELECT_FIRST_SENTENCE, null); + audioBookPosHandler.postDelayed(audioBookPosRunnable, 500); + + BackgroundThread.instance().postGUI(() -> { + mPlayPauseButton.setVisibility(View.VISIBLE); + backButton.setVisibility(View.VISIBLE); + forwardButton.setVisibility(View.VISIBLE); + stopButton.setVisibility(View.VISIBLE); + optionsButton.setVisibility(View.VISIBLE); + }); + + if(callback != null){ + callback.onComplete(); + } + } + } + ); + }else{ + wordTimingAudiobookMatcher = null; + } + } } diff --git a/android/src/org/coolreader/crengine/WordTimingAudiobookMatcher.java b/android/src/org/coolreader/crengine/WordTimingAudiobookMatcher.java new file mode 100644 index 000000000..148547d9b --- /dev/null +++ b/android/src/org/coolreader/crengine/WordTimingAudiobookMatcher.java @@ -0,0 +1,254 @@ +package org.coolreader.crengine; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class WordTimingAudiobookMatcher { + public static final Logger log = L.create("wordtiming"); + + private static class WordTiming { + String word; + Double startTime; + File audioFile; + + public WordTiming(String word, Double startTime, File audioFile){ + this.word = word; + this.startTime = startTime; + this.audioFile = audioFile; + } + } + + private final File wordTimingsFile; + private final List allSentences; + private final Map sentencesByStartPos = new HashMap<>(); + private final Map fileCache = new HashMap<>(); + private String wordTimingsDir; + private List wordTimings; + + public WordTimingAudiobookMatcher(File wordTimingsFile, List allSentences) { + this.wordTimingsFile = wordTimingsFile; + this.allSentences = allSentences; + for(SentenceInfo s : allSentences){ + sentencesByStartPos.put(s.startPos, s); + } + } + + public void parseWordTimingsFile(){ + this.wordTimingsDir = wordTimingsFile.getAbsoluteFile().getParent(); + + try { + BufferedReader br = new BufferedReader(new FileReader(wordTimingsFile)); + String line; + wordTimings = new ArrayList<>(); + while ((line = br.readLine()) != null) { + WordTiming wordTiming = parseWordTimingsLine(line); + if(wordTiming == null){ + log.d("ERROR: could not parse word timings line: " + line); + }else{ + wordTimings.add(wordTiming); + } + } + br.close(); + } catch(Exception e) { + log.d("ERROR: could not read word timings file: " + wordTimingsFile + " " + e); + wordTimings = new ArrayList<>(); + } + + for(int i=0; i 20){ + break; + }else{ + wordWtIndex++; + } + } + if(wordFound){ + if(firstWordTiming == null){ + firstWordTiming = wordTimings.get(wordWtIndex); + } + sentenceWtIndex = wordWtIndex + 1; + }else{ + matchFailed = true; + break; + } + } + if(matchFailed){ + s.startTime = prevStartTime; + s.audioFile = prevAudioFile; + }else{ + wtIndex = sentenceWtIndex; + s.startTime = firstWordTiming.startTime; + s.audioFile = firstWordTiming.audioFile; + prevStartTime = s.startTime; + prevAudioFile = s.audioFile; + } + } + + //start first sentence of all audio files at 0.0 + // prevents skipping intros + File curAudioFile = null; + for(SentenceInfo s : allSentences){ + if(curAudioFile == null || s.audioFile != curAudioFile){ + s.isFirstSentenceInAudioFile = true; + s.startTime = 0; + curAudioFile = s.audioFile; + } + } + } + + public SentenceInfo getSentence(String startPos){ + return sentencesByStartPos.get(startPos); + } + + private WordTiming parseWordTimingsLine(String line){ + int sep1 = line.indexOf(','); + int sep2 = line.indexOf(',', sep1+1); + if(sep1 < 0 || sep2 < 0 || sep1 >= line.length() || sep2 >= line.length()){ + return null; + } + String word = line.substring(sep1+1, sep2); + Double startTime = Double.parseDouble(line.substring(0, sep1)); + String audioFileName = line.substring(sep2+1); + if(!fileCache.containsKey(audioFileName)){ + fileCache.put(audioFileName, new File(wordTimingsDir + "/" + audioFileName)); + } + File audioFile = fileCache.get(audioFileName); + return new WordTiming(word, startTime, audioFile); + } + + private boolean wordsMatch(String word1, String word2){ + if(word1 == null && word2 == null) { + return true; + } else if(word1 == null || word2 == null) { + return false; + } else if(word1.equals(word2)) { + return true; + } else { + //expensive calculation, but relatively rarely performed + String word1Letters = ""; + String word2Letters = ""; + String word1Digits = ""; + String word2Digits = ""; + for(int i=0; i 0 && word2Letters.length() > 0) { + //if there is at least one letter in each word: compare only letters + return word1Letters.equals(word2Letters); + }else if(word1Digits.length() > 0 && word2Digits.length() > 0) { + //if there is at least one number in each word: compare only numbers + return word1Digits.equals(word2Digits); + }else{ + return word1.equals(word2); + } + } + } + + private List splitSentenceIntoWords(String sentence){ + List words = new ArrayList(); + + StringBuilder str = null; + boolean wordContainsLetterOrNumber = false; + for(int i=0; i sentences = SentenceInfoCache.maybeReadCache(new File(args[0])); + new WordTimingAudiobookMatcher(new File(args[1]), sentences).parseWordTimingsFile(); + } +} diff --git a/android/src/org/coolreader/tts/TTSControlBinder.java b/android/src/org/coolreader/tts/TTSControlBinder.java index ba9c8e991..5ce0a531a 100644 --- a/android/src/org/coolreader/tts/TTSControlBinder.java +++ b/android/src/org/coolreader/tts/TTSControlBinder.java @@ -24,6 +24,9 @@ import android.os.Handler; import android.speech.tts.Voice; +import org.coolreader.crengine.SentenceInfo; + +import java.io.File; import java.util.Locale; public class TTSControlBinder extends Binder { @@ -106,6 +109,10 @@ public void setSpeechRate(float rate, TTSControlService.BooleanResultCallback ca mService.setSpeechRate(rate, callback, new Handler()); } + public void isAudioBookPlaybackAfterSentence(SentenceInfo sentence, TTSControlService.BooleanResultCallback callback) { + mService.isAudioBookPlaybackAfterSentence(sentence, callback, new Handler()); + } + public void retrieveVolume(TTSControlService.VolumeResultCallback callback) { mService.retrieveVolume(callback, new Handler()); } @@ -122,4 +129,8 @@ public void setStatusListener(OnTTSStatusListener listener) { mService.setStatusListener(listener); } + public void setAudioFile(File audioFile, double startTime) { + mService.setAudioFile(audioFile, startTime); + } + } diff --git a/android/src/org/coolreader/tts/TTSControlService.java b/android/src/org/coolreader/tts/TTSControlService.java index e76be3fc1..af2d4124e 100644 --- a/android/src/org/coolreader/tts/TTSControlService.java +++ b/android/src/org/coolreader/tts/TTSControlService.java @@ -40,6 +40,7 @@ import android.media.MediaPlayer; import android.media.session.MediaSession; import android.media.session.PlaybackState; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; @@ -52,9 +53,11 @@ import org.coolreader.R; import org.coolreader.crengine.L; import org.coolreader.crengine.Logger; +import org.coolreader.crengine.SentenceInfo; import org.coolreader.db.BaseService; import org.coolreader.db.Task; +import java.io.File; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -95,6 +98,10 @@ public enum State { public static final String TTS_CONTROL_ACTION_PREV = "org.coolreader.tts.tts_prev"; public static final String TTS_CONTROL_ACTION_STOP = "org.coolreader.tts.tts_stop"; + private boolean useAudioBook = false; + private File audioFile = null; + private double startTime = 0; + private boolean mChannelCreated = false; private final TTSControlBinder mBinder = new TTSControlBinder(this); private NotificationManager mNotificationManager = null; @@ -150,6 +157,15 @@ public void onReceive(Context context, Intent intent) { } break; case TTSControlService.TTS_CONTROL_ACTION_NEXT: + if(useAudioBook){ + if (null != mStatusListener){ + if(mMediaPlayer != null){ + mMediaPlayer.stop(); + } + mStatusListener.onNextSentenceRequested(mBinder); + } + break; + } if (State.PLAYING == mState) { stopUtterance_impl(() -> { if (null != mStatusListener) @@ -161,6 +177,15 @@ public void onReceive(Context context, Intent intent) { } break; case TTSControlService.TTS_CONTROL_ACTION_PREV: + if(useAudioBook){ + if (null != mStatusListener){ + if(mMediaPlayer != null){ + mMediaPlayer.stop(); + } + mStatusListener.onPreviousSentenceRequested(mBinder); + } + break; + } if (State.PLAYING == mState) { stopUtterance_impl(() -> { if (null != mStatusListener) @@ -317,6 +342,10 @@ public boolean onMediaButtonEvent (Intent mediaButtonIntent) { @Override public void onPlay() { + if(useAudioBook){ + ensureAudioBookPlaying(); + return; + } if (null == mCurrentUtterance) { if (null != mStatusListener) mStatusListener.onCurrentSentenceRequested(mBinder); @@ -390,6 +419,10 @@ public void onPlay() { @Override public void onPause() { + if(useAudioBook){ + pauseAudioBook(); + return; + } stopUtterance_impl(null); synchronized (mLocker) { mState = State.PAUSED; @@ -585,6 +618,10 @@ public interface RetrieveStateCallback { void onResult(State state); } + public interface IntResultCallback { + void onResult(int result); + } + public interface BooleanResultCallback { void onResult(boolean result); } @@ -780,6 +817,9 @@ private boolean stopUtterance_impl(Runnable callback) { } private void playWrapper_api_less_than_21() { + if(useAudioBook){ + return; + } if (null != mCurrentUtterance) { if (!mPlaybackNowAuthorized) requestAudioFocusWrapper(); @@ -816,6 +856,9 @@ private void playWrapper_api_less_than_21() { } private void pauseWrapper_api_less_than_21() { + if(useAudioBook){ + return; + } stopUtterance_impl(null); synchronized (mLocker) { mState = State.PAUSED; @@ -1254,6 +1297,38 @@ public void work() { }); } + public void isAudioBookPlaybackAfterSentence( + SentenceInfo sentenceInfo, BooleanResultCallback callback, Handler handler + ) { + execTask(new Task("isAudioBookPlaybackAfterSentence") { + @Override + public void work() { + boolean isAfterSentence = false; + if(sentenceInfo != null && sentenceInfo.nextSentence != null){ + SentenceInfo nextSentenceInfo = sentenceInfo.nextSentence; + if(sentenceInfo.audioFile == TTSControlService.this.audioFile){ + if(nextSentenceInfo.isFirstSentenceInAudioFile){ + if(mState == State.PLAYING && (mMediaPlayer == null || !mMediaPlayer.isPlaying())){ + //this is the last sentence in the file, and the media player ended + isAfterSentence = true; + } + }else{ + if(mMediaPlayer != null && mMediaPlayer.isPlaying()){ + double curPos = mMediaPlayer.getCurrentPosition() / 1000.0; + if(curPos >= nextSentenceInfo.startTime){ + isAfterSentence = true; + } + } + } + } + } + + final boolean result = isAfterSentence; + sendTask(handler, () -> callback.onResult(result)); + } + }); + } + public void retrieveVolume(VolumeResultCallback callback, Handler handler) { execTask(new Task("retrieveVolume") { @Override @@ -1287,6 +1362,68 @@ public void setStatusListener(OnTTSStatusListener listener) { } } + + public void setAudioFile(File audioFile, double startTime) { + if(this.audioFile != null && !this.audioFile.equals(audioFile)){ + if(mMediaPlayer != null){ + mMediaPlayer.stop(); + } + } + + this.useAudioBook = audioFile == null ? false : true; + this.audioFile = audioFile; + this.startTime = startTime; + + if(mState == State.PLAYING){ + ensureAudioBookPlaying(); + } + } + + public void ensureAudioBookPlaying() { + if(mMediaPlayer != null && mMediaPlayer.isPlaying()){ + return; + } + File fileToPlay = audioFile; + if(fileToPlay != null && !fileToPlay.exists()){ + fileToPlay = getAlternativeAudioFile(fileToPlay); + } + + if(fileToPlay == null || !fileToPlay.exists()){ + return; + } + + try{ + if(mMediaPlayer != null){ + mMediaPlayer.stop(); + mMediaPlayer.release(); + mMediaPlayer = null; + } + mMediaPlayer = MediaPlayer.create( + getApplicationContext(), Uri.parse("file://" + fileToPlay.toString())); + }catch(Exception e){ + log.d("ERROR: " + e.getMessage()); + } + + int millis = (int) (startTime*1000.0 + 0.5); + mMediaPlayer.seekTo(millis); + + mMediaPlayer.start(); + mMediaSession.setPlaybackState( + mPlaybackStateBuilder.setState(PlaybackState.STATE_PLAYING, + PlaybackState.PLAYBACK_POSITION_UNKNOWN, 0).build()); + mState = State.PLAYING; + mStatusListener.onStateChanged(mState); + } + + public void pauseAudioBook() { + mMediaPlayer.pause(); + mMediaSession.setPlaybackState( + mPlaybackStateBuilder.setState(PlaybackState.STATE_PAUSED, + PlaybackState.PLAYBACK_POSITION_UNKNOWN, 0).build()); + mState = State.PAUSED; + mStatusListener.onStateChanged(mState); + } + // ====================================== // private implementation @@ -1395,7 +1532,42 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) return notification; } + private File getAlternativeAudioFile(File origAudioFile) { + if(origAudioFile == null) { + return null; + } + String fileNoExt = origAudioFile.toString().replaceAll("\\.\\w+$", ""); + File dir = origAudioFile.getParentFile(); + if(dir.exists() && dir.isDirectory()) { + Map> filesByExt = new HashMap<>(); + File firstFile = null; + for(File file : dir.listFiles()) { + if(!file.toString().startsWith(fileNoExt + ".")){ + continue; + } + String ext = file.toString().toLowerCase().replaceAll(".*\\.", ""); + if(filesByExt.get(ext) == null) { + filesByExt.put(ext, new ArrayList<>()); + } + filesByExt.get(ext).add(file); + if(firstFile == null) { + firstFile = file; + } + } + for(String ext : new String[]{"flac", "wav", "m4a", "ogg", "mp3"}) { + if(filesByExt.get(ext) != null){ + return filesByExt.get(ext).get(0); + } + } + return firstFile; + } + return null; + } + private void setupTTSHandlers() { + if(useAudioBook){ + return; + } if (null != mTTS) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) { mTTS.setOnUtteranceCompletedListener(utteranceId -> { diff --git a/cr3qt/src/cr3widget.cpp b/cr3qt/src/cr3widget.cpp index d2e36b6d4..5416485e9 100644 --- a/cr3qt/src/cr3widget.cpp +++ b/cr3qt/src/cr3widget.cpp @@ -537,6 +537,14 @@ bool CR3View::loadDocument( QString fileName ) return res; } +bool CR3View::exportSentenceInfo( QString inputFileName, QString outputFileName ) +{ + return _docview->exportSentenceInfo( + qt2cr(inputFileName).c_str(), + qt2cr(outputFileName).c_str() + ); +} + void CR3View::wheelEvent( QWheelEvent * event ) { // Get degrees delta from vertical scrolling diff --git a/cr3qt/src/cr3widget.h b/cr3qt/src/cr3widget.h index 679bd4912..ae7605fd2 100644 --- a/cr3qt/src/cr3widget.h +++ b/cr3qt/src/cr3widget.h @@ -63,6 +63,7 @@ class CR3View : public QWidget, public LVDocViewCallback bool loadDocument( QString fileName ); bool loadLastDocument(); + bool exportSentenceInfo( QString inputFileName, QString outputFileName ); void setDocumentText( QString text ); QScrollBar * scrollBar() const; diff --git a/cr3qt/src/main.cpp b/cr3qt/src/main.cpp index 1410ece93..622f3246c 100644 --- a/cr3qt/src/main.cpp +++ b/cr3qt/src/main.cpp @@ -29,6 +29,7 @@ #else #include #endif +#include #include "../crengine/include/crengine.h" #include "../crengine/include/cr3version.h" #include "mainwindow.h" @@ -77,6 +78,18 @@ static void printHelp() { " -v or --version: print program version\n" " --loglevel=ERROR|WARN|INFO|DEBUG|TRACE: set logging level\n" " --logfile=|stdout|stderr: set log file\n" + "\n" + " --get-sentence-info|-s INPUT_FILE_NAME OUTPUT_FILE_NAME\n" + " analyze INPUT_FILE_NAME and write sentence structure info to OUTPUT_FILE_NAME\n" + " -one sentence per line, formatted: START_POS,TEXT\n" + " -every word appears in exactly one sentence\n" + " -not every character appears; all newlines are omitted, and some whitespace\n" + " -START_POS is a UTF8-encoded string representing a unique position in the DOM of the first word\n" + " -START_POS never contains a comma\n" + " -e.g.: /body/DocFragment[3]/body/div/div[4]/p/a/text()[1].3\n" + " -TEXT is the full UTF8-encoded text of the sentence, without quotes or escaping\n" + " -TEXT never contains newline characters\n" + " -TEXT can contain commas, double quotes, and single quotes\n" ); } @@ -95,6 +108,9 @@ int main(int argc, char *argv[]) lString8 loglevel("ERROR"); lString8 logfile("stderr"); #endif + bool exportSentenceInfo = false; + QString exportSentenceInfoInputFileName; + QString exportSentenceInfoOutputFileName; for ( int i=1; iquit(); + }); + }else{ + w.show(); + } res = a.exec(); } } diff --git a/cr3qt/src/mainwindow.cpp b/cr3qt/src/mainwindow.cpp index 8cb2ade1a..2eee7fc33 100644 --- a/cr3qt/src/mainwindow.cpp +++ b/cr3qt/src/mainwindow.cpp @@ -504,6 +504,25 @@ void MainWindow::showEvent ( QShowEvent * event ) } } +void MainWindow::exportSentenceInfo(QString inputFileName, QString outputFileName) { + if (inputFileName.length() <= 0 ) { + CRLog::error("ERROR: no file to export sentenceinfo\n"); + } + + bool res = ui->view->exportSentenceInfo(inputFileName, outputFileName); + if ( res ) { + CRLog::info( + "\n\n\nSUCCESS: exported " + + inputFileName.toUtf8() + + " to " + + outputFileName.toUtf8() + + "\n\n\n" + ); + } else { + CRLog::error("\n\n\nERROR: export sentence info failed\n\n\n"); + } +} + static bool firstFocus = true; void MainWindow::focusInEvent ( QFocusEvent * event ) diff --git a/cr3qt/src/mainwindow.h b/cr3qt/src/mainwindow.h index b001acbbc..566dc106b 100644 --- a/cr3qt/src/mainwindow.h +++ b/cr3qt/src/mainwindow.h @@ -53,6 +53,7 @@ class MainWindow : public QMainWindow, public PropsChangeCallback virtual void focusInEvent ( QFocusEvent * event ); virtual void closeEvent ( QCloseEvent * event ); public slots: + void exportSentenceInfo(QString inputFileName, QString outputFileName); void contextMenu( QPoint pos ); void on_actionFindText_triggered(); private slots: diff --git a/crengine/include/lvdocview.h b/crengine/include/lvdocview.h index 5e8a73417..d4b12ebd4 100644 --- a/crengine/include/lvdocview.h +++ b/crengine/include/lvdocview.h @@ -579,6 +579,8 @@ class LVDocView : public CacheLoadingCallback virtual void clearSelection(); /// update selection -- command handler int onSelectionCommand( int cmd, int param ); + /// select the next sentence, for iterating through all + bool nextSentence(); /// navigation history @@ -953,6 +955,9 @@ class LVDocView : public CacheLoadingCallback /// load document from stream bool LoadDocument( LVStreamRef stream, const lChar32 * contentPath, bool metadataOnly = false ); + /// load document and export sentence info + bool exportSentenceInfo(const lChar32 * inputFileName, const lChar32 * outputFileName); + /// save last file position void savePosition(); /// restore last file position diff --git a/crengine/src/crskin.cpp b/crengine/src/crskin.cpp index 64c336d4b..6f687c2c9 100644 --- a/crengine/src/crskin.cpp +++ b/crengine/src/crskin.cpp @@ -955,8 +955,8 @@ static void wrapLine( lString32Collection & dst, lString32 stringToSplit, int ma if ( ch!=' ' && ch!=0 ) q = 1; else - q = (prevChar=='.' || prevChar==',' || prevChar==';' - || prevChar=='!' || prevChar=='?' + q = (prevChar=='.' || prevChar==',' || prevChar==':' + || prevChar==';' || prevChar=='!' || prevChar=='?' ) ? (wwquality && wgetRootNode(), m_doc->getRootNode()->getChildCount()); + if ( !ptrStart.thisSentenceStart() ) { + ptrStart.nextSentenceStart(); + + if ( !ptrStart.thisSentenceStart() ) { + return false; + } + } + + while ( 1 ) { + ldomXPointerEx ptrEnd(ptrStart); + ptrEnd.thisSentenceEnd(); + + //include last sentence even if it does not appear very sentence-like + if(ptrStart == ptrEnd){ + ptrEnd.setOffset(ptrEnd.getNode()->getText().length()); + } + + ldomXRange range(ptrStart, ptrEnd); + lString32 sentenceText = range.getRangeText(); + *out << UnicodeToUtf8(ptrStart.toString()) << "," << UnicodeToUtf8(sentenceText) << "\n"; + if ( !ptrStart.nextSentenceStart() ) { + break; + } + } + return true; +} + /// load document from file bool LVDocView::LoadDocument(const lChar32 * fname, bool metadataOnly) { if (!fname || !fname[0]) @@ -5410,7 +5448,7 @@ bool LVDocView::getBookmarkPosText(ldomXPointer bm, lString32 & titleText, lString32 txt = getSectionHeader(el); lChar32 lastch = !txt.empty() ? txt[txt.length() - 1] : 0; if (!titleText.empty()) { - if (lastch != '.' && lastch != '?' && lastch != '!') + if (lastch != '.' && lastch != '?' && lastch != '!' && lastch != ':' && lastch != ';') txt += "."; txt += " "; } @@ -6415,6 +6453,42 @@ int LVDocView::onSelectionCommand( int cmd, int param ) return 1; } +bool LVDocView::nextSentence(){ + LVRef pageRange = getPageDocumentRange(); + if (pageRange.isNull()) { + clearSelection(); + return false; + } + ldomXPointerEx pos( getBookmark() ); + ldomXRangeList & sel = getDocument()->getSelections(); + ldomXRange currSel; + if ( sel.length()>0 ){ + currSel = *sel[0]; + } + + if ( currSel.isNull() || currSel.getStart().isNull() ) { + // select first sentence on page + if ( pos.thisSentenceStart() ) { + currSel.setStart(pos); + } + } else if ( !currSel.getStart().isSentenceStart() ) { + currSel.getStart().thisSentenceStart(); + } else { + bool movedToNext = currSel.getStart().nextSentenceStart(); + if(!movedToNext){ + //presumably already on the last sentence + return false; + } + } + + currSel.setEnd(currSel.getStart()); + currSel.getEnd().thisSentenceEnd(); + + currSel.setFlags(1); + selectRange(currSel); + return true; +} + //static int cr_font_sizes[] = { 24, 29, 33, 39, 44 }; static int cr_interline_spaces[] = { 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, diff --git a/crengine/src/lvfont/lvwin32font.cpp b/crengine/src/lvfont/lvwin32font.cpp index cb6f60ad7..925b9f6b2 100644 --- a/crengine/src/lvfont/lvwin32font.cpp +++ b/crengine/src/lvfont/lvwin32font.cpp @@ -293,7 +293,7 @@ lUInt16 LVWin32DrawFont::measureText( break; if (flags[hwEnd-1]&LCHAR_ALLOW_WRAP_AFTER) break; - if (ch=='.' || ch==',' || ch=='!' || ch=='?' || ch=='?' || ch==':' || ch==';') + if (ch=='.' || ch==',' || ch=='!' || ch=='?' || ch==':' || ch==';') break; } @@ -630,7 +630,7 @@ lUInt16 LVWin32Font::measureText( break; if (flags[hwEnd-1]&LCHAR_ALLOW_WRAP_AFTER) break; - if (ch=='.' || ch==',' || ch=='!' || ch=='?' || ch=='?') + if (ch=='.' || ch==',' || ch=='!' || ch=='?' || ch==':' || ch==';') break; } diff --git a/crengine/src/lvtinydom.cpp b/crengine/src/lvtinydom.cpp index 3094645f3..ee0ab410c 100644 --- a/crengine/src/lvtinydom.cpp +++ b/crengine/src/lvtinydom.cpp @@ -12561,95 +12561,117 @@ bool ldomXPointerEx::isFirstVisibleTextInBlock() } // sentence navigation - -/// returns true if points to beginning of sentence -bool ldomXPointerEx::isSentenceStart() -{ - if ( isNull() ) - return false; - if ( !isText() || !isVisible() ) - return false; - ldomNode * node = getNode(); - lString32 text = node->getText(); - int textLen = text.length(); - int i = _data->getOffset(); - lChar32 currCh = i0 ? text[i-1] : 0; - lChar32 prevPrevNonSpace = 0; - lChar32 prevNonSpace = 0; - int prevNonSpace_i = -1; - for ( ;i>0; i-- ) { - lChar32 ch = text[i-1]; - if ( !IsUnicodeSpace(ch) ) { - prevNonSpace = ch; - prevNonSpace_i = i - 1; - break; - } - } - if (prevNonSpace) { - for (i = prevNonSpace_i; i>0; i-- ) { - lChar32 ch = text[i-1]; - if ( !IsUnicodeSpace(ch) ) { - prevPrevNonSpace = ch; - break; - } - } +lChar32 getChar(lString32 text, int idx) { + if (0 <= idx && idx < text.length()) { + return text[idx]; + } else { + return 0; } - if ( !prevNonSpace ) { - ldomXPointerEx pos(*this); - while ( !prevNonSpace && pos.prevVisibleText(true) ) { - lString32 prevText = pos.getText(); - for ( int j=prevText.length()-1; j>=0; j-- ) { - lChar32 ch = prevText[j]; - if ( !IsUnicodeSpace(ch) ) { - prevNonSpace = ch; - for (int k = j; k > 0; k--) { - ch = prevText[k-1]; - if (!IsUnicodeSpace(ch)) { - prevPrevNonSpace = ch; - break; - } - } - break; - } +} + +lChar32 getPrevNonSpaceChar(lString32 text, int idx, int skipCount) { + for(int i=idx-1; i>=0; i--){ + lChar32 ch = getChar(text, i); + if(!IsUnicodeSpace(ch)){ + if(skipCount > 0){ + skipCount--; + }else{ + return ch; } } } + return 0; +} - // skip separated separator. - if (1 == textLen) { - switch (currCh) { - case '.': - case '?': - case '!': - case U'\x2026': // horizontal ellipsis - return false; +lChar32 getNextNonSpaceChar(lString32 text, int idx, int skipCount) { + int len = text.length(); + for(int i=idx+1; i 0){ + skipCount--; + }else{ + return ch; + } } } + return 0; +} - if ( !IsUnicodeSpace(currCh) && IsUnicodeSpaceOrNull(prevCh) ) { - switch (prevNonSpace) { - case 0: +bool isCharSentenceEndMark(lChar32 ch) { + switch (ch) { case '.': case '?': case '!': + case ';': case U'\x2026': // horizontal ellipsis return true; + default: + return false; + } +} + +bool isCharDoubleQuoteEnd(lChar32 ch) { + switch (ch) { case '"': // QUOTATION MARK case U'\x201d': // RIGHT DOUBLE QUOTATION MARK - switch (prevPrevNonSpace) { - case '.': - case '?': - case '!': - case U'\x2026': // horizontal ellipsis - return true; - } - break; + return true; default: return false; + } +} + +/// returns true if points to beginning of sentence +bool ldomXPointerEx::isSentenceStart() +{ + if ( isNull() ) + return false; + if ( !isText() || !isVisible() ) + return false; + + ldomNode * node = getNode(); + lString32 text = node->getText(); + int textLen = text.length(); + int i = _data->getOffset(); + + lChar32 currCh = getChar(text, i); + lChar32 prevCh = getChar(text, i-1); + lChar32 prevNonSpaceCh = getPrevNonSpaceChar(text, i, 0); + lChar32 prevPrevNonSpaceCh = getPrevNonSpaceChar(text, i, 1); + + // look at previous text nodes if there is no previous non-space char in current + if ( !prevNonSpaceCh ) { + ldomXPointerEx pos(*this); + while(prevNonSpaceCh == 0 && pos.prevVisibleText(true)){ + // get last and second-to-last non-space chars from previous text + // if that node has no non-space chars, use the node before that one + lString32 prevText = pos.getText(); + prevNonSpaceCh = getPrevNonSpaceChar(prevText, prevText.length(), 0); + prevPrevNonSpaceCh = getPrevNonSpaceChar(prevText, prevText.length(), 1); } } - return false; + + if (isCharSentenceEndMark(currCh)){ + // current character is end punctuation (never sentence start) + return false; + }else if(IsUnicodeSpace(currCh)){ + // current character is a space (never sentence start) + return false; + }else if(prevCh != 0 && !IsUnicodeSpace(prevCh)){ + // previous char exists and is not a space (never sentence start) + return false; + }else if(prevNonSpaceCh == 0){ + // there is no previous non-space character + return true; + }else if(isCharSentenceEndMark(prevNonSpaceCh)){ + // previous non-space char is end punctuation + return true; + }else if(isCharDoubleQuoteEnd(prevNonSpaceCh) && isCharSentenceEndMark(prevPrevNonSpaceCh)){ + // previous two non-space chars are end-mark followed by dbl-quote + return true; + }else{ + return false; + } } /// returns true if points to end of sentence @@ -12659,40 +12681,42 @@ bool ldomXPointerEx::isSentenceEnd() return false; if ( !isText() || !isVisible() ) return false; + ldomNode * node = getNode(); lString32 text = node->getText(); int textLen = text.length(); int i = _data->getOffset(); - lChar32 currCh = i0 ? text[i-1] : 0; - lChar32 prevPrevCh = i>1 ? text[i-2] : 0; - if ( IsUnicodeSpaceOrNull(currCh) ) { - switch (prevCh) { - case 0: - case '.': - case '?': - case '!': - case U'\x2026': // horizontal ellipsis - return true; - case '"': - case U'\x201d': // RIGHT DOUBLE QUOTATION MARK - switch (prevPrevCh) { - case '.': - case '?': - case '!': - case U'\x2026': // horizontal ellipsis - return true; - } - break; - default: - break; - } + + lChar32 currCh = getChar(text, i); + lChar32 prevCh = getChar(text, i-1); + lChar32 prevPrevCh = getChar(text, i-2); + lChar32 nextNonSpaceCh = getNextNonSpaceChar(text, i, 0); + + if(!IsUnicodeSpaceOrNull(currCh)){ + // sentences must end with whitespace (or the end of the node) + return false; + }else if(prevCh == 0 && currCh == 0){ + // empty sentence + return false; + }else if(isCharSentenceEndMark(nextNonSpaceCh)){ + // next non-space char is end punctuation, so the sentence cannot end before that + // e.g.: "S1 . . . S2" => ["S1 . . . ", "S2"] + // instead of + // "S1 . . . S2" => ["S1 . ", ". ", ". ", "S2"] + return false; + }else if(isCharSentenceEndMark(prevCh)){ + // previous char is sentence end punctuation + return true; + }else if(isCharDoubleQuoteEnd(prevCh) && isCharSentenceEndMark(prevPrevCh)){ + // previous two chars are sentence end punctuation and dbl-quote + return true; + }else{ + // the current char may be a space between words, + // but it may also be the last word of a block with no end punctuation + // check if there is a next word to move to, and if not, it is sentence end + ldomXPointerEx pos(*this); + return !pos.nextVisibleWordStartInSentence(false); } - // word is not ended with . ! ? - // check whether it's last word of block - ldomXPointerEx pos(*this); - return !pos.nextVisibleWordStartInSentence(false); - //return !pos.thisVisibleWordEndInSentence(); } /// move to beginning of current visible text sentence