Ver código fonte

feat: 阅读界面的一些问题

HapeLee 1 mês atrás
pai
commit
9d79bd1030
40 arquivos alterados com 6027 adições e 233 exclusões
  1. 4794 0
      app/schemas/io.legado.app.data.AppDatabase/105.json
  2. 3 2
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  3. 3 0
      app/src/main/java/io/legado/app/data/entities/HighlightRule.kt
  4. 1 0
      app/src/main/java/io/legado/app/data/repository/HighlightRuleRepository.kt
  5. 181 72
      app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt
  6. 7 2
      app/src/main/java/io/legado/app/feature/reader/core/gesture/ReaderGestureSettingsPolicy.kt
  7. 48 12
      app/src/main/java/io/legado/app/feature/reader/core/layout/ChineseLineBreaker.kt
  8. 11 1
      app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderChapterBlockMeasurer.kt
  9. 51 18
      app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderPaginator.kt
  10. 26 3
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderEmphasisUnderlineRun.kt
  11. 17 8
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderNineSliceLayout.kt
  12. 13 5
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPage.kt
  13. 27 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPageContentClip.kt
  14. 30 2
      app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigator.kt
  15. 107 35
      app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelection.kt
  16. 12 2
      app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderPageTransition.kt
  17. 22 0
      app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderScrollState.kt
  18. 10 2
      app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt
  19. 16 6
      app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderStyleRangeMapper.kt
  20. 8 3
      app/src/main/java/io/legado/app/feature/reader/platform/AndroidReaderTextShaper.kt
  21. 34 0
      app/src/main/java/io/legado/app/feature/reader/platform/ReaderTextBackgroundLoader.kt
  22. 42 14
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  23. 4 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  24. 173 34
      app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt
  25. 6 0
      app/src/main/res/values-zh-rCN/strings.xml
  26. 6 0
      app/src/main/res/values-zh-rHK/strings.xml
  27. 6 0
      app/src/main/res/values-zh-rTW/strings.xml
  28. 6 0
      app/src/main/res/values/strings.xml
  29. 3 3
      app/src/test/java/io/legado/app/feature/reader/core/gesture/ReaderGestureSettingsPolicyTest.kt
  30. 27 0
      app/src/test/java/io/legado/app/feature/reader/core/layout/ChineseLineBreakerTest.kt
  31. 29 1
      app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderPaginatorTest.kt
  32. 40 0
      app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderSingleImageTest.kt
  33. 33 1
      app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderVolumeTitlePageTest.kt
  34. 40 0
      app/src/test/java/io/legado/app/feature/reader/core/model/ReaderEmphasisUnderlineRunTest.kt
  35. 69 0
      app/src/test/java/io/legado/app/feature/reader/core/model/ReaderNineSliceLayoutTest.kt
  36. 16 0
      app/src/test/java/io/legado/app/feature/reader/core/model/ReaderTextBackgroundRunTest.kt
  37. 37 0
      app/src/test/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigatorTest.kt
  38. 38 0
      app/src/test/java/io/legado/app/feature/reader/core/selection/ReaderSelectionTest.kt
  39. 20 7
      app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderPageTransitionTest.kt
  40. 11 0
      app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderScrollStateTest.kt

+ 4794 - 0
app/schemas/io.legado.app.data.AppDatabase/105.json

@@ -0,0 +1,4794 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 105,
+    "identityHash": "66502ca136787adfe5ef7e504109c178",
+    "entities": [
+      {
+        "tableName": "books",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, `listIntro` TEXT, PRIMARY KEY(`bookUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'loc_book'"
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customTag",
+            "columnName": "customTag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customCoverUrl",
+            "columnName": "customCoverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customIntro",
+            "columnName": "customIntro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "remark",
+            "columnName": "remark",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "charset",
+            "columnName": "charset",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTime",
+            "columnName": "latestChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckTime",
+            "columnName": "lastCheckTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckCount",
+            "columnName": "lastCheckCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "totalChapterNum",
+            "columnName": "totalChapterNum",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTitle",
+            "columnName": "durChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "durChapterIndex",
+            "columnName": "durChapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterPos",
+            "columnName": "durChapterPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTime",
+            "columnName": "durChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "canUpdate",
+            "columnName": "canUpdate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readConfig",
+            "columnName": "readConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncTime",
+            "columnName": "syncTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "listIntro",
+            "columnName": "listIntro",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_books_name_author",
+            "unique": false,
+            "columnNames": [
+              "name",
+              "author"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)"
+          },
+          {
+            "name": "index_books_durChapterTime",
+            "unique": false,
+            "columnNames": [
+              "durChapterTime"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_durChapterTime` ON `${TABLE_NAME}` (`durChapterTime`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_groups",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `enableRefresh` INTEGER NOT NULL DEFAULT 1, `show` INTEGER NOT NULL DEFAULT 1, `bookSort` INTEGER NOT NULL DEFAULT -1, `isPrivate` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`groupId`))",
+        "fields": [
+          {
+            "fieldPath": "groupId",
+            "columnName": "groupId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cover",
+            "columnName": "cover",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enableRefresh",
+            "columnName": "enableRefresh",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "show",
+            "columnName": "show",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "bookSort",
+            "columnName": "bookSort",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "isPrivate",
+            "columnName": "isPrivate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "groupId"
+          ]
+        }
+      },
+      {
+        "tableName": "book_sources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceUrl` TEXT NOT NULL, `bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL DEFAULT 1, `enabledExplore` INTEGER NOT NULL DEFAULT 1, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `exploreScreen` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, `ruleReview` TEXT, `eventListener` INTEGER NOT NULL DEFAULT 0, `customButton` INTEGER NOT NULL DEFAULT 0, `homepageModules` TEXT, PRIMARY KEY(`bookSourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookSourceUrl",
+            "columnName": "bookSourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceName",
+            "columnName": "bookSourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceGroup",
+            "columnName": "bookSourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceType",
+            "columnName": "bookSourceType",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrlPattern",
+            "columnName": "bookUrlPattern",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "enabledExplore",
+            "columnName": "enabledExplore",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceComment",
+            "columnName": "bookSourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "weight",
+            "columnName": "weight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "exploreUrl",
+            "columnName": "exploreUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "exploreScreen",
+            "columnName": "exploreScreen",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleExplore",
+            "columnName": "ruleExplore",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleSearch",
+            "columnName": "ruleSearch",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleBookInfo",
+            "columnName": "ruleBookInfo",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleToc",
+            "columnName": "ruleToc",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleReview",
+            "columnName": "ruleReview",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "eventListener",
+            "columnName": "eventListener",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customButton",
+            "columnName": "customButton",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "homepageModules",
+            "columnName": "homepageModules",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookSourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_sources_bookSourceUrl",
+            "unique": false,
+            "columnNames": [
+              "bookSourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapters",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `isVolume` INTEGER NOT NULL, `tocLevel` INTEGER NOT NULL DEFAULT 0, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `wordCount` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, `reviewImg` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVolume",
+            "columnName": "isVolume",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "tocLevel",
+            "columnName": "tocLevel",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "index",
+            "columnName": "index",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVip",
+            "columnName": "isVip",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isPay",
+            "columnName": "isPay",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resourceUrl",
+            "columnName": "resourceUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tag",
+            "columnName": "tag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "startFragmentId",
+            "columnName": "startFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "endFragmentId",
+            "columnName": "endFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "reviewImg",
+            "columnName": "reviewImg",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url",
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapters_bookUrl",
+            "unique": false,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_chapters_bookUrl_index",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "index"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "books",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "bookUrl"
+            ],
+            "referencedColumns": [
+              "bookUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "replace_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `group` TEXT, `pattern` TEXT NOT NULL DEFAULT '', `replacement` TEXT NOT NULL DEFAULT '', `scope` TEXT, `scopeTitle` INTEGER NOT NULL DEFAULT 0, `scopeContent` INTEGER NOT NULL DEFAULT 1, `excludeScope` TEXT, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `timeoutMillisecond` INTEGER NOT NULL DEFAULT 3000, `sortOrder` INTEGER NOT NULL DEFAULT 0)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "replacement",
+            "columnName": "replacement",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "scope",
+            "columnName": "scope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "scopeTitle",
+            "columnName": "scopeTitle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "scopeContent",
+            "columnName": "scopeContent",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "excludeScope",
+            "columnName": "excludeScope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "isRegex",
+            "columnName": "isRegex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "timeoutMillisecond",
+            "columnName": "timeoutMillisecond",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "3000"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_replace_rules_id",
+            "unique": false,
+            "columnNames": [
+              "id"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ]
+      },
+      {
+        "tableName": "searchBooks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, `chapterWordCountText` TEXT, `chapterWordCount` INTEGER NOT NULL DEFAULT -1, `respondTime` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterWordCountText",
+            "columnName": "chapterWordCountText",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chapterWordCount",
+            "columnName": "chapterWordCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_searchBooks_bookUrl",
+            "unique": true,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_searchBooks_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "book_sources",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "origin"
+            ],
+            "referencedColumns": [
+              "bookSourceUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "search_keywords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))",
+        "fields": [
+          {
+            "fieldPath": "word",
+            "columnName": "word",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "usage",
+            "columnName": "usage",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastUseTime",
+            "columnName": "lastUseTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "word"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_keywords_word",
+            "unique": true,
+            "columnNames": [
+              "word"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cookies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cookie",
+            "columnName": "cookie",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cookies_url",
+            "unique": true,
+            "columnNames": [
+              "url"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssSources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `variableComment` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL DEFAULT 0, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `contentWhitelist` TEXT, `contentBlacklist` TEXT, `shouldOverrideUrlLoading` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL DEFAULT 1, `loadWithBaseUrl` INTEGER NOT NULL DEFAULT 1, `injectJs` TEXT, `preloadJs` TEXT, `startHtml` TEXT, `startStyle` TEXT, `startJs` TEXT, `showWebLog` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 0, `preload` INTEGER NOT NULL DEFAULT 0, `cacheFirst` INTEGER NOT NULL DEFAULT 0, `searchUrl` TEXT, `redirectPolicy` TEXT NOT NULL DEFAULT 'ASK_CROSS_ORIGIN', PRIMARY KEY(`sourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceName",
+            "columnName": "sourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceIcon",
+            "columnName": "sourceIcon",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceGroup",
+            "columnName": "sourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceComment",
+            "columnName": "sourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortUrl",
+            "columnName": "sortUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "singleUrl",
+            "columnName": "singleUrl",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "articleStyle",
+            "columnName": "articleStyle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "ruleArticles",
+            "columnName": "ruleArticles",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleNextPage",
+            "columnName": "ruleNextPage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleTitle",
+            "columnName": "ruleTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "rulePubDate",
+            "columnName": "rulePubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleDescription",
+            "columnName": "ruleDescription",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleImage",
+            "columnName": "ruleImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleLink",
+            "columnName": "ruleLink",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentWhitelist",
+            "columnName": "contentWhitelist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentBlacklist",
+            "columnName": "contentBlacklist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "shouldOverrideUrlLoading",
+            "columnName": "shouldOverrideUrlLoading",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "style",
+            "columnName": "style",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enableJs",
+            "columnName": "enableJs",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "loadWithBaseUrl",
+            "columnName": "loadWithBaseUrl",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "injectJs",
+            "columnName": "injectJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "preloadJs",
+            "columnName": "preloadJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startHtml",
+            "columnName": "startHtml",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startStyle",
+            "columnName": "startStyle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startJs",
+            "columnName": "startJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "showWebLog",
+            "columnName": "showWebLog",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "preload",
+            "columnName": "preload",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "cacheFirst",
+            "columnName": "cacheFirst",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "redirectPolicy",
+            "columnName": "redirectPolicy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'ASK_CROSS_ORIGIN'"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "sourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssSources_sourceUrl",
+            "unique": false,
+            "columnNames": [
+              "sourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "bookmarks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `bookUrl` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))",
+        "fields": [
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPos",
+            "columnName": "chapterPos",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterName",
+            "columnName": "chapterName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookText",
+            "columnName": "bookText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "time"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_bookmarks_bookName_bookAuthor",
+            "unique": false,
+            "columnNames": [
+              "bookName",
+              "bookAuthor"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssArticles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`, `sort`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link",
+            "sort"
+          ]
+        }
+      },
+      {
+        "tableName": "rssReadRecords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, `origin` TEXT NOT NULL DEFAULT '', `sort` TEXT NOT NULL DEFAULT '', `image` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, `pubDate` TEXT, PRIMARY KEY(`record`))",
+        "fields": [
+          {
+            "fieldPath": "record",
+            "columnName": "record",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "record"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssReadRecords_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssReadRecords_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ]
+      },
+      {
+        "tableName": "readRecordDetail",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `date` TEXT NOT NULL, `readTime` INTEGER NOT NULL DEFAULT 0, `readWords` INTEGER NOT NULL DEFAULT 0, `firstReadTime` INTEGER NOT NULL DEFAULT 0, `lastReadTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`, `date`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "date",
+            "columnName": "date",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "readWords",
+            "columnName": "readWords",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "firstReadTime",
+            "columnName": "firstReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastReadTime",
+            "columnName": "lastReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor",
+            "date"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecordSession",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "startTime",
+            "columnName": "startTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "endTime",
+            "columnName": "endTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "words",
+            "columnName": "words",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "rssStars",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "starTime",
+            "columnName": "starTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link"
+          ]
+        }
+      },
+      {
+        "tableName": "txtTocRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chapterRule` TEXT NOT NULL, `volumeRule` TEXT NOT NULL, `example` TEXT, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterRule",
+            "columnName": "chapterRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "volumeRule",
+            "columnName": "volumeRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "example",
+            "columnName": "example",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "serialNumber",
+            "columnName": "serialNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enable",
+            "columnName": "enable",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecord",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastRead",
+            "columnName": "lastRead",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor"
+          ]
+        }
+      },
+      {
+        "tableName": "httpTTS",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT DEFAULT '0', `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `loginCheckJs` TEXT, `speed` INTEGER, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentType",
+            "columnName": "contentType",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT",
+            "defaultValue": "'0'"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "speed",
+            "columnName": "speed",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "caches",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+        "fields": [
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "deadline",
+            "columnName": "deadline",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_caches_key",
+            "unique": true,
+            "columnNames": [
+              "key"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ruleSubs",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "autoUpdate",
+            "columnName": "autoUpdate",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "update",
+            "columnName": "update",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "dictRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `urlRule` TEXT NOT NULL, `showRule` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT 1, `sortNumber` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))",
+        "fields": [
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "urlRule",
+            "columnName": "urlRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showRule",
+            "columnName": "showRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "name"
+          ]
+        }
+      },
+      {
+        "tableName": "keyboardAssists",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL DEFAULT 0, `key` TEXT NOT NULL DEFAULT '', `value` TEXT NOT NULL DEFAULT '', `serialNo` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`type`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "serialNo",
+            "columnName": "serialNo",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "type",
+            "key"
+          ]
+        }
+      },
+      {
+        "tableName": "servers",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `config` TEXT, `sortNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "config",
+            "columnName": "config",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "search_content_history",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookName` TEXT DEFAULT '', `bookAuthor` TEXT DEFAULT '', `query` TEXT NOT NULL, `time` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "query",
+            "columnName": "query",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_content_history_bookName_bookAuthor_query",
+            "unique": true,
+            "columnNames": [
+              "bookName",
+              "bookAuthor",
+              "query"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_content_history_bookName_bookAuthor_query` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`, `query`)"
+          }
+        ]
+      },
+      {
+        "tableName": "homepage_modules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceUrl` TEXT NOT NULL, `moduleKey` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `args` TEXT, `layoutConfig` TEXT, `url` TEXT, `isEnabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `customSetId` TEXT, `isUserCreated` INTEGER NOT NULL, `customTitle` TEXT, `customSetTitle` TEXT, `sourceJsonHash` TEXT, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "moduleKey",
+            "columnName": "moduleKey",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "args",
+            "columnName": "args",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "layoutConfig",
+            "columnName": "layoutConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customSetId",
+            "columnName": "customSetId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isUserCreated",
+            "columnName": "isUserCreated",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customTitle",
+            "columnName": "customTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customSetTitle",
+            "columnName": "customSetTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceJsonHash",
+            "columnName": "sourceJsonHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncedAt",
+            "columnName": "syncedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "homepage_custom_sets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "highlightRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `sampleText` TEXT NOT NULL, `targetScope` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `position` INTEGER NOT NULL, `textColor` INTEGER, `bgColor` INTEGER, `underlineMode` INTEGER NOT NULL, `underlineColor` INTEGER, `underlineWidth` REAL NOT NULL, `underlineOffset` REAL NOT NULL, `underlineSvgPath` TEXT, `bgImage` TEXT, `bgImageFit` INTEGER NOT NULL, `bgImageScale` REAL NOT NULL, `configName` TEXT, `fontPath` TEXT, `fontWeight` INTEGER NOT NULL, `isItalic` INTEGER NOT NULL, `fontSizeOffset` INTEGER NOT NULL, `npLeft` REAL NOT NULL, `npRight` REAL NOT NULL, `npTop` REAL NOT NULL, `npBottom` REAL NOT NULL, `manualNineSlice` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sampleText",
+            "columnName": "sampleText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "targetScope",
+            "columnName": "targetScope",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "textColor",
+            "columnName": "textColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "bgColor",
+            "columnName": "bgColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "underlineMode",
+            "columnName": "underlineMode",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineColor",
+            "columnName": "underlineColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "underlineWidth",
+            "columnName": "underlineWidth",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineOffset",
+            "columnName": "underlineOffset",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineSvgPath",
+            "columnName": "underlineSvgPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bgImage",
+            "columnName": "bgImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bgImageFit",
+            "columnName": "bgImageFit",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bgImageScale",
+            "columnName": "bgImageScale",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "configName",
+            "columnName": "configName",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "fontPath",
+            "columnName": "fontPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "fontWeight",
+            "columnName": "fontWeight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isItalic",
+            "columnName": "isItalic",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "fontSizeOffset",
+            "columnName": "fontSizeOffset",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npLeft",
+            "columnName": "npLeft",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npRight",
+            "columnName": "npRight",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npTop",
+            "columnName": "npTop",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npBottom",
+            "columnName": "npBottom",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "manualNineSlice",
+            "columnName": "manualNineSlice",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_provider_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `protocol` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `modelsUrl` TEXT, `apiKey` TEXT NOT NULL, `authType` TEXT NOT NULL, `secretRef` TEXT, `headersJson` TEXT, `chatPath` TEXT, `responsesPath` TEXT, `messagesPath` TEXT, `modelsPath` TEXT, `customHeadersJson` TEXT, `enabled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "protocol",
+            "columnName": "protocol",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelsUrl",
+            "columnName": "modelsUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "apiKey",
+            "columnName": "apiKey",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authType",
+            "columnName": "authType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "secretRef",
+            "columnName": "secretRef",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "headersJson",
+            "columnName": "headersJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chatPath",
+            "columnName": "chatPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "responsesPath",
+            "columnName": "responsesPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "messagesPath",
+            "columnName": "messagesPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "modelsPath",
+            "columnName": "modelsPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customHeadersJson",
+            "columnName": "customHeadersJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_model_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `providerId` TEXT NOT NULL, `displayName` TEXT NOT NULL, `modelId` TEXT NOT NULL, `contextWindow` INTEGER NOT NULL, `maxOutputTokens` INTEGER NOT NULL, `capabilities` TEXT NOT NULL, `defaultParamsJson` TEXT, `enabled` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "providerId",
+            "columnName": "providerId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelId",
+            "columnName": "modelId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contextWindow",
+            "columnName": "contextWindow",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "maxOutputTokens",
+            "columnName": "maxOutputTokens",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "capabilities",
+            "columnName": "capabilities",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultParamsJson",
+            "columnName": "defaultParamsJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_model_profiles_providerId",
+            "unique": false,
+            "columnNames": [
+              "providerId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_model_profiles_providerId` ON `${TABLE_NAME}` (`providerId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_task_presets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `name` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `promptTemplate` TEXT NOT NULL, `paramsJson` TEXT, `chunkPolicyJson` TEXT, `enabled` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "promptTemplate",
+            "columnName": "promptTemplate",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paramsJson",
+            "columnName": "paramsJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chunkPolicyJson",
+            "columnName": "chunkPolicyJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isDefault",
+            "columnName": "isDefault",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_task_presets_taskType",
+            "unique": false,
+            "columnNames": [
+              "taskType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_taskType` ON `${TABLE_NAME}` (`taskType`)"
+          },
+          {
+            "name": "index_ai_task_presets_modelProfileId",
+            "unique": false,
+            "columnNames": [
+              "modelProfileId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_modelProfileId` ON `${TABLE_NAME}` (`modelProfileId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_artifacts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER, `contentHash` TEXT NOT NULL, `promptHash` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `status` INTEGER NOT NULL, `output` TEXT, `errorMessage` TEXT, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "promptHash",
+            "columnName": "promptHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "output",
+            "columnName": "output",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "errorMessage",
+            "columnName": "errorMessage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_artifacts_bookUrl_chapterIndex_taskType",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "taskType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_bookUrl_chapterIndex_taskType` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `taskType`)"
+          },
+          {
+            "name": "index_ai_artifacts_contentHash_promptHash_modelProfileId",
+            "unique": false,
+            "columnNames": [
+              "contentHash",
+              "promptHash",
+              "modelProfileId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_contentHash_promptHash_modelProfileId` ON `${TABLE_NAME}` (`contentHash`, `promptHash`, `modelProfileId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_chat_conversations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `reasoningLevel` TEXT NOT NULL, `modelProfileId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "reasoningLevel",
+            "columnName": "reasoningLevel",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_chat_messages",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `role` TEXT NOT NULL, `partsJson` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `branchIndex` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `parentMessageId` TEXT, `thinkingDuration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "conversationId",
+            "columnName": "conversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "role",
+            "columnName": "role",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "partsJson",
+            "columnName": "partsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "branchIndex",
+            "columnName": "branchIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isSelected",
+            "columnName": "isSelected",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentMessageId",
+            "columnName": "parentMessageId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "thinkingDuration",
+            "columnName": "thinkingDuration",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_chat_messages_conversationId_createdAt",
+            "unique": false,
+            "columnNames": [
+              "conversationId",
+              "createdAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_chat_messages_conversationId_createdAt` ON `${TABLE_NAME}` (`conversationId`, `createdAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_memory",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "conversationId",
+            "columnName": "conversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "conversationId",
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_memory_conversationId",
+            "unique": false,
+            "columnNames": [
+              "conversationId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_memory_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "highlight_tag_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `pattern` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "tag_group_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "book_content_processes",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER, `kind` TEXT NOT NULL, `stage` TEXT NOT NULL, `target` TEXT NOT NULL, `anchorJson` TEXT NOT NULL, `actionJson` TEXT NOT NULL, `styleJson` TEXT, `source` TEXT NOT NULL, `aiArtifactId` TEXT, `sourceContentHash` TEXT, `enabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `status` INTEGER NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "stage",
+            "columnName": "stage",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "target",
+            "columnName": "target",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "anchorJson",
+            "columnName": "anchorJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "actionJson",
+            "columnName": "actionJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "styleJson",
+            "columnName": "styleJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "aiArtifactId",
+            "columnName": "aiArtifactId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceContentHash",
+            "columnName": "sourceContentHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_content_processes_bookUrl_chapterIndex_enabled_sortOrder",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "enabled",
+              "sortOrder"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_bookUrl_chapterIndex_enabled_sortOrder` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `enabled`, `sortOrder`)"
+          },
+          {
+            "name": "index_book_content_processes_bookUrl_kind",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "kind"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_bookUrl_kind` ON `${TABLE_NAME}` (`bookUrl`, `kind`)"
+          },
+          {
+            "name": "index_book_content_processes_aiArtifactId",
+            "unique": false,
+            "columnNames": [
+              "aiArtifactId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_aiArtifactId` ON `${TABLE_NAME}` (`aiArtifactId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_prompt_presets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `name` TEXT NOT NULL, `instruction` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `builtIn` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "instruction",
+            "columnName": "instruction",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "builtIn",
+            "columnName": "builtIn",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_prompt_presets_taskType_enabled_sortNumber",
+            "unique": false,
+            "columnNames": [
+              "taskType",
+              "enabled",
+              "sortNumber"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_prompt_presets_taskType_enabled_sortNumber` ON `${TABLE_NAME}` (`taskType`, `enabled`, `sortNumber`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `aliasesJson` TEXT NOT NULL, `avatarUri` TEXT, `tagsJson` TEXT NOT NULL, `role` TEXT NOT NULL, `voiceGender` TEXT NOT NULL DEFAULT 'unknown', `voiceAgeBand` TEXT NOT NULL DEFAULT 'unknown', `personality` TEXT NOT NULL, `summary` TEXT NOT NULL, `status` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "aliasesJson",
+            "columnName": "aliasesJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "avatarUri",
+            "columnName": "avatarUri",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tagsJson",
+            "columnName": "tagsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "role",
+            "columnName": "role",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "voiceGender",
+            "columnName": "voiceGender",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'unknown'"
+          },
+          {
+            "fieldPath": "voiceAgeBand",
+            "columnName": "voiceAgeBand",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'unknown'"
+          },
+          {
+            "fieldPath": "personality",
+            "columnName": "personality",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_profiles_bookUrl_name",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "name"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_book_character_profiles_bookUrl_name` ON `${TABLE_NAME}` (`bookUrl`, `name`)"
+          },
+          {
+            "name": "index_book_character_profiles_bookUrl_updatedAt",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "updatedAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_profiles_bookUrl_updatedAt` ON `${TABLE_NAME}` (`bookUrl`, `updatedAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_events",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `characterId` TEXT NOT NULL, `chapterIndex` INTEGER, `chapterTitle` TEXT NOT NULL, `eventTimeText` TEXT NOT NULL, `content` TEXT NOT NULL, `importance` INTEGER NOT NULL, `sourceTextHash` TEXT, `evidenceJson` TEXT NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterId",
+            "columnName": "characterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "chapterTitle",
+            "columnName": "chapterTitle",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "eventTimeText",
+            "columnName": "eventTimeText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "importance",
+            "columnName": "importance",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceTextHash",
+            "columnName": "sourceTextHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_events_bookUrl_characterId_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "characterId",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_events_bookUrl_characterId_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `characterId`, `chapterIndex`)"
+          },
+          {
+            "name": "index_book_character_events_bookUrl_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_events_bookUrl_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_relations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `fromCharacterId` TEXT NOT NULL, `toCharacterId` TEXT NOT NULL, `relationType` TEXT NOT NULL, `summary` TEXT NOT NULL, `attitude` TEXT NOT NULL, `evidenceJson` TEXT NOT NULL, `chapterIndex` INTEGER, `status` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "fromCharacterId",
+            "columnName": "fromCharacterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "toCharacterId",
+            "columnName": "toCharacterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "relationType",
+            "columnName": "relationType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "attitude",
+            "columnName": "attitude",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_relations_bookUrl_fromCharacterId_toCharacterId",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "fromCharacterId",
+              "toCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_fromCharacterId_toCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `fromCharacterId`, `toCharacterId`)"
+          },
+          {
+            "name": "index_book_character_relations_bookUrl_fromCharacterId",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "fromCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_fromCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `fromCharacterId`)"
+          },
+          {
+            "name": "index_book_character_relations_bookUrl_toCharacterId",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "toCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_toCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `toCharacterId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_knowledge_entries",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `keywordsJson` TEXT NOT NULL, `content` TEXT NOT NULL, `scopeStartChapter` INTEGER, `scopeEndChapter` INTEGER, `priority` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `evidenceJson` TEXT NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "keywordsJson",
+            "columnName": "keywordsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "scopeStartChapter",
+            "columnName": "scopeStartChapter",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "scopeEndChapter",
+            "columnName": "scopeEndChapter",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "priority",
+            "columnName": "priority",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_knowledge_entries_bookUrl_type_priority",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "type",
+              "priority"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_knowledge_entries_bookUrl_type_priority` ON `${TABLE_NAME}` (`bookUrl`, `type`, `priority`)"
+          },
+          {
+            "name": "index_book_knowledge_entries_bookUrl_title",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "title"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_knowledge_entries_bookUrl_title` ON `${TABLE_NAME}` (`bookUrl`, `title`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_outline_nodes",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `parentId` TEXT, `nodeType` TEXT NOT NULL, `title` TEXT NOT NULL, `summary` TEXT NOT NULL, `startChapterIndex` INTEGER, `endChapterIndex` INTEGER, `order` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentId",
+            "columnName": "parentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "nodeType",
+            "columnName": "nodeType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "startChapterIndex",
+            "columnName": "startChapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "endChapterIndex",
+            "columnName": "endChapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_outline_nodes_bookUrl_nodeType_startChapterIndex_endChapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "nodeType",
+              "startChapterIndex",
+              "endChapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_outline_nodes_bookUrl_nodeType_startChapterIndex_endChapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `nodeType`, `startChapterIndex`, `endChapterIndex`)"
+          },
+          {
+            "name": "index_book_outline_nodes_bookUrl_parentId_order",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "parentId",
+              "order"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_outline_nodes_bookUrl_parentId_order` ON `${TABLE_NAME}` (`bookUrl`, `parentId`, `order`)"
+          }
+        ]
+      },
+      {
+        "tableName": "read_aloud_voices",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `engineType` TEXT NOT NULL, `engineId` TEXT NOT NULL DEFAULT '', `speakerId` TEXT NOT NULL DEFAULT '', `displayName` TEXT NOT NULL, `traitsJson` TEXT NOT NULL DEFAULT '[]', `emotionCatalogJson` TEXT NOT NULL DEFAULT '[]', `managedBy` TEXT NOT NULL DEFAULT 'user', `enabled` INTEGER NOT NULL DEFAULT 1, `available` INTEGER NOT NULL DEFAULT 1, `revision` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineType",
+            "columnName": "engineType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineId",
+            "columnName": "engineId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "speakerId",
+            "columnName": "speakerId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "traitsJson",
+            "columnName": "traitsJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'[]'"
+          },
+          {
+            "fieldPath": "emotionCatalogJson",
+            "columnName": "emotionCatalogJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'[]'"
+          },
+          {
+            "fieldPath": "managedBy",
+            "columnName": "managedBy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'user'"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "available",
+            "columnName": "available",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "revision",
+            "columnName": "revision",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_read_aloud_voices_engineType_engineId_speakerId",
+            "unique": true,
+            "columnNames": [
+              "engineType",
+              "engineId",
+              "speakerId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_read_aloud_voices_engineType_engineId_speakerId` ON `${TABLE_NAME}` (`engineType`, `engineId`, `speakerId`)"
+          },
+          {
+            "name": "index_read_aloud_voices_enabled_displayName",
+            "unique": false,
+            "columnNames": [
+              "enabled",
+              "displayName"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_read_aloud_voices_enabled_displayName` ON `${TABLE_NAME}` (`enabled`, `displayName`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_voice_bindings",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `subjectType` TEXT NOT NULL, `subjectId` TEXT NOT NULL, `voiceId` TEXT NOT NULL, `locked` INTEGER NOT NULL DEFAULT 0, `source` TEXT NOT NULL DEFAULT 'user', `confidence` REAL NOT NULL DEFAULT 1, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`, `subjectType`, `subjectId`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "subjectType",
+            "columnName": "subjectType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "subjectId",
+            "columnName": "subjectId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "voiceId",
+            "columnName": "voiceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "locked",
+            "columnName": "locked",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'user'"
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl",
+            "subjectType",
+            "subjectId"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_voice_bindings_bookUrl_subjectType",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "subjectType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_voice_bindings_bookUrl_subjectType` ON `${TABLE_NAME}` (`bookUrl`, `subjectType`)"
+          },
+          {
+            "name": "index_book_voice_bindings_voiceId",
+            "unique": false,
+            "columnNames": [
+              "voiceId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_voice_bindings_voiceId` ON `${TABLE_NAME}` (`voiceId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapter_speech_analysis",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `contentHash` TEXT NOT NULL, `resolverVersion` TEXT NOT NULL, `characterRevision` TEXT NOT NULL DEFAULT '', `status` TEXT NOT NULL DEFAULT 'pending', `error` TEXT NOT NULL DEFAULT '', `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resolverVersion",
+            "columnName": "resolverVersion",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterRevision",
+            "columnName": "characterRevision",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'pending'"
+          },
+          {
+            "fieldPath": "error",
+            "columnName": "error",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapter_speech_analysis_bookUrl_chapterIndex_contentHash_resolverVersion",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "contentHash",
+              "resolverVersion"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapter_speech_analysis_bookUrl_chapterIndex_contentHash_resolverVersion` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `contentHash`, `resolverVersion`)"
+          },
+          {
+            "name": "index_chapter_speech_analysis_bookUrl_chapterIndex_updatedAt",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "updatedAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_analysis_bookUrl_chapterIndex_updatedAt` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `updatedAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapter_speech_segments",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `analysisId` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `paragraphIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `chapterPosition` INTEGER NOT NULL, `text` TEXT NOT NULL, `roleType` TEXT NOT NULL, `characterId` TEXT, `characterName` TEXT NOT NULL DEFAULT '', `emotion` TEXT NOT NULL DEFAULT '', `confidence` REAL NOT NULL DEFAULT 0, `source` TEXT NOT NULL, `userLocked` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "analysisId",
+            "columnName": "analysisId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paragraphIndex",
+            "columnName": "paragraphIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPosition",
+            "columnName": "chapterPosition",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "text",
+            "columnName": "text",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "roleType",
+            "columnName": "roleType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterId",
+            "columnName": "characterId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "characterName",
+            "columnName": "characterName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "emotion",
+            "columnName": "emotion",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "userLocked",
+            "columnName": "userLocked",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapter_speech_segments_analysisId_paragraphIndex_start_end",
+            "unique": true,
+            "columnNames": [
+              "analysisId",
+              "paragraphIndex",
+              "start",
+              "end"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapter_speech_segments_analysisId_paragraphIndex_start_end` ON `${TABLE_NAME}` (`analysisId`, `paragraphIndex`, `start`, `end`)"
+          },
+          {
+            "name": "index_chapter_speech_segments_bookUrl_chapterIndex_paragraphIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "paragraphIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_segments_bookUrl_chapterIndex_paragraphIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `paragraphIndex`)"
+          },
+          {
+            "name": "index_chapter_speech_segments_characterId",
+            "unique": false,
+            "columnNames": [
+              "characterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_segments_characterId` ON `${TABLE_NAME}` (`characterId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cloud_tts_engines",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `provider` TEXT NOT NULL, `baseUrl` TEXT NOT NULL DEFAULT '', `apiKey` TEXT NOT NULL DEFAULT '', `secretKey` TEXT NOT NULL DEFAULT '', `region` TEXT NOT NULL DEFAULT '', `appId` TEXT NOT NULL DEFAULT '', `model` TEXT NOT NULL DEFAULT '', `optionsJson` TEXT NOT NULL DEFAULT '{}', `enabled` INTEGER NOT NULL DEFAULT 1, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "provider",
+            "columnName": "provider",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "apiKey",
+            "columnName": "apiKey",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "secretKey",
+            "columnName": "secretKey",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "region",
+            "columnName": "region",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "appId",
+            "columnName": "appId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "model",
+            "columnName": "model",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "optionsJson",
+            "columnName": "optionsJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'{}'"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cloud_tts_engines_provider_enabled",
+            "unique": false,
+            "columnNames": [
+              "provider",
+              "enabled"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_cloud_tts_engines_provider_enabled` ON `${TABLE_NAME}` (`provider`, `enabled`)"
+          }
+        ]
+      },
+      {
+        "tableName": "exact_chapter_page_counts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookId` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `contentHash` INTEGER NOT NULL, `layoutSignature` INTEGER NOT NULL, `engineVersion` INTEGER NOT NULL, `pageCount` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`bookId`, `chapterId`, `layoutSignature`), FOREIGN KEY(`bookId`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "bookId",
+            "columnName": "bookId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterId",
+            "columnName": "chapterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "layoutSignature",
+            "columnName": "layoutSignature",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineVersion",
+            "columnName": "engineVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pageCount",
+            "columnName": "pageCount",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookId",
+            "chapterId",
+            "layoutSignature"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_exact_chapter_page_counts_bookId_layoutSignature",
+            "unique": false,
+            "columnNames": [
+              "bookId",
+              "layoutSignature"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_exact_chapter_page_counts_bookId_layoutSignature` ON `${TABLE_NAME}` (`bookId`, `layoutSignature`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "books",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "bookId"
+            ],
+            "referencedColumns": [
+              "bookUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "book_marks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL DEFAULT '', `bookAuthor` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER, `anchorJson` TEXT NOT NULL, `styleJson` TEXT, `note` TEXT NOT NULL, `chapterName` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "anchorJson",
+            "columnName": "anchorJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "styleJson",
+            "columnName": "styleJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "note",
+            "columnName": "note",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterName",
+            "columnName": "chapterName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_marks_bookUrl_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_marks_bookUrl_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`)"
+          }
+        ]
+      }
+    ],
+    "views": [
+      {
+        "viewName": "book_sources_part",
+        "createSql": "CREATE VIEW `${VIEW_NAME}` AS select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore, \n    (loginUrl is not null and trim(loginUrl) <> '') hasLoginUrl, lastUpdateTime, respondTime, weight, \n    (exploreUrl is not null and trim(exploreUrl) <> '') hasExploreUrl \n    from book_sources"
+      }
+    ],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '66502ca136787adfe5ef7e504109c178')"
+    ]
+  }
+}

+ 3 - 2
app/src/main/java/io/legado/app/data/AppDatabase.kt

@@ -112,7 +112,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 104,
+    version = 105,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -191,7 +191,8 @@ val appDb by lazy {
         // book_marks 新表:Room AutoMigration 支持新增表,自动 CREATE TABLE
         AutoMigration(from = 101, to = 102),
         // httpTTS 新增可空列 speed(源级语速)
-        AutoMigration(from = 103, to = 104)
+        AutoMigration(from = 103, to = 104),
+        AutoMigration(from = 104, to = 105)
     ]
 )
 abstract class AppDatabase : RoomDatabase() {

+ 3 - 0
app/src/main/java/io/legado/app/data/entities/HighlightRule.kt

@@ -1,5 +1,6 @@
 package io.legado.app.data.entities
 
+import androidx.room.ColumnInfo
 import androidx.room.Entity
 import androidx.room.PrimaryKey
 import kotlin.uuid.Uuid
@@ -33,6 +34,8 @@ data class HighlightRule(
     var npRight: Float = 0.1f,
     var npTop: Float = 0.1f,
     var npBottom: Float = 0.1f,
+    @ColumnInfo(defaultValue = "1")
+    var manualNineSlice: Boolean = true,
 ) {
 
     fun styleSummary(): String {

+ 1 - 0
app/src/main/java/io/legado/app/data/repository/HighlightRuleRepository.kt

@@ -181,6 +181,7 @@ class HighlightRuleRepository(
             npRight = runCatching { rule.npRight }.getOrDefault(0.1f).coerceIn(0f, 0.5f),
             npTop = runCatching { rule.npTop }.getOrDefault(0.1f).coerceIn(0f, 0.5f),
             npBottom = runCatching { rule.npBottom }.getOrDefault(0.1f).coerceIn(0f, 0.5f),
+            manualNineSlice = runCatching { rule.manualNineSlice }.getOrDefault(true),
         )
     }
 

+ 181 - 72
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt

@@ -109,7 +109,10 @@ import io.legado.app.feature.reader.core.model.ReaderTipAlignment
 import io.legado.app.feature.reader.core.model.ReaderTipRow
 import io.legado.app.feature.reader.core.model.ReaderTipRowLayout
 import io.legado.app.feature.reader.core.model.ReaderTipVisual
+import io.legado.app.feature.reader.core.model.contentClipPadPx
+import io.legado.app.feature.reader.core.model.emphasisUnderlineRunsFor
 import io.legado.app.feature.reader.core.model.textBackgroundRuns
+import io.legado.app.feature.reader.core.navigation.ReaderPageNavigator
 import io.legado.app.feature.reader.core.readaloud.ReaderVisibleTextPosition
 import io.legado.app.feature.reader.core.readaloud.ReaderVisibleTextPositionPolicy
 import io.legado.app.feature.reader.core.selection.ReaderPageChangeOrigin
@@ -185,6 +188,13 @@ fun ReaderCanvasSurface(
     onPreviousPage: () -> ReaderPageWindow?,
     onNextPage: () -> ReaderPageWindow?,
     onPageBoundaryReached: (ReaderTurnDirection) -> Unit,
+    /**
+     * 书中业务上是否存在邻章(对照旧 View `ReadView.hasNextChapter()` / `hasPrevChapter()`)。
+     * 邻章排版可能滞后于阅读进度,此时窗口里还没有邻页,但翻页必须照常放行,
+     * 由宿主决定"预置加载占位页"还是"直接启动该章排版"。
+     */
+    hasNextChapter: () -> Boolean,
+    hasPreviousChapter: () -> Boolean,
     onToggleMenu: () -> Unit,
     onToggleBookmark: () -> Unit,
     swipeToBookmarkEnabled: Boolean,
@@ -247,6 +257,23 @@ fun ReaderCanvasSurface(
     val latestPreviousPage by rememberUpdatedState(onPreviousPage)
     val latestNextPage by rememberUpdatedState(onNextPage)
     val latestPageBoundaryReached by rememberUpdatedState(onPageBoundaryReached)
+    val latestHasNextChapter by rememberUpdatedState(hasNextChapter)
+    val latestHasPreviousChapter by rememberUpdatedState(hasPreviousChapter)
+
+    /**
+     * 翻页放行:窗口里有邻页,或书中业务上存在邻章。手势协程长驻,必须现读最新值,
+     * 否则读到的是协程启动时的窗口(对照旧 View 每次事件现读 pageSource 的语义)。
+     */
+    fun canTurn(direction: ReaderTurnDirection, window: ReaderPageWindow = latestPages): Boolean =
+        when (direction) {
+            ReaderTurnDirection.NEXT -> ReaderPageNavigator.canTurnNext(
+                window,
+                latestHasNextChapter()
+            )
+
+            ReaderTurnDirection.PREVIOUS ->
+                ReaderPageNavigator.canTurnPrevious(window, latestHasPreviousChapter())
+        }
     val latestAutoPageStop by rememberUpdatedState(onAutoPageStop)
     val latestAutoPagePaused by rememberUpdatedState(autoPagePaused)
     val latestAutoPageActive by rememberUpdatedState(autoPageActive)
@@ -313,6 +340,10 @@ fun ReaderCanvasSurface(
     var autoPageRemainingMillis by remember(current.id, autoReadSpeedSeconds) {
         mutableLongStateOf(ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds))
     }
+    // 连续滚动动画运行标志(fling / 点击滚动步距)。旧 View 的
+    // `ScrollPageDelegate.onAnimStart/Stop` 会 `autoPager.pause()/resume()`:两者同时写
+    // scrollOffset 会叠加成双倍速度,动画期间必须把自动滚屏挂起。
+    var scrollMotionActive by remember { mutableStateOf(false) }
     var textSelection by remember { mutableStateOf<ReaderSelection?>(null) }
     // API 28+ 的平台放大镜直接采样当前 Compose View;未指定 sourceCenter 时会立即隐藏。
     // 采样点始终是正在移动的文字端点,而不是被把手遮挡的手指位置。
@@ -490,7 +521,10 @@ fun ReaderCanvasSurface(
     }
     fun tapPageTurn(direction: ReaderTurnDirection) {
         val window = latestPages
-        if ((if (direction == ReaderTurnDirection.PREVIOUS) window.previous else window.next) == null) {
+        // 放行以"书中是否还有邻章"为准(对照旧 View TextPageFactory.hasNext/hasPrev):
+        // 邻章排版滞后于阅读进度时窗口里还没有邻页,但仍必须把翻页交给宿主,
+        // 由宿主预置加载占位页或启动该章排版;否则停在末页只弹"没有下一页"。
+        if (!canTurn(direction, window)) {
             latestPageBoundaryReached(direction)
             return
         }
@@ -522,24 +556,47 @@ fun ReaderCanvasSurface(
         }
     }
     fun applyScrollResult(result: ReaderScrollResult, window: ReaderPageWindow) {
-        scrollOffset = result.offsetPx
+        val crossing = result.crossing
+        if (crossing == null) {
+            scrollOffset = result.offsetPx
+            return
+        }
         // 跨页换窗同步完成:宿主回调当帧返回新窗口,写入 pending 供本帧之后的
         // 绘制与手势直接使用(宿主 StateFlow 回声随后到达,仅确认不等待)。
         // 对照旧 View 版 ContentTextView.scroll 的同步折算语义。
-        when (result.crossing) {
-            ReaderScrollCrossing.PREVIOUS -> latestPreviousPage()?.let { newWindow ->
-                scrollOwnCrossing = true
-                scrollPendingBase = window
-                scrollPendingWindow = newWindow
-            }
-            ReaderScrollCrossing.NEXT -> latestNextPage()?.let { newWindow ->
-                scrollOwnCrossing = true
-                scrollPendingBase = window
-                scrollPendingWindow = newWindow
+        val newWindow = when (crossing) {
+            ReaderScrollCrossing.PREVIOUS -> latestPreviousPage()
+            ReaderScrollCrossing.NEXT -> latestNextPage()
+        }
+        if (newWindow == null) {
+            // 邻章业务上存在但排版未就绪(旧 View 的"章节未加载"态):这次越界偏移是
+            // 相对邻页坐标系的,直接写入会把当前页画到页外。停在当前页边界(等同于
+            // ReaderScrollPolicy 的 bottom 语义),装载完成后 current.id 变化会归零偏移。
+            val current = window.current
+            scrollOffset = if (crossing == ReaderScrollCrossing.NEXT && current != null) {
+                minOf(0f, current.scrollViewportExtentPx() - current.scrollExtentPx)
+            } else {
+                0f
             }
-            null -> Unit
+            return
         }
+        scrollOffset = result.offsetPx
+        scrollOwnCrossing = true
+        scrollPendingBase = window
+        scrollPendingWindow = newWindow
     }
+
+    /**
+     * 滚动路径触边界一律静默。
+     *
+     * 旧 View 的 `ScrollPageDelegate` 触边界时只把偏移钳回边界并重绘,没有任何提示;
+     * "没有下一页"的 Toast 属于分页模式([tapPageTurn] / `PageDelegate`)的行为。滚动
+     * 模式保留它会变成连续滑动时的反复弹窗,因此这里显式吞掉,只留出可读的落点。
+     */
+    fun reportScrollBoundary(@Suppress("UNUSED_PARAMETER") direction: ReaderTurnDirection) {
+        if (transitionMode != ReaderTransitionMode.SCROLL) latestPageBoundaryReached(direction)
+    }
+
     fun tapScrollPage(direction: ReaderTurnDirection) {
         val window = currentPageWindow()
         val page = window.current ?: return
@@ -553,8 +610,7 @@ fun ReaderCanvasSurface(
             nextPlus = window.nextPlus,
         )
         pageMotionJob?.cancel()
-        val steps = ReaderGestureSettingsPolicy.scrollPageAnimationSteps(latestNoAnimationScrollPage)
-        if (steps == 1) {
+        if (!ReaderGestureSettingsPolicy.animatesScrollPage(latestNoAnimationScrollPage)) {
             val window = currentPageWindow()
             val currentPage = window.current ?: return
             val result = ReaderScrollPolicy.apply(
@@ -563,33 +619,49 @@ fun ReaderCanvasSurface(
                 window.previous?.scrollExtentPx ?: 0f,
                 currentPage.scrollExtentPx,
                 currentPage.scrollViewportExtentPx(),
-                window.previous != null,
-                window.next != null,
+                ReaderPageNavigator.canTurnPrevious(window, latestHasPreviousChapter()),
+                ReaderPageNavigator.canTurnNext(window, latestHasNextChapter()),
             )
             applyScrollResult(result, window)
-            if (result.hitBoundary) latestPageBoundaryReached(direction)
+            if (result.hitBoundary) reportScrollBoundary(direction)
             return
         }
         pageMotionJob = animationScope.launch {
-            repeat(steps) {
-                val window = currentPageWindow()
-                val currentPage = window.current ?: return@launch
-                val result = ReaderScrollPolicy.apply(
-                    scrollOffset,
-                    distance / steps,
-                    window.previous?.scrollExtentPx ?: 0f,
-                    currentPage.scrollExtentPx,
-                    currentPage.scrollViewportExtentPx(),
-                    window.previous != null,
-                    window.next != null,
+            scrollMotionActive = true
+            try {
+                // 时长随步距缩放(旧 PageDelegate.startScroll:animationSpeed * |dy| / viewHeight),
+                // 不再固定 18 帧——固定帧数会让"保留一行"的短步距走成整屏的时长。
+                val durationMillis = ReaderScrollPolicy.stepDurationMillis(
+                    distance,
+                    page.scrollViewportExtentPx(),
                 )
-                applyScrollResult(result, window)
-                if (result.hitBoundary) {
-                    latestPageBoundaryReached(direction)
-                    return@launch
+                var lastValue = 0f
+                Animatable(0f).animateTo(
+                    distance,
+                    tween(durationMillis = durationMillis, easing = LinearEasing),
+                ) {
+                    val delta = value - lastValue
+                    lastValue = value
+                    val window = currentPageWindow()
+                    val currentPage = window.current ?: return@animateTo
+                    val result = ReaderScrollPolicy.apply(
+                        scrollOffset,
+                        delta,
+                        window.previous?.scrollExtentPx ?: 0f,
+                        currentPage.scrollExtentPx,
+                        currentPage.scrollViewportExtentPx(),
+                        ReaderPageNavigator.canTurnPrevious(window, latestHasPreviousChapter()),
+                        ReaderPageNavigator.canTurnNext(window, latestHasNextChapter()),
+                    )
+                    applyScrollResult(result, window)
+                    // 触边界即停:与 fling 同一条中断路径(animateTo 的块里不能调用
+                    // 挂起的 stop(),用异常跳出后在外层收尾)。
+                    if (result.hitBoundary) throw ReaderScrollBoundaryReached()
                 }
-                // 按帧驱动步进:跟随合成器节拍,掉帧时步长自动摊平,不与显示帧脱节。
-                withFrameNanos { }
+            } catch (_: ReaderScrollBoundaryReached) {
+                reportScrollBoundary(direction)
+            } finally {
+                scrollMotionActive = false
             }
         }
     }
@@ -692,6 +764,7 @@ fun ReaderCanvasSurface(
         autoReadSpeedSeconds,
         transitionMode,
         isEInkMode,
+        scrollMotionActive,
         current.id,
         textSelection,
     ) {
@@ -704,6 +777,9 @@ fun ReaderCanvasSurface(
             return@LaunchedEffect
         }
         if (autoPagePaused) return@LaunchedEffect
+        // 滚动动画期间挂起:对照旧 View 的 ReadView.onScrollAnimStart/Stop →
+        // autoPager.pause()/resume(),避免 fling / 点击步距与自动滚屏叠加。
+        if (scrollMotionActive) return@LaunchedEffect
         if (ReaderAutoPagePolicy.visualMode(isEInkMode) == ReaderAutoPageVisualMode.DISCRETE) {
             autoRevealPx = 0f
             val plannedMillis = autoPageRemainingMillis
@@ -711,7 +787,7 @@ fun ReaderCanvasSurface(
             try {
                 delay(plannedMillis)
                 autoPageRemainingMillis = ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds)
-                if (latestPages.next == null) latestAutoPageStop() else latestNextPage()
+                if (!canTurn(ReaderTurnDirection.NEXT)) latestAutoPageStop() else latestNextPage()
             } finally {
                 if (ReaderAutoPagePolicy.shouldPreserveRemainingTime(
                         menuPaused = latestAutoPagePaused,
@@ -737,16 +813,27 @@ fun ReaderCanvasSurface(
                 val viewport = page.scrollViewportExtentPx()
                 val delta = viewport /
                     ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds).toFloat() * elapsedMs
-                val result = ReaderScrollPolicy.apply(scrollOffset, -delta, window.previous?.scrollExtentPx ?: 0f, page.scrollExtentPx, page.scrollViewportExtentPx(), window.previous != null, window.next != null)
+                val result = ReaderScrollPolicy.apply(
+                    scrollOffset,
+                    -delta,
+                    window.previous?.scrollExtentPx ?: 0f,
+                    page.scrollExtentPx,
+                    page.scrollViewportExtentPx(),
+                    ReaderPageNavigator.canTurnPrevious(window, latestHasPreviousChapter()),
+                    ReaderPageNavigator.canTurnNext(window, latestHasNextChapter())
+                )
                 applyScrollResult(result, window)
-                if (result.hitBoundary) { latestAutoPageStop(); break }
+                // 滚动模式到书末不自动关闭自动翻页:旧 View 的 AutoPager.computeOffset 在
+                // isScroll 分支只把累计偏移交给 curPage.scroll() 钳制,"失败即 stop"这条
+                // 路径只存在于分页模式的 progress >= height 分支。这里同样保持开关开启态,
+                // 由用户停止,避免翻到书末自动阅读被悄悄关掉。
             } else {
                 val viewport = current.heightPx.toFloat().coerceAtLeast(1f)
                 val delta = viewport /
                     ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds).toFloat() * elapsedMs
                 autoRevealPx += delta
                 if (autoRevealPx >= viewport) {
-                    if (latestPages.next == null) {
+                    if (!canTurn(ReaderTurnDirection.NEXT)) {
                         latestAutoPageStop()
                         break
                     }
@@ -1012,19 +1099,27 @@ fun ReaderCanvasSurface(
                                         } else {
                                             null
                                         }
-                                if (hit != null && hit.chapterIndex == selection.chapterIndex) {
+                                // 滚动模式堆叠的下邻页属于下一章,允许选区跨过去(旧 View 的
+                                // 选区分词同样覆盖 relativePage 0..2);分页模式保持单章。
+                                val canCrossChapter =
+                                    transitionMode == ReaderTransitionMode.SCROLL
+                                if (hit != null && (canCrossChapter ||
+                                            hit.chapterIndex == selection.chapterIndex)
+                                ) {
                                     val updatedSelection = when {
                                         grabbedEndpoint != null -> selection.moveEndpoint(
                                             grabbedEndpoint,
                                             hit.anchor,
                                             hit.anchorIsTitle,
+                                            chapter = hit.chapterIndex,
                                         )
 
                                         else -> ReaderSelectionPolicy.extend(
                                             selection,
                                             page,
                                             cursorViewportX,
-                                            pageY
+                                            pageY,
+                                            allowChapterCrossing = canCrossChapter,
                                         )
                                     }
                                     if (updatedSelection != selection) {
@@ -1108,7 +1203,14 @@ fun ReaderCanvasSurface(
                         if (horizontalTurn && transitionMode != ReaderTransitionMode.SCROLL) {
                             transition = horizontalDrag?.transition(
                                 total.x, size.width.toFloat(),
-                                latestPages.previous != null, latestPages.next != null,
+                                ReaderPageNavigator.canTurnPrevious(
+                                    latestPages,
+                                    latestHasPreviousChapter(),
+                                ),
+                                ReaderPageNavigator.canTurnNext(
+                                    latestPages,
+                                    latestHasNextChapter(),
+                                ),
                             ) ?: ReaderPageTransition(pageExtentPx = size.width.toFloat())
                             transition.direction?.takeIf { transitionMode == ReaderTransitionMode.SIMULATION }
                                 ?.let {
@@ -1139,8 +1241,11 @@ fun ReaderCanvasSurface(
                                     window.previous?.scrollExtentPx ?: 0f,
                                     page.scrollExtentPx,
                                     page.scrollViewportExtentPx(),
-                                    window.previous != null,
-                                    window.next != null
+                                    ReaderPageNavigator.canTurnPrevious(
+                                        window,
+                                        latestHasPreviousChapter(),
+                                    ),
+                                    ReaderPageNavigator.canTurnNext(window, latestHasNextChapter())
                                 )
                                 applyScrollResult(result, window)
                                 if (result.hitBoundary) {
@@ -1208,14 +1313,14 @@ fun ReaderCanvasSurface(
                         ) { bookmarkOffset = value }
                     }
                 } else if (scrollDrag) {
-                    val boundary = scrollHitBoundary
-                    if (boundary != null) {
-                        latestPageBoundaryReached(boundary)
-                    } else {
+                    if (scrollHitBoundary == null) {
+                        // 触边界已在拖拽期间把偏移钳住,松手不再启动 fling(也不提示,
+                        // 见 [reportScrollBoundary])。
                         val velocity = if (released) velocityTracker.calculateVelocity().y else 0f
                         pageMotionJob = animationScope.launch {
-                            var lastValue = 0f
+                            scrollMotionActive = true
                             try {
+                                var lastValue = 0f
                                 Animatable(0f).animateDecay(velocity, scrollDecay) {
                                     val delta = value - lastValue
                                     lastValue = value
@@ -1227,20 +1332,23 @@ fun ReaderCanvasSurface(
                                         window.previous?.scrollExtentPx ?: 0f,
                                         page.scrollExtentPx,
                                         page.scrollViewportExtentPx(),
-                                        window.previous != null,
-                                        window.next != null,
+                                        ReaderPageNavigator.canTurnPrevious(
+                                            window,
+                                            latestHasPreviousChapter(),
+                                        ),
+                                        ReaderPageNavigator.canTurnNext(
+                                            window,
+                                            latestHasNextChapter(),
+                                        ),
                                     )
                                     applyScrollResult(result, window)
                                     if (result.hitBoundary) throw ReaderScrollBoundaryReached()
                                 }
                             } catch (_: ReaderScrollBoundaryReached) {
                                 // Reaching the first/last content boundary ends the fling immediately.
-                                val direction = if (velocity > 0f) {
-                                    ReaderTurnDirection.PREVIOUS
-                                } else {
-                                    ReaderTurnDirection.NEXT
-                                }
-                                latestPageBoundaryReached(direction)
+                                // 旧 View 的 ScrollPageDelegate 同样只是停住,不弹提示。
+                            } finally {
+                                scrollMotionActive = false
                             }
                         }
                     }
@@ -1284,10 +1392,15 @@ fun ReaderCanvasSurface(
             )
         }
         if (transitionMode == ReaderTransitionMode.SCROLL) {
+            val contentClipPad = current.contentClipPadPx
             Box(Modifier
                 .fillMaxSize()
                 .drawWithContent {
-                    clipRect(top = current.contentTopPx, bottom = current.contentBottomPx) {
+                    // 外扩阴影/斜体溢出,对照旧 View 的 ChapterProvider.visibleRect。
+                    clipRect(
+                        top = current.contentTopPx - contentClipPad,
+                        bottom = current.contentBottomPx + contentClipPad,
+                    ) {
                         this@drawWithContent.drawContent()
                     }
                 }) {
@@ -1607,7 +1720,10 @@ private fun ScrollPageStack(
             val data = cache.ensure(page)
             val selectedBounds = if (activeSelection != null || page.searchStart != null) {
                 data.textElements
-                    .filter { page.isSearchResult(it) || activeSelection?.contains(it) == true }
+                    .filter {
+                        page.isSearchResult(it) ||
+                                activeSelection?.contains(it, page.id.chapterIndex) == true
+                    }
                     .map(ReaderElement.Text::bounds)
                     .mergeSelectionBounds()
             } else emptyList()
@@ -1986,7 +2102,10 @@ private fun ReaderPageCanvas(
         page.searchEndInclusive,
     ) {
         textElements
-            .filter { page.isSearchResult(it) || activeSelection?.contains(it) == true }
+            .filter {
+                page.isSearchResult(it) ||
+                        activeSelection?.contains(it, page.id.chapterIndex) == true
+            }
             .map(ReaderElement.Text::bounds)
             .mergeSelectionBounds()
     }
@@ -2131,18 +2250,8 @@ private fun ReaderPage.resolvedColorArgb(text: ReaderElement.Text, accentColorAr
 
 private fun ReaderPage.dynamicEmphasisUnderlineRuns(): List<ReaderEmphasisUnderlineRun> {
     val style = emphasisUnderlineStyle ?: return emptyList()
-    val lines = LinkedHashMap<Pair<Float, Float>, MutableList<ReaderElement.Text>>()
-    elements.filterIsInstance<ReaderElement.Text>()
-        .filter { isSearchResult(it) || isReadAloud(it) }
-        .forEach { text -> lines.getOrPut(text.bounds.top to text.bounds.bottom) { mutableListOf() } += text }
-    return lines.values.map { line ->
-        ReaderEmphasisUnderlineRun(
-            startPx = line.minOf { it.bounds.left },
-            endPx = line.maxOf { it.bounds.right },
-            yPx = line.maxOf { it.bounds.bottom } - style.bottomOffsetPx,
-            style = style,
-        )
-    }
+    // 命中位置只决定哪一行划线,线仍是整行(对照旧 View TextLine.drawTextLine)。
+    return emphasisUnderlineRunsFor(style) { isSearchResult(it) || isReadAloud(it) }
 }
 
 @Composable

+ 7 - 2
app/src/main/java/io/legado/app/feature/reader/core/gesture/ReaderGestureSettingsPolicy.kt

@@ -6,6 +6,11 @@ object ReaderGestureSettingsPolicy {
         configuredTouchSlopPx.takeIf { it > 0 }?.toFloat()
             ?: platformTouchSlopPx.coerceAtLeast(0f)
 
-    /** Only discrete scroll-page commands honor this setting; direct dragging still flings. */
-    fun scrollPageAnimationSteps(noAnimation: Boolean): Int = if (noAnimation) 1 else 18
+    /**
+     * 滚动点击翻页是否播放动画。
+     *
+     * 关闭时一步到位(旧 `noAnim` 分支直接 `curPage.scroll(offset)`);开启时按时长
+     * 插值(旧 `PageDelegate.startScroll`),不再是固定帧数。
+     */
+    fun animatesScrollPage(noAnimation: Boolean): Boolean = !noAnimation
 }

+ 48 - 12
app/src/main/java/io/legado/app/feature/reader/core/layout/ChineseLineBreaker.kt

@@ -37,21 +37,44 @@ class ChineseLineBreaker(
             var carriedWidth = 0f
             var carriedCharacters = 0
             var carriedClusters = 0
+            var hungLine = false
             val currentWidthLimit = if (widths.isEmpty()) firstWidthLimit else widthLimit
             if (lineWidth > currentWidthLimit) {
+                // 旧 ZhLayout 把行尾标点的处置分成两类:可压缩的窄标点回退到更早的合法边界
+                // (BREAK_MORE_CHAR),全角标点则直接悬挂在本行右边界之外(CPS_1/2/3:
+                // `offset = 0f`,行宽允许超过 width)。下面三个判定与旧版逐条对应。
+                fun compressibleAt(at: Int): Boolean =
+                    at in widthsPx.indices && widthsPx[at] < ideographWidthPx
+
+                val previousClosing = index > 0 && clusters[index - 1] in closing
+                val previousOpening = index > 0 && clusters[index - 1] in opening
+                val secondPreviousOpening = index > 1 && clusters[index - 2] in opening
+                val nextClosing = index < clusters.lastIndex && clusters[index + 1] in closing
+                val compressibleAround = when {
+                    previousOpening && secondPreviousOpening ->
+                        compressibleAt(index - 1) || compressibleAt(index - 2)
+
+                    cluster in closing && previousClosing ->
+                        compressibleAt(index) || compressibleAt(index - 1)
+
+                    cluster in closing && secondPreviousOpening ->
+                        compressibleAt(index) || compressibleAt(index - 2)
+
+                    else -> false
+                }
+                // 悬挂条件同时排除了「下一字仍是行尾标点」——旧版此时会 reCheck 成回退。
+                val hangs = index > 0 && !compressibleAround && !nextClosing &&
+                        ((previousOpening && secondPreviousOpening) ||
+                                (cluster in closing && (previousClosing || secondPreviousOpening)))
                 var mode = when {
-                    index > 0 && clusters[index - 1] in opening -> Mode.PULL_PREVIOUS
-                    cluster in closing -> Mode.PULL_PREVIOUS
+                    hangs -> Mode.HANG
+                    index > 0 && (previousOpening || cluster in closing) -> Mode.PULL_PREVIOUS
                     else -> Mode.NORMAL
                 }
                 var rewindClusters = 0
                 var rewindCharacters = 0
-                // 本引擎不做行尾标点压缩(旧 ZhLayout 的 CPS_*),凡 PULL_PREVIOUS 会把
-                // 收尾标点留到下一行行首的,都必须回退到更早的非标点边界
-                val needsRecheck = mode == Mode.PULL_PREVIOUS && (
-                    (index > 0 && clusters[index - 1] in closing) ||
-                        (index < clusters.lastIndex && clusters[index + 1] in closing)
-                    )
+                // 可压缩标点会把收尾标点留到下一行行首,必须回退到更早的非标点边界
+                val needsRecheck = mode == Mode.PULL_PREVIOUS && (previousClosing || nextClosing)
                 if (needsRecheck && index > 2) {
                     val lineStart = if (widths.isEmpty()) indentCharacters else clusterStarts.last()
                     mode = Mode.NORMAL
@@ -82,6 +105,15 @@ class ChineseLineBreaker(
                         carriedCharacters = clusters[index - 1].length + cluster.length
                         carriedClusters = 2
                     }
+                    Mode.HANG -> {
+                        // 标点留在本行(行宽超出右边界,旧版正是靠这个避免标点落到下一行行首),
+                        // 下一行从它之后重新开始,本行不向下一行携带任何宽度。
+                        carriedWidth = 0f
+                        addStart(textLength + cluster.length, index + 1)
+                        carriedCharacters = 0
+                        carriedClusters = 0
+                        hungLine = true
+                    }
                     Mode.REWIND -> {
                         carriedWidth = currentWidth + previousWidth
                         addStart(textLength - rewindCharacters, index - rewindClusters)
@@ -94,9 +126,13 @@ class ChineseLineBreaker(
             }
             if (index == clusters.lastIndex) {
                 if (starts.size == widths.size + 1) {
-                    starts += textLength + cluster.length
-                    clusterStarts += index + 1
-                    widths += lineWidth
+                    // 悬挂已把标点留在本行、并让下一行从它之后开始,旧 ZhLayout 在
+                    // breakCharCnt == 0 时同样不再补一行。未发生断行的普通收尾仍要补行。
+                    if (!hungLine) {
+                        starts += textLength + cluster.length
+                        clusterStarts += index + 1
+                        widths += lineWidth
+                    }
                 } else if (carriedClusters > 0) {
                     starts += starts.last() + carriedCharacters
                     clusterStarts += clusterStarts.last() + carriedClusters
@@ -113,7 +149,7 @@ class ChineseLineBreaker(
         clusterStarts += cluster
     }
 
-    private enum class Mode { NORMAL, PULL_PREVIOUS, REWIND }
+    private enum class Mode { NORMAL, PULL_PREVIOUS, REWIND, HANG }
 
     companion object {
         internal fun isForbiddenBreak(previous: String, next: String): Boolean =

+ 11 - 1
app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderChapterBlockMeasurer.kt

@@ -63,6 +63,8 @@ data class ReaderChapterMeasureStyle(
     val titleAlignment: ReaderTextAlignment,
     val imagePageBreakBefore: Boolean = false,
     val imagePageBreakAfter: Boolean = false,
+    /** 单图样式:标题段排版结束后立即断页,让章标题独占一页(对齐旧 TextChapterLayout)。 */
+    val titlePageBreakAfter: Boolean = false,
     val bodyLineHeightPx: Float? = null,
     val bodyBaselineOffsetPx: Float? = null,
     val titleLineHeightPx: Float? = null,
@@ -293,7 +295,7 @@ class ReaderChapterBlockMeasurer(
             flushInline(skipBlank = hasStandaloneImage)
             return null
         }
-        source.blocks.forEach { block ->
+        source.blocks.forEachIndexed { index, block ->
             when (block) {
                 is ReaderChapterSourceBlock.Text -> {
                     addStyledParagraph(
@@ -302,6 +304,14 @@ class ReaderChapterBlockMeasurer(
                         block.fontSizeScale,
                         block.isSubtitle,
                     )?.let { return it }
+                    // 旧 TextChapterLayout 在单图样式的标题段排版完(`durY += titleBottomSpacing`
+                    // 之后)直接 `prepareNextPageIfNeed()`——无参调用无条件结束当前页,于是标题
+                    // 独占一页、正文从下一页开始。标题分成多段时只在最后一段之后断页。
+                    if (block.isTitle && style.titlePageBreakAfter &&
+                        (source.blocks.getOrNull(index + 1) as? ReaderChapterSourceBlock.Text)?.isTitle != true
+                    ) {
+                        blocks += ReaderMeasuredBlock.PageBreak
+                    }
                 }
                 is ReaderChapterSourceBlock.Image -> {
                     val placeholderExtent = (style.bodyLineHeightPx ?: style.bodyStyle.fontSizePx)

+ 51 - 18
app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderPaginator.kt

@@ -6,9 +6,27 @@ import io.legado.app.feature.reader.core.model.ReaderPage
 import io.legado.app.feature.reader.core.model.ReaderPageDecoration
 import io.legado.app.feature.reader.core.model.ReaderPageId
 import io.legado.app.feature.reader.core.model.ReaderRect
+import io.legado.app.feature.reader.core.model.ReaderTextBackgroundImage
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
 import kotlin.math.max
 
+private fun ReaderTextBackgroundImage.fitIntoLineGap(
+    lineHeightPx: Float,
+    lineSpacingMultiplier: Float,
+): ReaderTextBackgroundImage {
+    val largestFixedRow = maxOf(contentInsetTopPx, contentInsetBottomPx)
+    if (largestFixedRow <= 0f) return this
+    val halfGap = ((lineSpacingMultiplier - 1f).coerceAtLeast(0f) * lineHeightPx) / 2f
+    val frameScale = (halfGap / largestFixedRow).coerceIn(0f, 1f)
+    if (frameScale >= 1f) return this
+    return copy(
+        contentInsetLeftPx = contentInsetLeftPx * frameScale,
+        contentInsetRightPx = contentInsetRightPx * frameScale,
+        contentInsetTopPx = contentInsetTopPx * frameScale,
+        contentInsetBottomPx = contentInsetBottomPx * frameScale,
+    )
+}
+
 enum class ReaderTextAlignment { START, CENTER, END, JUSTIFY }
 enum class ReaderImageScaleMode { CONTAIN_NO_UPSCALE, FIT_WIDTH, FIT_PAGE }
 
@@ -46,6 +64,12 @@ data class ReaderPaginationConfig(
     val columnCount: Int = 1,
     val lineSpacingMultiplier: Float = 1f,
     val continuousScroll: Boolean = false,
+    /**
+     * 章末页在堆叠高度上额外留出的空档。旧 `TextChapterLayout.setTypeText` 收尾时统一
+     * `height = max(height, durY + 20.dpToPx())`,让下一章正文与本章末尾之间不会贴在一起。
+     * 只在连续滚动模式生效——分页模式每页高度恒为一屏,这个量不参与布局。
+     */
+    val chapterEndPaddingPx: Float = 0f,
     val textBottomJustify: Boolean = false,
     val pageUnderline: ReaderPageUnderline? = null,
     val inlineImagesPreserveScrollLine: Boolean = true,
@@ -426,6 +450,10 @@ object ReaderPaginator {
             fun backgroundImage(index: Int) =
                 (paragraph.items[index] as? ReaderMeasuredInlineItem.Text)
                     ?.style?.backgroundImage?.takeIf { it.fit == 3 }
+                    ?.fitIntoLineGap(
+                        lineHeightPx = paragraph.lineHeightPx,
+                        lineSpacingMultiplier = paragraph.lineSpacingMultiplier,
+                    )
 
             fun backgroundInsetBefore(index: Int, lineStart: Int): Float {
                 val image = backgroundImage(index) ?: return 0f
@@ -584,18 +612,20 @@ object ReaderPaginator {
                 val underlineElementStart = elements.size + indentItems
                 lineItems.forEachIndexed { itemIndex, item ->
                     x += backgroundInsetBefore(itemIndex)
-                    val itemBackground = (item as? ReaderMeasuredInlineItem.Text)
-                        ?.style?.backgroundImage
+                    val itemBackground = backgroundImage(from + itemIndex)
                     when (item) {
                         is ReaderMeasuredInlineItem.Text -> {
                             val expandedWordSpace = if (item.value == " ") wordSpaceExtra else 0f
+                            val itemStyle = if (itemBackground == null) item.style else {
+                                item.style.copy(backgroundImage = itemBackground)
+                            }
                             elements += ReaderElement.Text(
                                 bounds = ReaderRect(
                                     x, y, x + item.widthPx + expandedWordSpace, y + actualLineHeight,
                                 ),
                                 baselinePx = y + lineBaselineOffset + item.baselineShiftPx,
                                 value = item.value,
-                                style = item.style,
+                                style = itemStyle,
                                 selected = false,
                                 emphasized = paragraph.emphasized,
                                 link = item.link,
@@ -605,20 +635,10 @@ object ReaderPaginator {
                                 // 富文本逐项样式:与前一项同背景图才视作同一 run 的延续
                                 continuesBackgroundRun = itemBackground != null &&
                                         itemIndex > 0 &&
-                                        (lineItems[itemIndex - 1] as? ReaderMeasuredInlineItem.Text)
-                                            ?.style?.backgroundImage == itemBackground,
-                                backgroundFrameTopPx = item.style.backgroundImage?.takeIf { it.fit == 3 }?.let { image ->
-                                    val halfGap = ((paragraph.lineSpacingMultiplier - 1f).coerceAtLeast(0f) * actualLineHeight) / 2f
-                                    val scale = (halfGap / maxOf(image.contentInsetTopPx, image.contentInsetBottomPx)
-                                        .coerceAtLeast(0.1f)).coerceIn(0f, 1f)
-                                    image.contentInsetTopPx * scale
-                                } ?: 0f,
-                                backgroundFrameBottomPx = item.style.backgroundImage?.takeIf { it.fit == 3 }?.let { image ->
-                                    val halfGap = ((paragraph.lineSpacingMultiplier - 1f).coerceAtLeast(0f) * actualLineHeight) / 2f
-                                    val scale = (halfGap / maxOf(image.contentInsetTopPx, image.contentInsetBottomPx)
-                                        .coerceAtLeast(0.1f)).coerceIn(0f, 1f)
-                                    image.contentInsetBottomPx * scale
-                                } ?: 0f,
+                                        backgroundImage(from + itemIndex - 1) == itemBackground,
+                                backgroundFrameTopPx = itemBackground?.contentInsetTopPx ?: 0f,
+                                backgroundFrameBottomPx = itemBackground?.contentInsetBottomPx
+                                    ?: 0f,
                             )
                         }
                         is ReaderMeasuredInlineItem.Image -> {
@@ -706,6 +726,7 @@ object ReaderPaginator {
             }
         }
         finishPage()
+        var centeredPageShiftPx = 0f
         if (config.titlePageCenterVertical && pages.size == 1) {
             // 卷页只有标题块:按字形实际占位整体下移到内容区垂直中点。布局期平移
             // 保证命中测试、选区与进度映射共用同一几何。
@@ -719,10 +740,22 @@ object ReaderPaginator {
                     for (elementIndex in pageElements.indices) {
                         pageElements[elementIndex] = shiftElement(pageElements[elementIndex], delta)
                     }
+                    centeredPageShiftPx = delta
                 }
             }
         }
         return pages.mapIndexed { pageIndex, pageElements ->
+            // 连续滚动模式按 scrollExtentPx 堆叠相邻页。中间页沿用排版游标(对照旧 View 逐页
+            // 堆叠 durY,ReaderLineSpacingTest 明确要求「不是一整屏」);章末页取「内容覆盖
+            // 高度」与「内容区高度下限」的较大者——旧 View 的章末/提示页同样按可见高度收口
+            // (`TextPage.kt` 的 `height = ChapterProvider.visibleHeight`),否则残页与卷名页
+            // 会让下一章正文压在本页内容上,滚动也会立刻跨页。
+            // 章末页再留出 [chapterEndPaddingPx],对照旧 View 的 `height = durY + 20dp`。
+            val contentCoveredExtentPx = pageExtents[pageIndex] + centeredPageShiftPx
+            val stackedExtentPx = if (config.continuousScroll && pageIndex == pages.lastIndex) {
+                maxOf(contentCoveredExtentPx, config.contentBottomPx - config.paddingTopPx) +
+                        config.chapterEndPaddingPx
+            } else pageExtents[pageIndex]
             ReaderPage(
                 id = ReaderPageId(config.chapterIndex, pageIndex),
                 chapterTitle = config.chapterTitle,
@@ -735,7 +768,7 @@ object ReaderPaginator {
                 } else pageExtents[pageIndex] - config.paddingBottomPx,
                 elements = pageElements,
                 revision = config.revision,
-                scrollExtentPx = pageExtents[pageIndex],
+                scrollExtentPx = stackedExtentPx,
                 decoration = config.decoration,
                 inlineImagesPreserveScrollLine = config.inlineImagesPreserveScrollLine,
                 emphasisUnderlineStyle = config.emphasisUnderlineStyle,

+ 26 - 3
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderEmphasisUnderlineRun.kt

@@ -14,12 +14,35 @@ data class ReaderEmphasisUnderlineRun(
 )
 
 /** Restores the legacy whole-line underline used for search hits and read-aloud paragraphs. */
-fun ReaderPage.emphasisUnderlineRuns(): List<ReaderEmphasisUnderlineRun> {
+fun ReaderPage.emphasisUnderlineRuns(): List<ReaderEmphasisUnderlineRun> =
+    underlineRuns(
+        styleSelector = { line -> line.firstNotNullOfOrNull(ReaderElement.Text::emphasisUnderline) },
+        isHit = { it.emphasisUnderline != null },
+    )
+
+/**
+ * 运行期命中(搜索结果、朗读段落)的整行下划线,命中判定由调用方提供。
+ *
+ * 对照旧 View `TextLine.drawTextLine`:`if (useUnderline && (isReadAloud || searchResultColumnCount > 0))`
+ * 时执行 `drawLine(lineStart + indentWidth, lineY, lineEnd, lineY)` —— 命中位置只决定
+ * 「哪一行要划线」,线的范围始终是整行(首行缩进之后到行尾)。取命中元素自身的边界会把线缩成
+ * 命中词的宽度,与旧版不一致。
+ */
+fun ReaderPage.emphasisUnderlineRunsFor(
+    style: ReaderEmphasisUnderline,
+    isHit: (ReaderElement.Text) -> Boolean,
+): List<ReaderEmphasisUnderlineRun> =
+    underlineRuns(styleSelector = { style }, isHit = isHit)
+
+private fun ReaderPage.underlineRuns(
+    styleSelector: (List<ReaderElement.Text>) -> ReaderEmphasisUnderline?,
+    isHit: (ReaderElement.Text) -> Boolean,
+): List<ReaderEmphasisUnderlineRun> {
     val lines = elements.filterIsInstance<ReaderElement.Text>()
         .groupBy { it.bounds.top to it.bounds.bottom }
     return lines.values.mapNotNull { line ->
-        val style = line.firstNotNullOfOrNull(ReaderElement.Text::emphasisUnderline)
-            ?: return@mapNotNull null
+        if (line.none(isHit)) return@mapNotNull null
+        val style = styleSelector(line) ?: return@mapNotNull null
         ReaderEmphasisUnderlineRun(
             startPx = line.minOf { it.bounds.left },
             endPx = line.maxOf { it.bounds.right },

+ 17 - 8
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderNineSliceLayout.kt

@@ -1,5 +1,7 @@
 package io.legado.app.feature.reader.core.model
 
+import kotlin.math.roundToInt
+
 data class ReaderIntRect(val left: Int, val top: Int, val right: Int, val bottom: Int)
 
 data class ReaderNineSliceCell(
@@ -16,17 +18,24 @@ object ReaderNineSliceLayout {
         image: ReaderTextBackgroundImage,
     ): List<ReaderNineSliceCell> {
         if (bitmapWidth <= 0 || bitmapHeight <= 0) return emptyList()
+        val borderPx = if (image.hasNinePatchBorder) 1 else 0
+        val sourceLeft = borderPx
+        val sourceTop = borderPx
+        val sourceRight = (bitmapWidth - borderPx).coerceAtLeast(sourceLeft)
+        val sourceBottom = (bitmapHeight - borderPx).coerceAtLeast(sourceTop)
+        val sourceWidth = sourceRight - sourceLeft
+        val sourceHeight = sourceBottom - sourceTop
         val sx = intArrayOf(
-            0,
-            (bitmapWidth * image.ninePatchLeft.coerceIn(0f, 1f)).toInt(),
-            (bitmapWidth * (1f - image.ninePatchRight.coerceIn(0f, 1f))).toInt(),
-            bitmapWidth,
+            sourceLeft,
+            sourceLeft + (sourceWidth * image.ninePatchLeft.coerceIn(0f, 1f)).roundToInt(),
+            sourceRight - (sourceWidth * image.ninePatchRight.coerceIn(0f, 1f)).roundToInt(),
+            sourceRight,
         )
         val sy = intArrayOf(
-            0,
-            (bitmapHeight * image.ninePatchTop.coerceIn(0f, 1f)).toInt(),
-            (bitmapHeight * (1f - image.ninePatchBottom.coerceIn(0f, 1f))).toInt(),
-            bitmapHeight,
+            sourceTop,
+            sourceTop + (sourceHeight * image.ninePatchTop.coerceIn(0f, 1f)).roundToInt(),
+            sourceBottom - (sourceHeight * image.ninePatchBottom.coerceIn(0f, 1f)).roundToInt(),
+            sourceBottom,
         )
         if (sx[1] > sx[2] || sy[1] > sy[2]) return emptyList()
         val dx = floatArrayOf(frame.left, content.left, content.right, frame.right)

+ 13 - 5
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPage.kt

@@ -38,7 +38,11 @@ data class ReaderTextBackgroundImage(
     val contentInsetRightPx: Float = 0f,
     val contentInsetTopPx: Float = 0f,
     val contentInsetBottomPx: Float = 0f,
-)
+) {
+    val hasNinePatchBorder: Boolean
+        get() = source.substringBefore('?').substringBefore('#')
+            .endsWith(".9.png", ignoreCase = true)
+}
 
 fun ReaderTextBackgroundImage.withBitmapWidth(widthPx: Int): ReaderTextBackgroundImage {
     return withBitmapSize(widthPx, 0)
@@ -46,11 +50,15 @@ fun ReaderTextBackgroundImage.withBitmapWidth(widthPx: Int): ReaderTextBackgroun
 
 fun ReaderTextBackgroundImage.withBitmapSize(widthPx: Int, heightPx: Int): ReaderTextBackgroundImage {
     if (fit != 3 || widthPx <= 0) return this
+    val borderPx = if (hasNinePatchBorder) 1 else 0
+    val contentWidthPx = (widthPx - borderPx * 2).coerceAtLeast(0)
+    val contentHeightPx = (heightPx - borderPx * 2).coerceAtLeast(0)
+    val fixedScale = scale.coerceIn(0.1f, 5f)
     return copy(
-        contentInsetLeftPx = (widthPx * ninePatchLeft.coerceIn(0f, 1f)).toInt().toFloat(),
-        contentInsetRightPx = (widthPx * ninePatchRight.coerceIn(0f, 1f)).toInt().toFloat(),
-        contentInsetTopPx = (heightPx.coerceAtLeast(0) * ninePatchTop.coerceIn(0f, 1f)).toInt().toFloat(),
-        contentInsetBottomPx = (heightPx.coerceAtLeast(0) * ninePatchBottom.coerceIn(0f, 1f)).toInt().toFloat(),
+        contentInsetLeftPx = contentWidthPx * ninePatchLeft.coerceIn(0f, 1f) * fixedScale,
+        contentInsetRightPx = contentWidthPx * ninePatchRight.coerceIn(0f, 1f) * fixedScale,
+        contentInsetTopPx = contentHeightPx * ninePatchTop.coerceIn(0f, 1f) * fixedScale,
+        contentInsetBottomPx = contentHeightPx * ninePatchBottom.coerceIn(0f, 1f) * fixedScale,
     )
 }
 

+ 27 - 0
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPageContentClip.kt

@@ -0,0 +1,27 @@
+package io.legado.app.feature.reader.core.model
+
+/**
+ * 滚动模式内容裁剪的外扩量。
+ *
+ * 对照旧 View `ChapterProvider` 的 `visibleRect`:可见矩形按
+ * `shadowPad = 阴影半径 + 2`(`Build.VERSION_CODES.Q` 及以上的 `shadowLayerRadius`)与
+ * `italicPad = 字号 * 0.25`(仅斜体)向外扩,否则贴边裁切会把文字阴影和斜体字缘切掉。
+ * 取页内所有文字元素的最大值,等价于旧版那个全局 Paint 的取值在混排样式下的推广。
+ */
+val ReaderPage.contentClipPadPx: Float
+    get() {
+        var pad = 0f
+        elements.forEach { element ->
+            val text = element as? ReaderElement.Text ?: return@forEach
+            text.style.shadow?.let { shadow ->
+                pad = maxOf(pad, shadow.radiusPx.coerceAtLeast(0f) + SHADOW_RADIUS_PADDING_PX)
+            }
+            if (text.style.italic) {
+                pad = maxOf(pad, text.style.fontSizePx * ITALIC_PAD_RATIO)
+            }
+        }
+        return pad
+    }
+
+private const val SHADOW_RADIUS_PADDING_PX = 2f
+private const val ITALIC_PAD_RATIO = 0.25f

+ 30 - 2
app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigator.kt

@@ -3,6 +3,7 @@ package io.legado.app.feature.reader.core.navigation
 import io.legado.app.feature.reader.core.model.ReaderElement
 import io.legado.app.feature.reader.core.model.ReaderPage
 import io.legado.app.feature.reader.core.model.ReaderPageWindow
+import io.legado.app.feature.reader.core.navigation.ReaderPageNavigator.locate
 
 data class ReaderNavigationResult(
     val pageIndex: Int,
@@ -70,13 +71,40 @@ object ReaderPageNavigator {
         return ReaderNavigationResult(target, window(pages, target), target != pageIndex + delta)
     }
 
-    fun locate(pages: List<ReaderPage>, chapterIndex: Int, chapterPosition: Int): Int {
+    /**
+     * 翻页放行条件(对照旧 View `TextPageFactory.hasNext()` / `hasPrev()`):
+     * 旧实现是 `hasNextChapter() || 本章还有下一页`、`hasPrevChapter() || pageIndex > 0`,
+     * 只看"书里还有没有邻章/本章还有没有下一页",与邻章是否已完成排版无关。
+     *
+     * Canvas 早期版本只看 [ReaderPageWindow.next] 是否为 null(排版就绪),邻章排版滞后
+     * 时会把可翻的边界误判成书末,点按/拖拽只弹"没有下一页"且不再触发装载,形成死端。
+     * 排版未就绪的邻章在旧 View 里由 `moveToNextChapter` → 标题占位页承接;Canvas 的等价
+     * 承接是 `ReadBookController.crossComposeChapterBoundary`(预置"加载中"占位页或直接启动
+     * 该章排版)。因此放行必须回到业务语义,把翻页交给宿主处理。
+     */
+    fun canTurnNext(window: ReaderPageWindow, hasNextChapter: Boolean): Boolean =
+        window.next != null || hasNextChapter
+
+    /** 上一页放行条件,对照旧 View `TextPageFactory.hasPrev()`。 */
+    fun canTurnPrevious(window: ReaderPageWindow, hasPreviousChapter: Boolean): Boolean =
+        window.previous != null || hasPreviousChapter
+
+    /**
+     * 按章内位置定位页下标;该章不在 [pages] 中时返回 null。
+     *
+     * 注意与 [locate] 的区别:本函数不把"未定位"折叠成 0。0 是全书首页的合法下标,
+     * 调用方拿它发布窗口会把阅读位置跳到书首(见 `publishDirectReaderPageWindow`)。
+     */
+    fun locateOrNull(pages: List<ReaderPage>, chapterIndex: Int, chapterPosition: Int): Int? {
         val chapterPages = pages.withIndex().filter { it.value.id.chapterIndex == chapterIndex }
-        if (chapterPages.isEmpty()) return 0
+        if (chapterPages.isEmpty()) return null
         return chapterPages.lastOrNull { pageStart(it.value) <= chapterPosition }?.index
             ?: chapterPages.first().index
     }
 
+    fun locate(pages: List<ReaderPage>, chapterIndex: Int, chapterPosition: Int): Int =
+        locateOrNull(pages, chapterIndex, chapterPosition) ?: 0
+
     fun chapterPosition(pages: List<ReaderPage>, pageIndex: Int): ReaderChapterPagePosition? {
         val page = pages.getOrNull(pageIndex) ?: return null
         val chapterIndex = page.id.chapterIndex

+ 107 - 35
app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelection.kt

@@ -23,9 +23,25 @@ data class ReaderSelection(
     val focus: Int,
     val anchorIsTitle: Boolean = false,
     val focusIsTitle: Boolean = anchorIsTitle,
+    /**
+     * focus 所在的章;[chapterIndex] 始终是 anchor 所在的章。滚动模式的连续堆叠允许选区
+     * 跨到邻章页——旧 View 的 `ContentTextView.upSelectChars` 在滚动模式遍历 relativePage
+     * 0..2,而 `TextPageFactory.nextPlusPage` 在章末会给出下一章首页,因此那时选区含邻章
+     * 首页的文字;分页模式两边恒相等。
+     */
+    val focusChapterIndex: Int = chapterIndex,
 ) {
-    // Title offsets and body offsets are independent. Document order puts the title first.
-    private val forward: Boolean get() = comparePosition(anchor, anchorIsTitle, focus, focusIsTitle) <= 0
+    // Title offsets and body offsets are independent. Document order puts the title first
+    // and the body after it; the chapter is the outermost key.
+    private val forward: Boolean
+        get() = comparePosition(
+            chapterIndex, anchorIsTitle, anchor,
+            focusChapterIndex, focusIsTitle, focus,
+        ) <= 0
+    val startChapterIndex: Int get() = if (forward) chapterIndex else focusChapterIndex
+    val endChapterIndex: Int get() = if (forward) focusChapterIndex else chapterIndex
+    private val startIsTitle: Boolean get() = if (forward) anchorIsTitle else focusIsTitle
+    private val endIsTitle: Boolean get() = if (forward) focusIsTitle else anchorIsTitle
     val start: Int get() = if (forward) anchor else focus
     val endInclusive: Int get() = if (forward) focus else anchor
     val includesTitle: Boolean get() = anchorIsTitle || focusIsTitle
@@ -35,17 +51,30 @@ data class ReaderSelection(
         else -> start
     }
 
-    fun contains(element: ReaderElement.Text): Boolean =
-        comparePosition(element.chapterPosition, element.emphasized, start, if (forward) anchorIsTitle else focusIsTitle) >= 0 &&
-            comparePosition(element.chapterPosition, element.emphasized, endInclusive, if (forward) focusIsTitle else anchorIsTitle) <= 0
+    fun contains(element: ReaderElement.Text, pageChapterIndex: Int): Boolean =
+        comparePosition(
+            pageChapterIndex, element.emphasized, element.chapterPosition,
+            startChapterIndex, startIsTitle, start,
+        ) >= 0 && comparePosition(
+            pageChapterIndex, element.emphasized, element.chapterPosition,
+            endChapterIndex, endIsTitle, endInclusive,
+        ) <= 0
 
-    fun moveStart(position: Int, isTitle: Boolean = false): ReaderSelection =
-        if (forward) copy(anchor = position, anchorIsTitle = isTitle)
-        else copy(focus = position, focusIsTitle = isTitle)
+    fun moveStart(
+        position: Int,
+        isTitle: Boolean = false,
+        chapter: Int = startChapterIndex,
+    ): ReaderSelection =
+        if (forward) copy(anchor = position, anchorIsTitle = isTitle, chapterIndex = chapter)
+        else copy(focus = position, focusIsTitle = isTitle, focusChapterIndex = chapter)
 
-    fun moveEnd(position: Int, isTitle: Boolean = false): ReaderSelection =
-        if (forward) copy(focus = position, focusIsTitle = isTitle)
-        else copy(anchor = position, anchorIsTitle = isTitle)
+    fun moveEnd(
+        position: Int,
+        isTitle: Boolean = false,
+        chapter: Int = endChapterIndex,
+    ): ReaderSelection =
+        if (forward) copy(focus = position, focusIsTitle = isTitle, focusChapterIndex = chapter)
+        else copy(anchor = position, anchorIsTitle = isTitle, chapterIndex = chapter)
 
     fun visualStartEndpoint(): ReaderSelectionEndpoint =
         if (forward) ReaderSelectionEndpoint.ANCHOR else ReaderSelectionEndpoint.FOCUS
@@ -57,51 +86,79 @@ data class ReaderSelection(
         endpoint: ReaderSelectionEndpoint,
         position: Int,
         isTitle: Boolean = false,
+        chapter: Int = when (endpoint) {
+            ReaderSelectionEndpoint.ANCHOR -> chapterIndex
+            ReaderSelectionEndpoint.FOCUS -> focusChapterIndex
+        },
     ): ReaderSelection = when (endpoint) {
-        ReaderSelectionEndpoint.ANCHOR -> copy(anchor = position, anchorIsTitle = isTitle)
-        ReaderSelectionEndpoint.FOCUS -> copy(focus = position, focusIsTitle = isTitle)
+        ReaderSelectionEndpoint.ANCHOR ->
+            copy(anchor = position, anchorIsTitle = isTitle, chapterIndex = chapter)
+
+        ReaderSelectionEndpoint.FOCUS ->
+            copy(focus = position, focusIsTitle = isTitle, focusChapterIndex = chapter)
     }
 
     fun selectedText(page: ReaderPage): String {
         return selectedText(listOf(page))
     }
 
-    /** Collects a selection across every available page without duplicating page-boundary glyphs. */
+    /**
+     * Collects a selection across every available page without duplicating page-boundary
+     * glyphs. Pages from neighbouring chapters are included when the selection spans them.
+     */
     fun selectedText(pages: List<ReaderPage>): String {
-        val elements = pages.asSequence()
-            .filter { it.id.chapterIndex == chapterIndex }
-            .flatMap { it.elements.asSequence().filterIsInstance<ReaderElement.Text>() }
-            .filter(::contains)
-            .distinctBy { Triple(it.emphasized, it.chapterPosition, it.value) }
-            .sortedWith(documentOrder)
+        val ordered = pages.asSequence()
+            .flatMap { page ->
+                page.elements.asSequence()
+                    .filterIsInstance<ReaderElement.Text>()
+                    .map { page.id.chapterIndex to it }
+            }
+            .filter { (chapter, text) -> contains(text, chapter) }
+            .distinctBy { (chapter, text) ->
+                Triple(chapter, text.emphasized, text.chapterPosition) to text.value
+            }
+            .sortedWith(documentOrderByChapter)
             .toList()
         return buildString {
-            var previous: ReaderElement.Text? = null
-            elements.forEach { element ->
-                previous?.let { prior ->
-                    if (prior.emphasized != element.emphasized ||
-                        (prior.paragraphIndex >= 0 && element.paragraphIndex >= 0 &&
-                            prior.paragraphIndex != element.paragraphIndex)
+            var previous: Pair<Int, ReaderElement.Text>? = null
+            ordered.forEach { (chapter, text) ->
+                previous?.let { (priorChapter, prior) ->
+                    if (priorChapter != chapter ||
+                        prior.emphasized != text.emphasized ||
+                        (prior.paragraphIndex >= 0 && text.paragraphIndex >= 0 &&
+                                prior.paragraphIndex != text.paragraphIndex)
                     ) append('\n')
                 }
-                append(element.value)
-                previous = element
+                append(text.value)
+                previous = chapter to text
             }
         }
     }
 
-    fun bounds(page: ReaderPage): List<ReaderRect> = if (page.id.chapterIndex != chapterIndex) emptyList() else page.elements
+    fun bounds(page: ReaderPage): List<ReaderRect> = page.elements
         .filterIsInstance<ReaderElement.Text>()
-        .filter(::contains)
+        .filter { contains(it, page.id.chapterIndex) }
         .sortedWith(documentOrder)
         .map(ReaderElement.Text::bounds)
 
     private companion object {
         val documentOrder = compareBy<ReaderElement.Text> { !it.emphasized }.thenBy { it.chapterPosition }
+        val documentOrderByChapter = compareBy<Pair<Int, ReaderElement.Text>> { it.first }
+            .thenBy { !it.second.emphasized }
+            .thenBy { it.second.chapterPosition }
 
-        fun comparePosition(left: Int, leftIsTitle: Boolean, right: Int, rightIsTitle: Boolean): Int =
-            if (leftIsTitle == rightIsTitle) left.compareTo(right)
+        fun comparePosition(
+            leftChapter: Int,
+            leftIsTitle: Boolean,
+            left: Int,
+            rightChapter: Int,
+            rightIsTitle: Boolean,
+            right: Int,
+        ): Int {
+            if (leftChapter != rightChapter) return leftChapter.compareTo(rightChapter)
+            return if (leftIsTitle == rightIsTitle) left.compareTo(right)
             else if (leftIsTitle) -1 else 1
+        }
     }
 }
 
@@ -187,10 +244,25 @@ object ReaderSelectionPolicy {
         )
     }
 
-    fun extend(selection: ReaderSelection, page: ReaderPage, x: Float, y: Float): ReaderSelection {
-        if (selection.chapterIndex != page.id.chapterIndex) return selection
+    /**
+     * [allowChapterCrossing] 只在滚动模式传 true:视口里堆叠的就是当前页与下一章首页
+     * (旧 View 同样把这一页纳入选区分词,见 [ReaderSelection.focusChapterIndex])。
+     * 分页模式保持单章,与旧 View 的 `last = if (isScroll) 2 else 0` 一致。
+     */
+    fun extend(
+        selection: ReaderSelection,
+        page: ReaderPage,
+        x: Float,
+        y: Float,
+        allowChapterCrossing: Boolean = false,
+    ): ReaderSelection {
+        if (!allowChapterCrossing && selection.chapterIndex != page.id.chapterIndex) return selection
         return (page.elementAt(x, y) as? ReaderElement.Text)?.let {
-            selection.copy(focus = it.chapterPosition, focusIsTitle = it.emphasized)
+            selection.copy(
+                focus = it.chapterPosition,
+                focusIsTitle = it.emphasized,
+                focusChapterIndex = page.id.chapterIndex,
+            )
         } ?: selection
     }
 }

+ 12 - 2
app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderPageTransition.kt

@@ -85,12 +85,22 @@ data class ReaderHorizontalDrag(
 }
 
 object ReaderPageTransitionPolicy {
+    /**
+     * 翻页收尾动画的基准时长。
+     *
+     * 点击与外部按键(音量键 / 媒体键 / 空格 / D-pad)在画布层已合并到同一条收尾路径
+     * (`externalPageTurns -> dispatchTapAction -> tapPageTurn`),因此两者共用这一个基准;
+     * 旧 View 中按键固定 100ms 的独立档位随之消失。360 是产品确认保留的取值,不要"回退"
+     * 成旧 View 的 300——那会让点击与按键重新分叉。
+     */
+    private const val BASE_DURATION_MILLIS = 360
+
     fun settleDurationMillis(
         mode: ReaderTransitionMode,
         currentOffsetPx: Float,
         targetOffsetPx: Float,
         pageExtentPx: Float,
-        baseDurationMillis: Int = 360,
+        baseDurationMillis: Int = BASE_DURATION_MILLIS,
     ): Int {
         if (pageExtentPx <= 0f || baseDurationMillis <= 0 || currentOffsetPx == targetOffsetPx) return 0
         // FadePageDelegate animates its normalized alpha for the full configured duration.
@@ -106,7 +116,7 @@ object ReaderPageTransitionPolicy {
         currentOffsetPx: Float,
         targetOffsetPx: Float,
         pageExtentPx: Float,
-        baseDurationMillis: Int = 300,
+        baseDurationMillis: Int = BASE_DURATION_MILLIS,
     ): Int {
         if (pageExtentPx <= 0f || baseDurationMillis <= 0) return 0
         return (baseDurationMillis * abs(targetOffsetPx - currentOffsetPx) / pageExtentPx)

+ 22 - 0
app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderScrollState.kt

@@ -3,6 +3,7 @@ package io.legado.app.feature.reader.core.transition
 import io.legado.app.feature.reader.core.model.ReaderElement
 import io.legado.app.feature.reader.core.model.ReaderPage
 import io.legado.app.feature.reader.core.transition.ReaderScrollPolicy.apply
+import kotlin.math.abs
 
 enum class ReaderScrollCrossing { PREVIOUS, NEXT }
 
@@ -70,6 +71,24 @@ object ReaderScrollPolicy {
         return if (direction == ReaderTurnDirection.PREVIOUS) effective else -effective
     }
 
+    /**
+     * 点击/按键滚动翻页的时长:对照旧 `PageDelegate.startScroll` 的
+     * `animationSpeed * |dy| / viewHeight`(`animationSpeed` 即
+     * `ReadView.defaultAnimationSpeed = 300`)。
+     *
+     * 时长随步距缩放,而不是固定帧数:旧版整屏步距(图片页)与"保留一行"步距(文本页,
+     * 约为一屏减一行)落在这条曲线的不同位置,固定 18 帧会把短步距拖成与整屏一样久。
+     */
+    fun stepDurationMillis(
+        distancePx: Float,
+        viewportExtentPx: Float,
+        animationSpeedMillis: Int = LEGACY_ANIMATION_SPEED_MILLIS,
+    ): Int {
+        if (viewportExtentPx <= 0f) return animationSpeedMillis
+        val scaled = animationSpeedMillis * abs(distancePx) / viewportExtentPx
+        return scaled.coerceIn(1f, Int.MAX_VALUE.toFloat()).toInt()
+    }
+
     fun apply(
         offsetPx: Float,
         deltaPx: Float,
@@ -102,4 +121,7 @@ object ReaderScrollPolicy {
         }
         return ReaderScrollResult(next)
     }
+
+    /** 旧 `ReadView.defaultAnimationSpeed`:滚动与翻页动画的基准速度(ms / 一屏)。 */
+    private const val LEGACY_ANIMATION_SPEED_MILLIS = 300
 }

+ 10 - 2
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt

@@ -23,6 +23,7 @@ import io.legado.app.feature.reader.platform.ReaderPerfTrace
 import io.legado.app.help.book.BookContent
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ImageProvider
+import io.legado.app.utils.dpToPx
 import kotlinx.coroutines.CancellationException
 import splitties.init.appCtx
 
@@ -171,11 +172,14 @@ object LegacyReaderChapterPaginator {
                 bodyIndentCharacters = ReadBookConfig.paragraphIndent.length,
                 bodyIndentText = ReadBookConfig.paragraphIndent,
                 bodyAlignment = if (ReadBookConfig.textFullJustify) ReaderTextAlignment.JUSTIFY else ReaderTextAlignment.START,
+                // 旧 TextChapterLayout 让 `imgStyleSingle` 的章标题同样居中(水平,见
+                // `addCharsToLineNatural` 的 startX;垂直见 setTypeText 的 durY 分支)。
                 titleAlignment = if (ReadBookConfig.isMiddleTitle || chapter.isVolume ||
-                    content.textList.isEmpty()
+                    content.textList.isEmpty() || singleImage
                 ) ReaderTextAlignment.CENTER else ReaderTextAlignment.START,
                 imagePageBreakBefore = singleImage,
                 imagePageBreakAfter = singleImage,
+                titlePageBreakAfter = singleImage && content.textList.isNotEmpty(),
                 imageLayoutMode = imageLayoutMode,
                 imageAvailableWidthPx = (
                     viewportWidthPx / paginationStyle.columnCount(viewportWidthPx, viewportHeightPx) -
@@ -216,6 +220,7 @@ object LegacyReaderChapterPaginator {
                 baselineOffsetPx = paginationStyle.bodyBaselineOffsetPx,
                 lineSpacingMultiplier = paginationStyle.lineSpacingExtra,
                 continuousScroll = paginationStyle.isScroll,
+                chapterEndPaddingPx = CHAPTER_END_PADDING_DP.dpToPx(),
                 inlineImagesPreserveScrollLine = imageLayoutMode == ReaderImageLayoutMode.INLINE,
                 textBottomJustify = paginationStyle.textBottomJustify,
                 pageUnderline = paginationStyle.pageUnderline,
@@ -223,7 +228,7 @@ object LegacyReaderChapterPaginator {
                 paragraphSpacingPx = paginationStyle.bodyTextHeightPx * paginationStyle.paragraphSpacing / 10f,
                 titleTopSpacingPx = paginationStyle.titleTopSpacingPx,
                 titleBottomSpacingPx = paginationStyle.titleBottomSpacingPx,
-                titlePageCenterVertical = chapter.isVolume || content.textList.isEmpty(),
+                titlePageCenterVertical = chapter.isVolume || content.textList.isEmpty() || singleImage,
                 titleParagraphSpacingPx = paginationStyle.titleTextHeightPx * paginationStyle.paragraphSpacing / 10f,
                 titleSegmentSpacingPx = paginationStyle.titleTextHeightPx * paginationStyle.titleLineSpacingSub,
                 letterSpacingPx = bodyPaint.letterSpacing * bodyPaint.textSize,
@@ -234,3 +239,6 @@ object LegacyReaderChapterPaginator {
         return LegacyReaderChapterPaginationResult.Success(pages)
     }
 }
+
+/** 旧 `TextChapterLayout.setTypeText` 收尾时给章末页加的留白(20dp)。 */
+private const val CHAPTER_END_PADDING_DP = 20f

+ 16 - 6
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderStyleRangeMapper.kt

@@ -15,6 +15,7 @@ import io.legado.app.feature.reader.core.style.ReaderCharacterStyle
 import io.legado.app.feature.reader.core.style.ReaderStyleRange
 import io.legado.app.feature.reader.core.style.ReaderStyleTarget
 import io.legado.app.feature.reader.platform.ReaderTextBackgroundLoader
+import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.utils.GSON
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.fromJsonObject
@@ -104,7 +105,10 @@ object LegacyReaderStyleRangeMapper {
         underline = underlineMode.takeIf { it != 0 }?.let {
             ReaderUnderline(
                 mode = it,
-                colorArgb = underlineColor ?: textColor ?: 0xFF63C37D.toInt(),
+                // 兜底跟随正文色:旧 `TextLine.drawStyledUnderlines` 的
+                // `underlineColor ?: textColor ?: ChapterProvider.renderStyle.textColor`
+                // (即 ReadBookConfig.textColor),不是写死的主题绿。
+                colorArgb = underlineColor ?: textColor ?: ReadBookConfig.textColor,
                 widthPx = underlineWidth.dpToPx(),
                 offsetPx = underlineOffset.dpToPx(),
                 svgPath = underlineSvgPath.orEmpty(),
@@ -124,14 +128,17 @@ object LegacyReaderStyleRangeMapper {
         italic = isItalic,
         fontSizeOffsetPx = fontSizeOffset.toFloat().spToPx(),
         backgroundImage = bgImage?.takeIf(String::isNotBlank)?.let {
+            val automatic = if (manualNineSlice) null else {
+                ReaderTextBackgroundLoader.nineSliceFractions(it)
+            }
             ReaderTextBackgroundImage(
                 source = it,
                 fit = bgImageFit,
                 scale = bgImageScale,
-                ninePatchLeft = npLeft,
-                ninePatchRight = npRight,
-                ninePatchTop = npTop,
-                ninePatchBottom = npBottom,
+                ninePatchLeft = automatic?.left ?: npLeft,
+                ninePatchRight = automatic?.right ?: npRight,
+                ninePatchTop = automatic?.top ?: npTop,
+                ninePatchBottom = automatic?.bottom ?: npBottom,
             ).let { image ->
                 val (width, height) = backgroundImageSize(it)
                 image.withBitmapSize(width, height)
@@ -145,7 +152,10 @@ object LegacyReaderStyleRangeMapper {
         underline = underlineMode.takeIf { it != 0 }?.let {
             ReaderUnderline(
                 mode = it,
-                colorArgb = underlineColor ?: textColor ?: 0xFF63C37D.toInt(),
+                // 兜底跟随正文色:旧 `TextLine.drawStyledUnderlines` 的
+                // `underlineColor ?: textColor ?: ChapterProvider.renderStyle.textColor`
+                // (即 ReadBookConfig.textColor),不是写死的主题绿。
+                colorArgb = underlineColor ?: textColor ?: ReadBookConfig.textColor,
                 widthPx = underlineWidth.dpToPx(),
                 offsetPx = underlineOffset.dpToPx(),
                 svgPath = underlineSvgPath.orEmpty(),

+ 8 - 3
app/src/main/java/io/legado/app/feature/reader/platform/AndroidReaderTextShaper.kt

@@ -6,14 +6,14 @@ import android.os.Build
 import android.text.TextPaint
 import androidx.core.net.toUri
 import io.legado.app.feature.reader.core.layout.GlyphClusters
-import io.legado.app.feature.reader.core.layout.ReaderTextShaper
 import io.legado.app.feature.reader.core.layout.ReaderFontBounds
 import io.legado.app.feature.reader.core.layout.ReaderFontLineMetrics
+import io.legado.app.feature.reader.core.layout.ReaderTextShaper
 import io.legado.app.feature.reader.core.layout.clusterGlyphs
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
 import io.legado.app.utils.validFontLeading
-import java.io.File
 import splitties.init.appCtx
+import java.io.File
 
 object ReaderAndroidPaintFactory {
     /** 进程级字体缓存:同一 path/weight/italic/family 只做一次磁盘读取与解析。 */
@@ -30,7 +30,12 @@ object ReaderAndroidPaintFactory {
             isStrikeThruText = style.strikeThrough
             isUnderlineText = style.nativeUnderline
             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
-                setFontVariationSettings("'wght' ${style.fontWeight}")
+                // 对照旧 View `ChapterProvider`:只有显式字重(100..900,映射后落在
+                // bold/light 档位)才写 `wght`;`400` 在本仓库语义是"未设置/常规"
+                // (见 LegacyReaderStyleRangeMapper 的 400 约定),旧版此时完全不动
+                // Paint,强写 `wght 400` 会把字体自带的默认实例(如 500)改细。
+                style.fontWeight.takeIf { it != 400 }
+                    ?.let { setFontVariationSettings("'wght' $it") }
             }
             style.shadow?.let { setShadowLayer(it.radiusPx, it.dxPx, it.dyPx, it.colorArgb) }
         }

+ 34 - 0
app/src/main/java/io/legado/app/feature/reader/platform/ReaderTextBackgroundLoader.kt

@@ -10,6 +10,12 @@ import java.io.InputStream
 
 /** Android resource boundary shared by background measurement and Canvas drawing. */
 object ReaderTextBackgroundLoader {
+    data class NineSliceFractions(
+        val left: Float,
+        val right: Float,
+        val top: Float,
+        val bottom: Float,
+    )
     private val bitmaps = object : LruCache<String, Bitmap>(16 * 1024 * 1024) {
         override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount
     }
@@ -52,6 +58,34 @@ object ReaderTextBackgroundLoader {
         ?.let(bitmaps::get)
         ?.takeUnless(Bitmap::isRecycled)
 
+    /** Reads the first stretch run from a raw .9.png guide border. */
+    fun nineSliceFractions(source: String): NineSliceFractions? {
+        if (!isRawNinePatch(source)) return null
+        val bitmap = load(source) ?: return null
+        if (bitmap.width < 3 || bitmap.height < 3) return null
+        fun marked(color: Int): Boolean = android.graphics.Color.alpha(color) > 0 &&
+                android.graphics.Color.red(color) < 32 &&
+                android.graphics.Color.green(color) < 32 &&
+                android.graphics.Color.blue(color) < 32
+
+        fun run(length: Int, colorAt: (Int) -> Int): IntRange? {
+            val start = (1 until length - 1).firstOrNull { marked(colorAt(it)) } ?: return null
+            val end = (start until length - 1).takeWhile { marked(colorAt(it)) }.last()
+            return start..end
+        }
+
+        val horizontal = run(bitmap.width) { bitmap.getPixel(it, 0) } ?: return null
+        val vertical = run(bitmap.height) { bitmap.getPixel(0, it) } ?: return null
+        val width = (bitmap.width - 2).toFloat()
+        val height = (bitmap.height - 2).toFloat()
+        return NineSliceFractions(
+            left = (horizontal.first - 1) / width,
+            right = (bitmap.width - 2 - horizontal.last) / width,
+            top = (vertical.first - 1) / height,
+            bottom = (bitmap.height - 2 - vertical.last) / height,
+        )
+    }
+
     internal fun assetCandidates(source: String): List<String> = when {
         source.startsWith("assets://") -> listOf(source.removePrefix("assets://"))
         source.startsWith("content://") || File(source).exists() -> emptyList()

+ 42 - 14
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -980,12 +980,15 @@ class ReadBookController(
             // upContent 语义是"按 durChapterPos 重新定位"(对照旧 View upContent 重绘):
             // 朗读跨页走 moveToNextPage → upContent,只有重定位页面才会前进;缓存下标
             // 会让这类发布变成空操作,页面跟随朗读随之失效。
-            val index = ReaderPageNavigator.locate(
-                directReaderPages,
-                chapter.chapter.index,
-                ReadBook.durChapterPos,
-            ).also { directReaderPageIndex = it }
-            publishDirectReaderWindow(index)
+            // locate 在"当前章还没有页"时会折叠成 0(全书首页的合法下标),直接发布会把
+            // 阅读位置跳回书首;此时不发布窗口,交给相邻章预排与后续批次补页
+            // (排版失败时 updateReaderPaginationError 会给出重试入口)。
+            ReaderPageNavigator
+                .locateOrNull(directReaderPages, chapter.chapter.index, ReadBook.durChapterPos)
+                ?.let { index ->
+                    directReaderPageIndex = index
+                    publishDirectReaderWindow(index)
+                }
             scheduleAdjacentReaderPagination(
                 key, chapter, chapters, width, height, contentPadding, resolvedPaginationStyle
             )
@@ -1177,12 +1180,16 @@ class ReadBookController(
         // 重排可能改变元素位置与页 endPosition,页上下文缓存全部失效。
         directReaderPageContexts.clear()
         directReaderChapterPageCounts = directReaderPages.groupingBy { it.id.chapterIndex }.eachCount()
-        directReaderPageIndex = directReaderPages.takeIf { it.isNotEmpty() }?.let {
-            ReaderPageNavigator.locate(
-                it,
-                currentChapter.chapter.index,
-                ReadBook.durChapterPos,
-            )
+            directReaderPageIndex = directReaderPages.takeIf { it.isNotEmpty() }?.let { pages ->
+                // 当前章在批次结果中缺失时 locate 会折叠成 0(全书首页):保留原下标,
+                // 由下面的 publishDirectReaderWindow 重新收敛到合法范围,避免跳回书首。
+                ReaderPageNavigator.locateOrNull(
+                    pages,
+                    currentChapter.chapter.index,
+                    ReadBook.durChapterPos
+                )
+                    ?: directReaderPageIndex?.coerceIn(pages.indices)
+                    ?: 0
         }
         ReadBook.publishReaderPagination(
             directReaderPages.groupBy { it.id.chapterIndex }.mapNotNull { (chapterIndex, pages) ->
@@ -2236,7 +2243,11 @@ class ReadBookController(
         }
         val oldChapterIndex = directReaderPages[currentIndex].id.chapterIndex
         val newChapterIndex = navigation.window.current?.id?.chapterIndex ?: oldChapterIndex
+        // 页表已经跨进邻章时按页表切章;若书已停在目标章(上次翻页切了章而页表尚未
+        // 同步),不再推进一次,否则会整体跳过一章。
+        val alreadyAtNewChapter = ReadBook.durChapterIndex == newChapterIndex
         val chapterChanged = when {
+            alreadyAtNewChapter -> true
             newChapterIndex > oldChapterIndex -> ReadBook.moveToNextChapter(
                 upContent = false,
                 upContentInPlace = false,
@@ -2260,6 +2271,18 @@ class ReadBookController(
         return window
     }
 
+    /**
+     * 翻页放行业务条件,对照旧 View `ReadView.hasNextChapter()` / `hasPrevChapter()`:
+     * 只看书里业务上还有没有邻章,与邻章是否已完成 Canvas 排版无关。邻章未排版时
+     * [completeComposePageTurn] 会走 [crossComposeChapterBoundary] 预置加载占位页
+     * 或直接启动该章排版;早期实现用 window.next != null 放行,导致这一窗口期
+     * 只能弹出"没有下一页"并且不会触发装载(表现为读完本章无法进入下一章)。
+     */
+    fun hasNextComposeChapter(): Boolean =
+        ReadBook.durChapterIndex < ReadBook.simulatedChapterSize - 1
+
+    fun hasPreviousComposeChapter(): Boolean = ReadBook.durChapterIndex > 0
+
     /** Restores the legacy reader's feedback when a page turn reaches a book boundary. */
     fun showComposePageBoundary(direction: ReaderTurnDirection) {
         activity.toastOnUi(
@@ -2279,8 +2302,13 @@ class ReadBookController(
     private fun crossComposeChapterBoundary(currentIndex: Int, delta: Int): ReaderPageWindow? {
         val fromChapterIndex = directReaderPages[currentIndex].id.chapterIndex
         val targetChapterIndex = fromChapterIndex + delta
-        // 已处于该章占位/装载状态:不重复切章。
-        if (ReadBook.durChapterIndex == targetChapterIndex) return null
+        // 已处于该章占用状态(书已推进到目标章,页表却还停在上一章):这不代表翻页应当
+        // 被丢弃,而是目标章的排版还没落地。旧 View 里这一类边界由 moveToNextChapter 的
+        // 装载/重绘承接;这里补一次发布请求让该章排版推进,翻页结果由分页批次发布。
+        if (ReadBook.durChapterIndex == targetChapterIndex) {
+            publishReaderPageWindow()
+            return null
+        }
         // 占位页是死端:邻章装载完成前不允许从占位页继续向更远处串章
         // (正常路径下占位页已由 ensureBoundaryPlaceholderPages 预置,不会走到这里)。
         if (directReaderPages[currentIndex].isPlaceholder) return null

+ 4 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -719,6 +719,10 @@ fun ReadBookRouteScreen(
                 onPreviousPage = { controller.completeComposePageTurn(PageDirection.PREV) },
                 onNextPage = { controller.completeComposePageTurn(PageDirection.NEXT) },
                     onPageBoundaryReached = controller::showComposePageBoundary,
+                    // 放行条件读取"书中是否还有邻章"(旧 View hasNextChapter/hasPrevChapter),
+                    // 而不是邻章当前是否已排版完成:两章交接期间排版批次可能还没落地。
+                    hasNextChapter = controller::hasNextComposeChapter,
+                    hasPreviousChapter = controller::hasPreviousComposeChapter,
                 onToggleMenu = controller::showComposeActionMenu,
                 onToggleBookmark = { viewModel.onIntent(ReadBookIntent.ToggleBookmark) },
                 swipeToBookmarkEnabled = readPreferences.swipeToAddBookmark,

+ 173 - 34
app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt

@@ -9,9 +9,11 @@ import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGestures
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
@@ -23,6 +25,7 @@ import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Done
+import androidx.compose.material3.FilterChip
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
@@ -38,6 +41,7 @@ import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.StrokeCap
 import androidx.compose.ui.graphics.asImageBitmap
 import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringArrayResource
 import androidx.compose.ui.res.stringResource
@@ -59,8 +63,8 @@ import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.FontFolderState
 import io.legado.app.ui.widget.components.FontSelectSheet
 import io.legado.app.ui.widget.components.SectionTitle
-import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
@@ -69,12 +73,13 @@ import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
-import io.legado.app.utils.toastOnUi
 import java.io.File
+import kotlin.math.roundToInt
 
 @Composable
 fun HighlightRuleEditSheet(
@@ -140,6 +145,7 @@ fun HighlightRuleEditSheet(
     var npTop by remember(show, rule) { mutableFloatStateOf(initial.npTop) }
     var npBottom by remember(show, rule) { mutableFloatStateOf(initial.npBottom) }
     var showNinePatchEditor by remember(show, rule) { mutableStateOf(false) }
+    var manualNineSlice by remember(show, rule) { mutableStateOf(initial.manualNineSlice) }
 
     // Config binding state — empty set = global (applies to all configs)
     var configNames by remember(show, rule) {
@@ -252,6 +258,7 @@ fun HighlightRuleEditSheet(
                             npRight = npRight,
                             npTop = npTop,
                             npBottom = npBottom,
+                            manualNineSlice = manualNineSlice,
                         )
                     )
                 },
@@ -488,9 +495,29 @@ fun HighlightRuleEditSheet(
                         title = stringResource(R.string.highlight_bg_image_scale),
                         value = bgImageScale,
                         valueRange = 0.1f..5f,
+                        steps = 48,
+                        stepSize = 0.1f,
+                        showDecimal = true,
+                        valueFormat = { String.format("%.1f", it) },
                         description = String.format("%.1fx", bgImageScale),
-                        onValueChange = { bgImageScale = (it * 10).toInt() / 10f },
+                        onValueChange = { bgImageScale = (it * 10).roundToInt() / 10f },
                     )
+                    if (bgImageFit == 3) {
+                        TinySwitchSettingItem(
+                            title = stringResource(R.string.manual_nine_slice),
+                            checked = manualNineSlice,
+                            onCheckedChange = {
+                                manualNineSlice = it
+                                if (it) showNinePatchEditor = true
+                            },
+                        )
+                        if (manualNineSlice) {
+                            TinyClickableSettingItem(
+                                title = stringResource(R.string.edit_nine_slice),
+                                onClick = { showNinePatchEditor = true },
+                            )
+                        }
+                    }
                 }
             }
 
@@ -793,6 +820,8 @@ private fun NinePatchEditorDialog(
     var right by remember(show, imagePath) { mutableFloatStateOf(initialRight) }
     var top by remember(show, imagePath) { mutableFloatStateOf(initialTop) }
     var bottom by remember(show, imagePath) { mutableFloatStateOf(initialBottom) }
+    var stretchMode by remember(show) { mutableIntStateOf(0) }
+    var dragHandle by remember { mutableStateOf<NineSliceHandle?>(null) }
 
     val bitmap = remember(imagePath) {
         runCatching {
@@ -821,6 +850,28 @@ private fun NinePatchEditorDialog(
                 .padding(bottom = 16.dp)
                 .verticalScroll(rememberScrollState()),
         ) {
+            AppText(
+                stringResource(R.string.nine_slice_drag_hint),
+                modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+            )
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(horizontal = 16.dp),
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                listOf(
+                    R.string.nine_slice_mode_all,
+                    R.string.nine_slice_mode_horizontal,
+                    R.string.nine_slice_mode_vertical,
+                ).forEachIndexed { index, label ->
+                    FilterChip(
+                        selected = stretchMode == index,
+                        onClick = { stretchMode = index },
+                        label = { AppText(stringResource(label)) },
+                    )
+                }
+            }
             // Image preview with split lines — use single Canvas to avoid coordinate mismatch
             Box(
                 modifier = Modifier
@@ -830,22 +881,63 @@ private fun NinePatchEditorDialog(
                     .background(MaterialTheme.colorScheme.surfaceContainerLow),
             ) {
                 if (bitmap != null) {
-                    Canvas(modifier = Modifier.fillMaxSize()) {
+                    Canvas(
+                        modifier = Modifier
+                            .fillMaxSize()
+                            .pointerInput(bitmap, stretchMode) {
+                                detectDragGestures(
+                                    onDragStart = { point ->
+                                        val rect = nineSlicePreviewRect(
+                                            size.width.toFloat(), size.height.toFloat(),
+                                            bitmap.width.toFloat(), bitmap.height.toFloat(),
+                                        )
+                                        val candidates = buildList {
+                                            if (stretchMode != 2) {
+                                                add(NineSliceHandle.LEFT to kotlin.math.abs(point.x - (rect.left + rect.width * left)))
+                                                add(NineSliceHandle.RIGHT to kotlin.math.abs(point.x - (rect.right - rect.width * right)))
+                                            }
+                                            if (stretchMode != 1) {
+                                                add(NineSliceHandle.TOP to kotlin.math.abs(point.y - (rect.top + rect.height * top)))
+                                                add(NineSliceHandle.BOTTOM to kotlin.math.abs(point.y - (rect.bottom - rect.height * bottom)))
+                                            }
+                                        }
+                                        dragHandle = candidates.minByOrNull { it.second }
+                                            ?.takeIf { it.second <= 32.dp.toPx() }?.first
+                                    },
+                                    onDragEnd = { dragHandle = null },
+                                    onDragCancel = { dragHandle = null },
+                                ) { change, amount ->
+                                    val rect = nineSlicePreviewRect(
+                                        size.width.toFloat(), size.height.toFloat(),
+                                        bitmap.width.toFloat(), bitmap.height.toFloat(),
+                                    )
+                                    when (dragHandle) {
+                                        NineSliceHandle.LEFT -> left =
+                                            (left + amount.x / rect.width).coerceIn(0f, 0.5f)
+
+                                        NineSliceHandle.RIGHT -> right =
+                                            (right - amount.x / rect.width).coerceIn(0f, 0.5f)
+
+                                        NineSliceHandle.TOP -> top =
+                                            (top + amount.y / rect.height).coerceIn(0f, 0.5f)
+
+                                        NineSliceHandle.BOTTOM -> bottom =
+                                            (bottom - amount.y / rect.height).coerceIn(0f, 0.5f)
+
+                                        null -> Unit
+                                    }
+                                    if (dragHandle != null) change.consume()
+                                }
+                            }) {
                         val canvasWidth = size.width
                         val canvasHeight = size.height
                         val bw = bitmap.width.toFloat()
                         val bh = bitmap.height.toFloat()
-                        val imageAspect = bw / bh
-                        val canvasAspect = canvasWidth / canvasHeight
-                        val (imageW, imageH, offsetX, offsetY) = if (imageAspect > canvasAspect) {
-                            val w = canvasWidth
-                            val h = canvasWidth / imageAspect
-                            listOf(w, h, 0f, (canvasHeight - h) / 2f)
-                        } else {
-                            val h = canvasHeight
-                            val w = canvasHeight * imageAspect
-                            listOf(w, h, (canvasWidth - w) / 2f, 0f)
-                        }
+                        val preview = nineSlicePreviewRect(canvasWidth, canvasHeight, bw, bh)
+                        val imageW = preview.width
+                        val imageH = preview.height
+                        val offsetX = preview.left
+                        val offsetY = preview.top
 
                         // Draw bitmap
                         drawImage(
@@ -854,7 +946,7 @@ private fun NinePatchEditorDialog(
                             dstSize = androidx.compose.ui.unit.IntSize(imageW.toInt(), imageH.toInt()),
                         )
 
-                        val lineColor = Color.Red
+                        val lineColor = Color(0xFF16C96A)
                         val lineWidth = 2.dp.toPx()
 
                         // Left line
@@ -869,39 +961,86 @@ private fun NinePatchEditorDialog(
                         // Bottom line
                         val by = offsetY + imageH * (1f - bottom)
                         drawLine(lineColor, Offset(offsetX, by), Offset(offsetX + imageW, by), lineWidth)
+                        drawRect(
+                            lineColor.copy(alpha = 0.18f),
+                            topLeft = Offset(lx, ty),
+                            size = androidx.compose.ui.geometry.Size(rx - lx, by - ty),
+                        )
+                        val radius = 5.dp.toPx()
+                        listOf(
+                            Offset(lx, (ty + by) / 2f), Offset(rx, (ty + by) / 2f),
+                            Offset((lx + rx) / 2f, ty), Offset((lx + rx) / 2f, by),
+                        ).forEach { drawCircle(lineColor, radius, it) }
                     }
                 }
             }
-
-            // Sliders
-            TinySliderSettingItem(
+            NineSliceSlider(
                 title = stringResource(R.string.nine_patch_split_left),
                 value = left,
-                valueRange = 0f..0.5f,
-                description = String.format("%.0f%%", left * 100),
-                onValueChange = { left = (it * 100).toInt() / 100f },
+                onValueChange = { left = it },
             )
-            TinySliderSettingItem(
+            NineSliceSlider(
                 title = stringResource(R.string.nine_patch_split_right),
                 value = right,
-                valueRange = 0f..0.5f,
-                description = String.format("%.0f%%", right * 100),
-                onValueChange = { right = (it * 100).toInt() / 100f },
+                onValueChange = { right = it },
             )
-            TinySliderSettingItem(
+            NineSliceSlider(
                 title = stringResource(R.string.nine_patch_split_top),
                 value = top,
-                valueRange = 0f..0.5f,
-                description = String.format("%.0f%%", top * 100),
-                onValueChange = { top = (it * 100).toInt() / 100f },
+                onValueChange = { top = it },
             )
-            TinySliderSettingItem(
+            NineSliceSlider(
                 title = stringResource(R.string.nine_patch_split_bottom),
                 value = bottom,
-                valueRange = 0f..0.5f,
-                description = String.format("%.0f%%", bottom * 100),
-                onValueChange = { bottom = (it * 100).toInt() / 100f },
+                onValueChange = { bottom = it },
             )
         }
     }
 }
+
+@Composable
+private fun NineSliceSlider(
+    title: String,
+    value: Float,
+    onValueChange: (Float) -> Unit,
+) {
+    TinySliderSettingItem(
+        title = title,
+        value = value,
+        valueRange = 0f..0.5f,
+        steps = 49,
+        stepSize = 0.01f,
+        showDecimal = true,
+        valueFormat = { String.format("%.2f", it) },
+        description = String.format("%.0f%%", value * 100f),
+        onValueChange = { onValueChange((it * 100).roundToInt() / 100f) },
+    )
+}
+
+private enum class NineSliceHandle { LEFT, RIGHT, TOP, BOTTOM }
+
+private data class NineSlicePreviewRect(
+    val left: Float,
+    val top: Float,
+    val width: Float,
+    val height: Float,
+) {
+    val right get() = left + width
+    val bottom get() = top + height
+}
+
+private fun nineSlicePreviewRect(
+    canvasWidth: Float,
+    canvasHeight: Float,
+    bitmapWidth: Float,
+    bitmapHeight: Float,
+): NineSlicePreviewRect {
+    val imageAspect = bitmapWidth / bitmapHeight
+    return if (imageAspect > canvasWidth / canvasHeight) {
+        val height = canvasWidth / imageAspect
+        NineSlicePreviewRect(0f, (canvasHeight - height) / 2f, canvasWidth, height)
+    } else {
+        val width = canvasHeight * imageAspect
+        NineSlicePreviewRect((canvasWidth - width) / 2f, 0f, width, canvasHeight)
+    }
+}

+ 6 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -869,6 +869,12 @@
     <string name="nine_patch_split_right">右切分线</string>
     <string name="nine_patch_split_top">上切分线</string>
     <string name="nine_patch_split_bottom">下切分线</string>
+    <string name="manual_nine_slice">手动九切片</string>
+    <string name="edit_nine_slice">编辑拉伸区域</string>
+    <string name="nine_slice_drag_hint">拖拽绿色选区选择允许拉伸的原图区域</string>
+    <string name="nine_slice_mode_all">全部</string>
+    <string name="nine_slice_mode_horizontal">水平</string>
+    <string name="nine_slice_mode_vertical">垂直</string>
     <string name="preview_effect">预览效果</string>
     <string name="sample_text">示例文本</string>
     <string name="sure_delete">确定要删除吗?</string>

+ 6 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -1703,6 +1703,12 @@
     <string name="nine_patch_split_right">右切分線</string>
     <string name="nine_patch_split_top">上切分線</string>
     <string name="nine_patch_split_bottom">下切分線</string>
+    <string name="manual_nine_slice">手動九宮格切圖</string>
+    <string name="edit_nine_slice">編輯拉伸區域</string>
+    <string name="nine_slice_drag_hint">拖曳綠色選區,選擇允許拉伸的原圖區域</string>
+    <string name="nine_slice_mode_all">全部</string>
+    <string name="nine_slice_mode_horizontal">水平</string>
+    <string name="nine_slice_mode_vertical">垂直</string>
     <string name="bg_fit_stretch">拉伸</string>
     <string name="bg_fit_tile">平鋪</string>
     <string name="bg_image_fit">背景適配</string>

+ 6 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1636,6 +1636,12 @@
     <string name="nine_patch_split_right">右切分線</string>
     <string name="nine_patch_split_top">上切分線</string>
     <string name="nine_patch_split_bottom">下切分線</string>
+    <string name="manual_nine_slice">手動九宮格切圖</string>
+    <string name="edit_nine_slice">編輯拉伸區域</string>
+    <string name="nine_slice_drag_hint">拖曳綠色選區,選擇允許拉伸的原圖區域</string>
+    <string name="nine_slice_mode_all">全部</string>
+    <string name="nine_slice_mode_horizontal">水平</string>
+    <string name="nine_slice_mode_vertical">垂直</string>
     <string name="preview_effect">預覽</string>
     <string name="sample_text">範例文字</string>
     <string name="sure_delete">確定要刪除嗎?</string>

+ 6 - 0
app/src/main/res/values/strings.xml

@@ -900,6 +900,12 @@
     <string name="nine_patch_split_right">Right Split</string>
     <string name="nine_patch_split_top">Top Split</string>
     <string name="nine_patch_split_bottom">Bottom Split</string>
+    <string name="manual_nine_slice">Manual 9-slice</string>
+    <string name="edit_nine_slice">Edit stretch region</string>
+    <string name="nine_slice_drag_hint">Drag the green handles to select the source region that may stretch</string>
+    <string name="nine_slice_mode_all">All</string>
+    <string name="nine_slice_mode_horizontal">Horizontal</string>
+    <string name="nine_slice_mode_vertical">Vertical</string>
     <string name="preview_effect">Preview</string>
     <string name="sample_text">Sample Text</string>
     <string name="sure_delete">Are you sure you want to delete?</string>

+ 3 - 3
app/src/test/java/io/legado/app/feature/reader/core/gesture/ReaderGestureSettingsPolicyTest.kt

@@ -17,8 +17,8 @@ class ReaderGestureSettingsPolicyTest {
     }
 
     @Test
-    fun `no animation only collapses a commanded scroll page turn to one step`() {
-        assertEquals(1, ReaderGestureSettingsPolicy.scrollPageAnimationSteps(true))
-        assertEquals(18, ReaderGestureSettingsPolicy.scrollPageAnimationSteps(false))
+    fun `no animation collapses a commanded scroll page turn to a single jump`() {
+        assertEquals(false, ReaderGestureSettingsPolicy.animatesScrollPage(true))
+        assertEquals(true, ReaderGestureSettingsPolicy.animatesScrollPage(false))
     }
 }

+ 27 - 0
app/src/test/java/io/legado/app/feature/reader/core/layout/ChineseLineBreakerTest.kt

@@ -22,4 +22,31 @@ class ChineseLineBreakerTest {
         assertArrayEquals(intArrayOf(0, 1, 3, 4), result.lineStarts)
         assertArrayEquals(floatArrayOf(10f, 20f, 10f), result.lineWidthsPx, 0f)
     }
+
+    /**
+     * 旧 ZhLayout CPS_1:连续两个后置标点且标点不可压缩时,标点留在本行、行宽允许超出
+     * (`offset = 0f`)。取「行宽不超出」会把 `。”` 整体推到下一行行首。
+     */
+    @Test
+    fun fullWidthClosingPunctuationHangsAtTheLineEnd() {
+        val result = ChineseLineBreaker(
+            listOf("我", "是", "一", "。", "”"),
+            List(5) { 10f }, 0, 25, 10f, 0f,
+        )
+        assertEquals(2, result.lineCount)
+        assertArrayEquals(intArrayOf(0, 2, 5), result.lineStarts)
+        assertArrayEquals(floatArrayOf(20f, 30f), result.lineWidthsPx, 0f)
+    }
+
+    /** 窄标点(宽度小于一个汉字)仍按旧版 reCheck 回退,不悬挂。 */
+    @Test
+    fun narrowClosingPunctuationStillRewindsInsteadOfHanging() {
+        val result = ChineseLineBreaker(
+            listOf("我", "是", "一", "二", "。", "”"),
+            listOf(10f, 10f, 10f, 10f, 4f, 4f), 0, 45, 10f, 0f,
+        )
+        assertEquals(2, result.lineCount)
+        assertArrayEquals(intArrayOf(0, 3, 6), result.lineStarts)
+        assertArrayEquals(floatArrayOf(30f, 18f), result.lineWidthsPx, 0f)
+    }
 }

+ 29 - 1
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderPaginatorTest.kt

@@ -550,7 +550,7 @@ class ReaderPaginatorTest {
     }
 
     @Test
-    fun nineSliceVerticalFrameScalesIntoHalfTheAvailableLineGap() {
+    fun nineSliceFrameUniformlyScalesIntoAvailableLineGap() {
         val framedStyle = style.copy(backgroundImage = ReaderTextBackgroundImage(
             "frame.png", 3, 1f,
             contentInsetLeftPx = 3f,
@@ -574,6 +574,9 @@ class ReaderPaginatorTest {
         val glyph = page.elements.single() as ReaderElement.Text
         assertEquals(10f / 3f, glyph.backgroundFrameTopPx, 0.001f)
         assertEquals(5f, glyph.backgroundFrameBottomPx, 0.001f)
+        val fittedImage = glyph.style.backgroundImage!!
+        assertEquals(2.5f, fittedImage.contentInsetLeftPx, 0.001f)
+        assertEquals(10f / 3f, fittedImage.contentInsetRightPx, 0.001f)
         val run = page.textBackgroundRuns().single()
         assertEquals(glyph.bounds.top, run.contentBounds.top, 0f)
         assertEquals(glyph.bounds.bottom, run.contentBounds.bottom, 0f)
@@ -581,6 +584,31 @@ class ReaderPaginatorTest {
         assertEquals(glyph.bounds.bottom + 5f, run.bounds.bottom, 0.001f)
     }
 
+    /**
+     * 章末页的堆叠高度额外加 [ReaderPaginationConfig.chapterEndPaddingPx]:旧
+     * `TextChapterLayout.setTypeText` 收尾时 `height = max(height, durY + 20dp)`,让下一章
+     * 正文与本章末尾之间留一段空档。中间页不受影响。
+     */
+    @Test
+    fun scrollModeAddsTheLegacyChapterEndPaddingOnlyToTheLastPage() {
+        val scrollConfig = config.copy(continuousScroll = true, chapterEndPaddingPx = 20f)
+        val lines = (0..2).map { index ->
+            ReaderMeasuredParagraph(
+                index.toString(),
+                listOf(index.toString()),
+                listOf(20f),
+                style,
+                index
+            )
+        }
+        val pages = ReaderPaginator.paginate(lines, scrollConfig)
+        assertEquals(2, pages.size)
+        // 内容区高度 40f(45 − 5):中间页就是排版游标,章末页取「覆盖高度 / 内容区」的
+        // 较大者后再加 20f 留白(对照旧 TextChapterLayout 的 `height = durY + 20dp`)。
+        assertEquals(40f, pages[0].scrollExtentPx, 0.01f)
+        assertEquals(60f, pages[1].scrollExtentPx, 0.01f)
+    }
+
     @Test
     fun letterSpacedBackgroundRowStaysOneRunInsteadOfPerGlyph() {
         val bgStyle = style.copy(backgroundImage = ReaderTextBackgroundImage("bg.png", 1, 1f))

+ 40 - 0
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderSingleImageTest.kt

@@ -2,9 +2,12 @@ package io.legado.app.feature.reader.core.layout
 
 import io.legado.app.feature.reader.core.model.ReaderElement
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
+import io.legado.app.feature.reader.core.source.ReaderChapterSource
+import io.legado.app.feature.reader.core.source.ReaderChapterSourceBlock
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceParser
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
 import org.junit.Test
 
 class ReaderSingleImageTest {
@@ -92,6 +95,43 @@ class ReaderSingleImageTest {
         assertEquals(listOf(95f, 95f), pages.single().elements.map { it.bounds.top })
     }
 
+    /**
+     * 对照旧 View `TextChapterLayout`:`imgStyleSingle` 下标题段排版完会无条件结束当前页
+     * (`prepareNextPageIfNeed()`),标题因此独占一页且水平居中。
+     */
+    @Test
+    fun singleImageHeadingGetsItsOwnCenteredPage() = runBlocking {
+        val source = ReaderChapterSource(
+            1, "章标题", listOf(
+                ReaderChapterSourceBlock.Text("章标题", 0, true),
+                ReaderChapterSourceBlock.Text("正文正文", 3),
+            ), 7
+        )
+        val measurer = ReaderChapterBlockMeasurer(shaper, shaper, { null })
+
+        val plain = measurer.measure(source, style) as ReaderChapterMeasureResult.Success
+        assertEquals(2, plain.blocks.size)
+
+        val measured = measurer.measure(
+            source,
+            style.copy(
+                titlePageBreakAfter = true,
+                titleAlignment = ReaderTextAlignment.CENTER,
+            ),
+        ) as ReaderChapterMeasureResult.Success
+        assertTrue(measured.blocks[1] is ReaderMeasuredBlock.PageBreak)
+
+        val pages = ReaderPaginator.paginateBlocks(measured.blocks, config)
+        assertEquals(2, pages.size)
+        val heading = pages[0].elements.filterIsInstance<ReaderElement.Text>()
+        assertEquals("章标题", heading.joinToString("") { it.value })
+        assertEquals(45f, heading.first().bounds.left, 0f)
+        assertEquals(
+            "正文正文",
+            pages[1].elements.filterIsInstance<ReaderElement.Text>().joinToString("") { it.value },
+        )
+    }
+
     @Test fun inlineIconKeepsItsIndentPrefix() = runBlocking {
         val source = ReaderChapterSourceParser.parse(0, "", listOf("  <img src=\"icon\">甲"), false, false)
         val measured = ReaderChapterBlockMeasurer(shaper, shaper, { ReaderImageDimensions(10f, 10f) })

+ 33 - 1
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderVolumeTitlePageTest.kt

@@ -3,6 +3,7 @@ package io.legado.app.feature.reader.core.layout
 import io.legado.app.feature.reader.core.model.ReaderElement
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
 import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
 import org.junit.Test
 
 /** 卷/空正文章整页只有标题时的垂直居中行为。 */
@@ -14,12 +15,14 @@ class ReaderVolumeTitlePageTest {
         isTitle = true,
     )
 
-    private fun config(centerVertical: Boolean) = ReaderPaginationConfig(
+    private fun config(centerVertical: Boolean, continuousScroll: Boolean = false) =
+        ReaderPaginationConfig(
         chapterIndex = 0, chapterTitle = "第一卷", viewportWidthPx = 100, viewportHeightPx = 200,
         paddingLeftPx = 5f, paddingRightPx = 5f, paddingTopPx = 10f, paddingBottomPx = 20f,
         lineHeightPx = 20f, baselineOffsetPx = 15f,
         titleTopSpacingPx = 5f,
         titlePageCenterVertical = centerVertical,
+            continuousScroll = continuousScroll,
     )
 
     @Test
@@ -57,4 +60,33 @@ class ReaderVolumeTitlePageTest {
             assertEquals(top + 15f, glyph.baselinePx, 0.01f)
         }
     }
+
+    /**
+     * 连续滚动模式按 scrollExtentPx 堆叠相邻页(ScrollPageStack / ReaderPageViewportLayout),
+     * 短页的页高下限是「内容区高度」,因此卷名页无论是否居中都会撑满一屏;否则下一章正文
+     * 会压在本页卷名上。同见 `ReaderLongImageScrollTest
+     * .continuousPagesExcludeFixedViewportChromeFromTheirStackingExtent`。
+     */
+    @Test
+    fun scrollModeVerticallyCenteredTitleStaysInsidePageExtent() {
+        val centered = ReaderPaginator.paginate(
+            listOf(titleParagraph("第一卷 风起")),
+            config(centerVertical = true, continuousScroll = true),
+        ).single()
+        val topAnchored = ReaderPaginator.paginate(
+            listOf(titleParagraph("第一卷 风起")),
+            config(centerVertical = false, continuousScroll = true),
+        ).single()
+        val centeredBottom = centered.elements.filterIsInstance<ReaderElement.Text>()
+            .maxOf { it.bounds.bottom }
+        // 下一章从 contentTopPx + scrollExtentPx 处开始绘制:卷名必须落在本页页高内。
+        assertTrue(
+            "卷名 bottom=$centeredBottom 超出本页页高 " +
+                    "${centered.contentTopPx + centered.scrollExtentPx}",
+            centeredBottom <= centered.contentTopPx + centered.scrollExtentPx,
+        )
+        // 内容区高度 = 200 − 10 − 20 = 170:短页一律取该下限,居中与否都不改变页高。
+        assertEquals(170f, centered.scrollExtentPx, 0.01f)
+        assertEquals(170f, topAnchored.scrollExtentPx, 0.01f)
+    }
 }

+ 40 - 0
app/src/test/java/io/legado/app/feature/reader/core/model/ReaderEmphasisUnderlineRunTest.kt

@@ -33,6 +33,46 @@ class ReaderEmphasisUnderlineRunTest {
         assertEquals(listOf(19f, 39f), page.emphasisUnderlineRuns().map { it.yPx })
     }
 
+    @Test
+    fun `hit underlines the whole line even when only one glyph matches`() {
+        val page = page(
+            text(10f, 20f),
+            text(20f, 30f),
+            text(30f, 45f),
+        )
+
+        assertEquals(
+            listOf(ReaderEmphasisUnderlineRun(10f, 45f, 39f, emphasis)),
+            page.emphasisUnderlineRunsFor(emphasis) { it.chapterPosition == 20 },
+        )
+    }
+
+    @Test
+    fun `lines without a hit produce no rule`() {
+        val page = page(
+            text(0f, 10f, top = 0f, bottom = 20f),
+            text(5f, 15f, top = 20f, bottom = 40f),
+        )
+
+        assertEquals(
+            emptyList<ReaderEmphasisUnderlineRun>(),
+            page.emphasisUnderlineRunsFor(emphasis) { false },
+        )
+    }
+
+    @Test
+    fun `hit on the title line does not underline body lines`() {
+        val page = page(
+            text(0f, 30f, top = 0f, bottom = 20f),
+            text(0f, 40f, top = 20f, bottom = 40f),
+        )
+
+        assertEquals(
+            listOf(ReaderEmphasisUnderlineRun(0f, 30f, 19f, emphasis)),
+            page.emphasisUnderlineRunsFor(emphasis) { it.bounds.top == 0f },
+        )
+    }
+
     private fun text(
         left: Float,
         right: Float,

+ 69 - 0
app/src/test/java/io/legado/app/feature/reader/core/model/ReaderNineSliceLayoutTest.kt

@@ -37,4 +37,73 @@ class ReaderNineSliceLayoutTest {
         assertTrue(cells.all { it.destination.width > 0f && it.destination.height > 0f })
         assertEquals(1, cells.size)
     }
+
+    @Test
+    fun rawNinePatchGuideBorderIsExcludedFromEverySourceCell() {
+        val image = ReaderTextBackgroundImage(
+            "frame.9.png", 3, 1f,
+            ninePatchLeft = 0.2f,
+            ninePatchRight = 0.3f,
+            ninePatchTop = 0.1f,
+            ninePatchBottom = 0.2f,
+        )
+
+        val cells = ReaderNineSliceLayout.cells(
+            bitmapWidth = 52,
+            bitmapHeight = 42,
+            content = ReaderRect(10f, 4f, 35f, 32f),
+            frame = ReaderRect(0f, 0f, 50f, 40f),
+            image = image,
+        )
+
+        assertEquals(ReaderIntRect(1, 1, 11, 5), cells.first().source)
+        assertEquals(ReaderIntRect(36, 33, 51, 41), cells.last().source)
+    }
+
+    @Test
+    fun fixedCornersKeepOneUniformConfiguredScale() {
+        val image = ReaderTextBackgroundImage(
+            "frame.png", 3, 0.5f,
+            ninePatchLeft = 0.2f,
+            ninePatchRight = 0.3f,
+            ninePatchTop = 0.1f,
+            ninePatchBottom = 0.2f,
+        ).withBitmapSize(50, 40)
+        val content = ReaderRect(5f, 2f, 30f, 30f)
+        val frame = ReaderRect(
+            content.left - image.contentInsetLeftPx,
+            content.top - image.contentInsetTopPx,
+            content.right + image.contentInsetRightPx,
+            content.bottom + image.contentInsetBottomPx,
+        )
+
+        val topLeft = ReaderNineSliceLayout.cells(50, 40, content, frame, image).first()
+
+        assertEquals(topLeft.source.right - topLeft.source.left, 10)
+        assertEquals(topLeft.source.bottom - topLeft.source.top, 4)
+        assertEquals(5f, topLeft.destination.width, 0f)
+        assertEquals(2f, topLeft.destination.height, 0f)
+    }
+
+    @Test
+    fun fractionalMarginsRoundBackToTheirOriginalPixelBoundaries() {
+        val image = ReaderTextBackgroundImage(
+            "frame.9.png", 3, 1f,
+            ninePatchLeft = 7f / 31f,
+            ninePatchRight = 9f / 31f,
+            ninePatchTop = 5f / 29f,
+            ninePatchBottom = 8f / 29f,
+        )
+
+        val cells = ReaderNineSliceLayout.cells(
+            bitmapWidth = 33,
+            bitmapHeight = 31,
+            content = ReaderRect(7f, 5f, 22f, 21f),
+            frame = ReaderRect(0f, 0f, 31f, 29f),
+            image = image,
+        )
+
+        assertEquals(ReaderIntRect(1, 1, 8, 6), cells.first().source)
+        assertEquals(ReaderIntRect(23, 22, 32, 30), cells.last().source)
+    }
 }

+ 16 - 0
app/src/test/java/io/legado/app/feature/reader/core/model/ReaderTextBackgroundRunTest.kt

@@ -92,6 +92,22 @@ class ReaderTextBackgroundRunTest {
         assertEquals(8f, resolved.contentInsetBottomPx, 0f)
     }
 
+    @Test
+    fun `raw nine patch border is excluded when resolving fixed margins`() {
+        val resolved = image.copy(
+            source = "background.9.png",
+            ninePatchLeft = 0.2f,
+            ninePatchRight = 0.3f,
+            ninePatchTop = 0.1f,
+            ninePatchBottom = 0.2f,
+        ).withBitmapSize(52, 42)
+
+        assertEquals(10f, resolved.contentInsetLeftPx, 0f)
+        assertEquals(15f, resolved.contentInsetRightPx, 0.001f)
+        assertEquals(4f, resolved.contentInsetTopPx, 0f)
+        assertEquals(8f, resolved.contentInsetBottomPx, 0f)
+    }
+
     private fun text(
         left: Float,
         top: Float,

+ 37 - 0
app/src/test/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigatorTest.kt

@@ -3,6 +3,7 @@ package io.legado.app.feature.reader.core.navigation
 import io.legado.app.feature.reader.core.model.ReaderElement
 import io.legado.app.feature.reader.core.model.ReaderPage
 import io.legado.app.feature.reader.core.model.ReaderPageId
+import io.legado.app.feature.reader.core.model.ReaderPageWindow
 import io.legado.app.feature.reader.core.model.ReaderRect
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
 import org.junit.Assert.assertEquals
@@ -185,4 +186,40 @@ class ReaderPageNavigatorTest {
             ),
         )
     }
+
+    @Test
+    fun turnIsAllowedWhenOnlyTheBusinessNeighborChapterExists() {
+        // 对照旧 View TextPageFactory.hasNext()/hasPrev():放行看"书里还有没有邻章",
+        // 与邻章是否已排版无关。邻章排版滞后时窗口里没有邻页,也必须放行,否则跨章
+        // 翻页只会弹"没有下一页"且不再触发该章装载(读完本章无法进入下一章)。
+        val pages = listOf(page(0, 0), page(1, 20))
+        val lastPage = ReaderPageNavigator.window(pages, pageIndex = 1)
+        assertNull(lastPage.next)
+        assertTrue(ReaderPageNavigator.canTurnNext(lastPage, hasNextChapter = true))
+        assertTrue(!ReaderPageNavigator.canTurnNext(lastPage, hasNextChapter = false))
+
+        val firstPage = ReaderPageNavigator.window(pages, pageIndex = 0)
+        assertNull(firstPage.previous)
+        assertTrue(ReaderPageNavigator.canTurnPrevious(firstPage, hasPreviousChapter = true))
+        assertTrue(!ReaderPageNavigator.canTurnPrevious(firstPage, hasPreviousChapter = false))
+
+        // 邻页已就绪时与旧实现一致:不需要业务邻章也放行;窗口为空则不放行。
+        assertTrue(ReaderPageNavigator.canTurnNext(ReaderPageNavigator.window(pages, 0), false))
+        assertTrue(ReaderPageNavigator.canTurnPrevious(ReaderPageNavigator.window(pages, 1), false))
+        assertTrue(!ReaderPageNavigator.canTurnNext(ReaderPageWindow(), false))
+        assertTrue(!ReaderPageNavigator.canTurnPrevious(ReaderPageWindow(), false))
+    }
+
+    @Test
+    fun locateReportsMissingChapterInsteadOfCollapsingToBookStart() {
+        // 章不在页表中时 locate 折叠成 0(全书首页是合法下标),发布该下标会把阅读
+        // 位置跳回书首;调用方用 locateOrNull 区分"未定位"与"第一章"。
+        val pages = listOf(page(0, 0), page(1, 20))
+        assertEquals(0, ReaderPageNavigator.locate(pages, chapterIndex = 9, chapterPosition = 0))
+        assertNull(ReaderPageNavigator.locateOrNull(pages, chapterIndex = 9, chapterPosition = 0))
+        assertEquals(
+            1,
+            ReaderPageNavigator.locateOrNull(pages, chapterIndex = 4, chapterPosition = 25)
+        )
+    }
 }

+ 38 - 0
app/src/test/java/io/legado/app/feature/reader/core/selection/ReaderSelectionTest.kt

@@ -215,4 +215,42 @@ class ReaderSelectionTest {
 
         assertEquals("甲乙丙", ReaderSelection(0, 0, 2).selectedText(listOf(second, first)))
     }
+
+    /**
+     * 滚动模式视口里堆叠的下邻页属于下一章(旧 View 的选区分词同样遍历 relativePage 0..2,
+     * 而 `TextPageFactory.nextPlusPage` 在章末给出下一章首页),因此选区必须能跨过去,
+     * 且两页都要参与高亮绘制。
+     */
+    @Test
+    fun scrollModeSelectionExtendsIntoTheNextChapterPage() {
+        val nextChapter = page.copy(
+            id = ReaderPageId(1, 0),
+            elements = listOf(
+                ReaderElement.Text(
+                    ReaderRect(0f, 0f, 10f, 20f), 15f, "丁", style, false, false,
+                    chapterPosition = 0,
+                ),
+            ),
+        )
+        val started = ReaderSelectionPolicy.start(page, 25f, 10f)!!
+        val extended = ReaderSelectionPolicy.extend(
+            started, nextChapter, 5f, 10f, allowChapterCrossing = true,
+        )
+
+        assertEquals(0, extended.startChapterIndex)
+        assertEquals(1, extended.endChapterIndex)
+        assertEquals(1, extended.focusChapterIndex)
+        assertEquals("丙\n丁", extended.selectedText(listOf(nextChapter, page)))
+        assertEquals(1, extended.bounds(page).size)
+        assertEquals(1, extended.bounds(nextChapter).size)
+    }
+
+    @Test
+    fun pagedModeKeepsTheSelectionInsideOneChapter() {
+        val nextChapter = page.copy(id = ReaderPageId(1, 0))
+        val started = ReaderSelectionPolicy.start(page, 25f, 10f)!!
+
+        assertEquals(started, ReaderSelectionPolicy.extend(started, nextChapter, 5f, 10f))
+        assertEquals(0, started.endChapterIndex)
+    }
 }

+ 20 - 7
app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderPageTransitionTest.kt

@@ -15,28 +15,41 @@ class ReaderPageTransitionTest {
 
     @Test
     fun `settle duration scales with the remaining legacy scroll distance`() {
-        assertEquals(300, ReaderPageTransitionPolicy.settleDurationMillis(0f, -800f, 800f))
-        assertEquals(75, ReaderPageTransitionPolicy.settleDurationMillis(-600f, -800f, 800f))
-        assertEquals(225, ReaderPageTransitionPolicy.settleDurationMillis(-600f, 0f, 800f))
+        // 基准 360:点击与按键共用同一收尾基准(产品确认保留,不回退旧 View 的 300)。
+        assertEquals(360, ReaderPageTransitionPolicy.settleDurationMillis(0f, -800f, 800f))
+        assertEquals(90, ReaderPageTransitionPolicy.settleDurationMillis(-600f, -800f, 800f))
+        assertEquals(270, ReaderPageTransitionPolicy.settleDurationMillis(-600f, 0f, 800f))
         assertEquals(0, ReaderPageTransitionPolicy.settleDurationMillis(-800f, -800f, 800f))
     }
 
     @Test
-    fun `fade settle keeps the full legacy alpha animation duration`() {
+    fun `both settle overloads share the same base duration`() {
+        // 两个重载的默认基准必须一致:分叉过一次(360 / 300),测试便无法同时代表生产路径。
         assertEquals(
-            300,
+            ReaderPageTransitionPolicy.settleDurationMillis(0f, -800f, 800f),
+            ReaderPageTransitionPolicy.settleDurationMillis(
+                ReaderTransitionMode.FADE, 0f, -800f, 800f,
+            ),
+        )
+    }
+
+    @Test
+    fun `fade settle uses the full base duration instead of scaling it`() {
+        assertEquals(
+            360,
             ReaderPageTransitionPolicy.settleDurationMillis(
                 ReaderTransitionMode.FADE, -720f, -800f, 800f,
             ),
         )
         assertEquals(
-            300,
+            360,
             ReaderPageTransitionPolicy.settleDurationMillis(
                 ReaderTransitionMode.FADE, -720f, 0f, 800f,
             ),
         )
+        // 非 FADE 模式按剩余位移折算:360 * 80 / 800。
         assertEquals(
-            30,
+            36,
             ReaderPageTransitionPolicy.settleDurationMillis(
                 ReaderTransitionMode.SLIDE, -720f, -800f, 800f,
             ),

+ 11 - 0
app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderScrollStateTest.kt

@@ -171,6 +171,17 @@ class ReaderScrollStateTest {
         inlineImagesPreserveScrollLine = inlineImages,
     )
 
+    @Test
+    fun stepDurationScalesWithDistanceLikeTheLegacyScroller() {
+        // 旧 PageDelegate.startScroll:animationSpeed(300) * |dy| / viewHeight。
+        assertEquals(300, ReaderScrollPolicy.stepDurationMillis(-800f, 800f))
+        assertEquals(150, ReaderScrollPolicy.stepDurationMillis(-400f, 800f))
+        assertEquals(300, ReaderScrollPolicy.stepDurationMillis(800f, 800f))
+        // 零步距与零视口都不能退化成 0 时长(tween 需要正值)。
+        assertEquals(1, ReaderScrollPolicy.stepDurationMillis(0f, 800f))
+        assertEquals(300, ReaderScrollPolicy.stepDurationMillis(-800f, 0f))
+    }
+
     private fun text(top: Float, bottom: Float, position: Int) = ReaderElement.Text(
         bounds = ReaderRect(0f, top + 10f, 10f, bottom + 10f),
         baselinePx = bottom + 5f,