فهرست منبع

Merge pull request #1897 from 52fisher/feat-interface

feat&refactor:重构 TXT 目录解析、排版配置页面,新增分卷规则、目录更新与页脚样式功能
Kudomaga 3 ماه پیش
والد
کامیت
f8cec0806f
43فایلهای تغییر یافته به همراه2347 افزوده شده و 2049 حذف شده
  1. 10 4
      app/schemas/io.legado.app.data.AppDatabase/99.json
  2. 77 42
      app/src/main/assets/defaultData/txtTocRule.json
  3. 22 0
      app/src/main/java/io/legado/app/data/DatabaseMigrations.kt
  4. 4 1
      app/src/main/java/io/legado/app/data/entities/TxtTocRule.kt
  5. 3 0
      app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt
  6. 3 0
      app/src/main/java/io/legado/app/domain/gateway/ReadStyleMutation.kt
  7. 1 1
      app/src/main/java/io/legado/app/help/config/LocalConfig.kt
  8. 9 0
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  9. 42 12
      app/src/main/java/io/legado/app/model/localBook/TextFile.kt
  10. 23 3
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  11. 59 162
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt
  12. 6 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  13. 2 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  14. 18 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  15. 12 0
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  16. 131 23
      app/src/main/java/io/legado/app/ui/book/read/sheet/GlobalThemePage.kt
  17. 0 699
      app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt
  18. 0 40
      app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt
  19. 0 250
      app/src/main/java/io/legado/app/ui/book/read/sheet/PaddingConfigSheet.kt
  20. 8 7
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt
  21. 4 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderBookSheet.kt
  22. 0 734
      app/src/main/java/io/legado/app/ui/book/read/sheet/TextTitleSheet.kt
  23. 313 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/TypographyPage.kt
  24. 1133 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/TypographyTabs.kt
  25. 23 3
      app/src/main/java/io/legado/app/ui/book/toc/TocHierarchy.kt
  26. 7 0
      app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt
  27. 50 0
      app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt
  28. 14 10
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt
  29. 4 1
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt
  30. 1 1
      app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewContract.kt
  31. 13 9
      app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewScreen.kt
  32. 4 4
      app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewViewModel.kt
  33. 6 1
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt
  34. 33 2
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleContract.kt
  35. 75 1
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt
  36. 178 1
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt
  37. 14 1
      app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt
  38. 3 3
      app/src/main/res/layout/dialog_read_info.xml
  39. 14 9
      app/src/main/res/values-zh-rCN/strings.xml
  40. 1 7
      app/src/main/res/values-zh-rHK/strings.xml
  41. 1 7
      app/src/main/res/values-zh-rTW/strings.xml
  42. 13 8
      app/src/main/res/values/strings.xml
  43. 13 0
      app/src/test/java/io/legado/app/ui/book/toc/TocHierarchyTest.kt

+ 10 - 4
app/schemas/io.legado.app.data.AppDatabase/99.json

@@ -1721,7 +1721,7 @@
       },
       },
       {
       {
         "tableName": "txtTocRules",
         "tableName": "txtTocRules",
-        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `example` TEXT, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "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": [
         "fields": [
           {
           {
             "fieldPath": "id",
             "fieldPath": "id",
@@ -1736,8 +1736,14 @@
             "notNull": true
             "notNull": true
           },
           },
           {
           {
-            "fieldPath": "rule",
-            "columnName": "rule",
+            "fieldPath": "chapterRule",
+            "columnName": "chapterRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "volumeRule",
+            "columnName": "volumeRule",
             "affinity": "TEXT",
             "affinity": "TEXT",
             "notNull": true
             "notNull": true
           },
           },
@@ -4659,4 +4665,4 @@
       "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0458658832e21f9dde1ecae6a827691f')"
       "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0458658832e21f9dde1ecae6a827691f')"
     ]
     ]
   }
   }
-}
+}

+ 77 - 42
app/src/main/assets/defaultData/txtTocRule.json

@@ -3,7 +3,8 @@
     "id": -1,
     "id": -1,
     "enable": true,
     "enable": true,
     "name": "目录(去空白)",
     "name": "目录(去空白)",
-    "rule": "(?<=[ \\s])(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和]))).{0,30}$",
+    "chapterRule": "(?<=[ \\s])(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和]))).{0,30}$",
+    "volumeRule": "第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:卷|集(?![合和]))",
     "example": "第一章 假装第一章前面有空白但我不要",
     "example": "第一章 假装第一章前面有空白但我不要",
     "serialNumber": 0
     "serialNumber": 0
   },
   },
@@ -11,7 +12,8 @@
     "id": -2,
     "id": -2,
     "enable": true,
     "enable": true,
     "name": "目录",
     "name": "目录",
-    "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|篇(?!张))).{0,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|篇(?!张))).{0,30}$",
+    "volumeRule": "第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:卷|集(?![合和]))",
     "example": "第一章 标准的粤语就是这样",
     "example": "第一章 标准的粤语就是这样",
     "serialNumber": 1
     "serialNumber": 1
   },
   },
@@ -19,7 +21,8 @@
     "id": -3,
     "id": -3,
     "enable": false,
     "enable": false,
     "name": "目录(匹配简介)",
     "name": "目录(匹配简介)",
-    "rule": "(?<=[ \\s])(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|篇(?!张))).{0,30}$",
+    "chapterRule": "(?<=[ \\s])(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|篇(?!张))).{0,30}$",
+    "volumeRule": "第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:卷|集(?![合和]))",
     "example": "简介 老夫诸葛村夫",
     "example": "简介 老夫诸葛村夫",
     "serialNumber": 2
     "serialNumber": 2
   },
   },
@@ -27,7 +30,8 @@
     "id": -4,
     "id": -4,
     "enable": false,
     "enable": false,
     "name": "目录(古典、轻小说备用)",
     "name": "目录(古典、轻小说备用)",
-    "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|话|篇(?!张))).{0,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|话|篇(?!张))).{0,30}$",
+    "volumeRule": "第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:卷|集(?![合和]))",
     "example": "第一章 比上面只多了回和话",
     "example": "第一章 比上面只多了回和话",
     "serialNumber": 3
     "serialNumber": 3
   },
   },
@@ -35,7 +39,8 @@
     "id": -5,
     "id": -5,
     "enable": false,
     "enable": false,
     "name": "数字(纯数字标题)",
     "name": "数字(纯数字标题)",
-    "rule": "(?<=[ \\s])\\d+\\.?[  \\t]{0,4}$",
+    "chapterRule": "(?<=[ \\s])\\d+\\.?[  \\t]{0,4}$",
+    "volumeRule": "",
     "example": "12",
     "example": "12",
     "serialNumber": 4
     "serialNumber": 4
   },
   },
@@ -43,7 +48,8 @@
     "id": -6,
     "id": -6,
     "enable": false,
     "enable": false,
     "name": "大写数字(纯数字标题)",
     "name": "大写数字(纯数字标题)",
-    "rule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,12}[  \\t]{0,4}$",
+    "chapterRule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,12}[  \\t]{0,4}$",
+    "volumeRule": "",
     "example": "一百七十",
     "example": "一百七十",
     "serialNumber": 5
     "serialNumber": 5
   },
   },
@@ -51,23 +57,26 @@
     "id": -7,
     "id": -7,
     "enable": false,
     "enable": false,
     "name": "数字混合(纯数字标题)",
     "name": "数字混合(纯数字标题)",
-    "rule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟\\d]{1,12}[  \\t]{0,4}$",
+    "chapterRule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟\\d]{1,12}[  \\t]{0,4}$",
+    "volumeRule": "",
     "example": "12\n一百七十",
     "example": "12\n一百七十",
     "serialNumber": 6
     "serialNumber": 6
   },
   },
   {
   {
     "id": -8,
     "id": -8,
     "enable": true,
     "enable": true,
-    "name": "数字 分隔符 标题名称",
-    "rule": "^[  \\t]{0,4}\\d{1,5}[::,., 、_—\\-].{1,30}$",
-    "example": "1、这个就是标题",
+    "name": "数字 可选分隔符 标题名称",
+    "chapterRule": "^[  \\t]{0,4}\\d{1,5}[::,., 、_—\\-]?.{1,30}$",
+    "volumeRule": "",
+    "example": "1、这个就是标题\n02美好的明天",
     "serialNumber": 7
     "serialNumber": 7
   },
   },
   {
   {
     "id": -9,
     "id": -9,
     "enable": true,
     "enable": true,
     "name": "大写数字 分隔符 标题名称",
     "name": "大写数字 分隔符 标题名称",
-    "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?)[ 、_—\\-].{1,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?)[ 、_—\\-].{1,30}$",
+    "volumeRule": "",
     "example": "一、只有前面的数字有差别\n二十四章 我瞎编的标题",
     "example": "一、只有前面的数字有差别\n二十四章 我瞎编的标题",
     "serialNumber": 8
     "serialNumber": 8
   },
   },
@@ -75,7 +84,8 @@
     "id": -10,
     "id": -10,
     "enable": false,
     "enable": false,
     "name": "数字混合 分隔符 标题名称",
     "name": "数字混合 分隔符 标题名称",
-    "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?[ 、_—\\-]|\\d{1,5}章?[::,., 、_—\\-]).{0,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?[ 、_—\\-]|\\d{1,5}章?[::,., 、_—\\-]).{0,30}$",
+    "volumeRule": "",
     "example": "1、人参公鸡\n二百二十章 boy next door",
     "example": "1、人参公鸡\n二百二十章 boy next door",
     "serialNumber": 9
     "serialNumber": 9
   },
   },
@@ -83,7 +93,8 @@
     "id": -11,
     "id": -11,
     "enable": true,
     "enable": true,
     "name": "正文 标题/序号",
     "name": "正文 标题/序号",
-    "rule": "^[  \\t]{0,4}正文[  ]{1,4}.{0,20}$",
+    "chapterRule": "^[  \\t]{0,4}正文[  ]{1,4}.{0,20}$",
+    "volumeRule": "",
     "example": "正文 我奶常山赵子龙",
     "example": "正文 我奶常山赵子龙",
     "serialNumber": 10
     "serialNumber": 10
   },
   },
@@ -91,7 +102,8 @@
     "id": -12,
     "id": -12,
     "enable": true,
     "enable": true,
     "name": "Chapter/Section/Part/Episode 序号 标题",
     "name": "Chapter/Section/Part/Episode 序号 标题",
-    "rule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][oO][.、]|[Ee]pisode|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)\\s{0,4}\\d{1,4}.{0,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][oO][.、]|[Ee]pisode|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)\\s{0,4}\\d{1,4}.{0,30}$",
+    "volumeRule": "",
     "example": "Chapter 1 MyGrandmaIsNB",
     "example": "Chapter 1 MyGrandmaIsNB",
     "serialNumber": 11
     "serialNumber": 11
   },
   },
@@ -99,7 +111,8 @@
     "id": -13,
     "id": -13,
     "enable": false,
     "enable": false,
     "name": "Chapter(去简介)",
     "name": "Chapter(去简介)",
-    "rule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][Oo]\\.|[Ee]pisode)\\s{0,4}\\d{1,4}.{0,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][Oo]\\.|[Ee]pisode)\\s{0,4}\\d{1,4}.{0,30}$",
+    "volumeRule": "",
     "example": "Chapter 1 MyGrandmaIsNB",
     "example": "Chapter 1 MyGrandmaIsNB",
     "serialNumber": 12
     "serialNumber": 12
   },
   },
@@ -107,7 +120,8 @@
     "id": -14,
     "id": -14,
     "enable": true,
     "enable": true,
     "name": "特殊符号 序号 标题",
     "name": "特殊符号 序号 标题",
-    "rule": "(?<=[\\s ])[【〔〖「『〈[\\[](?:第|[Cc]hapter)[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,10}[章节].{0,20}$",
+    "chapterRule": "(?<=[\\s ])[【〔〖「『〈[\\[](?:第|[Cc]hapter)[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,10}[章节].{0,20}$",
+    "volumeRule": "",
     "example": "【第一章 后面的符号可以没有",
     "example": "【第一章 后面的符号可以没有",
     "serialNumber": 13
     "serialNumber": 13
   },
   },
@@ -115,7 +129,8 @@
     "id": -15,
     "id": -15,
     "enable": false,
     "enable": false,
     "name": "特殊符号 标题(成对)",
     "name": "特殊符号 标题(成对)",
-    "rule": "(?<=[\\s ]{0,4})(?:[\\[〈「『〖〔《(【\\(].{1,30}[\\)】)》〕〗』」〉\\]]?|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$",
+    "chapterRule": "(?<=[\\s ]{0,4})(?:[\\[〈「『〖〔《(【\\(].{1,30}[\\)】)》〕〗』」〉\\]]?|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$",
+    "volumeRule": "",
     "example": "『加个直角引号更专业』\n(11)我奶常山赵子聋",
     "example": "『加个直角引号更专业』\n(11)我奶常山赵子聋",
     "serialNumber": 14
     "serialNumber": 14
   },
   },
@@ -123,7 +138,8 @@
     "id": -16,
     "id": -16,
     "enable": true,
     "enable": true,
     "name": "特殊符号 标题(单个)",
     "name": "特殊符号 标题(单个)",
-    "rule": "(?<=[\\s ]{0,4})(?:[☆★✦✧].{1,30}|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$",
+    "chapterRule": "(?<=[\\s ]{0,4})(?:[☆★✦✧].{1,30}|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$",
+    "volumeRule": "",
     "example": "☆、晋江作者最喜欢的格式",
     "example": "☆、晋江作者最喜欢的格式",
     "serialNumber": 15
     "serialNumber": 15
   },
   },
@@ -131,7 +147,8 @@
     "id": -17,
     "id": -17,
     "enable": true,
     "enable": true,
     "name": "章/卷 序号 标题",
     "name": "章/卷 序号 标题",
-    "rule": "^[ \\t ]{0,4}(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[卷章][\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8})[  ]{0,4}.{0,30}$",
+    "chapterRule": "^[ \\t ]{0,4}(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[卷章][\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8})[  ]{0,4}.{0,30}$",
+    "volumeRule": "",
     "example": "卷五 开源盛世",
     "example": "卷五 开源盛世",
     "serialNumber": 16
     "serialNumber": 16
   },
   },
@@ -139,72 +156,90 @@
     "id": -18,
     "id": -18,
     "enable": false,
     "enable": false,
     "name": "顶格标题",
     "name": "顶格标题",
-    "rule": "^\\S.{1,20}$",
+    "chapterRule": "^\\S.{1,20}$",
+    "volumeRule": "",
     "example": "20字以内顶格写的都是标题",
     "example": "20字以内顶格写的都是标题",
     "serialNumber": 17
     "serialNumber": 17
   },
   },
   {
   {
     "id": -19,
     "id": -19,
     "enable": false,
     "enable": false,
-    "name": "双标题(前向)",
-    "rule": "(?m)(?<=[ \\t ]{0,4})第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$(?=[\\s ]{0,8}第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章)",
-    "example": "第一章 真正的标题\n第一章 这个不要",
+    "name": "中文顶格标题",
+    "chapterRule": "^[一-龟]{1,20}$",
+    "volumeRule": "",
+    "example": "20字以内顶格写的中文视为标题",
     "serialNumber": 18
     "serialNumber": 18
   },
   },
   {
   {
     "id": -20,
     "id": -20,
     "enable": false,
     "enable": false,
-    "name": "双标题(后向)",
-    "rule": "(?m)(?<=[ \\t ]{0,4}第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$[\\s ]{0,8})第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$",
-    "example": "第一章 这个标题不要\n第一章真正的标题",
+    "name": "双标题(前向)",
+    "chapterRule": "(?m)(?<=[ \\t ]{0,4})第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$(?=[\\s ]{0,8}第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章)",
+    "volumeRule": "",
+    "example": "第一章 真正的标题\n第一章 这个不要",
     "serialNumber": 19
     "serialNumber": 19
   },
   },
   {
   {
     "id": -21,
     "id": -21,
+    "enable": false,
+    "name": "双标题(后向)",
+    "chapterRule": "(?m)(?<=[ \\t ]{0,4}第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$[\\s ]{0,8})第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$",
+    "volumeRule": "",
+    "example": "第一章 这个标题不要\n第一章真正的标题",
+    "serialNumber": 20
+  },
+  {
+    "id": -22,
     "enable": true,
     "enable": true,
     "name": "书名 括号 序号",
     "name": "书名 括号 序号",
-    "rule": "^[一-龥]{1,20}[  \\t]{0,4}[((][\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[))][  \\t]{0,4}$",
+    "chapterRule": "^[一-龥]{1,20}[  \\t]{0,4}[((][\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[))][  \\t]{0,4}$",
+    "volumeRule": "",
     "example": "标题后面数字有括号(12)",
     "example": "标题后面数字有括号(12)",
-    "serialNumber": 20
+    "serialNumber": 21
   },
   },
   {
   {
-    "id": -22,
+    "id": -23,
     "enable": true,
     "enable": true,
     "name": "书名 序号",
     "name": "书名 序号",
-    "rule": "^[一-龥]{1,20}[  \\t]{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[  \\t]{0,4}$",
+    "chapterRule": "^[一-龥]{1,20}[  \\t]{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[  \\t]{0,4}$",
+    "volumeRule": "",
     "example": "标题后面数字没有括号124",
     "example": "标题后面数字没有括号124",
-    "serialNumber": 21
+    "serialNumber": 22
   },
   },
   {
   {
-    "id": -23,
+    "id": -24,
     "enable": false,
     "enable": false,
     "name": "特定字符 标题 特定符号",
     "name": "特定字符 标题 特定符号",
-    "rule": "(?<=\\={3,6}).{1,40}?(?=\\=)",
+    "chapterRule": "(?<=\\={3,6}).{1,40}?(?=\\=)",
+    "volumeRule": "",
     "example": "===起这种标题干什么===",
     "example": "===起这种标题干什么===",
-    "serialNumber": 22
+    "serialNumber": 23
   },
   },
   {
   {
-    "id": -24,
+    "id": -25,
     "enable": true,
     "enable": true,
     "name": "字数分割 分节阅读",
     "name": "字数分割 分节阅读",
-    "rule": "(?<=[  \\t]{0,4})(?:.{0,15}分[页节章段]阅读[-_ ]|第\\s{0,4}[\\d零一二两三四五六七八九十百千万]{1,6}\\s{0,4}[页节]).{0,30}$",
+    "chapterRule": "(?<=[  \\t]{0,4})(?:.{0,15}分[页节章段]阅读[-_ ]|第\\s{0,4}[\\d零一二两三四五六七八九十百千万]{1,6}\\s{0,4}[页节]).{0,30}$",
+    "volumeRule": "",
     "example": "分节|分页|分段阅读\n第一页",
     "example": "分节|分页|分段阅读\n第一页",
-    "serialNumber": 23
+    "serialNumber": 24
   },
   },
   {
   {
-    "id": -25,
+    "id": -26,
     "enable": false,
     "enable": false,
     "name": "通用规则",
     "name": "通用规则",
-    "rule": "(?im)^.{0,6}(?:[引楔]子|正文(?!完|结)|[引序前]言|[序终]章|扉页|[上中下][部篇卷]|卷首语|后记|尾声|番外|={2,4}|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|页[、  ]|集(?![合和])|部(?![分是门落])|篇(?!张))).{0,40}$|^.{0,6}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟a-z]{1,8}[、.  ].{0,20}$",
+    "chapterRule": "(?im)^.{0,6}(?:[引楔]子|正文(?!完|结)|[引序前]言|[序终]章|扉页|[上中下][部篇卷]|卷首语|后记|尾声|番外|={2,4}|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|页[、  ]|集(?![合和])|部(?![分是门落])|篇(?!张))).{0,40}$|^.{0,6}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟a-z]{1,8}[、.  ].{0,20}$",
+    "volumeRule": "",
     "example": "激进规则,适配更多非常用格式",
     "example": "激进规则,适配更多非常用格式",
-    "serialNumber": 24
+    "serialNumber": 25
   },
   },
   {
   {
     "id": -100,
     "id": -100,
     "enable": false,
     "enable": false,
     "name": "默认分章规则",
     "name": "默认分章规则",
-    "rule": "",
+    "chapterRule": "",
+    "volumeRule": "",
     "example": "兜底规则,请勿改动此内容",
     "example": "兜底规则,请勿改动此内容",
     "serialNumber": 99
     "serialNumber": 99
   }
   }
-]
+]

+ 22 - 0
app/src/main/java/io/legado/app/data/DatabaseMigrations.kt

@@ -499,6 +499,28 @@ object DatabaseMigrations {
 
 
     private val migration_98_99 = object : Migration(98, 99) {
     private val migration_98_99 = object : Migration(98, 99) {
         override fun migrate(database: SupportSQLiteDatabase) {
         override fun migrate(database: SupportSQLiteDatabase) {
+            database.execSQL(
+                """
+                CREATE TABLE IF NOT EXISTS `txtTocRules_new` (
+                    `id` INTEGER NOT NULL,
+                    `name` TEXT NOT NULL,
+                    `chapterRule` TEXT NOT NULL,
+                    `volumeRule` TEXT NOT NULL DEFAULT '',
+                    `example` TEXT,
+                    `serialNumber` INTEGER NOT NULL,
+                    `enable` INTEGER NOT NULL,
+                    PRIMARY KEY(`id`)
+                )
+                """.trimIndent()
+            )
+            database.execSQL(
+                """
+                INSERT INTO txtTocRules_new (id, name, chapterRule, volumeRule, example, serialNumber, enable)
+                SELECT id, name, rule, '', example, serialNumber, enable FROM txtTocRules
+                """.trimIndent()
+            )
+            database.execSQL("DROP TABLE txtTocRules")
+            database.execSQL("ALTER TABLE txtTocRules_new RENAME TO txtTocRules")
             database.execSQL("ALTER TABLE highlightRules ADD COLUMN fontWeight INTEGER NOT NULL DEFAULT 400")
             database.execSQL("ALTER TABLE highlightRules ADD COLUMN fontWeight INTEGER NOT NULL DEFAULT 400")
             database.execSQL("ALTER TABLE highlightRules ADD COLUMN isItalic INTEGER NOT NULL DEFAULT 0")
             database.execSQL("ALTER TABLE highlightRules ADD COLUMN isItalic INTEGER NOT NULL DEFAULT 0")
             database.execSQL("ALTER TABLE highlightRules ADD COLUMN npLeft REAL NOT NULL DEFAULT 0.1")
             database.execSQL("ALTER TABLE highlightRules ADD COLUMN npLeft REAL NOT NULL DEFAULT 0.1")

+ 4 - 1
app/src/main/java/io/legado/app/data/entities/TxtTocRule.kt

@@ -2,6 +2,7 @@ package io.legado.app.data.entities
 
 
 import androidx.room.Entity
 import androidx.room.Entity
 import androidx.room.PrimaryKey
 import androidx.room.PrimaryKey
+import com.google.gson.annotations.SerializedName
 
 
 
 
 @Entity(tableName = "txtTocRules")
 @Entity(tableName = "txtTocRules")
@@ -9,7 +10,9 @@ data class TxtTocRule(
     @PrimaryKey
     @PrimaryKey
     var id: Long = System.currentTimeMillis(),
     var id: Long = System.currentTimeMillis(),
     var name: String = "",
     var name: String = "",
-    var rule: String = "",
+    @SerializedName(value = "chapterRule", alternate = ["rule"])
+    var chapterRule: String = "",
+    var volumeRule: String = "",
     var example: String? = null,
     var example: String? = null,
     var serialNumber: Int = -1,
     var serialNumber: Int = -1,
     var enable: Boolean = true
     var enable: Boolean = true

+ 3 - 0
app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt

@@ -180,6 +180,7 @@ class ReadBookStyleConfigRepository(
             ReadStyleIntKey.TipFooterMiddle -> ReadBookConfig.tipFooterMiddle = value
             ReadStyleIntKey.TipFooterMiddle -> ReadBookConfig.tipFooterMiddle = value
             ReadStyleIntKey.TipFooterRight -> ReadBookConfig.tipFooterRight = value
             ReadStyleIntKey.TipFooterRight -> ReadBookConfig.tipFooterRight = value
             ReadStyleIntKey.HeaderFontSize -> ReadBookConfig.headerFontSize = value
             ReadStyleIntKey.HeaderFontSize -> ReadBookConfig.headerFontSize = value
+            ReadStyleIntKey.FooterFontSize -> ReadBookConfig.footerFontSize = value
             ReadStyleIntKey.PageAnim -> ReadBookConfig.pageAnim = value
             ReadStyleIntKey.PageAnim -> ReadBookConfig.pageAnim = value
             ReadStyleIntKey.UnderlineHeight -> ReadBookConfig.underlineHeight = value
             ReadStyleIntKey.UnderlineHeight -> ReadBookConfig.underlineHeight = value
             ReadStyleIntKey.UnderlinePadding -> ReadBookConfig.underlinePadding = value
             ReadStyleIntKey.UnderlinePadding -> ReadBookConfig.underlinePadding = value
@@ -223,6 +224,7 @@ class ReadBookStyleConfigRepository(
             ReadStyleBooleanKey.UnderlineExtend -> ReadBookConfig.underlineExtend = value
             ReadStyleBooleanKey.UnderlineExtend -> ReadBookConfig.underlineExtend = value
             ReadStyleBooleanKey.ShowHeaderLine -> ReadBookConfig.showHeaderLine = value
             ReadStyleBooleanKey.ShowHeaderLine -> ReadBookConfig.showHeaderLine = value
             ReadStyleBooleanKey.ShowFooterLine -> ReadBookConfig.showFooterLine = value
             ReadStyleBooleanKey.ShowFooterLine -> ReadBookConfig.showFooterLine = value
+            ReadStyleBooleanKey.ApplyHeaderStyle -> ReadBookConfig.applyHeaderStyle = value
             ReadStyleBooleanKey.StatusIconDark -> ReadBookConfig.durConfig.setCurStatusIconDark(value)
             ReadStyleBooleanKey.StatusIconDark -> ReadBookConfig.durConfig.setCurStatusIconDark(value)
         }
         }
     }
     }
@@ -234,6 +236,7 @@ class ReadBookStyleConfigRepository(
             ReadStyleStringKey.TitleFont -> ReadBookConfig.titleFont = value
             ReadStyleStringKey.TitleFont -> ReadBookConfig.titleFont = value
             ReadStyleStringKey.TitleSegFlag -> ReadBookConfig.titleSegFlag = value
             ReadStyleStringKey.TitleSegFlag -> ReadBookConfig.titleSegFlag = value
             ReadStyleStringKey.HeaderFont -> ReadBookConfig.headerFont = value
             ReadStyleStringKey.HeaderFont -> ReadBookConfig.headerFont = value
+            ReadStyleStringKey.FooterFont -> ReadBookConfig.footerFont = value
             ReadStyleStringKey.CustomTipHeaderLeft -> ReadBookConfig.customTipHeaderLeft = value
             ReadStyleStringKey.CustomTipHeaderLeft -> ReadBookConfig.customTipHeaderLeft = value
             ReadStyleStringKey.CustomTipHeaderMiddle -> ReadBookConfig.customTipHeaderMiddle = value
             ReadStyleStringKey.CustomTipHeaderMiddle -> ReadBookConfig.customTipHeaderMiddle = value
             ReadStyleStringKey.CustomTipHeaderRight -> ReadBookConfig.customTipHeaderRight = value
             ReadStyleStringKey.CustomTipHeaderRight -> ReadBookConfig.customTipHeaderRight = value

+ 3 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadStyleMutation.kt

@@ -32,6 +32,7 @@ enum class ReadStyleIntKey {
     TipFooterMiddle,
     TipFooterMiddle,
     TipFooterRight,
     TipFooterRight,
     HeaderFontSize,
     HeaderFontSize,
+    FooterFontSize,
     PageAnim,
     PageAnim,
     UnderlineHeight,
     UnderlineHeight,
     UnderlinePadding,
     UnderlinePadding,
@@ -71,6 +72,7 @@ enum class ReadStyleBooleanKey {
     UnderlineExtend,
     UnderlineExtend,
     ShowHeaderLine,
     ShowHeaderLine,
     ShowFooterLine,
     ShowFooterLine,
+    ApplyHeaderStyle,
     StatusIconDark,
     StatusIconDark,
 }
 }
 
 
@@ -80,6 +82,7 @@ enum class ReadStyleStringKey {
     TitleFont,
     TitleFont,
     TitleSegFlag,
     TitleSegFlag,
     HeaderFont,
     HeaderFont,
+    FooterFont,
     CustomTipHeaderLeft,
     CustomTipHeaderLeft,
     CustomTipHeaderMiddle,
     CustomTipHeaderMiddle,
     CustomTipHeaderRight,
     CustomTipHeaderRight,

+ 1 - 1
app/src/main/java/io/legado/app/help/config/LocalConfig.kt

@@ -99,7 +99,7 @@ by appCtx.getSharedPreferences("local", Context.MODE_PRIVATE) {
         get() = !isLastVersion(6, "httpTtsVersion")
         get() = !isLastVersion(6, "httpTtsVersion")
 
 
     val needUpTxtTocRule: Boolean
     val needUpTxtTocRule: Boolean
-        get() = !isLastVersion(3, "txtTocRuleVersion")
+        get() = !isLastVersion(4, "txtTocRuleVersion")
 
 
     val needUpRssSources: Boolean
     val needUpRssSources: Boolean
         get() = !isLastVersion(6, "rssSourceVersion")
         get() = !isLastVersion(6, "rssSourceVersion")

+ 9 - 0
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -357,6 +357,12 @@ object ReadBookConfig {
             config.footerFontSize = value
             config.footerFontSize = value
         }
         }
 
 
+    var applyHeaderStyle: Boolean
+        get() = config.applyHeaderStyle
+        set(value) {
+            config.applyHeaderStyle = value
+        }
+
     var textBold: Int
     var textBold: Int
         get() = config.textBold
         get() = config.textBold
         set(value) {
         set(value) {
@@ -815,6 +821,7 @@ object ReadBookConfig {
             exportConfig.footerFont = shareConfig.footerFont
             exportConfig.footerFont = shareConfig.footerFont
             exportConfig.headerFontSize = shareConfig.headerFontSize
             exportConfig.headerFontSize = shareConfig.headerFontSize
             exportConfig.footerFontSize = shareConfig.footerFontSize
             exportConfig.footerFontSize = shareConfig.footerFontSize
+            exportConfig.applyHeaderStyle = shareConfig.applyHeaderStyle
             exportConfig.textBold = shareConfig.textBold
             exportConfig.textBold = shareConfig.textBold
             exportConfig.textSize = shareConfig.textSize
             exportConfig.textSize = shareConfig.textSize
             exportConfig.letterSpacing = shareConfig.letterSpacing
             exportConfig.letterSpacing = shareConfig.letterSpacing
@@ -924,6 +931,7 @@ object ReadBookConfig {
         var footerFont: String = "",//页脚字体
         var footerFont: String = "",//页脚字体
         var headerFontSize: Int = 12,//页眉字号
         var headerFontSize: Int = 12,//页眉字号
         var footerFontSize: Int = 12,//页脚字号
         var footerFontSize: Int = 12,//页脚字号
+        var applyHeaderStyle: Boolean = true,//页脚是否应用页眉字体样式
         var textBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
         var textBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
         var textSize: Int = 20,//文字大小
         var textSize: Int = 20,//文字大小
         var textItalic: Boolean = false,// 是否启用斜体
         var textItalic: Boolean = false,// 是否启用斜体
@@ -1089,6 +1097,7 @@ object ReadBookConfig {
             "footerFont" to footerFont,
             "footerFont" to footerFont,
             "headerFontSize" to headerFontSize,
             "headerFontSize" to headerFontSize,
             "footerFontSize" to footerFontSize,
             "footerFontSize" to footerFontSize,
+            "applyHeaderStyle" to applyHeaderStyle,
             "textBold" to textBold,
             "textBold" to textBold,
             "textSize" to textSize,
             "textSize" to textSize,
             "letterSpacing" to letterSpacing,
             "letterSpacing" to letterSpacing,

+ 42 - 12
app/src/main/java/io/legado/app/model/localBook/TextFile.kt

@@ -94,11 +94,12 @@ class TextFile(private var book: Book) {
                 charset = book.fileCharset()
                 charset = book.fileCharset()
                 if (book.tocUrl.isBlank() || modified) {
                 if (book.tocUrl.isBlank() || modified) {
                     val blockContent = String(buffer, 0, length, charset)
                     val blockContent = String(buffer, 0, length, charset)
-                    book.tocUrl = getTocRule(blockContent)?.pattern() ?: ""
+                    book.tocUrl = getTocRule(blockContent)?.chapterRule ?: ""
                 }
                 }
             }
             }
         }
         }
-        val (toc, wordCount) = analyze(book.tocUrl.toPattern(Pattern.MULTILINE))
+        val volumePattern = getVolumePattern(book.tocUrl)
+        val (toc, wordCount) = analyze(book.tocUrl.toPattern(Pattern.MULTILINE), volumePattern)
         book.wordCount = StringUtils.wordCountFormat(wordCount)
         book.wordCount = StringUtils.wordCountFormat(wordCount)
         book.upKind()
         book.upKind()
         toc.forEachIndexed { index, bookChapter ->
         toc.forEachIndexed { index, bookChapter ->
@@ -108,6 +109,7 @@ class TextFile(private var book: Book) {
         }
         }
         var tocLevel = 0
         var tocLevel = 0
         toc.forEach { chapter ->
         toc.forEach { chapter ->
+            if (chapter.isVolume) tocLevel = 0
             chapter.tocLevel = tocLevel
             chapter.tocLevel = tocLevel
             if (chapter.isVolume) tocLevel = 1
             if (chapter.isVolume) tocLevel = 1
         }
         }
@@ -156,7 +158,7 @@ class TextFile(private var book: Book) {
     /**
     /**
      * 按规则解析目录
      * 按规则解析目录
      */
      */
-    private fun analyze(pattern: Pattern?): Pair<ArrayList<BookChapter>, Int> {
+    private fun analyze(pattern: Pattern?, volumePattern: Pattern? = null): Pair<ArrayList<BookChapter>, Int> {
         if (pattern == null || pattern.pattern().isNullOrEmpty()) {
         if (pattern == null || pattern.pattern().isNullOrEmpty()) {
             return analyze()
             return analyze()
         }
         }
@@ -261,8 +263,12 @@ class TextFile(private var book: Book) {
                         } else { //否则就block分割之后,上一个章节的剩余内容
                         } else { //否则就block分割之后,上一个章节的剩余内容
                             //获取上一章节
                             //获取上一章节
                             val lastChapter = toc.last()
                             val lastChapter = toc.last()
-                            lastChapter.isVolume =
-                                chapterContent.substringAfter(lastChapter.title).isBlank()
+                            if (volumePattern == null) {
+                                lastChapter.isVolume =
+                                    chapterContent.substringAfter(lastChapter.title).isBlank()
+                            } else {
+                                lastChapter.isVolume = volumePattern.matcher(lastChapter.title).find()
+                            }
                             //将当前段落添加上一章去
                             //将当前段落添加上一章去
                             lastChapter.end = lastChapter.end!! + chapterLength
                             lastChapter.end = lastChapter.end!! + chapterLength
                             lastChapterWordCount += chapterContent.length
                             lastChapterWordCount += chapterContent.length
@@ -281,8 +287,12 @@ class TextFile(private var book: Book) {
                         if (toc.isNotEmpty()) { //获取章节内容
                         if (toc.isNotEmpty()) { //获取章节内容
                             //获取上一章节
                             //获取上一章节
                             val lastChapter = toc.last()
                             val lastChapter = toc.last()
-                            lastChapter.isVolume =
-                                chapterContent.substringAfter(lastChapter.title).isBlank()
+                            if (volumePattern == null) {
+                                lastChapter.isVolume =
+                                    chapterContent.substringAfter(lastChapter.title).isBlank()
+                            } else {
+                                lastChapter.isVolume = volumePattern.matcher(lastChapter.title).find()
+                            }
                             lastChapter.end =
                             lastChapter.end =
                                 lastChapter.start!! + chapterLength
                                 lastChapter.start!! + chapterLength
                             lastChapter.wordCount =
                             lastChapter.wordCount =
@@ -319,7 +329,11 @@ class TextFile(private var book: Book) {
                     it.wordCount = StringUtils.wordCountFormat(lastChapterWordCount)
                     it.wordCount = StringUtils.wordCountFormat(lastChapterWordCount)
                 }
                 }
             }
             }
+            // 检查最后一章是否为分卷(循环中无法检测到最后一章),并处理长章节拆分
             toc.lastOrNull()?.let { chapter ->
             toc.lastOrNull()?.let { chapter ->
+                if (volumePattern != null) {
+                    chapter.isVolume = volumePattern.matcher(chapter.title).find()
+                }
                 //章节字数太多进行拆分
                 //章节字数太多进行拆分
                 if (book.getSplitLongChapter() && chapter.end!! - chapter.start!! > maxLengthWithToc) {
                 if (book.getSplitLongChapter() && chapter.end!! - chapter.start!! > maxLengthWithToc) {
                     val end = chapter.end!!
                     val end = chapter.end!!
@@ -446,13 +460,13 @@ class TextFile(private var book: Book) {
     /**
     /**
      * 获取合适的目录规则
      * 获取合适的目录规则
      */
      */
-    private fun getTocRule(content: String): Pattern? {
+    private fun getTocRule(content: String): TxtTocRule? {
         val rules = getTocRules().reversed()
         val rules = getTocRules().reversed()
         var maxNum = 1
         var maxNum = 1
-        var tocPattern: Pattern? = null
+        var bestRule: TxtTocRule? = null
         for (tocRule in rules) {
         for (tocRule in rules) {
             val pattern = try {
             val pattern = try {
-                tocRule.rule.toPattern(Pattern.MULTILINE)
+                tocRule.chapterRule.toPattern(Pattern.MULTILINE)
             } catch (e: PatternSyntaxException) {
             } catch (e: PatternSyntaxException) {
                 AppLog.put("TXT目录规则正则语法错误:${tocRule.name}\n$e", e)
                 AppLog.put("TXT目录规则正则语法错误:${tocRule.name}\n$e", e)
                 continue
                 continue
@@ -468,10 +482,26 @@ class TextFile(private var book: Book) {
             }
             }
             if (num >= maxNum) {
             if (num >= maxNum) {
                 maxNum = num
                 maxNum = num
-                tocPattern = pattern
+                bestRule = tocRule
             }
             }
         }
         }
-        return tocPattern
+        return bestRule
+    }
+
+    /**
+     * 根据章节正则查找对应的分卷正则(搜索全部规则,含已禁用的)
+     */
+    private fun getVolumePattern(chapterPattern: String): Pattern? {
+        if (chapterPattern.isBlank()) return null
+        val rule = appDb.txtTocRuleDao.all.find { it.chapterRule == chapterPattern }
+        val volumeRule = rule?.volumeRule
+        if (volumeRule.isNullOrBlank()) return null
+        return try {
+            volumeRule.toPattern(Pattern.MULTILINE)
+        } catch (e: PatternSyntaxException) {
+            AppLog.put("TXT分卷规则正则语法错误:${rule?.name}\n$e", e)
+            null
+        }
     }
     }
 
 
     /**
     /**

+ 23 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -58,11 +58,9 @@ data class ReadBookMenuState(
 sealed interface ReadBookMenuRoute {
 sealed interface ReadBookMenuRoute {
     data object Main : ReadBookMenuRoute
     data object Main : ReadBookMenuRoute
     data object ReadStyle : ReadBookMenuRoute
     data object ReadStyle : ReadBookMenuRoute
-    data object TextTitle : ReadBookMenuRoute
     data object ReadAloud : ReadBookMenuRoute
     data object ReadAloud : ReadBookMenuRoute
     data object AutoRead : ReadBookMenuRoute
     data object AutoRead : ReadBookMenuRoute
-    data object PaddingConfig : ReadBookMenuRoute
-    data object HeaderFooterConfig : ReadBookMenuRoute
+    data object TypographyConfig : ReadBookMenuRoute
 }
 }
 
 
 @Stable
 @Stable
@@ -154,6 +152,19 @@ data class ReadSheetConfigUiState(
     val footerPaddingBottom: Int = 0,
     val footerPaddingBottom: Int = 0,
     val footerPaddingLeft: Int = 0,
     val footerPaddingLeft: Int = 0,
     val footerPaddingRight: Int = 0,
     val footerPaddingRight: Int = 0,
+    // 页眉页脚字体
+    val headerFont: String = "",
+    val footerFont: String = "",
+    val headerFontSize: Int = 12,
+    val footerFontSize: Int = 12,
+    val applyHeaderStyle: Boolean = true,
+    val tipDividerColor: Int = 0,
+    val tipHeaderColor: Int = 0,
+    val tipHeaderColorNight: Int = 0,
+    val tipFooterColor: Int = 0,
+    val tipFooterColorNight: Int = 0,
+    val textFullJustify: Boolean = true,
+    val textBottomJustify: Boolean = true,
     val configNames: ImmutableList<String> = persistentListOf(),
     val configNames: ImmutableList<String> = persistentListOf(),
 )
 )
 
 
@@ -1194,6 +1205,15 @@ sealed interface ConfigUpdate {
     data class HeaderFontSize(val value: Int) : ConfigUpdate {
     data class HeaderFontSize(val value: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
     }
     }
+    data class FooterFont(val path: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle)
+    }
+    data class FooterFontSize(val value: Int) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle)
+    }
+    data class ApplyHeaderStyle(val value: Boolean) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle)
+    }
     data class TipHeaderColor(val color: Int) : ConfigUpdate {
     data class TipHeaderColor(val color: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
     }
     }

+ 59 - 162
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt

@@ -81,6 +81,7 @@ import androidx.compose.material.icons.filled.SwapVert
 import androidx.compose.material.icons.filled.Sync
 import androidx.compose.material.icons.filled.Sync
 import androidx.compose.material.icons.filled.Toc
 import androidx.compose.material.icons.filled.Toc
 import androidx.compose.material.icons.filled.Translate
 import androidx.compose.material.icons.filled.Translate
+import androidx.compose.material.icons.filled.Tune
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Surface
 import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
 import androidx.compose.material3.Text
@@ -158,12 +159,10 @@ import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.help.config.ReadStyleResolver
 import io.legado.app.help.config.ReadStyleResolver
 import io.legado.app.ui.animation.DampedDragAnimation
 import io.legado.app.ui.animation.DampedDragAnimation
 import io.legado.app.ui.book.read.sheet.AutoReadContent
 import io.legado.app.ui.book.read.sheet.AutoReadContent
-import io.legado.app.ui.book.read.sheet.HeaderFooterPage
-import io.legado.app.ui.book.read.sheet.PaddingConfigContent
 import io.legado.app.ui.book.read.sheet.ReadAloudContent
 import io.legado.app.ui.book.read.sheet.ReadAloudContent
 import io.legado.app.ui.book.read.sheet.ReadMenuButtonInfo
 import io.legado.app.ui.book.read.sheet.ReadMenuButtonInfo
 import io.legado.app.ui.book.read.sheet.ReadStyleContent
 import io.legado.app.ui.book.read.sheet.ReadStyleContent
-import io.legado.app.ui.book.read.sheet.ReadStyleTextTitleContent
+import io.legado.app.ui.book.read.sheet.TypographyPage
 import io.legado.app.ui.book.read.sheet.readMenuButtonInfos
 import io.legado.app.ui.book.read.sheet.readMenuButtonInfos
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppSlider
 import io.legado.app.ui.widget.components.AppSlider
@@ -211,15 +210,14 @@ fun ReadBookMenuBar(
     } else {
     } else {
         ReadBookMenuContent.Route(currentRoute)
         ReadBookMenuContent.Route(currentRoute)
     }
     }
-    val dialogLikeRoute = currentRoute == ReadBookMenuRoute.PaddingConfig || currentRoute == ReadBookMenuRoute.HeaderFooterConfig
+    val dialogLikeRoute = currentRoute == ReadBookMenuRoute.TypographyConfig
     var readStylePage by remember { mutableIntStateOf(0) }
     var readStylePage by remember { mutableIntStateOf(0) }
     LaunchedEffect(currentRoute) {
     LaunchedEffect(currentRoute) {
         if (currentRoute != ReadBookMenuRoute.ReadStyle) {
         if (currentRoute != ReadBookMenuRoute.ReadStyle) {
             readStylePage = 0
             readStylePage = 0
         }
         }
     }
     }
-    val hideTopBar = dialogLikeRoute ||
-            currentRoute == ReadBookMenuRoute.TextTitle
+    val hideTopBar = dialogLikeRoute
     val menuColors = readMenuColors(preferences.readBarStyle)
     val menuColors = readMenuColors(preferences.readBarStyle)
 
 
     Box(Modifier.fillMaxSize()) {
     Box(Modifier.fillMaxSize()) {
@@ -437,7 +435,7 @@ private fun ReadBookMenuSurface(
         is ReadBookMenuContent.Route -> contentTarget.route
         is ReadBookMenuContent.Route -> contentTarget.route
     }
     }
     val expanded = route != ReadBookMenuRoute.Main
     val expanded = route != ReadBookMenuRoute.Main
-    val dialogLikeRoute = route == ReadBookMenuRoute.PaddingConfig || route == ReadBookMenuRoute.HeaderFooterConfig
+    val dialogLikeRoute = route == ReadBookMenuRoute.TypographyConfig
     val density = LocalDensity.current
     val density = LocalDensity.current
     val windowSize = LocalWindowInfo.current.containerSize
     val windowSize = LocalWindowInfo.current.containerSize
     var surfaceHeightPx by remember { mutableIntStateOf(0) }
     var surfaceHeightPx by remember { mutableIntStateOf(0) }
@@ -701,11 +699,8 @@ private fun ReadBookMenuSurface(
                             onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
                             onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
                         ) {
                         ) {
                             ReadStyleContent(
                             ReadStyleContent(
-                                onOpenPaddingConfig = {
-                                    onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.PaddingConfig))
-                                },
-                                onOpenHeaderFooterConfig = {
-                                    onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.HeaderFooterConfig))
+                                onOpenTypographyConfig = {
+                                    onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.TypographyConfig))
                                 },
                                 },
                                 onOpenMoreConfig = {
                                 onOpenMoreConfig = {
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.MoreConfig))
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.MoreConfig))
@@ -713,8 +708,11 @@ private fun ReadBookMenuSurface(
                                 onOpenBgTextConfig = { index ->
                                 onOpenBgTextConfig = { index ->
                                     onIntent(ReadBookIntent.OpenBgTextConfig(index))
                                     onIntent(ReadBookIntent.OpenBgTextConfig(index))
                                 },
                                 },
-                                onOpenTextTitle = {
-                                    onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.TextTitle))
+                                onOpenUnderlineConfig = {
+                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.UnderlineConfig))
+                                },
+                                onOpenShadowSet = {
+                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ShadowSet))
                                 },
                                 },
                                 onOpenFontSelect = {
                                 onOpenFontSelect = {
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.FontSelect))
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.FontSelect))
@@ -733,48 +731,24 @@ private fun ReadBookMenuSurface(
                         }
                         }
                     }
                     }
 
 
-                    ReadBookMenuRoute.PaddingConfig -> {
+                    ReadBookMenuRoute.TypographyConfig -> {
                         ReadBookMenuRoutePage(
                         ReadBookMenuRoutePage(
-                            title = stringResource(R.string.padding),
+                            title = stringResource(R.string.compose_type),
                             maxHeight = maxHeight,
                             maxHeight = maxHeight,
                             scrollContent = false,
                             scrollContent = false,
                             animateSize = false,
                             animateSize = false,
                             bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp,
                             bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp,
                             onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
                             onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
                         ) {
                         ) {
-                            PaddingConfigContent(
+                            TypographyPage(
                                 config = state.sheetConfig,
                                 config = state.sheetConfig,
                                 onIntent = onIntent,
                                 onIntent = onIntent,
-                                modifier = Modifier.padding(horizontal = 16.dp),
-                            )
-                        }
-                    }
-
-                    ReadBookMenuRoute.HeaderFooterConfig -> {
-                        ReadBookMenuRoutePage(
-                            title = stringResource(R.string.header_footer),
-                            maxHeight = maxHeight,
-                            scrollContent = false,
-                            animateSize = false,
-                            bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp,
-                            onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
-                        ) {
-                            HeaderFooterPage(
-                                onIntent = onIntent,
-                            )
-                        }
-                    }
-
-                    ReadBookMenuRoute.TextTitle -> {
-                        ReadBookMenuRoutePage(
-                            title = stringResource(R.string.read_config_text_effects),
-                            maxHeight = maxHeight,
-                            bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp,
-                            animateSize = false,
-                            onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
-                        ) {
-                            ReadStyleTextTitleContent(
-                                config = state.sheetConfig,
+                                onOpenFontSelect = {
+                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.FontSelect))
+                                },
+                                onOpenTitleFontSelect = {
+                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.TitleFontSelect))
+                                },
                                 onOpenShadowSet = {
                                 onOpenShadowSet = {
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ShadowSet))
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ShadowSet))
                                 },
                                 },
@@ -784,13 +758,7 @@ private fun ReadBookMenuSurface(
                                 onOpenHighlightRule = {
                                 onOpenHighlightRule = {
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.HighlightRuleConfig))
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.HighlightRuleConfig))
                                 },
                                 },
-                                onOpenFontSelect = {
-                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.FontSelect))
-                                },
-                                onOpenTitleFontSelect = {
-                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.TitleFontSelect))
-                                },
-                                onIntent = onIntent,
+                                sameTitleRemoved = state.sameTitleRemoved,
                             )
                             )
                         }
                         }
                     }
                     }
@@ -2040,7 +2008,6 @@ private fun OverflowDropdownMenu(
         expanded = expanded,
         expanded = expanded,
         onDismissRequest = onDismiss,
         onDismissRequest = onDismiss,
     ) { dismiss ->
     ) { dismiss ->
-        var imageStyleExpanded by remember { mutableStateOf(false) }
 
 
         // Compact mode: moved from top bar buttons
         // Compact mode: moved from top bar buttons
         if (state.menuConfig.titleBarCompact) {
         if (state.menuConfig.titleBarCompact) {
@@ -2077,23 +2044,7 @@ private fun OverflowDropdownMenu(
             PillDivider()
             PillDivider()
         }
         }
 
 
-        // Source actions
-        if (!state.isLocalBook) {
-            RoundDropdownMenuItem(
-                text = stringResource(R.string.menu_refresh_all),
-                leadingIcon = menuIcon(Icons.Default.Replay),
-                onClick = { dismiss(); onIntent(ReadBookIntent.MenuRefreshAll) },
-            )
-        }
-
-        PillDivider()
-
-        // Content operations
-        RoundDropdownMenuItem(
-            text = stringResource(R.string.bookmark_add),
-            leadingIcon = menuIcon(Icons.Default.Bookmark),
-            onClick = { dismiss(); onIntent(ReadBookIntent.AddBookmark) },
-        )
+        // 内容处理
         RoundDropdownMenuItem(
         RoundDropdownMenuItem(
             text = stringResource(R.string.edit_content),
             text = stringResource(R.string.edit_content),
             leadingIcon = menuIcon(Icons.Default.Edit),
             leadingIcon = menuIcon(Icons.Default.Edit),
@@ -2103,59 +2054,27 @@ private fun OverflowDropdownMenu(
             },
             },
         )
         )
         RoundDropdownMenuItem(
         RoundDropdownMenuItem(
-            text = stringResource(R.string.update_toc),
-            leadingIcon = menuIcon(Icons.Default.Replay),
-            onClick = { dismiss(); onIntent(ReadBookIntent.MenuUpdateToc) },
+            text = stringResource(R.string.bookmark_add),
+            leadingIcon = menuIcon(Icons.Default.Bookmark),
+            onClick = { dismiss(); onIntent(ReadBookIntent.AddBookmark) },
         )
         )
         RoundDropdownMenuItem(
         RoundDropdownMenuItem(
-            text = stringResource(R.string.simulated_reading),
-            leadingIcon = menuIcon(Icons.Default.AutoStories),
+            text = stringResource(R.string.highlight_rule_config),
+            leadingIcon = menuIcon(Icons.Default.Tune),
             onClick = {
             onClick = {
                 dismiss()
                 dismiss()
-                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.SimulatedReading))
+                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.HighlightRuleConfig))
             },
             },
         )
         )
-        RoundDropdownMenuItem(
-            text = stringResource(R.string.reverse_content),
-            leadingIcon = menuIcon(Icons.Default.SwapVert),
-            onClick = { dismiss(); onIntent(ReadBookIntent.MenuReverseContent) },
-        )
-
-        PillDivider()
-
-        // Checkable items
-        RoundDropdownMenuItem(
-            text = stringResource(R.string.replace_rule_title),
-            leadingIcon = menuIcon(Icons.Default.FindReplace),
-            isSelected = state.useReplaceRule,
-            onClick = { onIntent(ReadBookIntent.MenuEnableReplace) },
-        )
         RoundDropdownMenuItem(
         RoundDropdownMenuItem(
             text = stringResource(R.string.replace_rule_title_setting),
             text = stringResource(R.string.replace_rule_title_setting),
             leadingIcon = menuIcon(Icons.Default.Settings),
             leadingIcon = menuIcon(Icons.Default.Settings),
             onClick = { dismiss(); onIntent(ReadBookIntent.MenuSettingReplace) },
             onClick = { dismiss(); onIntent(ReadBookIntent.MenuSettingReplace) },
         )
         )
         RoundDropdownMenuItem(
         RoundDropdownMenuItem(
-            text = stringResource(R.string.effective_replaces),
-            leadingIcon = menuIcon(Icons.Default.Rule),
-            onClick = {
-                dismiss()
-                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.EffectiveReplaces))
-            },
-        )
-        RoundDropdownMenuItem(
-            text = stringResource(R.string.content_processes),
-            leadingIcon = menuIcon(Icons.Default.Edit),
-            onClick = {
-                dismiss()
-                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ContentProcesses))
-            },
-        )
-        RoundDropdownMenuItem(
-            text = stringResource(R.string.same_title_removed),
-            leadingIcon = menuIcon(Icons.Default.CleanHands),
-            isSelected = state.sameTitleRemoved,
-            onClick = { onIntent(ReadBookIntent.MenuSameTitleRemoved) },
+            text = stringResource(R.string.reverse_content),
+            leadingIcon = menuIcon(Icons.Default.SwapVert),
+            onClick = { dismiss(); onIntent(ReadBookIntent.MenuReverseContent) },
         )
         )
         RoundDropdownMenuItem(
         RoundDropdownMenuItem(
             text = stringResource(R.string.re_segment),
             text = stringResource(R.string.re_segment),
@@ -2163,8 +2082,6 @@ private fun OverflowDropdownMenu(
             isSelected = state.reSegment,
             isSelected = state.reSegment,
             onClick = { onIntent(ReadBookIntent.MenuReSegment) },
             onClick = { onIntent(ReadBookIntent.MenuReSegment) },
         )
         )
-
-        // EPUB
         if (state.isEpub) {
         if (state.isEpub) {
             RoundDropdownMenuItem(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.del_ruby_tag),
                 text = stringResource(R.string.del_ruby_tag),
@@ -2182,47 +2099,7 @@ private fun OverflowDropdownMenu(
 
 
         PillDivider()
         PillDivider()
 
 
-        // Config
-        Box {
-            RoundDropdownMenuItem(
-                text = stringResource(R.string.image_style),
-                leadingIcon = menuIcon(Icons.Default.Image),
-                onClick = { imageStyleExpanded = true },
-            )
-            RoundDropdownMenu(
-                expanded = imageStyleExpanded,
-                onDismissRequest = { imageStyleExpanded = false },
-            ) { subDismiss ->
-                RoundDropdownMenuItem(
-                    text = stringResource(R.string.btn_default_s),
-                    onClick = {
-                        subDismiss()
-                        onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleDefault))
-                    },
-                )
-                RoundDropdownMenuItem(
-                    text = stringResource(R.string.image_style_full),
-                    onClick = {
-                        subDismiss()
-                        onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleFull))
-                    },
-                )
-                RoundDropdownMenuItem(
-                    text = stringResource(R.string.image_style_text),
-                    onClick = {
-                        subDismiss()
-                        onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleText))
-                    },
-                )
-                RoundDropdownMenuItem(
-                    text = stringResource(R.string.image_style_single),
-                    onClick = {
-                        subDismiss()
-                        onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleSingle))
-                    },
-                )
-            }
-        }
+        // 阅读设置
         RoundDropdownMenuItem(
         RoundDropdownMenuItem(
             text = stringResource(R.string.book_page_anim),
             text = stringResource(R.string.book_page_anim),
             leadingIcon = menuIcon(Icons.Default.Animation),
             leadingIcon = menuIcon(Icons.Default.Animation),
@@ -2232,15 +2109,27 @@ private fun OverflowDropdownMenu(
             },
             },
         )
         )
         RoundDropdownMenuItem(
         RoundDropdownMenuItem(
-            text = stringResource(R.string.config_btn),
-            leadingIcon = menuIcon(Icons.Default.Extension),
+            text = stringResource(R.string.simulated_reading),
+            leadingIcon = menuIcon(Icons.Default.AutoStories),
             onClick = {
             onClick = {
                 dismiss()
                 dismiss()
-                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ToolButtonConfig))
+                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.SimulatedReading))
             },
             },
         )
         )
 
 
-        // Progress sync
+        PillDivider()
+
+        // 书源
+        if (!state.isLocalBook) {
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.menu_refresh_all),
+                leadingIcon = menuIcon(Icons.Default.Replay),
+                onClick = { dismiss(); onIntent(ReadBookIntent.MenuRefreshAll) },
+            )
+            PillDivider()
+        }
+
+        // 进度同步
         if (state.isReadingProgressSyncConfigured) {
         if (state.isReadingProgressSyncConfigured) {
             RoundDropdownMenuItem(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.get_book_progress),
                 text = stringResource(R.string.get_book_progress),
@@ -2252,10 +2141,18 @@ private fun OverflowDropdownMenu(
                 leadingIcon = menuIcon(Icons.Default.Sync),
                 leadingIcon = menuIcon(Icons.Default.Sync),
                 onClick = { dismiss(); onIntent(ReadBookIntent.MenuCoverProgress) },
                 onClick = { dismiss(); onIntent(ReadBookIntent.MenuCoverProgress) },
             )
             )
+            PillDivider()
         }
         }
 
 
-        PillDivider()
-
+        // 其他
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.config_btn),
+            leadingIcon = menuIcon(Icons.Default.Extension),
+            onClick = {
+                dismiss()
+                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ToolButtonConfig))
+            },
+        )
         RoundDropdownMenuItem(
         RoundDropdownMenuItem(
             text = stringResource(R.string.log),
             text = stringResource(R.string.log),
             leadingIcon = menuIcon(Icons.Default.BugReport),
             leadingIcon = menuIcon(Icons.Default.BugReport),

+ 6 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -367,7 +367,12 @@ fun ReadBookRouteScreen(
                             ReadBookEffect.OpenTtsEnginesAndVoices -> onOpenTtsEnginesAndVoices()
                             ReadBookEffect.OpenTtsEnginesAndVoices -> onOpenTtsEnginesAndVoices()
                             ReadBookEffect.OpenTtsCache -> onOpenTtsCache()
                             ReadBookEffect.OpenTtsCache -> onOpenTtsCache()
                             is ReadBookEffect.MenuSettingReplace -> {
                             is ReadBookEffect.MenuSettingReplace -> {
-                                replaceLauncher.launch(Intent(context, ReplaceRuleActivity::class.java))
+                                replaceLauncher.launch(
+                                    ReplaceRuleActivity.startIntent(
+                                        context = context,
+                                        bookUrl = ReadBook.book?.bookUrl
+                                    )
+                                )
                             }
                             }
                             is ReadBookEffect.TextActionReplace -> {
                             is ReadBookEffect.TextActionReplace -> {
                                 val scopes = arrayListOf<String>()
                                 val scopes = arrayListOf<String>()

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -184,7 +184,7 @@ fun ReadBookScreen(
         onReplaceRuleChanged = { onIntent(ReadBookIntent.ReplaceRuleChanged) },
         onReplaceRuleChanged = { onIntent(ReadBookIntent.ReplaceRuleChanged) },
         onNavigateToTextEffects = {
         onNavigateToTextEffects = {
             onIntent(ReadBookIntent.DismissSheet)
             onIntent(ReadBookIntent.DismissSheet)
-            onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.TextTitle))
+            onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.TypographyConfig))
         },
         },
         onOpenContentProcesses = {
         onOpenContentProcesses = {
             onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ContentProcesses))
             onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ContentProcesses))
@@ -532,7 +532,7 @@ fun ReadBookScreen(
         is ReadBookSheet.BookNavigation -> Unit
         is ReadBookSheet.BookNavigation -> Unit
 
 
         is ReadBookSheet.InfoConfig -> {
         is ReadBookSheet.InfoConfig -> {
-            // Integrated into ReadStyleSheet's HeaderFooterPage
+            // Integrated into TypographyPage
             LaunchedEffect(state.activeSheet) {
             LaunchedEffect(state.activeSheet) {
                 onIntent(ReadBookIntent.DismissSheet)
                 onIntent(ReadBookIntent.DismissSheet)
             }
             }

+ 18 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -2501,6 +2501,18 @@ class ReadBookViewModel(
         footerPaddingBottom = ReadBookConfig.footerPaddingBottom,
         footerPaddingBottom = ReadBookConfig.footerPaddingBottom,
         footerPaddingLeft = ReadBookConfig.footerPaddingLeft,
         footerPaddingLeft = ReadBookConfig.footerPaddingLeft,
         footerPaddingRight = ReadBookConfig.footerPaddingRight,
         footerPaddingRight = ReadBookConfig.footerPaddingRight,
+        headerFont = ReadBookConfig.headerFont,
+        footerFont = ReadBookConfig.footerFont,
+        headerFontSize = ReadBookConfig.headerFontSize,
+        footerFontSize = ReadBookConfig.footerFontSize,
+        applyHeaderStyle = ReadBookConfig.applyHeaderStyle,
+        tipDividerColor = ReadBookConfig.tipDividerColor,
+        tipHeaderColor = ReadBookConfig.tipHeaderColor,
+        tipHeaderColorNight = ReadBookConfig.tipHeaderColorNight,
+        tipFooterColor = ReadBookConfig.tipFooterColor,
+        tipFooterColorNight = ReadBookConfig.tipFooterColorNight,
+        textFullJustify = readSettingsRepository.currentSettings.textFullJustify,
+        textBottomJustify = readSettingsRepository.currentSettings.textBottomJustify,
         configNames = readBookStyleConfigRepository.currentState.items.map { it.name }
         configNames = readBookStyleConfigRepository.currentState.items.map { it.name }
             .filter { it.isNotBlank() }
             .filter { it.isNotBlank() }
             .toImmutableList(),
             .toImmutableList(),
@@ -4981,6 +4993,9 @@ class ReadBookViewModel(
             is ConfigUpdate.CustomTipFooterRight,
             is ConfigUpdate.CustomTipFooterRight,
             is ConfigUpdate.HeaderFont,
             is ConfigUpdate.HeaderFont,
             is ConfigUpdate.HeaderFontSize,
             is ConfigUpdate.HeaderFontSize,
+            is ConfigUpdate.FooterFont,
+            is ConfigUpdate.FooterFontSize,
+            is ConfigUpdate.ApplyHeaderStyle,
             is ConfigUpdate.TipHeaderColor,
             is ConfigUpdate.TipHeaderColor,
             is ConfigUpdate.TipHeaderColorNight,
             is ConfigUpdate.TipHeaderColorNight,
             is ConfigUpdate.TipFooterColor,
             is ConfigUpdate.TipFooterColor,
@@ -5639,6 +5654,7 @@ class ReadBookViewModel(
         is ConfigUpdate.TipFooterMiddle -> intMutation(ReadStyleIntKey.TipFooterMiddle, value)
         is ConfigUpdate.TipFooterMiddle -> intMutation(ReadStyleIntKey.TipFooterMiddle, value)
         is ConfigUpdate.TipFooterRight -> intMutation(ReadStyleIntKey.TipFooterRight, value)
         is ConfigUpdate.TipFooterRight -> intMutation(ReadStyleIntKey.TipFooterRight, value)
         is ConfigUpdate.HeaderFont -> stringMutation(ReadStyleStringKey.HeaderFont, path)
         is ConfigUpdate.HeaderFont -> stringMutation(ReadStyleStringKey.HeaderFont, path)
+        is ConfigUpdate.FooterFont -> stringMutation(ReadStyleStringKey.FooterFont, path)
         is ConfigUpdate.CustomTipHeaderLeft ->
         is ConfigUpdate.CustomTipHeaderLeft ->
             stringMutation(ReadStyleStringKey.CustomTipHeaderLeft, value)
             stringMutation(ReadStyleStringKey.CustomTipHeaderLeft, value)
         is ConfigUpdate.CustomTipHeaderMiddle ->
         is ConfigUpdate.CustomTipHeaderMiddle ->
@@ -5652,6 +5668,8 @@ class ReadBookViewModel(
         is ConfigUpdate.CustomTipFooterRight ->
         is ConfigUpdate.CustomTipFooterRight ->
             stringMutation(ReadStyleStringKey.CustomTipFooterRight, value)
             stringMutation(ReadStyleStringKey.CustomTipFooterRight, value)
         is ConfigUpdate.HeaderFontSize -> intMutation(ReadStyleIntKey.HeaderFontSize, value)
         is ConfigUpdate.HeaderFontSize -> intMutation(ReadStyleIntKey.HeaderFontSize, value)
+        is ConfigUpdate.FooterFontSize -> intMutation(ReadStyleIntKey.FooterFontSize, value)
+        is ConfigUpdate.ApplyHeaderStyle -> booleanMutation(ReadStyleBooleanKey.ApplyHeaderStyle, value)
         is ConfigUpdate.TipHeaderColor -> colorMutation(ReadStyleColorKey.TipHeader, color)
         is ConfigUpdate.TipHeaderColor -> colorMutation(ReadStyleColorKey.TipHeader, color)
         is ConfigUpdate.TipHeaderColorNight ->
         is ConfigUpdate.TipHeaderColorNight ->
             colorMutation(ReadStyleColorKey.TipHeaderNight, color)
             colorMutation(ReadStyleColorKey.TipHeaderNight, color)

+ 12 - 0
app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt

@@ -418,6 +418,18 @@ class PageView(
             view.textSize = customTextSize
             view.textSize = customTextSize
             view.batteryMode = BatteryView.BatteryMode.NO_BATTERY
             view.batteryMode = BatteryView.BatteryMode.NO_BATTERY
         }
         }
+
+        // 当页脚不应用页眉字体样式时,覆盖页脚视图的字体和字号
+        if (!ReadBookConfig.applyHeaderStyle) {
+            val footerTypeface = loadTypeface(ReadBookConfig.footerFont) ?: tipTypeface
+            val footerTextSize = ReadBookConfig.footerFontSize.toFloat()
+            listOf(tvFooterLeft, tvFooterMiddle, tvFooterRight).forEach { view ->
+                if (view.tag != null) {
+                    view.typeface = footerTypeface
+                    view.textSize = footerTextSize
+                }
+            }
+        }
     }
     }
 
 
     /**
     /**

+ 131 - 23
app/src/main/java/io/legado/app/ui/book/read/sheet/GlobalThemePage.kt

@@ -23,10 +23,12 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.DarkMode
 import androidx.compose.material.icons.filled.DarkMode
+import androidx.compose.material.icons.filled.FormatUnderlined
 import androidx.compose.material.icons.filled.GridView
 import androidx.compose.material.icons.filled.GridView
+import androidx.compose.material.icons.filled.Layers
 import androidx.compose.material.icons.filled.LightMode
 import androidx.compose.material.icons.filled.LightMode
-import androidx.compose.material.icons.filled.SpaceBar
-import androidx.compose.material.icons.filled.TextFields
+import androidx.compose.material.icons.filled.Tablet
+import androidx.compose.material.icons.filled.Translate
 import androidx.compose.material.icons.filled.Visibility
 import androidx.compose.material.icons.filled.Visibility
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Icon
 import androidx.compose.material3.PlainTooltip
 import androidx.compose.material3.PlainTooltip
@@ -45,12 +47,14 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringArrayResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import androidx.core.graphics.toColorInt
 import androidx.core.graphics.toColorInt
 import coil.compose.AsyncImage
 import coil.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
+import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.domain.model.settings.ReadStyleItem
 import io.legado.app.domain.model.settings.ReadStyleItem
 import io.legado.app.help.config.ReadStyleResolver
 import io.legado.app.help.config.ReadStyleResolver
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
@@ -76,13 +80,14 @@ fun GlobalThemePage(
     onToggleDayNight: () -> Unit,
     onToggleDayNight: () -> Unit,
     eyeProtectionEnabled: Boolean,
     eyeProtectionEnabled: Boolean,
     onOpenBgTextConfig: (Int) -> Unit,
     onOpenBgTextConfig: (Int) -> Unit,
-    onOpenTextTitle: () -> Unit,
-    onOpenPaddingConfig: () -> Unit,
+    onOpenUnderlineConfig: () -> Unit,
+    onOpenShadowSet: () -> Unit,
     onShareLayoutChange: (Boolean) -> Unit,
     onShareLayoutChange: (Boolean) -> Unit,
     onStyleSelect: (Int) -> Unit,
     onStyleSelect: (Int) -> Unit,
     modifier: Modifier = Modifier,
     modifier: Modifier = Modifier,
     onIntent: (ReadBookIntent) -> Unit,
     onIntent: (ReadBookIntent) -> Unit,
     styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(),
     styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(),
+    preferences: ReadPreferences = ReadPreferences(),
 ) {
 ) {
     // Derive values directly from styleConfig (reactive state)
     // Derive values directly from styleConfig (reactive state)
     val textSize = styleConfig.textSize
     val textSize = styleConfig.textSize
@@ -90,6 +95,8 @@ fun GlobalThemePage(
     val styleSelect = styleConfig.styleSelect
     val styleSelect = styleConfig.styleSelect
     val shareLayout = styleConfig.shareLayout
     val shareLayout = styleConfig.shareLayout
     val isNightTheme = LegadoTheme.isDark
     val isNightTheme = LegadoTheme.isDark
+    val chineseConverterType = preferences.chineseConverterType
+    val doubleHorizontalPage = preferences.doubleHorizontalPage
 
 
     val configList = styleConfig.styleItems
     val configList = styleConfig.styleItems
 
 
@@ -113,7 +120,7 @@ fun GlobalThemePage(
                 },
                 },
             )
             )
             NormalCard(
             NormalCard(
-                onClick = onOpenTextTitle,
+                onClick = onOpenUnderlineConfig,
                 modifier = Modifier
                 modifier = Modifier
                     .height(56.dp)
                     .height(56.dp)
                     .aspectRatio(1f),
                     .aspectRatio(1f),
@@ -125,8 +132,27 @@ fun GlobalThemePage(
                     modifier = Modifier.fillMaxSize(),
                     modifier = Modifier.fillMaxSize(),
                 ) {
                 ) {
                     Icon(
                     Icon(
-                        imageVector = Icons.Default.TextFields,
-                        contentDescription = stringResource(R.string.read_config_text_effects),
+                        imageVector = Icons.Default.FormatUnderlined,
+                        contentDescription = stringResource(R.string.use_underline),
+                        tint = LegadoTheme.colorScheme.onSurfaceVariant
+                    )
+                }
+            }
+            NormalCard(
+                onClick = onOpenShadowSet,
+                modifier = Modifier
+                    .height(56.dp)
+                    .aspectRatio(1f),
+                containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+                cornerRadius = 12.dp
+            ) {
+                Box(
+                    contentAlignment = Alignment.Center,
+                    modifier = Modifier.fillMaxSize(),
+                ) {
+                    Icon(
+                        imageVector = Icons.Default.Layers,
+                        contentDescription = stringResource(R.string.text_shadow_set),
                         tint = LegadoTheme.colorScheme.onSurfaceVariant
                         tint = LegadoTheme.colorScheme.onSurfaceVariant
                     )
                     )
                 }
                 }
@@ -320,6 +346,10 @@ fun GlobalThemePage(
         val currentPageAnimDisplay =
         val currentPageAnimDisplay =
             pageAnimEntries.getOrNull(pageAnimEntryValues.indexOf(pageAnim.toString())) ?: ""
             pageAnimEntries.getOrNull(pageAnimEntryValues.indexOf(pageAnim.toString())) ?: ""
 
 
+        var showDualPageMenu by remember { mutableStateOf(false) }
+        val doublePageEntries = stringArrayResource(R.array.double_page_title)
+        val doublePageValues = stringArrayResource(R.array.double_page_value)
+
         Row(
         Row(
             modifier = Modifier.fillMaxWidth(),
             modifier = Modifier.fillMaxWidth(),
             horizontalArrangement = Arrangement.spacedBy(8.dp),
             horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -356,23 +386,101 @@ fun GlobalThemePage(
                     }
                     }
                 }
                 }
             }
             }
-            NormalCard(
-                onClick = onOpenPaddingConfig,
-                modifier = Modifier
-                    .height(56.dp)
-                    .aspectRatio(1f),
-                containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
-                cornerRadius = 12.dp
-            ) {
-                Box(
-                    contentAlignment = Alignment.Center,
-                    modifier = Modifier.fillMaxSize(),
+            // 中文简繁互转 — dropdown menu
+            Box {
+                var showChineseConvertMenu by remember { mutableStateOf(false) }
+                val chineseConvertEntries = stringArrayResource(R.array.chinese_mode)
+                val chineseConvertValues = remember { arrayOf("0", "1", "2") }
+                NormalCard(
+                    onClick = { showChineseConvertMenu = true },
+                    modifier = Modifier
+                        .height(56.dp)
+                        .aspectRatio(1f),
+                    containerColor = if (chineseConverterType > 0) {
+                        LegadoTheme.colorScheme.secondaryContainer
+                    } else {
+                        LegadoTheme.colorScheme.surfaceContainerLow
+                    },
+                    cornerRadius = 12.dp
                 ) {
                 ) {
-                    Icon(
-                        imageVector = Icons.Default.SpaceBar,
-                        contentDescription = stringResource(R.string.padding),
-                        tint = LegadoTheme.colorScheme.onSurfaceVariant
-                    )
+                    Box(
+                        contentAlignment = Alignment.Center,
+                        modifier = Modifier.fillMaxSize(),
+                    ) {
+                        Icon(
+                            imageVector = Icons.Default.Translate,
+                            contentDescription = stringResource(R.string.chinese_converter),
+                            tint = if (chineseConverterType > 0) {
+                                LegadoTheme.colorScheme.onSecondaryContainer
+                            } else {
+                                LegadoTheme.colorScheme.onSurfaceVariant
+                            }
+                        )
+                    }
+                }
+                RoundDropdownMenu(
+                    expanded = showChineseConvertMenu,
+                    onDismissRequest = { showChineseConvertMenu = false },
+                ) { dismiss ->
+                    chineseConvertEntries.forEachIndexed { index, display ->
+                        RoundDropdownMenuItem(
+                            text = display,
+                            isSelected = chineseConvertValues[index] == chineseConverterType.toString(),
+                            onClick = {
+                                onIntent(ReadBookIntent.UpdateConfig(
+                                    ConfigUpdate.ChineseConverterType(chineseConvertValues[index].toInt())
+                                ))
+                                dismiss()
+                            },
+                        )
+                    }
+                }
+            }
+            // 平板/横屏双页 — dropdown menu
+            Box {
+                NormalCard(
+                    onClick = { showDualPageMenu = true },
+                    modifier = Modifier
+                        .height(56.dp)
+                        .aspectRatio(1f),
+                    containerColor = if (doubleHorizontalPage != "0") {
+                        LegadoTheme.colorScheme.secondaryContainer
+                    } else {
+                        LegadoTheme.colorScheme.surfaceContainerLow
+                    },
+                    cornerRadius = 12.dp
+                ) {
+                    Box(
+                        contentAlignment = Alignment.Center,
+                        modifier = Modifier.fillMaxSize(),
+                    ) {
+                        Icon(
+                            imageVector = Icons.Default.Tablet,
+                            contentDescription = stringResource(R.string.double_page_horizontal),
+                            tint = if (doubleHorizontalPage != "0") {
+                                LegadoTheme.colorScheme.onSecondaryContainer
+                            } else {
+                                LegadoTheme.colorScheme.onSurfaceVariant
+                            }
+                        )
+                    }
+                }
+                RoundDropdownMenu(
+                    expanded = showDualPageMenu,
+                    onDismissRequest = { showDualPageMenu = false },
+                ) { dismiss ->
+                    doublePageEntries.forEachIndexed { index, display ->
+                        RoundDropdownMenuItem(
+                            text = display,
+                            isSelected = doublePageValues[index] == doubleHorizontalPage,
+                            onClick = {
+                                onIntent(ReadBookIntent.UpdateConfig(
+                                    ConfigUpdate.DoubleHorizontalPage(doublePageValues[index])
+                                ))
+                                dismiss()
+                            },
+                        )
+                    }
                 }
                 }
             }
             }
         }
         }

+ 0 - 699
app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt

@@ -1,699 +0,0 @@
-package io.legado.app.ui.book.read.sheet
-
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.shrinkVertically
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ChevronRight
-import androidx.compose.material.icons.filled.ExpandMore
-import androidx.compose.material.icons.filled.TextFields
-import androidx.compose.material3.Icon
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateMapOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshotFlow
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clipToBounds
-import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringArrayResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.core.net.toUri
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import io.legado.app.R
-import io.legado.app.data.repository.ReadSettingsRepository
-import io.legado.app.help.config.ReadBookConfig
-import io.legado.app.ui.book.read.ConfigUpdate
-import io.legado.app.ui.book.read.ReadBookIntent
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.FontFolderState
-import io.legado.app.ui.widget.components.FontSelectSheet
-import io.legado.app.ui.widget.components.dialog.CustomTipDialog
-import io.legado.app.ui.widget.components.SectionTitle
-import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
-import io.legado.app.ui.widget.components.pager.pagerHeight
-import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
-import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
-import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
-import io.legado.app.ui.widget.components.settingItem.TinyColorModeSettingItem
-import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem
-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.tabRow.CardTabRow
-import io.legado.app.utils.getCompatColor
-import kotlinx.coroutines.launch
-import org.koin.compose.koinInject
-
-// Color picker IDs
-private const val COLOR_HEADER = 7
-private const val COLOR_FOOTER = 8
-private const val COLOR_DIVIDER = 9
-private const val COLOR_HEADER_NIGHT = 10
-private const val COLOR_FOOTER_NIGHT = 11
-
-@Composable
-internal fun HeaderFooterPage(
-    modifier: Modifier = Modifier,
-    onIntent: (ReadBookIntent) -> Unit,
-) {
-    val context = LocalContext.current
-    val scope = rememberCoroutineScope()
-
-    val tabTitles = listOf(
-        stringResource(R.string.header),
-        stringResource(R.string.footer),
-        stringResource(R.string.general),
-    )
-    val pagerState = rememberPagerState(pageCount = { 3 })
-    var selectedTab by remember { mutableIntStateOf(0) }
-    var clickScrollCount by remember { mutableIntStateOf(0) }
-    val childPagerNestedScrollConnection = rememberPagerFlingPassThroughConnection(
-        state = pagerState,
-        orientation = Orientation.Horizontal,
-    )
-
-    val pageHeights = remember { mutableStateMapOf<Int, Int>() }
-    val animatedHeight by rememberPagerAnimatedHeight(pagerState, pageHeights)
-
-    // Header state
-    var headerMode by remember { mutableIntStateOf(ReadBookConfig.headerMode) }
-    var headerLeft by remember { mutableIntStateOf(ReadBookConfig.tipHeaderLeft) }
-    var headerMiddle by remember { mutableIntStateOf(ReadBookConfig.tipHeaderMiddle) }
-    var headerRight by remember { mutableIntStateOf(ReadBookConfig.tipHeaderRight) }
-
-    // Footer state
-    var footerMode by remember { mutableIntStateOf(ReadBookConfig.footerMode) }
-    var footerLeft by remember { mutableIntStateOf(ReadBookConfig.tipFooterLeft) }
-    var footerMiddle by remember { mutableIntStateOf(ReadBookConfig.tipFooterMiddle) }
-    var footerRight by remember { mutableIntStateOf(ReadBookConfig.tipFooterRight) }
-
-    // 对哪个位置正在编辑自定义模板(null 表示未打开弹窗)
-    var editingCustomTarget by remember { mutableStateOf<CustomTipTarget?>(null) }
-
-    // Line toggles
-    var showHeaderLine by remember { mutableStateOf(ReadBookConfig.showHeaderLine) }
-    var showFooterLine by remember { mutableStateOf(ReadBookConfig.showFooterLine) }
-
-    // Global state
-    var headerFontSize by remember { mutableIntStateOf(ReadBookConfig.headerFontSize) }
-
-    var showColorPicker by remember { mutableStateOf(false) }
-    var colorPickerId by remember { mutableIntStateOf(0) }
-    var colorPickerInitial by remember { mutableIntStateOf(0) }
-    var showFontSelect by remember { mutableStateOf(false) }
-
-    var expandHeaderPadding by remember { mutableStateOf(false) }
-    var expandFooterPadding by remember { mutableStateOf(false) }
-
-    val headerScrollState = rememberScrollState()
-    val footerScrollState = rememberScrollState()
-
-    LaunchedEffect(expandHeaderPadding, headerScrollState.maxValue) {
-        if (expandHeaderPadding) {
-            headerScrollState.scrollTo(headerScrollState.maxValue)
-        }
-    }
-
-    LaunchedEffect(expandFooterPadding, footerScrollState.maxValue) {
-        if (expandFooterPadding) {
-            footerScrollState.scrollTo(footerScrollState.maxValue)
-        }
-    }
-
-    DisposableEffect(Unit) {
-        onDispose {
-            onIntent(ReadBookIntent.SaveReadStyleConfig)
-        }
-    }
-
-    // Header padding state
-    var headerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingTop.toFloat()) }
-    var headerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingBottom.toFloat()) }
-    var headerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingLeft.toFloat()) }
-    var headerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingRight.toFloat()) }
-
-    // Footer padding state
-    var footerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingTop.toFloat()) }
-    var footerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingBottom.toFloat()) }
-    var footerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingLeft.toFloat()) }
-    var footerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingRight.toFloat()) }
-
-    val tipNames = remember { ReadBookConfig.tipNames }
-    val tipValues = remember { ReadBookConfig.tipValues }
-
-    fun clearRepeat(repeat: Int) {
-        if (repeat == ReadBookConfig.tipNone) return
-        if (headerLeft == repeat) {
-            headerLeft = ReadBookConfig.tipNone
-            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderLeft(ReadBookConfig.tipNone)))
-        }
-        if (headerMiddle == repeat) {
-            headerMiddle = ReadBookConfig.tipNone
-            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderMiddle(ReadBookConfig.tipNone)))
-        }
-        if (headerRight == repeat) {
-            headerRight = ReadBookConfig.tipNone
-            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderRight(ReadBookConfig.tipNone)))
-        }
-        if (footerLeft == repeat) {
-            footerLeft = ReadBookConfig.tipNone
-            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterLeft(ReadBookConfig.tipNone)))
-        }
-        if (footerMiddle == repeat) {
-            footerMiddle = ReadBookConfig.tipNone
-            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterMiddle(ReadBookConfig.tipNone)))
-        }
-        if (footerRight == repeat) {
-            footerRight = ReadBookConfig.tipNone
-            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterRight(ReadBookConfig.tipNone)))
-        }
-    }
-
-    fun applyTipValue(target: CustomTipTarget, value: Int) {
-        when (target) {
-            CustomTipTarget.HEADER_LEFT -> {
-                headerLeft = value
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderLeft(value)))
-            }
-            CustomTipTarget.HEADER_MIDDLE -> {
-                headerMiddle = value
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderMiddle(value)))
-            }
-            CustomTipTarget.HEADER_RIGHT -> {
-                headerRight = value
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderRight(value)))
-            }
-            CustomTipTarget.FOOTER_LEFT -> {
-                footerLeft = value
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterLeft(value)))
-            }
-            CustomTipTarget.FOOTER_MIDDLE -> {
-                footerMiddle = value
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterMiddle(value)))
-            }
-            CustomTipTarget.FOOTER_RIGHT -> {
-                footerRight = value
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterRight(value)))
-            }
-        }
-    }
-
-    /**
-     * 当某个位置的 tip 被切换时统一处理:清空同值重复、更新本地 state、派发 ConfigUpdate,
-     * 必要时自动弹出自定义模板编辑弹窗。
-     */
-    fun handleTipChange(target: CustomTipTarget, value: Int) {
-        if (value == ReadBookConfig.tipCustom) {
-            editingCustomTarget = target
-            return
-        }
-        clearRepeat(value)
-        applyTipValue(target, value)
-    }
-
-    LaunchedEffect(pagerState) {
-        snapshotFlow { pagerState.currentPage }.collect { page ->
-            if (clickScrollCount == 0) {
-                selectedTab = page
-            }
-        }
-    }
-
-    Column(
-        modifier = modifier
-            .fillMaxWidth()
-    ) {
-        CardTabRow(
-            tabTitles = tabTitles,
-            selectedTabIndex = selectedTab,
-            onTabSelected = { index ->
-                selectedTab = index
-                clickScrollCount++
-                scope.launch {
-                    try {
-                        pagerState.animateScrollToPage(
-                            page = index,
-                            animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
-                        )
-                    } finally {
-                        clickScrollCount = (clickScrollCount - 1).coerceAtLeast(0)
-                    }
-                }
-            },
-            modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
-        )
-        HorizontalPager(
-            state = pagerState,
-            verticalAlignment = Alignment.Top,
-            pageNestedScrollConnection = childPagerNestedScrollConnection,
-            modifier = Modifier
-                .fillMaxWidth()
-                .clipToBounds()
-                .pagerHeight(animatedHeight),
-        ) { page ->
-            Box(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .onSizeChanged { size ->
-                        pageHeights[page] = size.height
-                    }
-            ) {
-                when (page) {
-                0 -> {
-                    // Header tab
-                    Column(
-                        modifier = Modifier
-                            .fillMaxWidth()
-                            .padding(horizontal = 16.dp)
-                            .verticalScroll(headerScrollState),
-                    ) {
-                        TinySwitchSettingItem(
-                            title = stringResource(R.string.showLine),
-                            checked = showHeaderLine,
-                            onCheckedChange = {
-                                showHeaderLine = it
-                                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShowHeaderLine(it)))
-                            },
-                        )
-                        val headerModes = ReadBookConfig.getHeaderModes(context)
-                        TinyDropdownSettingItem(
-                            title = stringResource(R.string.header),
-                            selectedValue = headerMode.toString(),
-                            displayEntries = headerModes.values.toTypedArray(),
-                            entryValues = headerModes.keys.map { it.toString() }.toTypedArray(),
-                            onValueChange = {
-                                headerMode = it.toInt()
-                                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderMode(headerMode)))
-                            },
-                        )
-                        TipPositionDropdown(
-                            label = stringResource(R.string.left),
-                            value = headerLeft,
-                            tipNames = tipNames,
-                            tipValues = tipValues,
-                            onValueChange = { handleTipChange(CustomTipTarget.HEADER_LEFT, it) },
-                        )
-                        TipPositionDropdown(
-                            label = stringResource(R.string.middle),
-                            value = headerMiddle,
-                            tipNames = tipNames,
-                            tipValues = tipValues,
-                            onValueChange = { handleTipChange(CustomTipTarget.HEADER_MIDDLE, it) },
-                        )
-                        TipPositionDropdown(
-                            label = stringResource(R.string.right),
-                            value = headerRight,
-                            tipNames = tipNames,
-                            tipValues = tipValues,
-                            onValueChange = { handleTipChange(CustomTipTarget.HEADER_RIGHT, it) },
-                        )
-                        TinyColorModeSettingItem(
-                            title = stringResource(R.string.header_color),
-                            dayColor = if (ReadBookConfig.tipHeaderColor != 0) {
-                                ReadBookConfig.tipHeaderColor
-                            } else {
-                                ReadBookConfig.textColor
-                            },
-                            nightColor = if (ReadBookConfig.tipHeaderColorNight != 0) {
-                                ReadBookConfig.tipHeaderColorNight
-                            } else {
-                                ReadBookConfig.textColorNight
-                            },
-                            onClickColor = { isNight ->
-                                if (isNight) {
-                                    colorPickerId = COLOR_HEADER_NIGHT
-                                    colorPickerInitial = if (ReadBookConfig.tipHeaderColorNight != 0) {
-                                        ReadBookConfig.tipHeaderColorNight
-                                    } else {
-                                        ReadBookConfig.textColorNight
-                                    }
-                                } else {
-                                    colorPickerId = COLOR_HEADER
-                                    colorPickerInitial = if (ReadBookConfig.tipHeaderColor != 0) {
-                                        ReadBookConfig.tipHeaderColor
-                                    } else {
-                                        ReadBookConfig.textColor
-                                    }
-                                }
-                                showColorPicker = true
-                            },
-                        )
-
-                        TinyClickableSettingItem(
-                            title = stringResource(R.string.padding),
-                            description = stringResource(
-                                R.string.padding_format,
-                                headerPaddingTop.toInt(),
-                                headerPaddingBottom.toInt(),
-                                headerPaddingLeft.toInt(),
-                                headerPaddingRight.toInt(),
-                            ),
-                            trailingContent = {
-                                Icon(
-                                    imageVector = if (expandHeaderPadding) Icons.Default.ExpandMore else Icons.Default.ChevronRight,
-                                    contentDescription = null,
-                                    tint = LegadoTheme.colorScheme.onSurfaceVariant,
-                                    modifier = Modifier.size(20.dp),
-                                )
-                            },
-                            onClick = {
-                                expandHeaderPadding = !expandHeaderPadding
-                            },
-                        )
-                        AnimatedVisibility(
-                            visible = expandHeaderPadding,
-                            enter = expandVertically() + fadeIn(),
-                            exit = shrinkVertically() + fadeOut(),
-                        ) {
-                            Column(modifier = Modifier.fillMaxWidth()) {
-                                PaddingSliders(
-                                    top = headerPaddingTop, bottom = headerPaddingBottom,
-                                    left = headerPaddingLeft, right = headerPaddingRight,
-                                    onTopChange = {
-                                        headerPaddingTop = it
-                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingTop(it.toInt())))
-                                    },
-                                    onBottomChange = {
-                                        headerPaddingBottom = it
-                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingBottom(it.toInt())))
-                                    },
-                                    onLeftChange = {
-                                        headerPaddingLeft = it
-                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingLeft(it.toInt())))
-                                    },
-                                    onRightChange = {
-                                        headerPaddingRight = it
-                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingRight(it.toInt())))
-                                    },
-                                )
-                            }
-                        }
-                    }
-                }
-
-                1 -> {
-                    // Footer tab
-                    Column(
-                        modifier = Modifier
-                            .fillMaxWidth()
-                            .padding(horizontal = 16.dp)
-                            .verticalScroll(footerScrollState),
-                    ) {
-                        TinySwitchSettingItem(
-                            title = stringResource(R.string.showLine),
-                            checked = showFooterLine,
-                            onCheckedChange = {
-                                showFooterLine = it
-                                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShowFooterLine(it)))
-                            },
-                        )
-                        val footerModes = ReadBookConfig.getFooterModes(context)
-                        TinyDropdownSettingItem(
-                            title = stringResource(R.string.footer),
-                            selectedValue = footerMode.toString(),
-                            displayEntries = footerModes.values.toTypedArray(),
-                            entryValues = footerModes.keys.map { it.toString() }.toTypedArray(),
-                            onValueChange = {
-                                footerMode = it.toInt()
-                                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterMode(footerMode)))
-                            },
-                        )
-                        TipPositionDropdown(
-                            label = stringResource(R.string.left),
-                            value = footerLeft,
-                            tipNames = tipNames,
-                            tipValues = tipValues,
-                            onValueChange = { handleTipChange(CustomTipTarget.FOOTER_LEFT, it) },
-                        )
-                        TipPositionDropdown(
-                            label = stringResource(R.string.middle),
-                            value = footerMiddle,
-                            tipNames = tipNames,
-                            tipValues = tipValues,
-                            onValueChange = { handleTipChange(CustomTipTarget.FOOTER_MIDDLE, it) },
-                        )
-                        TipPositionDropdown(
-                            label = stringResource(R.string.right),
-                            value = footerRight,
-                            tipNames = tipNames,
-                            tipValues = tipValues,
-                            onValueChange = { handleTipChange(CustomTipTarget.FOOTER_RIGHT, it) },
-                        )
-                        TinyColorModeSettingItem(
-                            title = stringResource(R.string.footer_color),
-                            dayColor = if (ReadBookConfig.tipFooterColor != 0) {
-                                ReadBookConfig.tipFooterColor
-                            } else {
-                                ReadBookConfig.textColor
-                            },
-                            nightColor = if (ReadBookConfig.tipFooterColorNight != 0) {
-                                ReadBookConfig.tipFooterColorNight
-                            } else {
-                                ReadBookConfig.textColorNight
-                            },
-                            onClickColor = { isNight ->
-                                if (isNight) {
-                                    colorPickerId = COLOR_FOOTER_NIGHT
-                                    colorPickerInitial = if (ReadBookConfig.tipFooterColorNight != 0) {
-                                        ReadBookConfig.tipFooterColorNight
-                                    } else {
-                                        ReadBookConfig.textColorNight
-                                    }
-                                } else {
-                                    colorPickerId = COLOR_FOOTER
-                                    colorPickerInitial = if (ReadBookConfig.tipFooterColor != 0) {
-                                        ReadBookConfig.tipFooterColor
-                                    } else {
-                                        ReadBookConfig.textColor
-                                    }
-                                }
-                                showColorPicker = true
-                            },
-                        )
-
-                        TinyClickableSettingItem(
-                            title = stringResource(R.string.padding),
-                            description = stringResource(
-                                R.string.padding_format,
-                                footerPaddingTop.toInt(),
-                                footerPaddingBottom.toInt(),
-                                footerPaddingLeft.toInt(),
-                                footerPaddingRight.toInt(),
-                            ),
-                            trailingContent = {
-                                Icon(
-                                    imageVector = if (expandFooterPadding) Icons.Default.ExpandMore else Icons.Default.ChevronRight,
-                                    contentDescription = null,
-                                    tint = LegadoTheme.colorScheme.onSurfaceVariant,
-                                    modifier = Modifier.size(20.dp),
-                                )
-                            },
-                            onClick = {
-                                expandFooterPadding = !expandFooterPadding
-                            },
-                        )
-                        AnimatedVisibility(
-                            visible = expandFooterPadding,
-                            enter = expandVertically() + fadeIn(),
-                            exit = shrinkVertically() + fadeOut(),
-                        ) {
-                            Column(modifier = Modifier.fillMaxWidth()) {
-                                PaddingSliders(
-                                    top = footerPaddingTop, bottom = footerPaddingBottom,
-                                    left = footerPaddingLeft, right = footerPaddingRight,
-                                    onTopChange = {
-                                        footerPaddingTop = it
-                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingTop(it.toInt())))
-                                    },
-                                    onBottomChange = {
-                                        footerPaddingBottom = it
-                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingBottom(it.toInt())))
-                                    },
-                                    onLeftChange = {
-                                        footerPaddingLeft = it
-                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingLeft(it.toInt())))
-                                    },
-                                    onRightChange = {
-                                        footerPaddingRight = it
-                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingRight(it.toInt())))
-                                    },
-                                )
-                            }
-                        }
-                    }
-                }
-
-                2 -> {
-                    // Global tab
-                    Column(
-                        modifier = Modifier
-                            .fillMaxWidth()
-                            .padding(horizontal = 16.dp)
-                            .verticalScroll(rememberScrollState()),
-                    ) {
-                        SectionTitle(stringResource(R.string.read_config_divider_line))
-                        TinyColorSettingItem(
-                            title = stringResource(R.string.tip_divider_color),
-                            colorValue = when (ReadBookConfig.tipDividerColor) {
-                                -1 -> context.getCompatColor(R.color.divider)
-                                0 -> ReadBookConfig.textColor
-                                else -> ReadBookConfig.tipDividerColor
-                            },
-                            onClick = {
-                                colorPickerId = COLOR_DIVIDER
-                                colorPickerInitial = when (ReadBookConfig.tipDividerColor) {
-                                    -1 -> context.getCompatColor(R.color.divider)
-                                    0 -> ReadBookConfig.textColor
-                                    else -> ReadBookConfig.tipDividerColor
-                                }
-                                showColorPicker = true
-                            },
-                        )
-
-                        SectionTitle(stringResource(R.string.text_typeface))
-                        TinyClickableSettingItem(
-                            title = stringResource(R.string.header_font),
-                            description = stringResource(R.string.select_font),
-                            imageVector = Icons.Default.TextFields,
-                            onClick = { showFontSelect = true },
-                        )
-                        TinySliderSettingItem(
-                            title = stringResource(R.string.header_font_size),
-                            value = headerFontSize.toFloat(),
-                            valueRange = 0f..100f,
-                            onValueChange = { value ->
-                                headerFontSize = value.toInt()
-                                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderFontSize(value.toInt())))
-                                onIntent(ReadBookIntent.SaveReadStyleConfig)
-                            },
-                        )
-                    }
-                }
-            }
-        }
-    }
-}
-
-    // Color picker
-    ColorPickerSheet(
-        show = showColorPicker,
-        initialColor = colorPickerInitial,
-        onDismissRequest = { showColorPicker = false },
-        onColorSelected = { color ->
-            when (colorPickerId) {
-                COLOR_HEADER -> {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderColor(color)))
-                }
-
-                COLOR_HEADER_NIGHT -> {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderColorNight(color)))
-                }
-
-                COLOR_FOOTER -> {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterColor(color)))
-                }
-
-                COLOR_FOOTER_NIGHT -> {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterColorNight(color)))
-                }
-
-                COLOR_DIVIDER -> {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipDividerColor(color)))
-                }
-            }
-            showColorPicker = false
-        },
-    )
-
-    // Font selector for header/footer
-    val readSettingsRepository: ReadSettingsRepository = koinInject()
-    val preferences by readSettingsRepository.preferences.collectAsStateWithLifecycle(
-        initialValue = null
-    )
-    val fontFolderState = remember(preferences) {
-        val pref = preferences
-        if (pref == null) {
-            FontFolderState.Loading
-        } else {
-            FontFolderState.Loaded(pref.fontFolder.takeIf { it.isNotEmpty() }?.toUri())
-        }
-    }
-    val systemTypefaces = stringArrayResource(R.array.system_typefaces)
-
-    val editingCustomInitial: String = editingCustomTarget?.customTemplate.orEmpty()
-
-    CustomTipDialog(
-        show = editingCustomTarget != null,
-        initialTemplate = editingCustomInitial,
-        onConfirm = { template ->
-            editingCustomTarget?.let { target ->
-                applyTipValue(target, ReadBookConfig.tipCustom)
-                target.applyTemplate(template, onIntent)
-            }
-            editingCustomTarget = null
-        },
-        onDismissRequest = { editingCustomTarget = null },
-    )
-
-    FontSelectSheet(
-        show = showFontSelect,
-        title = stringResource(R.string.select_font),
-        folderState = fontFolderState,
-        selectedFontPath = ReadBookConfig.headerFont,
-        onDismissRequest = { showFontSelect = false },
-        onSelectFont = {
-            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderFont(it.uri.toString())))
-            onIntent(ReadBookIntent.SaveReadStyleConfig)
-            showFontSelect = false
-        },
-        onSelectSystemTypeface = {
-            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderFont("")))
-            onIntent(ReadBookIntent.SaveReadStyleConfig)
-            showFontSelect = false
-        },
-        onOpenFolderPicker = { /* handled by FontSelectSheet internally */ },
-        systemTypefaces = systemTypefaces,
-    )
-}
-
-@Composable
-internal fun TipPositionDropdown(
-    label: String,
-    value: Int,
-    tipNames: List<String>,
-    tipValues: Array<Int>,
-    onValueChange: (Int) -> Unit,
-) {
-    TinyDropdownSettingItem(
-        title = label,
-        selectedValue = value.toString(),
-        displayEntries = tipNames.toTypedArray(),
-        entryValues = tipValues.map { it.toString() }.toTypedArray(),
-        onValueChange = { onValueChange(it.toInt()) },
-    )
-}

+ 0 - 40
app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt

@@ -72,21 +72,12 @@ fun MoreConfigSheet(
                 onReadBodyToLhChange = {
                 onReadBodyToLhChange = {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ReadBodyToLh(it)))
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ReadBodyToLh(it)))
                 },
                 },
-                onTextFullJustifyChange = {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextFullJustify(it)))
-                },
-                onTextBottomJustifyChange = {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextBottomJustify(it)))
-                },
                 onAdaptSpecialStyleChange = {
                 onAdaptSpecialStyleChange = {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.AdaptSpecialStyle(it)))
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.AdaptSpecialStyle(it)))
                 },
                 },
                 onUseZhLayoutChange = {
                 onUseZhLayoutChange = {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.UseZhLayout(it)))
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.UseZhLayout(it)))
                 },
                 },
-                onUseUnderlineChange = {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.UseUnderlineGlobal(it)))
-                },
                 onOpenEyeProtectionConfig = {
                 onOpenEyeProtectionConfig = {
                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.EyeProtection))
                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.EyeProtection))
                 }
                 }
@@ -96,9 +87,6 @@ fun MoreConfigSheet(
             SectionTitle(stringResource(R.string.page_control))
             SectionTitle(stringResource(R.string.page_control))
             PageControlSettings(
             PageControlSettings(
                 preferences = preferences,
                 preferences = preferences,
-                onDoubleHorizontalPageChange = {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.DoubleHorizontalPage(it)))
-                },
                 onProgressBarBehaviorChange = {
                 onProgressBarBehaviorChange = {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ProgressBarBehavior(it)))
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ProgressBarBehavior(it)))
                 },
                 },
@@ -173,11 +161,8 @@ private fun ScreenSettings(
     onHideNavigationBarChange: (Boolean) -> Unit,
     onHideNavigationBarChange: (Boolean) -> Unit,
     onPaddingDisplayCutoutsChange: (Boolean) -> Unit,
     onPaddingDisplayCutoutsChange: (Boolean) -> Unit,
     onReadBodyToLhChange: (Boolean) -> Unit,
     onReadBodyToLhChange: (Boolean) -> Unit,
-    onTextFullJustifyChange: (Boolean) -> Unit,
-    onTextBottomJustifyChange: (Boolean) -> Unit,
     onAdaptSpecialStyleChange: (Boolean) -> Unit,
     onAdaptSpecialStyleChange: (Boolean) -> Unit,
     onUseZhLayoutChange: (Boolean) -> Unit,
     onUseZhLayoutChange: (Boolean) -> Unit,
-    onUseUnderlineChange: (Boolean) -> Unit,
     onOpenEyeProtectionConfig:() -> Unit,
     onOpenEyeProtectionConfig:() -> Unit,
 ) {
 ) {
     val screenDirectionEntries = stringArrayResource(R.array.screen_direction_title)
     val screenDirectionEntries = stringArrayResource(R.array.screen_direction_title)
@@ -219,16 +204,6 @@ private fun ScreenSettings(
         checked = preferences.readBodyToLh,
         checked = preferences.readBodyToLh,
         onCheckedChange = onReadBodyToLhChange,
         onCheckedChange = onReadBodyToLhChange,
     )
     )
-    TinySwitchSettingItem(
-        title = stringResource(R.string.text_full_justify),
-        checked = preferences.textFullJustify,
-        onCheckedChange = onTextFullJustifyChange,
-    )
-    TinySwitchSettingItem(
-        title = stringResource(R.string.text_bottom_justify),
-        checked = preferences.textBottomJustify,
-        onCheckedChange = onTextBottomJustifyChange,
-    )
     TinySwitchSettingItem(
     TinySwitchSettingItem(
         title = stringResource(R.string.adapt_special_style),
         title = stringResource(R.string.adapt_special_style),
         checked = preferences.adaptSpecialStyle,
         checked = preferences.adaptSpecialStyle,
@@ -239,11 +214,6 @@ private fun ScreenSettings(
         checked = preferences.useZhLayout,
         checked = preferences.useZhLayout,
         onCheckedChange = onUseZhLayoutChange,
         onCheckedChange = onUseZhLayoutChange,
     )
     )
-    TinySwitchSettingItem(
-        title = stringResource(R.string.use_underline),
-        checked = preferences.useUnderline,
-        onCheckedChange = onUseUnderlineChange,
-    )
     TinyClickableSettingItem(
     TinyClickableSettingItem(
         title = stringResource(R.string.eye_protection),
         title = stringResource(R.string.eye_protection),
         onClick = onOpenEyeProtectionConfig,
         onClick = onOpenEyeProtectionConfig,
@@ -253,25 +223,15 @@ private fun ScreenSettings(
 @Composable
 @Composable
 private fun PageControlSettings(
 private fun PageControlSettings(
     preferences: ReadPreferences,
     preferences: ReadPreferences,
-    onDoubleHorizontalPageChange: (String) -> Unit,
     onProgressBarBehaviorChange: (String) -> Unit,
     onProgressBarBehaviorChange: (String) -> Unit,
     onMouseWheelPageChange: (Boolean) -> Unit,
     onMouseWheelPageChange: (Boolean) -> Unit,
     onVolumeKeyPageChange: (Boolean) -> Unit,
     onVolumeKeyPageChange: (Boolean) -> Unit,
     onVolumeKeyPageOnPlayChange: (Boolean) -> Unit,
     onVolumeKeyPageOnPlayChange: (Boolean) -> Unit,
     onKeyPageOnLongPressChange: (Boolean) -> Unit,
     onKeyPageOnLongPressChange: (Boolean) -> Unit,
 ) {
 ) {
-    val doublePageEntries = stringArrayResource(R.array.double_page_title)
-    val doublePageValues = stringArrayResource(R.array.double_page_value)
     val progressBarEntries = stringArrayResource(R.array.progress_bar_behavior_title)
     val progressBarEntries = stringArrayResource(R.array.progress_bar_behavior_title)
     val progressBarValues = stringArrayResource(R.array.progress_bar_behavior_value)
     val progressBarValues = stringArrayResource(R.array.progress_bar_behavior_value)
 
 
-    TinyDropdownSettingItem(
-        title = stringResource(R.string.double_page_horizontal),
-        selectedValue = preferences.doubleHorizontalPage,
-        displayEntries = doublePageEntries,
-        entryValues = doublePageValues,
-        onValueChange = onDoubleHorizontalPageChange,
-    )
     TinyDropdownSettingItem(
     TinyDropdownSettingItem(
         title = stringResource(R.string.progress_bar_behavior),
         title = stringResource(R.string.progress_bar_behavior),
         selectedValue = preferences.progressBarBehavior,
         selectedValue = preferences.progressBarBehavior,

+ 0 - 250
app/src/main/java/io/legado/app/ui/book/read/sheet/PaddingConfigSheet.kt

@@ -1,250 +0,0 @@
-package io.legado.app.ui.book.read.sheet
-
-import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateMapOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshotFlow
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clipToBounds
-import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import io.legado.app.R
-import io.legado.app.ui.book.read.ReadSheetConfigUiState
-import io.legado.app.ui.book.read.ConfigUpdate
-import io.legado.app.ui.book.read.ReadBookIntent
-import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
-import io.legado.app.ui.widget.components.pager.pagerHeight
-import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
-import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
-import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
-import io.legado.app.ui.widget.components.tabRow.CardTabRow
-import kotlinx.coroutines.launch
-
-@Composable
-fun PaddingConfigSheet(
-    config: ReadSheetConfigUiState,
-    onDismissRequest: () -> Unit,
-    onIntent: (ReadBookIntent) -> Unit,
-) {
-    AppModalBottomSheet(
-        show = true,
-        onDismissRequest = {
-            onIntent(ReadBookIntent.SaveReadStyleConfig)
-            onDismissRequest()
-        },
-        title = stringResource(R.string.padding),
-    ) {
-        PaddingConfigContent(
-            config = config,
-            onIntent = onIntent,
-            modifier = Modifier
-                .padding(bottom = 16.dp),
-        )
-    }
-}
-
-@Composable
-fun PaddingConfigContent(
-    config: ReadSheetConfigUiState,
-    onIntent: (ReadBookIntent) -> Unit,
-    modifier: Modifier = Modifier,
-) {
-    val scope = rememberCoroutineScope()
-    val tabTitles = listOf(
-        stringResource(R.string.main_body),
-        stringResource(R.string.header),
-        stringResource(R.string.footer),
-    )
-    val pagerState = rememberPagerState(pageCount = { 3 })
-    var selectedTab by remember { mutableIntStateOf(0) }
-    var clickScrollCount by remember { mutableIntStateOf(0) }
-    val childPagerNestedScrollConnection = rememberPagerFlingPassThroughConnection(
-        state = pagerState,
-        orientation = Orientation.Horizontal,
-    )
-    val pageHeights = remember { mutableStateMapOf<Int, Int>() }
-    val animatedHeight by rememberPagerAnimatedHeight(pagerState, pageHeights)
-
-    var paddingTop by remember(config.paddingTop) { mutableFloatStateOf(config.paddingTop.toFloat()) }
-    var paddingBottom by remember(config.paddingBottom) { mutableFloatStateOf(config.paddingBottom.toFloat()) }
-    var paddingLeft by remember(config.paddingLeft) { mutableFloatStateOf(config.paddingLeft.toFloat()) }
-    var paddingRight by remember(config.paddingRight) { mutableFloatStateOf(config.paddingRight.toFloat()) }
-
-    var headerPaddingTop by remember(config.headerPaddingTop) { mutableFloatStateOf(config.headerPaddingTop.toFloat()) }
-    var headerPaddingBottom by remember(config.headerPaddingBottom) { mutableFloatStateOf(config.headerPaddingBottom.toFloat()) }
-    var headerPaddingLeft by remember(config.headerPaddingLeft) { mutableFloatStateOf(config.headerPaddingLeft.toFloat()) }
-    var headerPaddingRight by remember(config.headerPaddingRight) { mutableFloatStateOf(config.headerPaddingRight.toFloat()) }
-
-    var footerPaddingTop by remember(config.footerPaddingTop) { mutableFloatStateOf(config.footerPaddingTop.toFloat()) }
-    var footerPaddingBottom by remember(config.footerPaddingBottom) { mutableFloatStateOf(config.footerPaddingBottom.toFloat()) }
-    var footerPaddingLeft by remember(config.footerPaddingLeft) { mutableFloatStateOf(config.footerPaddingLeft.toFloat()) }
-    var footerPaddingRight by remember(config.footerPaddingRight) { mutableFloatStateOf(config.footerPaddingRight.toFloat()) }
-
-    LaunchedEffect(pagerState) {
-        snapshotFlow { pagerState.currentPage }.collect { page ->
-            if (clickScrollCount == 0) selectedTab = page
-        }
-    }
-
-    Column(
-        modifier = modifier.fillMaxWidth(),
-    ) {
-        CardTabRow(
-            tabTitles = tabTitles,
-            selectedTabIndex = selectedTab,
-            onTabSelected = { index ->
-                selectedTab = index
-                clickScrollCount++
-                scope.launch {
-                    try {
-                        pagerState.animateScrollToPage(
-                            page = index,
-                            animationSpec = tween(300, easing = FastOutSlowInEasing),
-                        )
-                    } finally {
-                        clickScrollCount = (clickScrollCount - 1).coerceAtLeast(0)
-                    }
-                }
-            },
-            modifier = Modifier.padding(bottom = 8.dp),
-        )
-
-        HorizontalPager(
-            state = pagerState,
-            verticalAlignment = Alignment.Top,
-            pageNestedScrollConnection = childPagerNestedScrollConnection,
-            modifier = Modifier
-                .fillMaxWidth()
-                .clipToBounds()
-                .pagerHeight(animatedHeight),
-        ) { page ->
-            Box(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .onSizeChanged { pageHeights[page] = it.height },
-            ) {
-                when (page) {
-                    0 -> PaddingSliders(
-                        top = paddingTop,
-                        bottom = paddingBottom,
-                        left = paddingLeft,
-                        right = paddingRight,
-                        onTopChange = {
-                            paddingTop = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingTop(it.toInt())))
-                        },
-                        onBottomChange = {
-                            paddingBottom = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingBottom(it.toInt())))
-                        },
-                        onLeftChange = {
-                            paddingLeft = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingLeft(it.toInt())))
-                        },
-                        onRightChange = {
-                            paddingRight = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingRight(it.toInt())))
-                        },
-                    )
-
-                    1 -> PaddingSliders(
-                        top = headerPaddingTop,
-                        bottom = headerPaddingBottom,
-                        left = headerPaddingLeft,
-                        right = headerPaddingRight,
-                        onTopChange = {
-                            headerPaddingTop = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingTop(it.toInt())))
-                        },
-                        onBottomChange = {
-                            headerPaddingBottom = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingBottom(it.toInt())))
-                        },
-                        onLeftChange = {
-                            headerPaddingLeft = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingLeft(it.toInt())))
-                        },
-                        onRightChange = {
-                            headerPaddingRight = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingRight(it.toInt())))
-                        },
-                    )
-
-                    2 -> PaddingSliders(
-                        top = footerPaddingTop,
-                        bottom = footerPaddingBottom,
-                        left = footerPaddingLeft,
-                        right = footerPaddingRight,
-                        onTopChange = {
-                            footerPaddingTop = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingTop(it.toInt())))
-                        },
-                        onBottomChange = {
-                            footerPaddingBottom = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingBottom(it.toInt())))
-                        },
-                        onLeftChange = {
-                            footerPaddingLeft = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingLeft(it.toInt())))
-                        },
-                        onRightChange = {
-                            footerPaddingRight = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingRight(it.toInt())))
-                        },
-                    )
-                }
-            }
-        }
-    }
-}
-
-@Composable
-internal fun PaddingSliders(
-    top: Float, bottom: Float, left: Float, right: Float,
-    onTopChange: (Float) -> Unit, onBottomChange: (Float) -> Unit,
-    onLeftChange: (Float) -> Unit, onRightChange: (Float) -> Unit,
-) {
-    Column(modifier = Modifier.fillMaxWidth()) {
-        TinySliderSettingItem(
-            title = stringResource(R.string.padding_top),
-            value = top,
-            valueRange = 0f..300f,
-            onValueChange = onTopChange,
-        )
-        TinySliderSettingItem(
-            title = stringResource(R.string.padding_bottom),
-            value = bottom,
-            valueRange = 0f..300f,
-            onValueChange = onBottomChange,
-        )
-        TinySliderSettingItem(
-            title = stringResource(R.string.padding_left),
-            value = left,
-            valueRange = 0f..300f,
-            onValueChange = onLeftChange,
-        )
-        TinySliderSettingItem(
-            title = stringResource(R.string.padding_right),
-            value = right,
-            valueRange = 0f..300f,
-            onValueChange = onRightChange,
-        )
-    }
-}

+ 8 - 7
app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt

@@ -38,11 +38,11 @@ import kotlinx.coroutines.launch
 
 
 @Composable
 @Composable
 fun ReadStyleContent(
 fun ReadStyleContent(
-    onOpenPaddingConfig: () -> Unit,
-    onOpenHeaderFooterConfig: () -> Unit,
+    onOpenTypographyConfig: () -> Unit,
     onOpenMoreConfig: () -> Unit,
     onOpenMoreConfig: () -> Unit,
     onOpenBgTextConfig: (Int) -> Unit,
     onOpenBgTextConfig: (Int) -> Unit,
-    onOpenTextTitle: () -> Unit,
+    onOpenUnderlineConfig: () -> Unit,
+    onOpenShadowSet: () -> Unit,
     onOpenFontSelect: () -> Unit,
     onOpenFontSelect: () -> Unit,
     onToggleDayNight: () -> Unit,
     onToggleDayNight: () -> Unit,
     onPageChanged: (Int) -> Unit = {},
     onPageChanged: (Int) -> Unit = {},
@@ -97,8 +97,8 @@ fun ReadStyleContent(
                         onToggleDayNight = onToggleDayNight,
                         onToggleDayNight = onToggleDayNight,
                         eyeProtectionEnabled = eyeProtectionEnabled,
                         eyeProtectionEnabled = eyeProtectionEnabled,
                         onOpenBgTextConfig = onOpenBgTextConfig,
                         onOpenBgTextConfig = onOpenBgTextConfig,
-                        onOpenTextTitle = onOpenTextTitle,
-                        onOpenPaddingConfig = onOpenPaddingConfig,
+                        onOpenUnderlineConfig = onOpenUnderlineConfig,
+                        onOpenShadowSet = onOpenShadowSet,
                         onShareLayoutChange = { shareLayout ->
                         onShareLayoutChange = { shareLayout ->
                             onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShareLayout(shareLayout)))
                             onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShareLayout(shareLayout)))
                         },
                         },
@@ -108,6 +108,7 @@ fun ReadStyleContent(
                         modifier = Modifier.padding(horizontal = 16.dp),
                         modifier = Modifier.padding(horizontal = 16.dp),
                         onIntent = onIntent,
                         onIntent = onIntent,
                         styleConfig = styleConfig,
                         styleConfig = styleConfig,
+                        preferences = preferences,
                     )
                     )
 
 
                     1 -> SystemMenuPage(
                     1 -> SystemMenuPage(
@@ -123,7 +124,7 @@ fun ReadStyleContent(
         val tabTitles = listOf(
         val tabTitles = listOf(
             stringResource(R.string.read_config_global_theme),
             stringResource(R.string.read_config_global_theme),
             stringResource(R.string.read_config_menu_system),
             stringResource(R.string.read_config_menu_system),
-            stringResource(R.string.header_footer),
+            stringResource(R.string.compose_type),
             stringResource(R.string.more_setting),
             stringResource(R.string.more_setting),
         )
         )
         CardTabRow(
         CardTabRow(
@@ -139,7 +140,7 @@ fun ReadStyleContent(
                             )
                             )
                         }
                         }
                     }
                     }
-                    2 -> onOpenHeaderFooterConfig()
+                    2 -> onOpenTypographyConfig()
                     3 -> onOpenMoreConfig()
                     3 -> onOpenMoreConfig()
                 }
                 }
             },
             },

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

@@ -911,6 +911,10 @@ private fun ReaderBookTocMenu(
             onClick = { dispatch(TocIntent.CollapseAllVolumes) },
             onClick = { dispatch(TocIntent.CollapseAllVolumes) },
         )
         )
     }
     }
+    RoundDropdownMenuItem(
+        text = stringResource(R.string.update_toc),
+        onClick = { dispatch(TocIntent.UpdateToc) },
+    )
     RoundDropdownMenuItem(
     RoundDropdownMenuItem(
         text = stringResource(R.string.replace_rule_title),
         text = stringResource(R.string.replace_rule_title),
         onClick = {
         onClick = {

+ 0 - 734
app/src/main/java/io/legado/app/ui/book/read/sheet/TextTitleSheet.kt

@@ -1,734 +0,0 @@
-package io.legado.app.ui.book.read.sheet
-
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.gestures.Orientation
-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.aspectRatio
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.PagerState
-import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.FormatItalic
-import androidx.compose.material.icons.filled.FormatUnderlined
-import androidx.compose.material.icons.filled.Layers
-import androidx.compose.material.icons.filled.TextFields
-import androidx.compose.material.icons.filled.Tune
-import androidx.compose.material3.Icon
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateMapOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshotFlow
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clipToBounds
-import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.res.stringArrayResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import io.legado.app.R
-import io.legado.app.ui.book.read.ConfigUpdate
-import io.legado.app.ui.book.read.ReadBookIntent
-import io.legado.app.ui.book.read.ReadSheetConfigUiState
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.AppSlider
-import io.legado.app.ui.widget.components.AppTextField
-import io.legado.app.ui.widget.components.SectionTitle
-import io.legado.app.ui.widget.components.ValueStepper
-import io.legado.app.ui.widget.components.alert.AppAlertDialog
-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.pager.pagerHeight
-import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
-import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
-import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
-import io.legado.app.ui.widget.components.settingItem.TinyColorModeSettingItem
-import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem
-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.tabRow.CardTabRow
-import kotlinx.coroutines.launch
-import kotlin.math.roundToInt
-
-// Color picker IDs
-private const val COLOR_TEXT = 1
-private const val COLOR_ACCENT = 2
-private const val COLOR_TITLE = 4
-private const val COLOR_TITLE_NIGHT = 5
-
-@Composable
-fun ReadStyleTextTitleContent(
-    config: ReadSheetConfigUiState,
-    onOpenShadowSet: () -> Unit,
-    onOpenUnderlineConfig: () -> Unit,
-    onOpenHighlightRule: () -> Unit,
-    onOpenFontSelect: () -> Unit,
-    onOpenTitleFontSelect: () -> Unit,
-    modifier: Modifier = Modifier,
-    onIntent: (ReadBookIntent) -> Unit,
-) {
-    val scope = rememberCoroutineScope()
-    val tabTitles = listOf(
-        stringResource(R.string.read_config_text_effects),
-        stringResource(R.string.read_config_layout_spacing),
-        stringResource(R.string.read_config_title_settings),
-    )
-    val pagerState = rememberPagerState(pageCount = { 3 })
-    var selectedTab by remember { mutableIntStateOf(0) }
-    var clickScrollCount by remember { mutableIntStateOf(0) }
-
-    LaunchedEffect(pagerState) {
-        snapshotFlow { pagerState.currentPage }.collect { page ->
-            if (clickScrollCount == 0) {
-                selectedTab = page
-            }
-        }
-    }
-
-    ReadStyleTextTitleContent(
-        config = config,
-        tabTitles = tabTitles,
-        selectedTab = selectedTab,
-        pagerState = pagerState,
-        onOpenShadowSet = onOpenShadowSet,
-        onOpenUnderlineConfig = onOpenUnderlineConfig,
-        onOpenHighlightRule = onOpenHighlightRule,
-        onOpenFontSelect = onOpenFontSelect,
-        onOpenTitleFontSelect = onOpenTitleFontSelect,
-        animateToPage = { page ->
-            selectedTab = page
-            clickScrollCount++
-            scope.launch {
-                try {
-                    pagerState.animateScrollToPage(
-                        page = page,
-                        animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
-                    )
-                } finally {
-                    clickScrollCount = (clickScrollCount - 1).coerceAtLeast(0)
-                }
-            }
-        },
-        modifier = modifier,
-        onIntent = onIntent,
-    )
-}
-
-@Composable
-internal fun ReadStyleTextTitleContent(
-    config: ReadSheetConfigUiState,
-    tabTitles: List<String>,
-    selectedTab: Int,
-    pagerState: PagerState,
-    onOpenShadowSet: () -> Unit,
-    onOpenUnderlineConfig: () -> Unit,
-    onOpenHighlightRule: () -> Unit,
-    onOpenFontSelect: () -> Unit,
-    onOpenTitleFontSelect: () -> Unit,
-    animateToPage: (Int) -> Unit,
-    modifier: Modifier = Modifier,
-    onIntent: (ReadBookIntent) -> Unit,
-) {
-    val pageHeights = remember { mutableStateMapOf<Int, Int>() }
-    val animatedHeight by rememberPagerAnimatedHeight(pagerState, pageHeights)
-    val childPagerNestedScrollConnection = rememberPagerFlingPassThroughConnection(
-        state = pagerState,
-        orientation = Orientation.Horizontal,
-    )
-
-    Column(
-        modifier = modifier
-            .fillMaxWidth()
-    ) {
-        CardTabRow(
-            tabTitles = tabTitles,
-            selectedTabIndex = selectedTab,
-            onTabSelected = { index ->
-                animateToPage(index)
-            },
-            modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
-        )
-        HorizontalPager(
-            state = pagerState,
-            verticalAlignment = Alignment.Top,
-            pageNestedScrollConnection = childPagerNestedScrollConnection,
-            modifier = Modifier
-                .fillMaxWidth()
-                .clipToBounds()
-                .pagerHeight(animatedHeight),
-        ) { page ->
-            Box(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .onSizeChanged { size ->
-                        pageHeights[page] = size.height
-                    }
-            ) {
-                when (page) {
-                    0 -> TextEffectsPage(
-                        config = config,
-                        onOpenShadowSet = onOpenShadowSet,
-                        onOpenUnderlineConfig = onOpenUnderlineConfig,
-                        onOpenHighlightRule = onOpenHighlightRule,
-                        onOpenFontSelect = onOpenFontSelect,
-                        onIntent = onIntent,
-                    )
-
-                    1 -> LayoutSpacingPage(config = config, onIntent = onIntent)
-                    2 -> TitleSettingsPage(
-                        config = config,
-                        onOpenTitleFontSelect = onOpenTitleFontSelect,
-                        onIntent = onIntent,
-                    )
-                }
-            }
-        }
-    }
-}
-
-// ========== Tab: Layout & Spacing ==========
-
-@Composable
-internal fun LayoutSpacingPage(
-    config: ReadSheetConfigUiState,
-    modifier: Modifier = Modifier,
-    onIntent: (ReadBookIntent) -> Unit,
-) {
-    var letterSpacing by remember { mutableFloatStateOf(config.letterSpacing) }
-    var lineSpacing by remember { mutableFloatStateOf(config.lineSpacing.toFloat()) }
-    var paragraphSpacing by remember { mutableFloatStateOf(config.paragraphSpacing.toFloat()) }
-    var indentCount by remember { mutableIntStateOf(config.paragraphIndentCount) }
-
-    Column(
-        modifier = modifier
-            .fillMaxWidth()
-            .padding(horizontal = 16.dp)
-            .verticalScroll(rememberScrollState()),
-    ) {
-        SectionTitle(stringResource(R.string.read_config_body_spacing))
-        TinySliderSettingItem(
-            title = stringResource(R.string.text_indent),
-            value = indentCount.toFloat(),
-            valueRange = 0f..4f,
-            steps = 3,
-            onValueChange = { value ->
-                indentCount = value.toInt()
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ParagraphIndent(" ".repeat(indentCount))))
-            },
-        )
-        TinySliderSettingItem(
-            title = stringResource(R.string.text_letter_spacing),
-            value = (letterSpacing * 100) + 50,
-            valueRange = 0f..100f,
-            steps = 99,
-            valueFormat = { ((it - 50) / 100f).toString() },
-            onValueChange = { value ->
-                letterSpacing = (value - 50) / 100f
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.LetterSpacing(letterSpacing)))
-            },
-        )
-        TinySliderSettingItem(
-            title = stringResource(R.string.line_size),
-            value = lineSpacing,
-            valueRange = 0f..20f,
-            steps = 19,
-            valueFormat = { ((it - 10) / 10f).toString() },
-            onValueChange = { value ->
-                lineSpacing = value
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.LineSpacing(value.toInt())))
-            },
-        )
-        TinySliderSettingItem(
-            title = stringResource(R.string.paragraph_size),
-            value = paragraphSpacing,
-            valueRange = 0f..20f,
-            steps = 19,
-            valueFormat = { (it / 10f).toString() },
-            onValueChange = { value ->
-                paragraphSpacing = value
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ParagraphSpacing(value.toInt())))
-            },
-        )
-    }
-}
-
-// ========== Text & Effects (sub-page) ==========
-
-@Composable
-internal fun TextEffectsPage(
-    config: ReadSheetConfigUiState,
-    onOpenShadowSet: () -> Unit,
-    onOpenUnderlineConfig: () -> Unit,
-    onOpenHighlightRule: () -> Unit,
-    onOpenFontSelect: () -> Unit,
-    modifier: Modifier = Modifier,
-    onIntent: (ReadBookIntent) -> Unit,
-) {
-    var textItalic by remember { mutableStateOf(config.textItalic) }
-    var textBold by remember { mutableIntStateOf(config.textBold) }
-
-    var showColorPicker by remember { mutableStateOf(false) }
-    var colorPickerId by remember { mutableIntStateOf(0) }
-    var colorPickerInitial by remember { mutableIntStateOf(0) }
-
-    Column(
-        modifier = modifier
-            .fillMaxWidth()
-            .padding(horizontal = 16.dp)
-            .verticalScroll(rememberScrollState()),
-    ) {
-        SectionTitle(stringResource(R.string.text_typeface))
-        TinySwitchSettingItem(
-            title = stringResource(R.string.read_config_italic),
-            checked = textItalic,
-            imageVector = Icons.Default.FormatItalic,
-            onCheckedChange = {
-                textItalic = it
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextItalic(it)))
-            },
-        )
-        FontWeightSetting(
-            value = textBold,
-            onValueChange = { value ->
-                textBold = value
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextBold(value)))
-            },
-        )
-
-        TinyClickableSettingItem(
-            title = stringResource(R.string.select_font),
-            imageVector = Icons.Default.TextFields,
-            onClick = onOpenFontSelect,
-        )
-
-        val chineseConvertEntries = stringArrayResource(R.array.chinese_mode)
-        val chineseConvertValues = remember { arrayOf("0", "1", "2") }
-        TinyDropdownSettingItem(
-            title = stringResource(R.string.chinese_converter),
-            selectedValue = config.chineseConverterType.toString(),
-            displayEntries = chineseConvertEntries,
-            entryValues = chineseConvertValues,
-            onValueChange = {
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ChineseConverterType(it.toInt())))
-            },
-        )
-
-        // Colors
-        SectionTitle(stringResource(R.string.read_color))
-        TinyColorSettingItem(
-            title = stringResource(R.string.text_color),
-            colorValue = config.textColor,
-            onClick = {
-                colorPickerId = COLOR_TEXT
-                colorPickerInitial = config.textColor
-                showColorPicker = true
-            },
-        )
-        TinyColorSettingItem(
-            title = stringResource(R.string.text_accent_color),
-            colorValue = config.textAccentColor,
-            onClick = {
-                colorPickerId = COLOR_ACCENT
-                colorPickerInitial = config.textAccentColor
-                showColorPicker = true
-            },
-        )
-
-        SectionTitle(stringResource(R.string.read_config_effects))
-        TinyClickableSettingItem(
-            title = stringResource(R.string.text_shadow_set),
-            description = stringResource(R.string.read_config_shadow_desc),
-            imageVector = Icons.Default.Layers,
-            onClick = onOpenShadowSet,
-        )
-        TinyClickableSettingItem(
-            title = stringResource(R.string.text_underline),
-            description = stringResource(R.string.read_config_underline_desc),
-            imageVector = Icons.Default.FormatUnderlined,
-            onClick = onOpenUnderlineConfig,
-        )
-        TinyClickableSettingItem(
-            title = stringResource(R.string.highlight_rule_config),
-            description = stringResource(R.string.read_config_regex_desc),
-            imageVector = Icons.Default.Tune,
-            onClick = onOpenHighlightRule,
-        )
-
-        Spacer(Modifier.height(8.dp))
-    }
-
-    // Color picker
-    ColorPickerSheet(
-        show = showColorPicker,
-        initialColor = colorPickerInitial,
-        onDismissRequest = { showColorPicker = false },
-        onColorSelected = { color ->
-            when (colorPickerId) {
-                COLOR_TEXT -> {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextColor(color)))
-                }
-
-                COLOR_ACCENT -> {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextAccentColor(color)))
-                }
-            }
-            showColorPicker = false
-        },
-    )
-}
-
-// ========== Title Settings (sub-page) ==========
-
-@Composable
-internal fun TitleSettingsPage(
-    config: ReadSheetConfigUiState,
-    onOpenTitleFontSelect: () -> Unit,
-    modifier: Modifier = Modifier,
-    onIntent: (ReadBookIntent) -> Unit,
-) {
-    var titleMode by remember(config.titleMode) { mutableIntStateOf(config.titleMode) }
-    var titleBold by remember(config.titleBold) { mutableIntStateOf(config.titleBold) }
-    var titleSegType by remember(config.titleSegType) { mutableIntStateOf(config.titleSegType) }
-    var titleSegDistance by remember(config.titleSegDistance) { mutableIntStateOf(config.titleSegDistance) }
-    var titleSegFlag by remember(config.titleSegFlag) { mutableStateOf(config.titleSegFlag) }
-    var titleSegScaling by remember(config.titleSegScaling) { mutableFloatStateOf(config.titleSegScaling) }
-    var titleLineSpacingExtra by remember(config.titleLineSpacingExtra) { mutableIntStateOf(config.titleLineSpacingExtra) }
-    var titleLineSpacingSub by remember(config.titleLineSpacingSub) { mutableIntStateOf(config.titleLineSpacingSub) }
-    var titleSize by remember(config.titleSize) { mutableIntStateOf(config.titleSize) }
-    var titleTopSpacing by remember(config.titleTopSpacing) { mutableIntStateOf(config.titleTopSpacing) }
-    var titleBottomSpacing by remember(config.titleBottomSpacing) { mutableIntStateOf(config.titleBottomSpacing) }
-
-    var showColorPicker by remember { mutableStateOf(false) }
-    var colorPickerId by remember { mutableIntStateOf(0) }
-    var colorPickerInitial by remember { mutableIntStateOf(0) }
-
-    Column(
-        modifier = modifier
-            .fillMaxWidth()
-            .padding(horizontal = 16.dp)
-            .verticalScroll(rememberScrollState()),
-    ) {
-        TinyDropdownSettingItem(
-            title = stringResource(R.string.body_title),
-            selectedValue = titleMode.toString(),
-            displayEntries = arrayOf(
-                stringResource(R.string.title_left),
-                stringResource(R.string.title_center),
-                stringResource(R.string.title_hide),
-            ),
-            entryValues = arrayOf("0", "1", "2"),
-            onValueChange = {
-                titleMode = it.toInt()
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleMode(titleMode)))
-            },
-        )
-
-        FontWeightSetting(
-            value = titleBold,
-            onValueChange = { value ->
-                titleBold = value
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleBold(value)))
-            },
-        )
-
-        TinyColorModeSettingItem(
-            title = stringResource(R.string.title_color),
-            dayColor = if (config.titleColor != 0) {
-                config.titleColor
-            } else {
-                config.textColorDay
-            },
-            nightColor = if (config.titleColorNight != 0) {
-                config.titleColorNight
-            } else {
-                config.textColorNight
-            },
-            onClickColor = { isNight ->
-                if (isNight) {
-                    colorPickerId = COLOR_TITLE_NIGHT
-                    colorPickerInitial = if (config.titleColorNight != 0) {
-                        config.titleColorNight
-                    } else {
-                        config.textColorNight
-                    }
-                } else {
-                    colorPickerId = COLOR_TITLE
-                    colorPickerInitial = if (config.titleColor != 0) {
-                        config.titleColor
-                    } else {
-                        config.textColorDay
-                    }
-                }
-                showColorPicker = true
-            },
-        )
-
-        // Title font
-        TinyClickableSettingItem(
-            title = stringResource(R.string.read_config_title_settings),
-            imageVector = Icons.Default.TextFields,
-            onClick = onOpenTitleFontSelect,
-        )
-
-        // Title segmentation
-        TinyDropdownSettingItem(
-            title = stringResource(R.string.split_title_mode),
-            selectedValue = titleSegType.toString(),
-            displayEntries = arrayOf(
-                stringResource(R.string.close),
-                stringResource(R.string.split_title_by_position),
-                stringResource(R.string.split_title_by_flag),
-                stringResource(R.string.split_title_by_regex),
-            ),
-            entryValues = arrayOf("0", "1", "2", "3"),
-            onValueChange = {
-                titleSegType = it.toInt()
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSegType(titleSegType)))
-            },
-        )
-        if (titleSegType == 1) {
-            TinySliderSettingItem(
-                title = stringResource(R.string.split_title_position),
-                value = titleSegDistance.toFloat(),
-                valueRange = 1f..20f,
-                steps = 18,
-                onValueChange = { value ->
-                    titleSegDistance = value.toInt()
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSegDistance(titleSegDistance)))
-                },
-            )
-        }
-        if (titleSegType == 2 || titleSegType == 3) {
-            var showFlagDialog by remember { mutableStateOf(false) }
-            var flagText by remember { mutableStateOf(titleSegFlag) }
-
-            TinyClickableSettingItem(
-                title = stringResource(R.string.rule_segment),
-                description = titleSegFlag.ifBlank { stringResource(R.string.split_title_mode) },
-                onClick = { showFlagDialog = true },
-            )
-
-            AppAlertDialog(
-                show = showFlagDialog,
-                onDismissRequest = { showFlagDialog = false },
-                title = stringResource(R.string.rule_segment),
-                content = {
-                    AppTextField(
-                        value = flagText,
-                        onValueChange = { flagText = it },
-                        singleLine = false,
-                        modifier = Modifier.fillMaxWidth(),
-                    )
-                },
-                confirmText = stringResource(android.R.string.ok),
-                onConfirm = {
-                    titleSegFlag = flagText
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSegFlag(titleSegFlag)))
-                    showFlagDialog = false
-                },
-                dismissText = stringResource(android.R.string.cancel),
-                onDismiss = { showFlagDialog = false },
-            )
-        }
-
-        // Title spacing sliders
-        TinySliderSettingItem(
-            title = stringResource(R.string.subtitle_scale),
-            value = titleSegScaling,
-            valueRange = 0f..2f,
-            steps = 19,
-            stepSize = 0.1f,
-            valueFormat = { "%.1f".format(it) },
-            onValueChange = { value ->
-                titleSegScaling = (value * 10).roundToInt() / 10f
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSegScaling(titleSegScaling)))
-            },
-        )
-        TinySliderSettingItem(
-            title = stringResource(R.string.heading_spacing),
-            value = titleLineSpacingExtra.toFloat(),
-            valueRange = 0f..20f,
-            onValueChange = { value ->
-                titleLineSpacingExtra = value.toInt()
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleLineSpacingExtra(titleLineSpacingExtra)))
-            },
-        )
-        TinySliderSettingItem(
-            title = stringResource(R.string.subtitle_margin),
-            value = titleLineSpacingSub.toFloat(),
-            valueRange = -30f..30f,
-            onValueChange = { value ->
-                titleLineSpacingSub = value.toInt()
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleLineSpacingSub(titleLineSpacingSub)))
-            },
-        )
-        TinySliderSettingItem(
-            title = stringResource(R.string.title_font_size),
-            value = titleSize.toFloat(),
-            valueRange = 0f..20f,
-            onValueChange = { value ->
-                titleSize = value.toInt()
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSize(titleSize)))
-            },
-        )
-        TinySliderSettingItem(
-            title = stringResource(R.string.title_margin_top),
-            value = titleTopSpacing.toFloat(),
-            valueRange = 0f..200f,
-            onValueChange = { value ->
-                titleTopSpacing = value.toInt()
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleTopSpacing(titleTopSpacing)))
-            },
-        )
-        TinySliderSettingItem(
-            title = stringResource(R.string.title_margin_bottom),
-            value = titleBottomSpacing.toFloat(),
-            valueRange = 0f..200f,
-            onValueChange = { value ->
-                titleBottomSpacing = value.toInt()
-                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleBottomSpacing(titleBottomSpacing)))
-            },
-        )
-
-    }
-
-    // Color picker
-    ColorPickerSheet(
-        show = showColorPicker,
-        initialColor = colorPickerInitial,
-        onDismissRequest = { showColorPicker = false },
-        onColorSelected = { color ->
-            when (colorPickerId) {
-                COLOR_TITLE -> {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleColor(color)))
-                }
-                COLOR_TITLE_NIGHT -> {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleColorNight(color)))
-                }
-            }
-            showColorPicker = false
-        },
-    )
-}
-
-@Composable
-private fun FontWeightSetting(
-    value: Int,
-    onValueChange: (Int) -> Unit,
-) {
-    var showVariableWeight by remember { mutableStateOf(false) }
-    var sliderValue by remember(value) {
-        mutableFloatStateOf(
-            when (value) {
-                2 -> 300f
-                0 -> 400f
-                1 -> 900f
-                else -> value.coerceIn(100, 900).toFloat()
-            }
-        )
-    }
-    val weightEntries = stringArrayResource(R.array.text_font_weight)
-
-    Column {
-        Row(
-            modifier = Modifier.fillMaxWidth(),
-            horizontalArrangement = Arrangement.spacedBy(8.dp),
-        ) {
-            Box(modifier = Modifier.weight(1f)) {
-                TinyDropdownSettingItem(
-                    title = stringResource(R.string.font_weight_text),
-                    selectedValue = value.toString(),
-                    displayEntries = arrayOf(
-                        weightEntries[2],
-                        weightEntries[0],
-                        weightEntries[1],
-                    ),
-                    entryValues = arrayOf("2", "0", "1"),
-                    onValueChange = { onValueChange(it.toInt()) },
-                )
-            }
-            NormalCard(
-                onClick = { showVariableWeight = !showVariableWeight },
-                modifier = Modifier
-                    .padding(bottom = 4.dp)
-                    .height(56.dp)
-                    .aspectRatio(1f),
-                containerColor = if (showVariableWeight) {
-                    LegadoTheme.colorScheme.secondaryContainer
-                } else {
-                    LegadoTheme.colorScheme.surfaceContainerLow
-                },
-                contentColor = if (showVariableWeight) {
-                    LegadoTheme.colorScheme.onSecondaryContainer
-                } else {
-                    LegadoTheme.colorScheme.onSurfaceVariant
-                },
-                cornerRadius = 12.dp,
-            ) {
-                Box(
-                    modifier = Modifier.fillMaxSize(),
-                    contentAlignment = Alignment.Center,
-                ) {
-                    Icon(
-                        imageVector = Icons.Default.Tune,
-                        contentDescription = stringResource(R.string.font_weight_text),
-                    )
-                }
-            }
-        }
-
-        AnimatedVisibility(visible = showVariableWeight) {
-            NormalCard(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .padding(bottom = 4.dp)
-                    .height(56.dp),
-                containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
-                cornerRadius = 12.dp,
-            ) {
-                ValueStepper(
-                    value = sliderValue,
-                    displayValue = sliderValue,
-                    valueRange = 100f..900f,
-                    onValueChange = {
-                        sliderValue = it
-                        onValueChange(it.toInt())
-                    },
-                    modifier = Modifier
-                        .fillMaxSize()
-                        .padding(horizontal = 12.dp),
-                    content = {
-                        AppSlider(
-                            value = sliderValue,
-                            onValueChange = { sliderValue = it },
-                            onValueChangeFinished = {
-                                onValueChange(sliderValue.toInt())
-                            },
-                            valueRange = 100f..900f,
-                            modifier = Modifier.weight(1f),
-                        )
-                    },
-                )
-            }
-        }
-    }
-}

+ 313 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/TypographyPage.kt

@@ -0,0 +1,313 @@
+package io.legado.app.ui.book.read.sheet
+
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.ui.book.read.ConfigUpdate
+import io.legado.app.ui.book.read.ReadBookIntent
+import io.legado.app.ui.book.read.ReadSheetConfigUiState
+import io.legado.app.ui.widget.components.pager.pagerHeight
+import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
+import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
+import io.legado.app.ui.widget.components.tabRow.CardTabRow
+import kotlinx.coroutines.launch
+
+/**
+ * 排版配置页 — 统一的 5 Tab 排版设置入口。
+ *
+ * Tabs: 正文 / 标题 / 页眉 / 页脚 / 边距
+ *
+ * 模态弹窗(FontSelectSheet / ColorPickerSheet / CustomTipDialog)状态上抛至本层级,
+ * 在 HorizontalPager 之外渲染,避免 clipToBounds 裁剪问题。
+ */
+@Composable
+fun TypographyPage(
+    config: ReadSheetConfigUiState,
+    onIntent: (ReadBookIntent) -> Unit,
+    onOpenFontSelect: () -> Unit,
+    onOpenTitleFontSelect: () -> Unit,
+    onOpenShadowSet: () -> Unit,
+    onOpenUnderlineConfig: () -> Unit,
+    onOpenHighlightRule: () -> Unit,
+    sameTitleRemoved: Boolean = false,
+    modifier: Modifier = Modifier,
+) {
+    val scope = rememberCoroutineScope()
+    val tabTitles = listOf(
+        stringResource(R.string.main_body),   // 正文
+        stringResource(R.string.body_title),   // 标题
+        stringResource(R.string.header),       // 页眉
+        stringResource(R.string.footer),       // 页脚
+        stringResource(R.string.padding),      // 边距
+    )
+    val pagerState = rememberPagerState(pageCount = { 5 })
+    var selectedTab by remember { mutableIntStateOf(0) }
+
+    // Hoisted modal sheet state — rendered outside the pager to avoid clipToBounds clipping
+    var activeColorPicker by remember { mutableStateOf<TypographyColorTarget?>(null) }
+    var activeHeaderFontSelect by remember { mutableStateOf(false) }
+    var activeFooterFontSelect by remember { mutableStateOf(false) }
+    var activeCustomTip by remember { mutableStateOf<CustomTipTarget?>(null) }
+
+    // Lifted tip state — shared between Header Tab and Footer Tab for cross-tab clearRepeat.
+    // Keys ensure re-initialization when ReadBookConfig changes from external sources.
+    var headerMode by remember(ReadBookConfig.headerMode) { mutableIntStateOf(ReadBookConfig.headerMode) }
+    var headerLeft by remember(ReadBookConfig.tipHeaderLeft) { mutableIntStateOf(ReadBookConfig.tipHeaderLeft) }
+    var headerMiddle by remember(ReadBookConfig.tipHeaderMiddle) { mutableIntStateOf(ReadBookConfig.tipHeaderMiddle) }
+    var headerRight by remember(ReadBookConfig.tipHeaderRight) { mutableIntStateOf(ReadBookConfig.tipHeaderRight) }
+    var footerMode by remember(ReadBookConfig.footerMode) { mutableIntStateOf(ReadBookConfig.footerMode) }
+    var footerLeft by remember(ReadBookConfig.tipFooterLeft) { mutableIntStateOf(ReadBookConfig.tipFooterLeft) }
+    var footerMiddle by remember(ReadBookConfig.tipFooterMiddle) { mutableIntStateOf(ReadBookConfig.tipFooterMiddle) }
+    var footerRight by remember(ReadBookConfig.tipFooterRight) { mutableIntStateOf(ReadBookConfig.tipFooterRight) }
+    var showHeaderLine by remember(ReadBookConfig.showHeaderLine) { mutableStateOf(ReadBookConfig.showHeaderLine) }
+    var showFooterLine by remember(ReadBookConfig.showFooterLine) { mutableStateOf(ReadBookConfig.showFooterLine) }
+
+    fun clearRepeat(repeat: Int) {
+        if (repeat == ReadBookConfig.tipNone) return
+        if (headerLeft == repeat) {
+            headerLeft = ReadBookConfig.tipNone
+            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderLeft(ReadBookConfig.tipNone)))
+        }
+        if (headerMiddle == repeat) {
+            headerMiddle = ReadBookConfig.tipNone
+            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderMiddle(ReadBookConfig.tipNone)))
+        }
+        if (headerRight == repeat) {
+            headerRight = ReadBookConfig.tipNone
+            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderRight(ReadBookConfig.tipNone)))
+        }
+        if (footerLeft == repeat) {
+            footerLeft = ReadBookConfig.tipNone
+            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterLeft(ReadBookConfig.tipNone)))
+        }
+        if (footerMiddle == repeat) {
+            footerMiddle = ReadBookConfig.tipNone
+            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterMiddle(ReadBookConfig.tipNone)))
+        }
+        if (footerRight == repeat) {
+            footerRight = ReadBookConfig.tipNone
+            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterRight(ReadBookConfig.tipNone)))
+        }
+    }
+
+    fun handleTipChange(target: CustomTipTarget, value: Int) {
+        if (value == ReadBookConfig.tipCustom) {
+            activeCustomTip = target
+            return
+        }
+        clearRepeat(value)
+        when (target) {
+            CustomTipTarget.HEADER_LEFT -> {
+                headerLeft = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderLeft(value)))
+            }
+            CustomTipTarget.HEADER_MIDDLE -> {
+                headerMiddle = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderMiddle(value)))
+            }
+            CustomTipTarget.HEADER_RIGHT -> {
+                headerRight = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderRight(value)))
+            }
+            CustomTipTarget.FOOTER_LEFT -> {
+                footerLeft = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterLeft(value)))
+            }
+            CustomTipTarget.FOOTER_MIDDLE -> {
+                footerMiddle = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterMiddle(value)))
+            }
+            CustomTipTarget.FOOTER_RIGHT -> {
+                footerRight = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterRight(value)))
+            }
+        }
+    }
+
+    val childPagerNestedScrollConnection = rememberPagerFlingPassThroughConnection(
+        state = pagerState,
+        orientation = Orientation.Horizontal,
+    )
+
+    val pageHeights = remember { mutableStateMapOf<Int, Int>() }
+    val animatedHeight by rememberPagerAnimatedHeight(pagerState, pageHeights)
+
+    LaunchedEffect(pagerState) {
+        snapshotFlow { pagerState.currentPage }.collect { page ->
+            selectedTab = page
+        }
+    }
+
+    Column(
+        modifier = modifier.fillMaxWidth(),
+    ) {
+        CardTabRow(
+            tabTitles = tabTitles,
+            selectedTabIndex = selectedTab,
+            onTabSelected = { index ->
+                scope.launch {
+                    pagerState.animateScrollToPage(
+                        page = index,
+                        animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
+                    )
+                }
+            },
+            modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
+        )
+
+        HorizontalPager(
+            state = pagerState,
+            verticalAlignment = Alignment.Top,
+            pageNestedScrollConnection = childPagerNestedScrollConnection,
+            modifier = Modifier
+                .weight(1f, fill = false)
+                .clipToBounds()
+                .pagerHeight(animatedHeight),
+        ) { page ->
+            Box(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .onSizeChanged { size ->
+                        pageHeights[page] = size.height
+                    }
+            ) {
+                when (page) {
+                    0 -> TypographyBodyTab(
+                        config = config,
+                        onIntent = onIntent,
+                        onOpenFontSelect = onOpenFontSelect,
+                        onOpenShadowSet = onOpenShadowSet,
+                        onOpenUnderlineConfig = onOpenUnderlineConfig,
+                        onOpenHighlightRule = onOpenHighlightRule,
+                        onOpenColorPicker = { activeColorPicker = it },
+                        modifier = Modifier.padding(horizontal = 16.dp),
+                    )
+
+                    1 -> TypographyTitleTab(
+                        config = config,
+                        onIntent = onIntent,
+                        onOpenTitleFontSelect = onOpenTitleFontSelect,
+                        onOpenColorPicker = { activeColorPicker = it },
+                        sameTitleRemoved = sameTitleRemoved,
+                        modifier = Modifier.padding(horizontal = 16.dp),
+                    )
+
+                    2 -> TypographyHeaderTab(
+                        config = config,
+                        onIntent = onIntent,
+                        onOpenHeaderFontSelect = { activeHeaderFontSelect = true },
+                        onOpenColorPicker = { activeColorPicker = it },
+                        onOpenCustomTip = { target: CustomTipTarget -> activeCustomTip = target },
+                        headerMode = headerMode,
+                        headerLeft = headerLeft,
+                        headerMiddle = headerMiddle,
+                        headerRight = headerRight,
+                        onHeaderModeChange = {
+                            headerMode = it
+                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderMode(it)))
+                        },
+                        onTipChange = ::handleTipChange,
+                        showHeaderLine = showHeaderLine,
+                        onShowHeaderLineChange = {
+                            showHeaderLine = it
+                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShowHeaderLine(it)))
+                        },
+                        modifier = Modifier.padding(horizontal = 16.dp),
+                    )
+
+                    3 -> TypographyFooterTab(
+                        config = config,
+                        onIntent = onIntent,
+                        onOpenFooterFontSelect = { activeFooterFontSelect = true },
+                        onOpenColorPicker = { activeColorPicker = it },
+                        onOpenCustomTip = { target: CustomTipTarget -> activeCustomTip = target },
+                        footerMode = footerMode,
+                        footerLeft = footerLeft,
+                        footerMiddle = footerMiddle,
+                        footerRight = footerRight,
+                        onFooterModeChange = {
+                            footerMode = it
+                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterMode(it)))
+                        },
+                        onTipChange = ::handleTipChange,
+                        showFooterLine = showFooterLine,
+                        onShowFooterLineChange = {
+                            showFooterLine = it
+                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShowFooterLine(it)))
+                        },
+                        modifier = Modifier.padding(horizontal = 16.dp),
+                    )
+
+                    4 -> TypographyMarginTab(
+                        config = config,
+                        onIntent = onIntent,
+                        modifier = Modifier.padding(horizontal = 16.dp),
+                    )
+                }
+            }
+        }
+    }
+
+    // --- Hoisted modal sheets (rendered outside the pager) ---
+
+    // Color picker
+    activeColorPicker?.let { target ->
+        TypographyColorPickerSheet(
+            target = target,
+            config = config,
+            onDismiss = { activeColorPicker = null },
+            onIntent = onIntent,
+        )
+    }
+
+    // Header font select
+    if (activeHeaderFontSelect) {
+        TypographyHeaderFontSelectSheet(
+            config = config,
+            onDismiss = { activeHeaderFontSelect = false },
+            onIntent = onIntent,
+        )
+    }
+
+    // Footer font select
+    if (activeFooterFontSelect) {
+        TypographyFooterFontSelectSheet(
+            config = config,
+            onDismiss = { activeFooterFontSelect = false },
+            onIntent = onIntent,
+        )
+    }
+
+    // Custom tip dialog
+    activeCustomTip?.let { target ->
+        TypographyCustomTipDialog(
+            target = target,
+            onDismiss = { activeCustomTip = null },
+            onIntent = onIntent,
+        )
+    }
+}

+ 1133 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/TypographyTabs.kt

@@ -0,0 +1,1133 @@
+package io.legado.app.ui.book.read.sheet
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CleanHands
+import androidx.compose.material.icons.filled.FormatItalic
+import androidx.compose.material.icons.filled.FormatUnderlined
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.Layers
+import androidx.compose.material.icons.filled.TextFields
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.core.net.toUri
+import io.legado.app.R
+import io.legado.app.data.entities.Book
+import io.legado.app.data.repository.ReadSettingsRepository
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.model.ReadBook
+import io.legado.app.ui.book.read.ConfigUpdate
+import io.legado.app.ui.book.read.ReadBookIntent
+import io.legado.app.ui.book.read.ReadSheetConfigUiState
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppSlider
+import io.legado.app.ui.widget.components.ValueStepper
+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.alert.AppAlertDialog
+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.dialog.CustomTipDialog
+import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
+import io.legado.app.ui.widget.components.settingItem.TinyColorModeSettingItem
+import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem
+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.utils.getCompatColor
+import org.koin.compose.koinInject
+import kotlin.math.roundToInt
+
+// region Modal target sealed interface
+
+/** Identifies which color picker to show in the typography page. */
+internal sealed interface TypographyColorTarget {
+    data object Text : TypographyColorTarget
+    data object TextAccent : TypographyColorTarget
+    data object Title : TypographyColorTarget
+    data object TitleNight : TypographyColorTarget
+    data object Header : TypographyColorTarget
+    data object HeaderNight : TypographyColorTarget
+    data object Footer : TypographyColorTarget
+    data object FooterNight : TypographyColorTarget
+    data object Divider : TypographyColorTarget
+}
+
+// endregion
+
+// region Dropdown & Slider helpers
+
+@Composable
+private fun FontWeightSetting(
+    value: Int,
+    onValueChange: (Int) -> Unit,
+) {
+    var showVariableWeight by remember { mutableStateOf(false) }
+    var sliderValue by remember(value) {
+        mutableFloatStateOf(
+            when (value) {
+                2 -> 300f
+                0 -> 400f
+                1 -> 900f
+                else -> value.coerceIn(100, 900).toFloat()
+            }
+        )
+    }
+    val weightEntries = stringArrayResource(R.array.text_font_weight)
+
+    Column {
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            Box(modifier = Modifier.weight(1f)) {
+                TinyDropdownSettingItem(
+                    title = stringResource(R.string.font_weight_text),
+                    selectedValue = value.toString(),
+                    displayEntries = arrayOf(weightEntries[2], weightEntries[0], weightEntries[1]),
+                    entryValues = arrayOf("2", "0", "1"),
+                    onValueChange = { onValueChange(it.toInt()) },
+                )
+            }
+            NormalCard(
+                onClick = { showVariableWeight = !showVariableWeight },
+                modifier = Modifier.padding(bottom = 4.dp).height(56.dp).aspectRatio(1f),
+                containerColor = if (showVariableWeight) LegadoTheme.colorScheme.secondaryContainer else LegadoTheme.colorScheme.surfaceContainerLow,
+                contentColor = if (showVariableWeight) LegadoTheme.colorScheme.onSecondaryContainer else LegadoTheme.colorScheme.onSurfaceVariant,
+                cornerRadius = 12.dp,
+            ) {
+                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+                    Icon(Icons.Default.Tune, stringResource(R.string.font_weight_text))
+                }
+            }
+        }
+
+        AnimatedVisibility(visible = showVariableWeight) {
+            NormalCard(
+                modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp).height(56.dp),
+                containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+                cornerRadius = 12.dp,
+            ) {
+                ValueStepper(
+                    value = sliderValue,
+                    displayValue = sliderValue,
+                    valueRange = 100f..900f,
+                    onValueChange = {
+                        sliderValue = it
+                        onValueChange(it.toInt())
+                    },
+                    modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
+                    content = {
+                        AppSlider(
+                            value = sliderValue,
+                            onValueChange = { sliderValue = it },
+                            onValueChangeFinished = { onValueChange(sliderValue.toInt()) },
+                            valueRange = 100f..900f,
+                            modifier = Modifier.weight(1f),
+                        )
+                    },
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun TipPositionDropdown(
+    label: String,
+    value: Int,
+    tipNames: List<String>,
+    tipValues: Array<Int>,
+    onValueChange: (Int) -> Unit,
+) {
+    TinyDropdownSettingItem(
+        title = label,
+        selectedValue = value.toString(),
+        displayEntries = tipNames.toTypedArray(),
+        entryValues = tipValues.map { it.toString() }.toTypedArray(),
+        onValueChange = { onValueChange(it.toInt()) },
+    )
+}
+
+@Composable
+private fun PaddingSliders(
+    top: Float,
+    bottom: Float,
+    left: Float,
+    right: Float,
+    onTopChange: (Float) -> Unit,
+    onBottomChange: (Float) -> Unit,
+    onLeftChange: (Float) -> Unit,
+    onRightChange: (Float) -> Unit,
+) {
+    TinySliderSettingItem(
+        title = stringResource(R.string.padding_top),
+        value = top,
+        valueRange = 0f..200f,
+        onValueChange = onTopChange,
+    )
+    TinySliderSettingItem(
+        title = stringResource(R.string.padding_bottom),
+        value = bottom,
+        valueRange = 0f..200f,
+        onValueChange = onBottomChange,
+    )
+    TinySliderSettingItem(
+        title = stringResource(R.string.padding_left),
+        value = left,
+        valueRange = 0f..200f,
+        onValueChange = onLeftChange,
+    )
+    TinySliderSettingItem(
+        title = stringResource(R.string.padding_right),
+        value = right,
+        valueRange = 0f..200f,
+        onValueChange = onRightChange,
+    )
+}
+
+// endregion
+
+// region Tab composables (placeholders — content implemented in subsequent tasks)
+
+@Composable
+internal fun TypographyBodyTab(
+    config: ReadSheetConfigUiState,
+    onIntent: (ReadBookIntent) -> Unit,
+    onOpenFontSelect: () -> Unit,
+    onOpenShadowSet: () -> Unit,
+    onOpenUnderlineConfig: () -> Unit,
+    onOpenHighlightRule: () -> Unit,
+    onOpenColorPicker: (TypographyColorTarget) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    var textItalic by remember(config.textItalic) { mutableStateOf(config.textItalic) }
+    var textBold by remember(config.textBold) { mutableIntStateOf(config.textBold) }
+    var letterSpacing by remember(config.letterSpacing) { mutableFloatStateOf(config.letterSpacing) }
+    var lineSpacing by remember(config.lineSpacing) { mutableFloatStateOf(config.lineSpacing.toFloat()) }
+    var paragraphSpacing by remember(config.paragraphSpacing) { mutableFloatStateOf(config.paragraphSpacing.toFloat()) }
+    var indentCount by remember(config.paragraphIndentCount) { mutableIntStateOf(config.paragraphIndentCount) }
+
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .verticalScroll(rememberScrollState()),
+    ) {
+        // 字体组
+        SectionTitle(title = stringResource(R.string.text_typeface))
+        TinyClickableSettingItem(
+            title = stringResource(R.string.select_font),
+            imageVector = Icons.Default.TextFields,
+            onClick = onOpenFontSelect,
+        )
+        TinySwitchSettingItem(
+            title = stringResource(R.string.read_config_italic),
+            checked = textItalic,
+            imageVector = Icons.Default.FormatItalic,
+            onCheckedChange = {
+                textItalic = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextItalic(it)))
+            },
+        )
+        FontWeightSetting(
+            value = textBold,
+            onValueChange = { value ->
+                textBold = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextBold(value)))
+            },
+        )
+        val chineseConvertEntries = stringArrayResource(R.array.chinese_mode)
+        val chineseConvertValues = remember { arrayOf("0", "1", "2") }
+        TinyDropdownSettingItem(
+            title = stringResource(R.string.chinese_converter),
+            selectedValue = config.chineseConverterType.toString(),
+            displayEntries = chineseConvertEntries,
+            entryValues = chineseConvertValues,
+            onValueChange = {
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ChineseConverterType(it.toInt())))
+            },
+        )
+
+        // 颜色组
+        SectionTitle(title = stringResource(R.string.read_color))
+        TinyColorSettingItem(
+            title = stringResource(R.string.text_color),
+            colorValue = config.textColor,
+            onClick = { onOpenColorPicker(TypographyColorTarget.Text) },
+        )
+        TinyColorSettingItem(
+            title = stringResource(R.string.text_accent_color),
+            colorValue = config.textAccentColor,
+            onClick = { onOpenColorPicker(TypographyColorTarget.TextAccent) },
+        )
+
+        // 效果组
+        SectionTitle(title = stringResource(R.string.read_config_effects))
+        TinyClickableSettingItem(
+            title = stringResource(R.string.text_shadow_set),
+            description = stringResource(R.string.read_config_shadow_desc),
+            imageVector = Icons.Default.Layers,
+            onClick = onOpenShadowSet,
+        )
+        TinyClickableSettingItem(
+            title = stringResource(R.string.text_underline),
+            description = stringResource(R.string.read_config_underline_desc),
+            imageVector = Icons.Default.FormatUnderlined,
+            onClick = onOpenUnderlineConfig,
+        )
+        TinyClickableSettingItem(
+            title = stringResource(R.string.highlight_rule_config),
+            description = stringResource(R.string.read_config_regex_desc),
+            imageVector = Icons.Default.Tune,
+            onClick = onOpenHighlightRule,
+        )
+
+        // 间距组
+        SectionTitle(title = stringResource(R.string.read_config_body_spacing))
+        TinySliderSettingItem(
+            title = stringResource(R.string.text_indent),
+            value = indentCount.toFloat(),
+            valueRange = 0f..4f,
+            steps = 3,
+            onValueChange = { value ->
+                indentCount = value.toInt()
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ParagraphIndent(" ".repeat(indentCount))))
+            },
+        )
+        TinySliderSettingItem(
+            title = stringResource(R.string.text_letter_spacing),
+            value = (letterSpacing * 100) + 50,
+            valueRange = 0f..100f,
+            steps = 99,
+            valueFormat = { ((it - 50) / 100f).toString() },
+            onValueChange = { value ->
+                letterSpacing = (value - 50) / 100f
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.LetterSpacing(letterSpacing)))
+            },
+        )
+        TinySliderSettingItem(
+            title = stringResource(R.string.line_size),
+            value = lineSpacing,
+            valueRange = 0f..20f,
+            steps = 19,
+            valueFormat = { ((it - 10) / 10f).toString() },
+            onValueChange = { value ->
+                lineSpacing = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.LineSpacing(value.toInt())))
+            },
+        )
+        TinySliderSettingItem(
+            title = stringResource(R.string.paragraph_size),
+            value = paragraphSpacing,
+            valueRange = 0f..20f,
+            steps = 19,
+            valueFormat = { (it / 10f).toString() },
+            onValueChange = { value ->
+                paragraphSpacing = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ParagraphSpacing(value.toInt())))
+            },
+        )
+
+        // 对齐组
+        SectionTitle(title = stringResource(R.string.text_alignment))
+        TinySwitchSettingItem(
+            title = stringResource(R.string.text_full_justify),
+            checked = config.textFullJustify,
+            onCheckedChange = {
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextFullJustify(it)))
+            },
+        )
+        TinySwitchSettingItem(
+            title = stringResource(R.string.text_bottom_justify),
+            checked = config.textBottomJustify,
+            onCheckedChange = {
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextBottomJustify(it)))
+            },
+        )
+
+        // 图片组
+        SectionTitle(title = stringResource(R.string.image_style))
+        val imgStyleEntries = arrayOf(
+            stringResource(R.string.btn_default_s),
+            stringResource(R.string.image_style_full),
+            stringResource(R.string.image_style_text),
+            stringResource(R.string.image_style_single),
+        )
+        val imgStyleValues = arrayOf(
+            Book.imgStyleDefault,
+            Book.imgStyleFull,
+            Book.imgStyleText,
+            Book.imgStyleSingle,
+        )
+        TinyDropdownSettingItem(
+            title = stringResource(R.string.image_style),
+            selectedValue = ReadBook.book?.getImageStyle() ?: Book.imgStyleDefault,
+            displayEntries = imgStyleEntries,
+            entryValues = imgStyleValues,
+            onValueChange = { style ->
+                onIntent(ReadBookIntent.MenuImageStyle(style))
+            },
+        )
+
+        Spacer(Modifier.height(8.dp))
+    }
+}
+
+@Composable
+internal fun TypographyTitleTab(
+    config: ReadSheetConfigUiState,
+    onIntent: (ReadBookIntent) -> Unit,
+    onOpenTitleFontSelect: () -> Unit,
+    onOpenColorPicker: (TypographyColorTarget) -> Unit,
+    sameTitleRemoved: Boolean = false,
+    modifier: Modifier = Modifier,
+) {
+    var titleMode by remember(config.titleMode) { mutableIntStateOf(config.titleMode) }
+    var titleBold by remember(config.titleBold) { mutableIntStateOf(config.titleBold) }
+    var titleSegType by remember(config.titleSegType) { mutableIntStateOf(config.titleSegType) }
+    var titleSegDistance by remember(config.titleSegDistance) { mutableIntStateOf(config.titleSegDistance) }
+    var titleSegFlag by remember(config.titleSegFlag) { mutableStateOf(config.titleSegFlag) }
+    var titleSegScaling by remember(config.titleSegScaling) { mutableFloatStateOf(config.titleSegScaling) }
+    var titleLineSpacingExtra by remember(config.titleLineSpacingExtra) { mutableIntStateOf(config.titleLineSpacingExtra) }
+    var titleLineSpacingSub by remember(config.titleLineSpacingSub) { mutableIntStateOf(config.titleLineSpacingSub) }
+
+    // Title font size: <8 means relative offset (textSize + titleSize), >=8 means absolute
+    var titleSize by remember(config.titleSize) {
+        val initial = if (config.titleSize < 8) {
+            (ReadBookConfig.textSize + config.titleSize).coerceIn(8, 60)
+        } else {
+            config.titleSize
+        }
+        mutableIntStateOf(initial)
+    }
+
+    var showFlagDialog by remember { mutableStateOf(false) }
+    var flagText by remember { mutableStateOf(titleSegFlag) }
+
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .verticalScroll(rememberScrollState()),
+    ) {
+        // 内容组
+        SectionTitle(title = stringResource(R.string.content))
+        TinyDropdownSettingItem(
+            title = stringResource(R.string.body_title),
+            selectedValue = titleMode.toString(),
+            displayEntries = arrayOf(
+                stringResource(R.string.title_left),
+                stringResource(R.string.title_center),
+                stringResource(R.string.title_hide),
+            ),
+            entryValues = arrayOf("0", "1", "2"),
+            onValueChange = {
+                titleMode = it.toInt()
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleMode(titleMode)))
+            },
+        )
+
+        // 字体组
+        SectionTitle(title = stringResource(R.string.text_font))
+        TinyClickableSettingItem(
+            title = stringResource(R.string.select_font),
+            imageVector = Icons.Default.TextFields,
+            onClick = onOpenTitleFontSelect,
+        )
+        FontWeightSetting(
+            value = titleBold,
+            onValueChange = { value ->
+                titleBold = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleBold(value)))
+            },
+        )
+        TinyColorModeSettingItem(
+            title = stringResource(R.string.title_color),
+            dayColor = if (config.titleColor != 0) config.titleColor else config.textColorDay,
+            nightColor = if (config.titleColorNight != 0) config.titleColorNight else config.textColorNight,
+            onClickColor = { isNight ->
+                if (isNight) {
+                    onOpenColorPicker(TypographyColorTarget.TitleNight)
+                } else {
+                    onOpenColorPicker(TypographyColorTarget.Title)
+                }
+            },
+        )
+        TinySliderSettingItem(
+            title = stringResource(R.string.title_font_size),
+            value = titleSize.toFloat(),
+            valueRange = 8f..60f,
+            steps = 51,
+            valueFormat = { "${it.toInt()}sp" },
+            onValueChange = { value ->
+                titleSize = value.toInt()
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSize(titleSize)))
+            },
+        )
+
+        // 分段组
+        SectionTitle(title = stringResource(R.string.title_segment))
+        TinyDropdownSettingItem(
+            title = stringResource(R.string.split_title_mode),
+            selectedValue = titleSegType.toString(),
+            displayEntries = arrayOf(
+                stringResource(R.string.close),
+                stringResource(R.string.split_title_by_position),
+                stringResource(R.string.split_title_by_flag),
+                stringResource(R.string.split_title_by_regex),
+            ),
+            entryValues = arrayOf("0", "1", "2", "3"),
+            onValueChange = {
+                titleSegType = it.toInt()
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSegType(titleSegType)))
+            },
+        )
+        if (titleSegType == 1) {
+            TinySliderSettingItem(
+                title = stringResource(R.string.split_title_position),
+                value = titleSegDistance.toFloat(),
+                valueRange = 1f..20f,
+                steps = 18,
+                onValueChange = { value ->
+                    titleSegDistance = value.toInt()
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSegDistance(titleSegDistance)))
+                },
+            )
+        }
+        if (titleSegType == 2 || titleSegType == 3) {
+            TinyClickableSettingItem(
+                title = stringResource(R.string.rule_segment),
+                description = titleSegFlag.ifBlank { stringResource(R.string.split_title_mode) },
+                onClick = { showFlagDialog = true },
+            )
+        }
+        TinySliderSettingItem(
+            title = stringResource(R.string.subtitle_scale),
+            value = titleSegScaling,
+            valueRange = 0f..2f,
+            steps = 19,
+            stepSize = 0.1f,
+            valueFormat = { "%.1f".format(it) },
+            onValueChange = { value ->
+                titleSegScaling = (value * 10).roundToInt() / 10f
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSegScaling(titleSegScaling)))
+            },
+        )
+
+        // 间距组
+        SectionTitle(title = stringResource(R.string.title_spacing))
+        TinySliderSettingItem(
+            title = stringResource(R.string.heading_spacing),
+            value = titleLineSpacingExtra.toFloat(),
+            valueRange = 0f..20f,
+            onValueChange = { value ->
+                titleLineSpacingExtra = value.toInt()
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleLineSpacingExtra(titleLineSpacingExtra)))
+            },
+        )
+        TinySliderSettingItem(
+            title = stringResource(R.string.subtitle_margin),
+            value = titleLineSpacingSub.toFloat(),
+            valueRange = -30f..30f,
+            onValueChange = { value ->
+                titleLineSpacingSub = value.toInt()
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleLineSpacingSub(titleLineSpacingSub)))
+            },
+        )
+
+        // 去重组
+        SectionTitle(title = stringResource(R.string.title_dedup))
+        TinySwitchSettingItem(
+            title = stringResource(R.string.same_title_removed),
+            checked = sameTitleRemoved,
+            imageVector = Icons.Default.CleanHands,
+            onCheckedChange = {
+                onIntent(ReadBookIntent.MenuSameTitleRemoved)
+            },
+        )
+
+        Spacer(Modifier.height(8.dp))
+    }
+
+    // Segmentation rule dialog
+    AppAlertDialog(
+        show = showFlagDialog,
+        onDismissRequest = { showFlagDialog = false },
+        title = stringResource(R.string.rule_segment),
+        content = {
+            AppTextField(
+                value = flagText,
+                onValueChange = { flagText = it },
+                singleLine = false,
+                modifier = Modifier.fillMaxWidth(),
+            )
+        },
+        confirmText = stringResource(android.R.string.ok),
+        onConfirm = {
+            titleSegFlag = flagText
+            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSegFlag(titleSegFlag)))
+            showFlagDialog = false
+        },
+        dismissText = stringResource(android.R.string.cancel),
+        onDismiss = { showFlagDialog = false },
+    )
+}
+
+@Composable
+internal fun TypographyHeaderTab(
+    config: ReadSheetConfigUiState,
+    onIntent: (ReadBookIntent) -> Unit,
+    onOpenHeaderFontSelect: () -> Unit,
+    onOpenColorPicker: (TypographyColorTarget) -> Unit,
+    onOpenCustomTip: (CustomTipTarget) -> Unit,
+    headerMode: Int,
+    headerLeft: Int,
+    headerMiddle: Int,
+    headerRight: Int,
+    onHeaderModeChange: (Int) -> Unit,
+    onTipChange: (CustomTipTarget, Int) -> Unit,
+    showHeaderLine: Boolean,
+    onShowHeaderLineChange: (Boolean) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val context = LocalContext.current
+    val tipNames = remember { ReadBookConfig.tipNames }
+    val tipValues = remember { ReadBookConfig.tipValues }
+
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .verticalScroll(rememberScrollState()),
+    ) {
+        // 内容组
+        SectionTitle(title = stringResource(R.string.content))
+        val headerModes = ReadBookConfig.getHeaderModes(context)
+        TinyDropdownSettingItem(
+            title = stringResource(R.string.header),
+            selectedValue = headerMode.toString(),
+            displayEntries = headerModes.values.toTypedArray(),
+            entryValues = headerModes.keys.map { it.toString() }.toTypedArray(),
+            onValueChange = { onHeaderModeChange(it.toInt()) },
+        )
+        TipPositionDropdown(
+            label = stringResource(R.string.left),
+            value = headerLeft,
+            tipNames = tipNames,
+            tipValues = tipValues,
+            onValueChange = { onTipChange(CustomTipTarget.HEADER_LEFT, it) },
+        )
+        TipPositionDropdown(
+            label = stringResource(R.string.middle),
+            value = headerMiddle,
+            tipNames = tipNames,
+            tipValues = tipValues,
+            onValueChange = { onTipChange(CustomTipTarget.HEADER_MIDDLE, it) },
+        )
+        TipPositionDropdown(
+            label = stringResource(R.string.right),
+            value = headerRight,
+            tipNames = tipNames,
+            tipValues = tipValues,
+            onValueChange = { onTipChange(CustomTipTarget.HEADER_RIGHT, it) },
+        )
+
+        // 分隔线组
+        SectionTitle(title = stringResource(R.string.read_config_divider_line))
+        TinySwitchSettingItem(
+            title = stringResource(R.string.showLine),
+            checked = showHeaderLine,
+            onCheckedChange = { onShowHeaderLineChange(it) },
+        )
+        TinyColorSettingItem(
+            title = stringResource(R.string.tip_divider_color),
+            colorValue = when (config.tipDividerColor) {
+                -1 -> context.getCompatColor(R.color.divider)
+                0 -> ReadBookConfig.textColor
+                else -> config.tipDividerColor
+            },
+            onClick = { onOpenColorPicker(TypographyColorTarget.Divider) },
+        )
+
+        // 字体组
+        SectionTitle(title = stringResource(R.string.text_font))
+        TinyClickableSettingItem(
+            title = stringResource(R.string.select_font),
+            imageVector = Icons.Default.TextFields,
+            onClick = onOpenHeaderFontSelect,
+        )
+        TinySliderSettingItem(
+            title = stringResource(R.string.title_font_size),
+            value = config.headerFontSize.toFloat(),
+            valueRange = 0f..100f,
+            onValueChange = { value ->
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderFontSize(value.toInt())))
+                onIntent(ReadBookIntent.SaveReadStyleConfig)
+            },
+        )
+        TinyColorModeSettingItem(
+            title = stringResource(R.string.title_color),
+            dayColor = if (config.tipHeaderColor != 0) config.tipHeaderColor else config.textColorDay,
+            nightColor = if (config.tipHeaderColorNight != 0) config.tipHeaderColorNight else config.textColorNight,
+            onClickColor = { isNight ->
+                if (isNight) {
+                    onOpenColorPicker(TypographyColorTarget.HeaderNight)
+                } else {
+                    onOpenColorPicker(TypographyColorTarget.Header)
+                }
+            },
+        )
+
+        Spacer(Modifier.height(8.dp))
+    }
+}
+
+@Composable
+internal fun TypographyFooterTab(
+    config: ReadSheetConfigUiState,
+    onIntent: (ReadBookIntent) -> Unit,
+    onOpenFooterFontSelect: () -> Unit,
+    onOpenColorPicker: (TypographyColorTarget) -> Unit,
+    onOpenCustomTip: (CustomTipTarget) -> Unit,
+    footerMode: Int,
+    footerLeft: Int,
+    footerMiddle: Int,
+    footerRight: Int,
+    onFooterModeChange: (Int) -> Unit,
+    onTipChange: (CustomTipTarget, Int) -> Unit,
+    showFooterLine: Boolean,
+    onShowFooterLineChange: (Boolean) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val context = LocalContext.current
+    val tipNames = remember { ReadBookConfig.tipNames }
+    val tipValues = remember { ReadBookConfig.tipValues }
+
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .verticalScroll(rememberScrollState()),
+    ) {
+        // 内容组
+        SectionTitle(title = stringResource(R.string.content))
+        val footerModes = ReadBookConfig.getFooterModes(context)
+        TinyDropdownSettingItem(
+            title = stringResource(R.string.footer),
+            selectedValue = footerMode.toString(),
+            displayEntries = footerModes.values.toTypedArray(),
+            entryValues = footerModes.keys.map { it.toString() }.toTypedArray(),
+            onValueChange = { onFooterModeChange(it.toInt()) },
+        )
+        TipPositionDropdown(
+            label = stringResource(R.string.left),
+            value = footerLeft,
+            tipNames = tipNames,
+            tipValues = tipValues,
+            onValueChange = { onTipChange(CustomTipTarget.FOOTER_LEFT, it) },
+        )
+        TipPositionDropdown(
+            label = stringResource(R.string.middle),
+            value = footerMiddle,
+            tipNames = tipNames,
+            tipValues = tipValues,
+            onValueChange = { onTipChange(CustomTipTarget.FOOTER_MIDDLE, it) },
+        )
+        TipPositionDropdown(
+            label = stringResource(R.string.right),
+            value = footerRight,
+            tipNames = tipNames,
+            tipValues = tipValues,
+            onValueChange = { onTipChange(CustomTipTarget.FOOTER_RIGHT, it) },
+        )
+
+        // 分隔线组
+        SectionTitle(title = stringResource(R.string.read_config_divider_line))
+        TinySwitchSettingItem(
+            title = stringResource(R.string.showLine),
+            checked = showFooterLine,
+            onCheckedChange = { onShowFooterLineChange(it) },
+        )
+
+        // 字体组
+        SectionTitle(title = stringResource(R.string.text_font))
+        TinySwitchSettingItem(
+            title = stringResource(R.string.apply_header_style),
+            checked = config.applyHeaderStyle,
+            onCheckedChange = {
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ApplyHeaderStyle(it)))
+            },
+        )
+        if (!config.applyHeaderStyle) {
+            TinyClickableSettingItem(
+                title = stringResource(R.string.select_font),
+                imageVector = Icons.Default.TextFields,
+                onClick = onOpenFooterFontSelect,
+            )
+            TinySliderSettingItem(
+                title = stringResource(R.string.title_font_size),
+                value = config.footerFontSize.toFloat(),
+                valueRange = 0f..100f,
+                onValueChange = { value ->
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterFontSize(value.toInt())))
+                    onIntent(ReadBookIntent.SaveReadStyleConfig)
+                },
+            )
+            TinyColorModeSettingItem(
+                title = stringResource(R.string.title_color),
+                dayColor = if (config.tipFooterColor != 0) config.tipFooterColor else config.textColorDay,
+                nightColor = if (config.tipFooterColorNight != 0) config.tipFooterColorNight else config.textColorNight,
+                onClickColor = { isNight ->
+                    if (isNight) {
+                        onOpenColorPicker(TypographyColorTarget.FooterNight)
+                    } else {
+                        onOpenColorPicker(TypographyColorTarget.Footer)
+                    }
+                },
+            )
+        }
+
+        Spacer(Modifier.height(8.dp))
+    }
+}
+
+@Composable
+internal fun TypographyMarginTab(
+    config: ReadSheetConfigUiState,
+    onIntent: (ReadBookIntent) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    // Body padding state
+    var paddingTop by remember(config.paddingTop) { mutableFloatStateOf(config.paddingTop.toFloat()) }
+    var paddingBottom by remember(config.paddingBottom) { mutableFloatStateOf(config.paddingBottom.toFloat()) }
+    var paddingLeft by remember(config.paddingLeft) { mutableFloatStateOf(config.paddingLeft.toFloat()) }
+    var paddingRight by remember(config.paddingRight) { mutableFloatStateOf(config.paddingRight.toFloat()) }
+
+    // Title padding state
+    var titleTopSpacing by remember(config.titleTopSpacing) { mutableIntStateOf(config.titleTopSpacing) }
+    var titleBottomSpacing by remember(config.titleBottomSpacing) { mutableIntStateOf(config.titleBottomSpacing) }
+
+    // Header padding state
+    var headerPaddingTop by remember(config.headerPaddingTop) { mutableFloatStateOf(config.headerPaddingTop.toFloat()) }
+    var headerPaddingBottom by remember(config.headerPaddingBottom) { mutableFloatStateOf(config.headerPaddingBottom.toFloat()) }
+    var headerPaddingLeft by remember(config.headerPaddingLeft) { mutableFloatStateOf(config.headerPaddingLeft.toFloat()) }
+    var headerPaddingRight by remember(config.headerPaddingRight) { mutableFloatStateOf(config.headerPaddingRight.toFloat()) }
+
+    // Footer padding state
+    var footerPaddingTop by remember(config.footerPaddingTop) { mutableFloatStateOf(config.footerPaddingTop.toFloat()) }
+    var footerPaddingBottom by remember(config.footerPaddingBottom) { mutableFloatStateOf(config.footerPaddingBottom.toFloat()) }
+    var footerPaddingLeft by remember(config.footerPaddingLeft) { mutableFloatStateOf(config.footerPaddingLeft.toFloat()) }
+    var footerPaddingRight by remember(config.footerPaddingRight) { mutableFloatStateOf(config.footerPaddingRight.toFloat()) }
+
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .verticalScroll(rememberScrollState()),
+    ) {
+        // 正文边距
+        SectionTitle(title = stringResource(R.string.body_padding))
+        PaddingSliders(
+            top = paddingTop, bottom = paddingBottom,
+            left = paddingLeft, right = paddingRight,
+            onTopChange = {
+                paddingTop = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingTop(it.toInt())))
+            },
+            onBottomChange = {
+                paddingBottom = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingBottom(it.toInt())))
+            },
+            onLeftChange = {
+                paddingLeft = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingLeft(it.toInt())))
+            },
+            onRightChange = {
+                paddingRight = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingRight(it.toInt())))
+            },
+        )
+
+        // 标题边距
+        SectionTitle(title = stringResource(R.string.title_padding))
+        TinySliderSettingItem(
+            title = stringResource(R.string.title_margin_top),
+            value = titleTopSpacing.toFloat(),
+            valueRange = 0f..200f,
+            onValueChange = { value ->
+                titleTopSpacing = value.toInt()
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleTopSpacing(titleTopSpacing)))
+            },
+        )
+        TinySliderSettingItem(
+            title = stringResource(R.string.title_margin_bottom),
+            value = titleBottomSpacing.toFloat(),
+            valueRange = 0f..200f,
+            onValueChange = { value ->
+                titleBottomSpacing = value.toInt()
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleBottomSpacing(titleBottomSpacing)))
+            },
+        )
+
+        // 页眉边距
+        SectionTitle(title = stringResource(R.string.header_padding))
+        PaddingSliders(
+            top = headerPaddingTop, bottom = headerPaddingBottom,
+            left = headerPaddingLeft, right = headerPaddingRight,
+            onTopChange = {
+                headerPaddingTop = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingTop(it.toInt())))
+            },
+            onBottomChange = {
+                headerPaddingBottom = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingBottom(it.toInt())))
+            },
+            onLeftChange = {
+                headerPaddingLeft = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingLeft(it.toInt())))
+            },
+            onRightChange = {
+                headerPaddingRight = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingRight(it.toInt())))
+            },
+        )
+
+        // 页脚边距
+        SectionTitle(title = stringResource(R.string.footer_padding))
+        PaddingSliders(
+            top = footerPaddingTop, bottom = footerPaddingBottom,
+            left = footerPaddingLeft, right = footerPaddingRight,
+            onTopChange = {
+                footerPaddingTop = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingTop(it.toInt())))
+            },
+            onBottomChange = {
+                footerPaddingBottom = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingBottom(it.toInt())))
+            },
+            onLeftChange = {
+                footerPaddingLeft = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingLeft(it.toInt())))
+            },
+            onRightChange = {
+                footerPaddingRight = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingRight(it.toInt())))
+            },
+        )
+
+        Spacer(Modifier.height(8.dp))
+    }
+}
+
+// endregion
+
+// region Hoisted modal sheet composables
+
+@Composable
+internal fun TypographyColorPickerSheet(
+    target: TypographyColorTarget,
+    config: ReadSheetConfigUiState,
+    onDismiss: () -> Unit,
+    onIntent: (ReadBookIntent) -> Unit,
+) {
+    val context = LocalContext.current
+    val initialColor = remember(target, config) {
+        when (target) {
+            TypographyColorTarget.Text -> config.textColor
+            TypographyColorTarget.TextAccent -> config.textAccentColor
+            TypographyColorTarget.Title ->
+                if (config.titleColor != 0) config.titleColor else config.textColorDay
+            TypographyColorTarget.TitleNight ->
+                if (config.titleColorNight != 0) config.titleColorNight else config.textColorNight
+            TypographyColorTarget.Header ->
+                if (config.tipHeaderColor != 0) config.tipHeaderColor
+                else config.textColorDay
+            TypographyColorTarget.HeaderNight ->
+                if (config.tipHeaderColorNight != 0) config.tipHeaderColorNight
+                else config.textColorNight
+            TypographyColorTarget.Footer ->
+                if (config.tipFooterColor != 0) config.tipFooterColor
+                else config.textColorDay
+            TypographyColorTarget.FooterNight ->
+                if (config.tipFooterColorNight != 0) config.tipFooterColorNight
+                else config.textColorNight
+            TypographyColorTarget.Divider -> when (config.tipDividerColor) {
+                -1 -> context.getCompatColor(R.color.divider)
+                0 -> config.textColor
+                else -> config.tipDividerColor
+            }
+        }
+    }
+
+    ColorPickerSheet(
+        show = true,
+        initialColor = initialColor,
+        onDismissRequest = onDismiss,
+        onColorSelected = { color ->
+            when (target) {
+                TypographyColorTarget.Text ->
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextColor(color)))
+                TypographyColorTarget.TextAccent ->
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextAccentColor(color)))
+                TypographyColorTarget.Title ->
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleColor(color)))
+                TypographyColorTarget.TitleNight ->
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleColorNight(color)))
+                TypographyColorTarget.Header ->
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderColor(color)))
+                TypographyColorTarget.HeaderNight ->
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderColorNight(color)))
+                TypographyColorTarget.Footer ->
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterColor(color)))
+                TypographyColorTarget.FooterNight ->
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterColorNight(color)))
+                TypographyColorTarget.Divider ->
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipDividerColor(color)))
+            }
+            onDismiss()
+        },
+    )
+}
+
+@Composable
+internal fun TypographyHeaderFontSelectSheet(
+    config: ReadSheetConfigUiState,
+    onDismiss: () -> Unit,
+    onIntent: (ReadBookIntent) -> Unit,
+) {
+    TypographyFontSelectSheet(
+        selectedFontPath = ReadBookConfig.headerFont,
+        onSelectFont = { fileDoc ->
+            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderFont(fileDoc.uri.toString())))
+            onIntent(ReadBookIntent.SaveReadStyleConfig)
+            onDismiss()
+        },
+        onSelectSystemTypeface = {
+            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderFont("")))
+            onIntent(ReadBookIntent.SaveReadStyleConfig)
+            onDismiss()
+        },
+        onDismiss = onDismiss,
+    )
+}
+
+@Composable
+internal fun TypographyFooterFontSelectSheet(
+    config: ReadSheetConfigUiState,
+    onDismiss: () -> Unit,
+    onIntent: (ReadBookIntent) -> Unit,
+) {
+    TypographyFontSelectSheet(
+        selectedFontPath = ReadBookConfig.footerFont,
+        onSelectFont = { fileDoc ->
+            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterFont(fileDoc.uri.toString())))
+            onIntent(ReadBookIntent.SaveReadStyleConfig)
+            onDismiss()
+        },
+        onSelectSystemTypeface = {
+            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterFont("")))
+            onIntent(ReadBookIntent.SaveReadStyleConfig)
+            onDismiss()
+        },
+        onDismiss = onDismiss,
+    )
+}
+
+@Composable
+internal fun TypographyCustomTipDialog(
+    target: CustomTipTarget,
+    onDismiss: () -> Unit,
+    onIntent: (ReadBookIntent) -> Unit,
+) {
+    val initialTemplate = remember(target) { target.customTemplate }
+
+    CustomTipDialog(
+        show = true,
+        initialTemplate = initialTemplate,
+        onConfirm = { template ->
+            applyTipValue(target, ReadBookConfig.tipCustom, onIntent)
+            target.applyTemplate(template, onIntent)
+            onDismiss()
+        },
+        onDismissRequest = onDismiss,
+    )
+}
+
+// endregion
+
+// region Private helpers
+
+@Composable
+private fun TypographyFontSelectSheet(
+    selectedFontPath: String,
+    onSelectFont: (io.legado.app.utils.FileDoc) -> Unit,
+    onSelectSystemTypeface: (Int) -> Unit,
+    onDismiss: () -> Unit,
+) {
+    val readSettingsRepository: ReadSettingsRepository = koinInject()
+    val preferences by readSettingsRepository.preferences.collectAsStateWithLifecycle(
+        initialValue = null
+    )
+    val fontFolderState = remember(preferences) {
+        val pref = preferences
+        if (pref == null) {
+            FontFolderState.Loading
+        } else {
+            FontFolderState.Loaded(pref.fontFolder.takeIf { it.isNotEmpty() }?.toUri())
+        }
+    }
+    val systemTypefaces = stringArrayResource(R.array.system_typefaces)
+
+    FontSelectSheet(
+        show = true,
+        title = stringResource(R.string.select_font),
+        folderState = fontFolderState,
+        selectedFontPath = selectedFontPath,
+        onDismissRequest = onDismiss,
+        onSelectFont = onSelectFont,
+        onSelectSystemTypeface = onSelectSystemTypeface,
+        onOpenFolderPicker = { /* handled by FontSelectSheet internally */ },
+        systemTypefaces = systemTypefaces,
+    )
+}
+
+private fun applyTipValue(
+    target: CustomTipTarget,
+    value: Int,
+    onIntent: (ReadBookIntent) -> Unit,
+) {
+    val configUpdate = when (target) {
+        CustomTipTarget.HEADER_LEFT -> ConfigUpdate.TipHeaderLeft(value)
+        CustomTipTarget.HEADER_MIDDLE -> ConfigUpdate.TipHeaderMiddle(value)
+        CustomTipTarget.HEADER_RIGHT -> ConfigUpdate.TipHeaderRight(value)
+        CustomTipTarget.FOOTER_LEFT -> ConfigUpdate.TipFooterLeft(value)
+        CustomTipTarget.FOOTER_MIDDLE -> ConfigUpdate.TipFooterMiddle(value)
+        CustomTipTarget.FOOTER_RIGHT -> ConfigUpdate.TipFooterRight(value)
+    }
+    onIntent(ReadBookIntent.UpdateConfig(configUpdate))
+}
+
+// endregion

+ 23 - 3
app/src/main/java/io/legado/app/ui/book/toc/TocHierarchy.kt

@@ -2,40 +2,57 @@ package io.legado.app.ui.book.toc
 
 
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
 
 
-/** Filters a pre-order flattened TOC while preserving nested collapse boundaries. */
+/**
+ * 过滤折叠后的目录列表,保留被折叠卷的标题,隐藏其子项。
+ *
+ * 支持两种场景:
+ * 1. 扁平 TXT 分卷(所有 tocLevel 均为 0):通过 isVolume 标志界定卷边界,
+ *    折叠某卷后隐藏该卷到下一卷之间的所有非卷章节。
+ * 2. 多层级目录(存在 tocLevel > 0 的章节):用栈记录被折叠的祖先层级,
+ *    栈非空时跳过所有更深层的子项。
+ */
 internal fun filterCollapsedToc(
 internal fun filterCollapsedToc(
     items: List<TocDomainItem>,
     items: List<TocDomainItem>,
     collapsedIds: Set<Int>,
     collapsedIds: Set<Int>,
 ): List<TocDomainItem> = buildList {
 ): List<TocDomainItem> = buildList {
+    // 扁平 TXT 分卷场景:无层级信息,仅靠 isVolume 判定卷边界
     if (items.none { it.chapter.tocLevel > 0 }) {
     if (items.none { it.chapter.tocLevel > 0 }) {
         var collapsed = false
         var collapsed = false
         for (item in items) {
         for (item in items) {
             if (item.chapter.isVolume) {
             if (item.chapter.isVolume) {
+                // 遇到卷名:始终显示,并更新折叠状态
                 add(item)
                 add(item)
                 collapsed = item.chapter.index in collapsedIds
                 collapsed = item.chapter.index in collapsedIds
             } else if (!collapsed) {
             } else if (!collapsed) {
+                // 非卷名章节:仅当未被折叠时显示
                 add(item)
                 add(item)
             }
             }
         }
         }
         return@buildList
         return@buildList
     }
     }
+    // 多层级目录场景:用栈追踪被折叠的祖先层级
     val collapsedAncestorLevels = ArrayDeque<Int>()
     val collapsedAncestorLevels = ArrayDeque<Int>()
     for (item in items) {
     for (item in items) {
         val level = item.chapter.tocLevel
         val level = item.chapter.tocLevel
+        // 弹出层级 >= 当前的祖先,因为当前项不属于它们的子树
         while (collapsedAncestorLevels.lastOrNull()?.let { it >= level } == true) {
         while (collapsedAncestorLevels.lastOrNull()?.let { it >= level } == true) {
             collapsedAncestorLevels.removeLast()
             collapsedAncestorLevels.removeLast()
         }
         }
-
+        // 栈为空说明不在任何被折叠的卷内,显示该项
         if (collapsedAncestorLevels.isEmpty()) {
         if (collapsedAncestorLevels.isEmpty()) {
             add(item)
             add(item)
         }
         }
+        // 当前项是被折叠的卷:将其层级入栈,后续更深层级的子项将被隐藏
         if (item.chapter.isVolume && item.chapter.index in collapsedIds) {
         if (item.chapter.isVolume && item.chapter.index in collapsedIds) {
             collapsedAncestorLevels.addLast(level)
             collapsedAncestorLevels.addLast(level)
         }
         }
     }
     }
 }
 }
 
 
-/** Reverses siblings at every level and keeps every parent before its descendants. */
+/**
+ * 反转目录层级:在每个层级内反转兄弟节点顺序,保持父节点在子节点之前。
+ * 根据 tocLevel 重建树结构,递归反转后扁平化输出。
+ */
 internal fun List<BookChapter>.reverseTocHierarchy(): List<BookChapter> {
 internal fun List<BookChapter>.reverseTocHierarchy(): List<BookChapter> {
     if (size < 2) return this
     if (size < 2) return this
 
 
@@ -44,9 +61,11 @@ internal fun List<BookChapter>.reverseTocHierarchy(): List<BookChapter> {
         val children: MutableList<TocNode> = mutableListOf(),
         val children: MutableList<TocNode> = mutableListOf(),
     )
     )
 
 
+    // 根据 tocLevel 重建树:ancestors 栈深度对应当前层级
     val roots = mutableListOf<TocNode>()
     val roots = mutableListOf<TocNode>()
     val ancestors = ArrayDeque<TocNode>()
     val ancestors = ArrayDeque<TocNode>()
     for (chapter in this) {
     for (chapter in this) {
+        // 弹出栈直到栈深度 == 当前 tocLevel,使当前节点挂到正确的父节点下
         while (ancestors.size > chapter.tocLevel) {
         while (ancestors.size > chapter.tocLevel) {
             ancestors.removeLast()
             ancestors.removeLast()
         }
         }
@@ -55,6 +74,7 @@ internal fun List<BookChapter>.reverseTocHierarchy(): List<BookChapter> {
         ancestors.addLast(node)
         ancestors.addLast(node)
     }
     }
 
 
+    // 递归反转:每个层级的 children 逆序输出,保持父在子前
     fun MutableList<TocNode>.appendReversedTo(result: MutableList<BookChapter>) {
     fun MutableList<TocNode>.appendReversedTo(result: MutableList<BookChapter>) {
         asReversed().forEach { node ->
         asReversed().forEach { node ->
             result.add(node.chapter)
             result.add(node.chapter)

+ 7 - 0
app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt

@@ -444,6 +444,13 @@ fun TocScreen(
                                     onIntent(TocIntent.ReverseToc)
                                     onIntent(TocIntent.ReverseToc)
                                 }
                                 }
                             )
                             )
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.update_toc),
+                                onClick = {
+                                    dismiss()
+                                    onIntent(TocIntent.UpdateToc)
+                                }
+                            )
                             PillDivider()
                             PillDivider()
                             RoundDropdownMenuItem(
                             RoundDropdownMenuItem(
                                 text = stringResource(R.string.replace_rule_title),
                                 text = stringResource(R.string.replace_rule_title),

+ 50 - 0
app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt

@@ -8,6 +8,7 @@ import androidx.lifecycle.SavedStateHandle
 import androidx.lifecycle.viewModelScope
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.base.BaseRuleViewModel
 import io.legado.app.base.BaseRuleViewModel
+import io.legado.app.constant.AppLog
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
@@ -18,12 +19,17 @@ import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.ContentProcessor
+import io.legado.app.help.book.isEpub
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.isMobi
 import io.legado.app.help.bookmark.BookmarkExporter
 import io.legado.app.help.bookmark.BookmarkExporter
 import io.legado.app.model.CacheBook
 import io.legado.app.model.CacheBook
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
 import io.legado.app.model.cache.CacheBookDownloadState
 import io.legado.app.model.cache.CacheBookDownloadState
+import io.legado.app.model.localBook.EpubFile
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.LocalBook
+import io.legado.app.model.localBook.MobiFile
+import io.legado.app.model.webBook.WebBook
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.ui.widget.components.list.SelectableItem
 import io.legado.app.ui.widget.components.list.SelectableItem
@@ -132,6 +138,7 @@ sealed interface TocIntent {
     data object ToggleSplitLongChapter : TocIntent
     data object ToggleSplitLongChapter : TocIntent
     data object ExpandAllVolumes : TocIntent
     data object ExpandAllVolumes : TocIntent
     data object CollapseAllVolumes : TocIntent
     data object CollapseAllVolumes : TocIntent
+    data object UpdateToc : TocIntent
 }
 }
 
 
 sealed interface TocEffect {
 sealed interface TocEffect {
@@ -543,6 +550,7 @@ class TocViewModel(
             TocIntent.ToggleSplitLongChapter -> toggleSplitLongChapter()
             TocIntent.ToggleSplitLongChapter -> toggleSplitLongChapter()
             TocIntent.ExpandAllVolumes -> expandAllVolumes()
             TocIntent.ExpandAllVolumes -> expandAllVolumes()
             TocIntent.CollapseAllVolumes -> collapseAllVolumes()
             TocIntent.CollapseAllVolumes -> collapseAllVolumes()
+            TocIntent.UpdateToc -> updateToc()
         }
         }
     }
     }
 
 
@@ -555,6 +563,48 @@ class TocViewModel(
         //bookState.value = newBook
         //bookState.value = newBook
     }
     }
 
 
+    fun updateToc() = execute {
+        val book = bookState.value ?: return@execute
+        if (book.isLocal) {
+            if (book.isEpub) {
+                BookHelp.clearCache(book)
+                EpubFile.clear()
+            }
+            if (book.isMobi) {
+                MobiFile.clear()
+            }
+            kotlin.runCatching {
+                LocalBook.getChapterList(book).let {
+                    appDb.bookChapterDao.delByBook(book.bookUrl)
+                    appDb.bookChapterDao.insert(*it.toTypedArray())
+                    appDb.bookDao.update(book)
+                }
+            }.onFailure {
+                AppLog.put("LoadTocError:${it.localizedMessage}", it)
+                _effects.tryEmit(TocEffect.ShowMessage(it.localizedMessage ?: "Error"))
+            }
+        } else {
+            val source = appDb.bookSourceDao.getBookSource(book.origin)
+            source?.let {
+                val oldBook = book.copy()
+                WebBook.getChapterListAwait(it, book, true)
+                    .onSuccess { cList ->
+                        if (oldBook.bookUrl == book.bookUrl) {
+                            appDb.bookDao.update(book)
+                        } else {
+                            appDb.bookDao.replace(oldBook, book)
+                            BookHelp.updateCacheFolder(oldBook, book)
+                        }
+                        appDb.bookChapterDao.delByBook(oldBook.bookUrl)
+                        appDb.bookChapterDao.insert(*cList.toTypedArray())
+                    }.onFailure {
+                        AppLog.put("LoadTocError:${it.localizedMessage}", it)
+                        _effects.tryEmit(TocEffect.ShowMessage(it.localizedMessage ?: "Error"))
+                    }
+            }
+        }
+    }
+
     fun toggleUseReplace() {
     fun toggleUseReplace() {
         viewModelScope.launch {
         viewModelScope.launch {
             readSettingsRepository.setTocUiUseReplace(!tocPreferences.value.useReplace)
             readSettingsRepository.setTocUiUseReplace(!tocPreferences.value.useReplace)

+ 14 - 10
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt

@@ -224,7 +224,7 @@ fun TxtRuleScreen(
         onConfirm = { onIntent(TxtTocRuleIntent.SaveImportedRules) },
         onConfirm = { onIntent(TxtTocRuleIntent.SaveImportedRules) },
         itemTitle = { rule -> rule.name },
         itemTitle = { rule -> rule.name },
         itemSubtitle = { rule ->
         itemSubtitle = { rule ->
-            rule.rule.takeIf { it.isNotBlank() }
+            rule.chapterRule.takeIf { it.isNotBlank() }
         }
         }
     )
     )
 
 
@@ -252,8 +252,9 @@ fun TxtRuleScreen(
         show = showEditSheet,
         show = showEditSheet,
         rule = editingRule,
         rule = editingRule,
         title = stringResource(R.string.txt_toc_rule),
         title = stringResource(R.string.txt_toc_rule),
-        label1 = stringResource(R.string.regex),
+        label1 = stringResource(R.string.chapter_rule),
         label2 = stringResource(R.string.example),
         label2 = stringResource(R.string.example),
+        label3 = stringResource(R.string.volume_rule),
         onDismissRequest = {
         onDismissRequest = {
             showEditSheet = false
             showEditSheet = false
             editingRule = null
             editingRule = null
@@ -265,12 +266,12 @@ fun TxtRuleScreen(
                     return@RuleEditSheet
                     return@RuleEditSheet
                 }
                 }
 
 
-                updatedRule.rule.isBlank() -> {
+                updatedRule.chapterRule.isBlank() -> {
                     context.toastOnUi(R.string.cannot_empty)
                     context.toastOnUi(R.string.cannot_empty)
                     return@RuleEditSheet
                     return@RuleEditSheet
                 }
                 }
 
 
-                runCatching { Regex(updatedRule.rule) }.isFailure -> {
+                runCatching { Regex(updatedRule.chapterRule) }.isFailure -> {
                     context.toastOnUi(R.string.invalid_format)
                     context.toastOnUi(R.string.invalid_format)
                     return@RuleEditSheet
                     return@RuleEditSheet
                 }
                 }
@@ -284,18 +285,21 @@ fun TxtRuleScreen(
         toFields = { r ->
         toFields = { r ->
             RuleEditFields(
             RuleEditFields(
                 name = r?.name ?: "",
                 name = r?.name ?: "",
-                rule1 = r?.rule ?: "",
-                rule2 = r?.example ?: ""
+                rule1 = r?.chapterRule ?: "",
+                rule2 = r?.example ?: "",
+                rule3 = r?.volumeRule ?: ""
             )
             )
         },
         },
         fromFields = { fields, old ->
         fromFields = { fields, old ->
             old?.copy(
             old?.copy(
                 name = fields.name,
                 name = fields.name,
-                rule = fields.rule1,
+                chapterRule = fields.rule1,
+                volumeRule = fields.rule3,
                 example = fields.rule2
                 example = fields.rule2
             ) ?: TxtTocRule(
             ) ?: TxtTocRule(
                 name = fields.name,
                 name = fields.name,
-                rule = fields.rule1,
+                chapterRule = fields.rule1,
+                volumeRule = fields.rule3,
                 example = fields.rule2
                 example = fields.rule2
             )
             )
         },
         },
@@ -393,7 +397,7 @@ fun TxtRuleScreen(
                 items(rules, key = { it.id }) { item ->
                 items(rules, key = { it.id }) { item ->
 
 
                     val isItemHighLighted = if (isPickMode) {
                     val isItemHighLighted = if (isPickMode) {
-                        item.rule.rule == initialRule
+                        item.rule.chapterRule == initialRule
                     } else {
                     } else {
                         selectedIds.contains(item.id)
                         selectedIds.contains(item.id)
                     }
                     }
@@ -424,7 +428,7 @@ fun TxtRuleScreen(
                         inSelectionMode = inSelectionMode,
                         inSelectionMode = inSelectionMode,
                         onToggleSelection = {
                         onToggleSelection = {
                             if (isPickMode) {
                             if (isPickMode) {
-                                onPickRule.invoke(item.rule.rule)
+                                onPickRule.invoke(item.rule.chapterRule)
                                 onBackClick()
                                 onBackClick()
                             } else {
                             } else {
                                 onIntent(TxtTocRuleIntent.ToggleSelection(item.id))
                                 onIntent(TxtTocRuleIntent.ToggleSelection(item.id))

+ 4 - 1
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt

@@ -161,7 +161,10 @@ class TxtTocRuleViewModel(
     }
     }
 
 
     override fun hasChanged(newRule: TxtTocRule, oldRule: TxtTocRule): Boolean {
     override fun hasChanged(newRule: TxtTocRule, oldRule: TxtTocRule): Boolean {
-        return newRule.name != oldRule.name || newRule.rule != oldRule.rule || newRule.enable != oldRule.enable
+        return newRule.name != oldRule.name ||
+            newRule.chapterRule != oldRule.chapterRule ||
+            newRule.volumeRule != oldRule.volumeRule ||
+            newRule.enable != oldRule.enable
     }
     }
 
 
     override suspend fun findOldRule(newRule: TxtTocRule): TxtTocRule? {
     override suspend fun findOldRule(newRule: TxtTocRule): TxtTocRule? {

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewContract.kt

@@ -24,7 +24,7 @@ data class TxtTocRulePreviewUiState(
         else rules.filter {
         else rules.filter {
             it.rule.name.contains(searchQuery, ignoreCase = true) ||
             it.rule.name.contains(searchQuery, ignoreCase = true) ||
                     it.rule.example?.contains(searchQuery, ignoreCase = true) == true ||
                     it.rule.example?.contains(searchQuery, ignoreCase = true) == true ||
-                    it.rule.rule.contains(searchQuery, ignoreCase = true)
+                    it.rule.chapterRule.contains(searchQuery, ignoreCase = true)
         }.toImmutableList()
         }.toImmutableList()
 }
 }
 
 

+ 13 - 9
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewScreen.kt

@@ -135,8 +135,9 @@ fun TxtTocRulePreviewScreen(
             show = true,
             show = true,
             rule = rule,
             rule = rule,
             title = stringResource(R.string.txt_toc_rule),
             title = stringResource(R.string.txt_toc_rule),
-            label1 = stringResource(R.string.regex),
+            label1 = stringResource(R.string.chapter_rule),
             label2 = stringResource(R.string.example),
             label2 = stringResource(R.string.example),
+            label3 = stringResource(R.string.volume_rule),
             onDismissRequest = { onIntent(TxtTocRulePreviewIntent.DismissEditDialog) },
             onDismissRequest = { onIntent(TxtTocRulePreviewIntent.DismissEditDialog) },
             onSave = { updatedRule ->
             onSave = { updatedRule ->
                 onIntent(TxtTocRulePreviewIntent.SaveRule(updatedRule))
                 onIntent(TxtTocRulePreviewIntent.SaveRule(updatedRule))
@@ -146,18 +147,21 @@ fun TxtTocRulePreviewScreen(
             toFields = { r ->
             toFields = { r ->
                 RuleEditFields(
                 RuleEditFields(
                     name = r?.name ?: "",
                     name = r?.name ?: "",
-                    rule1 = r?.rule ?: "",
-                    rule2 = r?.example ?: ""
+                    rule1 = r?.chapterRule ?: "",
+                    rule2 = r?.example ?: "",
+                    rule3 = r?.volumeRule ?: ""
                 )
                 )
             },
             },
             fromFields = { fields, old ->
             fromFields = { fields, old ->
                 old?.copy(
                 old?.copy(
                     name = fields.name,
                     name = fields.name,
-                    rule = fields.rule1,
+                    chapterRule = fields.rule1,
+                    volumeRule = fields.rule3,
                     example = fields.rule2
                     example = fields.rule2
                 ) ?: TxtTocRule(
                 ) ?: TxtTocRule(
                     name = fields.name,
                     name = fields.name,
-                    rule = fields.rule1,
+                    chapterRule = fields.rule1,
+                    volumeRule = fields.rule3,
                     example = fields.rule2
                     example = fields.rule2
                 )
                 )
             }
             }
@@ -251,9 +255,9 @@ fun TxtTocRulePreviewScreen(
                     itemsIndexed(displayRules, key = { _, item -> item.rule.id }) { _, item ->
                     itemsIndexed(displayRules, key = { _, item -> item.rule.id }) { _, item ->
                         RulePreviewCard(
                         RulePreviewCard(
                             item = item,
                             item = item,
-                            isSelected = item.rule.rule == state.selectedRule,
+                            isSelected = item.rule.chapterRule == state.selectedRule,
                             onClick = {
                             onClick = {
-                                onIntent(TxtTocRulePreviewIntent.SelectRule(item.rule.rule))
+                                onIntent(TxtTocRulePreviewIntent.SelectRule(item.rule.chapterRule))
                                 if (item.totalCount > 0) {
                                 if (item.totalCount > 0) {
                                     onIntent(TxtTocRulePreviewIntent.ShowChapterList(item))
                                     onIntent(TxtTocRulePreviewIntent.ShowChapterList(item))
                                 }
                                 }
@@ -274,9 +278,9 @@ fun TxtTocRulePreviewScreen(
                     itemsIndexed(displayRules, key = { _, item -> item.rule.id }) { _, item ->
                     itemsIndexed(displayRules, key = { _, item -> item.rule.id }) { _, item ->
                         RulePreviewListItem(
                         RulePreviewListItem(
                             item = item,
                             item = item,
-                            isSelected = item.rule.rule == state.selectedRule,
+                            isSelected = item.rule.chapterRule == state.selectedRule,
                             onClick = {
                             onClick = {
-                                onIntent(TxtTocRulePreviewIntent.SelectRule(item.rule.rule))
+                                onIntent(TxtTocRulePreviewIntent.SelectRule(item.rule.chapterRule))
                                 if (item.totalCount > 0) {
                                 if (item.totalCount > 0) {
                                     onIntent(TxtTocRulePreviewIntent.ShowChapterList(item))
                                     onIntent(TxtTocRulePreviewIntent.ShowChapterList(item))
                                 }
                                 }

+ 4 - 4
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewViewModel.kt

@@ -146,7 +146,7 @@ class TxtTocRulePreviewViewModel(
         if (book == null) return TocRulePreviewItem(rule = tocRule)
         if (book == null) return TocRulePreviewItem(rule = tocRule)
         return try {
         return try {
             val pattern = try {
             val pattern = try {
-                tocRule.rule.toPattern(Pattern.MULTILINE)
+                tocRule.chapterRule.toPattern(Pattern.MULTILINE)
             } catch (e: PatternSyntaxException) {
             } catch (e: PatternSyntaxException) {
                 return TocRulePreviewItem(rule = tocRule, totalCount = 0)
                 return TocRulePreviewItem(rule = tocRule, totalCount = 0)
             }
             }
@@ -164,12 +164,12 @@ class TxtTocRulePreviewViewModel(
 
 
     private suspend fun saveRuleAndRefresh(updatedRule: TxtTocRule) {
     private suspend fun saveRuleAndRefresh(updatedRule: TxtTocRule) {
         // Validate
         // Validate
-        if (updatedRule.name.isBlank() || updatedRule.rule.isBlank()) {
+        if (updatedRule.name.isBlank() || updatedRule.chapterRule.isBlank()) {
             _effects.tryEmit(TxtTocRulePreviewEffect.ShowToast(context.getString(R.string.cannot_empty)))
             _effects.tryEmit(TxtTocRulePreviewEffect.ShowToast(context.getString(R.string.cannot_empty)))
             _uiState.update { it.copy(editingRule = null) }
             _uiState.update { it.copy(editingRule = null) }
             return
             return
         }
         }
-        if (runCatching { updatedRule.rule.toPattern(Pattern.MULTILINE) }.isFailure) {
+        if (runCatching { updatedRule.chapterRule.toPattern(Pattern.MULTILINE) }.isFailure) {
             _effects.tryEmit(TxtTocRulePreviewEffect.ShowToast(context.getString(R.string.invalid_format)))
             _effects.tryEmit(TxtTocRulePreviewEffect.ShowToast(context.getString(R.string.invalid_format)))
             _uiState.update { it.copy(editingRule = null) }
             _uiState.update { it.copy(editingRule = null) }
             return
             return
@@ -219,7 +219,7 @@ class TxtTocRulePreviewViewModel(
             repository.insert(*defaultRules.toTypedArray())
             repository.insert(*defaultRules.toTypedArray())
             rules = repository.all()
             rules = repository.all()
         }
         }
-        return rules.filter { it.rule.isNotBlank() }.sortedBy { it.serialNumber }
+        return rules.filter { it.chapterRule.isNotBlank() }.sortedBy { it.serialNumber }
     }
     }
 
 
     private suspend fun analyzeWithPattern(book: Book, pattern: Pattern): Pair<List<String>, Int> {
     private suspend fun analyzeWithPattern(book: Book, pattern: Pattern): Pair<List<String>, Int> {

+ 6 - 1
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt

@@ -33,13 +33,16 @@ class ReplaceRuleActivity : BaseComposeActivity() {
 
 
     companion object {
     companion object {
         const val EXTRA_START_ROUTE = "start_route"
         const val EXTRA_START_ROUTE = "start_route"
+        const val EXTRA_BOOK_URL = "book_url"
         fun startIntent(
         fun startIntent(
             context: Context,
             context: Context,
-            editRoute: ReplaceEditRoute? = null
+            editRoute: ReplaceEditRoute? = null,
+            bookUrl: String? = null
         ): Intent = Intent(context, ReplaceRuleActivity::class.java).apply {
         ): Intent = Intent(context, ReplaceRuleActivity::class.java).apply {
             editRoute?.let {
             editRoute?.let {
                 putExtra(EXTRA_START_ROUTE, Json.encodeToString(it))
                 putExtra(EXTRA_START_ROUTE, Json.encodeToString(it))
             }
             }
+            bookUrl?.let { putExtra(EXTRA_BOOK_URL, it) }
         }
         }
     }
     }
 
 
@@ -55,6 +58,7 @@ class ReplaceRuleActivity : BaseComposeActivity() {
             action()
             action()
         }
         }
             val startRouteJson = intent.getStringExtra(EXTRA_START_ROUTE)
             val startRouteJson = intent.getStringExtra(EXTRA_START_ROUTE)
+            val bookUrl = intent.getStringExtra(EXTRA_BOOK_URL)
             val backStack = rememberNavBackStack(
             val backStack = rememberNavBackStack(
                 remember(startRouteJson) {
                 remember(startRouteJson) {
                     resolveStartRoute(startRouteJson)
                     resolveStartRoute(startRouteJson)
@@ -149,6 +153,7 @@ class ReplaceRuleActivity : BaseComposeActivity() {
                 entryProvider = entryProvider {
                 entryProvider = entryProvider {
                     entry<ReplaceRuleRoute> {
                     entry<ReplaceRuleRoute> {
                         ReplaceRuleRouteScreen(
                         ReplaceRuleRouteScreen(
+                            bookUrl = bookUrl,
                             onBackClick = { leaveScreen { finish() } },
                             onBackClick = { leaveScreen { finish() } },
                             onNavigateToEdit = { route -> backStack.add(route) }
                             onNavigateToEdit = { route -> backStack.add(route) }
                         )
                         )

+ 33 - 2
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleContract.kt

@@ -2,11 +2,16 @@ package io.legado.app.ui.replace
 
 
 import android.net.Uri
 import android.net.Uri
 import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.entities.ReplaceRule
+import io.legado.app.ui.book.read.ContentProcessConfigUiState
+import io.legado.app.ui.book.read.ContentProcessItemUi
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.list.InteractionState
 import io.legado.app.ui.widget.components.list.InteractionState
 import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.ui.widget.components.list.SelectableItem
 import io.legado.app.ui.widget.components.list.SelectableItem
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
 
 
 @Immutable
 @Immutable
 data class ReplaceRuleItemUi(
 data class ReplaceRuleItemUi(
@@ -41,13 +46,23 @@ data class ReplaceRuleItemUi(
     )
     )
 }
 }
 
 
+@Stable
 data class ReplaceRuleUiState(
 data class ReplaceRuleUiState(
     override val items: List<ReplaceRuleItemUi> = emptyList(),
     override val items: List<ReplaceRuleItemUi> = emptyList(),
     override val selectedIds: Set<Long> = emptySet(),
     override val selectedIds: Set<Long> = emptySet(),
     override val searchKey: String = "",
     override val searchKey: String = "",
     val sortMode: String = "desc",
     val sortMode: String = "desc",
     val selectedGroup: String? = null,
     val selectedGroup: String? = null,
-    val interaction: InteractionState = InteractionState()
+    val interaction: InteractionState = InteractionState(),
+    // Book-specific state (only active when bookUrl is provided)
+    val bookUrl: String? = null,
+    val replaceEnabled: Boolean = false,
+    val effectiveRules: ImmutableList<ReplaceRule> = persistentListOf(),
+    val chineseConvertActive: Boolean = false,
+    val reSegmentActive: Boolean = false,
+    val contentProcessState: ContentProcessConfigUiState = ContentProcessConfigUiState(),
+    val showEffectiveReplaces: Boolean = false,
+    val showContentProcesses: Boolean = false,
 ) : ListUiState<ReplaceRuleItemUi> {
 ) : ListUiState<ReplaceRuleItemUi> {
     override val isSearch: Boolean get() = interaction.isSearchMode
     override val isSearch: Boolean get() = interaction.isSearchMode
     override val isLoading: Boolean get() = interaction.isUploading
     override val isLoading: Boolean get() = interaction.isUploading
@@ -87,6 +102,22 @@ sealed interface ReplaceRuleIntent {
     data class AddGroup(val group: String) : ReplaceRuleIntent
     data class AddGroup(val group: String) : ReplaceRuleIntent
     data class DeleteGroup(val group: String) : ReplaceRuleIntent
     data class DeleteGroup(val group: String) : ReplaceRuleIntent
     data class UpGroup(val oldGroup: String, val newGroup: String?) : ReplaceRuleIntent
     data class UpGroup(val oldGroup: String, val newGroup: String?) : ReplaceRuleIntent
+    // Book-specific intents (only when bookUrl is provided)
+    data class InitBookData(val bookUrl: String) : ReplaceRuleIntent
+    data object ToggleReplaceEnable : ReplaceRuleIntent
+    data object ShowEffectiveReplaces : ReplaceRuleIntent
+    data object ShowContentProcesses : ReplaceRuleIntent
+    data object DismissEffectiveReplaces : ReplaceRuleIntent
+    data object DismissContentProcesses : ReplaceRuleIntent
+    data class DisableEffectiveRule(val rule: ReplaceRule) : ReplaceRuleIntent
+    data object DisableChineseConverter : ReplaceRuleIntent
+    data object DisableReSegment : ReplaceRuleIntent
+    data class ToggleContentProcess(val id: String, val enabled: Boolean) : ReplaceRuleIntent
+    data class RequestDeleteContentProcess(val item: ContentProcessItemUi) : ReplaceRuleIntent
+    data object ConfirmDeleteContentProcess : ReplaceRuleIntent
+    data object DismissDeleteContentProcess : ReplaceRuleIntent
 }
 }
 
 
-sealed interface ReplaceRuleEffect
+sealed interface ReplaceRuleEffect {
+    data class OpenReplaceEditor(val id: Long, val pattern: String?) : ReplaceRuleEffect
+}

+ 75 - 1
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.replace
 package io.legado.app.ui.replace
 
 
 import android.content.ClipData
 import android.content.ClipData
+import androidx.appcompat.app.AppCompatActivity
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.ExperimentalFoundationApi
@@ -44,6 +45,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.base.BaseRuleEvent
 import io.legado.app.base.BaseRuleEvent
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.entities.ReplaceRule
+import io.legado.app.ui.book.read.ReadBookIntent
+import io.legado.app.ui.book.read.sheet.ContentProcessesSheet
+import io.legado.app.ui.book.read.sheet.EffectiveReplacesSheet
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.ActionItem
@@ -62,6 +66,7 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.rules.RuleListScaffold
 import io.legado.app.ui.widget.components.rules.RuleListScaffold
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.showHelp
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 import org.koin.androidx.compose.koinViewModel
@@ -74,6 +79,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
 @Composable
 @Composable
 fun ReplaceRuleRouteScreen(
 fun ReplaceRuleRouteScreen(
     viewModel: ReplaceRuleViewModel = koinViewModel(),
     viewModel: ReplaceRuleViewModel = koinViewModel(),
+    bookUrl: String? = null,
     onBackClick: () -> Unit,
     onBackClick: () -> Unit,
     onNavigateToEdit: (ReplaceEditRoute) -> Unit,
     onNavigateToEdit: (ReplaceEditRoute) -> Unit,
 ) {
 ) {
@@ -81,6 +87,12 @@ fun ReplaceRuleRouteScreen(
     val importState by viewModel.importState.collectAsStateWithLifecycle()
     val importState by viewModel.importState.collectAsStateWithLifecycle()
     val groups by viewModel.allGroups.collectAsStateWithLifecycle()
     val groups by viewModel.allGroups.collectAsStateWithLifecycle()
 
 
+    LaunchedEffect(bookUrl) {
+        if (!bookUrl.isNullOrBlank()) {
+            viewModel.onIntent(ReplaceRuleIntent.InitBookData(bookUrl))
+        }
+    }
+
     ReplaceRuleScreen(
     ReplaceRuleScreen(
         state = uiState,
         state = uiState,
         importState = importState,
         importState = importState,
@@ -351,6 +363,23 @@ fun ReplaceRuleScreen(
         },
         },
         snackbarHostState = snackbarHostState,
         snackbarHostState = snackbarHostState,
         dropDownMenuContent = { dismiss ->
         dropDownMenuContent = { dismiss ->
+            // Book-specific items (only when bookUrl is provided)
+            if (state.bookUrl != null) {
+                RoundDropdownMenuItem(
+                    text = stringResource(R.string.replace_purify),
+                    isSelected = state.replaceEnabled,
+                    onClick = { onIntent(ReplaceRuleIntent.ToggleReplaceEnable) },
+                )
+                RoundDropdownMenuItem(
+                    text = stringResource(R.string.effective_replaces),
+                    onClick = { dismiss(); onIntent(ReplaceRuleIntent.ShowEffectiveReplaces) }
+                )
+                RoundDropdownMenuItem(
+                    text = stringResource(R.string.content_processes),
+                    onClick = { dismiss(); onIntent(ReplaceRuleIntent.ShowContentProcesses) }
+                )
+                PillDivider()
+            }
             RoundDropdownMenuItem(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.import_str),
                 text = stringResource(R.string.import_str),
                 onClick = { showImportSheet = true; dismiss() }
                 onClick = { showImportSheet = true; dismiss() }
@@ -361,7 +390,7 @@ fun ReplaceRuleScreen(
             )
             )
             RoundDropdownMenuItem(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.help),
                 text = stringResource(R.string.help),
-                onClick = { /*TODO*/ dismiss() }
+                onClick = { dismiss(); (context as? AppCompatActivity)?.showHelp("replaceRuleHelp") }
             )
             )
             PillDivider()
             PillDivider()
             RoundDropdownMenuItem(
             RoundDropdownMenuItem(
@@ -500,4 +529,49 @@ fun ReplaceRuleScreen(
             }
             }
         }
         }
     }
     }
+
+    // Book-specific sheets
+    EffectiveReplacesSheet(
+        show = state.showEffectiveReplaces,
+        effectiveRules = state.effectiveRules,
+        chineseConvertActive = state.chineseConvertActive,
+        reSegmentActive = state.reSegmentActive,
+        onDismissRequest = { onIntent(ReplaceRuleIntent.DismissEffectiveReplaces) },
+        onOpenReplaceEditor = { id, pattern ->
+            onIntent(ReplaceRuleIntent.DismissEffectiveReplaces)
+            onNavigateToEdit(ReplaceEditRoute(id = id, pattern = pattern))
+        },
+        onReplaceRuleChanged = { /* rules updated via DB flow */ },
+        onNavigateToTextEffects = {
+            onIntent(ReplaceRuleIntent.DismissEffectiveReplaces)
+        },
+        onOpenContentProcesses = {
+            onIntent(ReplaceRuleIntent.DismissEffectiveReplaces)
+            onIntent(ReplaceRuleIntent.ShowContentProcesses)
+        },
+        onDisableRule = { onIntent(ReplaceRuleIntent.DisableEffectiveRule(it)) },
+        onDisableChineseConverter = { onIntent(ReplaceRuleIntent.DisableChineseConverter) },
+        onDisableReSegment = { onIntent(ReplaceRuleIntent.DisableReSegment) },
+    )
+
+    val contentProcessIntent: (ReadBookIntent) -> Unit = { readBookIntent ->
+        when (readBookIntent) {
+            is ReadBookIntent.ToggleContentProcess ->
+                onIntent(ReplaceRuleIntent.ToggleContentProcess(readBookIntent.id, readBookIntent.enabled))
+            is ReadBookIntent.RequestDeleteContentProcess ->
+                onIntent(ReplaceRuleIntent.RequestDeleteContentProcess(readBookIntent.item))
+            ReadBookIntent.ConfirmDeleteContentProcess ->
+                onIntent(ReplaceRuleIntent.ConfirmDeleteContentProcess)
+            ReadBookIntent.DismissDeleteContentProcess ->
+                onIntent(ReplaceRuleIntent.DismissDeleteContentProcess)
+            else -> {}
+        }
+    }
+
+    ContentProcessesSheet(
+        show = state.showContentProcesses,
+        state = state.contentProcessState,
+        onIntent = contentProcessIntent,
+        onDismissRequest = { onIntent(ReplaceRuleIntent.DismissContentProcesses) },
+    )
 }
 }

+ 178 - 1
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt

@@ -7,24 +7,39 @@ import io.legado.app.base.BaseRuleViewModel
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb
+import io.legado.app.data.entities.BookContentProcess
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.entities.ReplaceRule
+import io.legado.app.data.repository.ReadSettingsRepository
 import io.legado.app.data.repository.ReplaceRuleRepository
 import io.legado.app.data.repository.ReplaceRuleRepository
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.data.repository.UploadRepository
+import io.legado.app.domain.gateway.BookContentProcessGateway
+import io.legado.app.domain.model.TextProcessAction
+import io.legado.app.domain.model.TextProcessAnchor
 import io.legado.app.help.ReplaceAnalyzer
 import io.legado.app.help.ReplaceAnalyzer
+import io.legado.app.help.config.AppConfig
+import io.legado.app.model.ReadBook
+import io.legado.app.ui.book.read.ContentProcessConfigUiState
+import io.legado.app.ui.book.read.ContentProcessItemUi
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.list.InteractionState
 import io.legado.app.ui.widget.components.list.InteractionState
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.putPrefString
 import io.legado.app.utils.putPrefString
 import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.splitNotBlank
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flatMapLatest
@@ -36,7 +51,9 @@ import kotlinx.coroutines.withContext
 
 
 class ReplaceRuleViewModel(
 class ReplaceRuleViewModel(
     application: Application,
     application: Application,
-    uploadRepository: UploadRepository
+    uploadRepository: UploadRepository,
+    private val bookContentProcessGateway: BookContentProcessGateway,
+    private val readSettingsRepository: ReadSettingsRepository,
 ) : BaseRuleViewModel<ReplaceRuleItemUi, ReplaceRule, Long, ReplaceRuleUiState>(
 ) : BaseRuleViewModel<ReplaceRuleItemUi, ReplaceRule, Long, ReplaceRuleUiState>(
     application,
     application,
     ReplaceRuleUiState(interaction = InteractionState(isLoading = true)),
     ReplaceRuleUiState(interaction = InteractionState(isLoading = true)),
@@ -48,6 +65,46 @@ class ReplaceRuleViewModel(
     private val _group = MutableStateFlow<String?>(null)
     private val _group = MutableStateFlow<String?>(null)
     val group = _group.asStateFlow()
     val group = _group.asStateFlow()
 
 
+    private val _effects = MutableSharedFlow<ReplaceRuleEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    private data class BookSpecificState(
+        val bookUrl: String? = null,
+        val replaceEnabled: Boolean = false,
+        val effectiveRules: ImmutableList<ReplaceRule> = persistentListOf(),
+        val chineseConvertActive: Boolean = false,
+        val reSegmentActive: Boolean = false,
+        val contentProcessState: ContentProcessConfigUiState = ContentProcessConfigUiState(),
+        val showEffectiveReplaces: Boolean = false,
+        val showContentProcesses: Boolean = false,
+    )
+
+    private val _bookState = MutableStateFlow(BookSpecificState())
+
+    // Must use `by lazy` — super.uiState depends on rawDataFlow (declared below),
+    // and eager initialization would access it before construction completes.
+    override val uiState: StateFlow<ReplaceRuleUiState> by lazy {
+        combine(
+            super.uiState,
+            _bookState
+        ) { baseState, bookState ->
+            baseState.copy(
+                bookUrl = bookState.bookUrl,
+                replaceEnabled = bookState.replaceEnabled,
+                effectiveRules = bookState.effectiveRules,
+                chineseConvertActive = bookState.chineseConvertActive,
+                reSegmentActive = bookState.reSegmentActive,
+                contentProcessState = bookState.contentProcessState,
+                showEffectiveReplaces = bookState.showEffectiveReplaces,
+                showContentProcesses = bookState.showContentProcesses,
+            )
+        }.stateIn(
+            scope = viewModelScope,
+            started = SharingStarted.WhileSubscribed(5000),
+            initialValue = initialState
+        )
+    }
+
     val allGroups: StateFlow<List<String>> = repository.flowGroups()
     val allGroups: StateFlow<List<String>> = repository.flowGroups()
         .stateIn(
         .stateIn(
             scope = viewModelScope,
             scope = viewModelScope,
@@ -105,6 +162,46 @@ class ReplaceRuleViewModel(
             is ReplaceRuleIntent.AddGroup -> addGroup(intent.group)
             is ReplaceRuleIntent.AddGroup -> addGroup(intent.group)
             is ReplaceRuleIntent.DeleteGroup -> delGroup(intent.group)
             is ReplaceRuleIntent.DeleteGroup -> delGroup(intent.group)
             is ReplaceRuleIntent.UpGroup -> upGroup(intent.oldGroup, intent.newGroup)
             is ReplaceRuleIntent.UpGroup -> upGroup(intent.oldGroup, intent.newGroup)
+            // Book-specific
+            is ReplaceRuleIntent.InitBookData -> initBookData(intent.bookUrl)
+            ReplaceRuleIntent.ToggleReplaceEnable -> toggleReplaceEnable()
+            ReplaceRuleIntent.ShowEffectiveReplaces -> _bookState.update { it.copy(showEffectiveReplaces = true) }
+            ReplaceRuleIntent.ShowContentProcesses -> {
+                _bookState.update { it.copy(showContentProcesses = true) }
+                loadContentProcesses()
+            }
+            ReplaceRuleIntent.DismissEffectiveReplaces -> _bookState.update { it.copy(showEffectiveReplaces = false) }
+            ReplaceRuleIntent.DismissContentProcesses -> _bookState.update { it.copy(showContentProcesses = false) }
+            is ReplaceRuleIntent.DisableEffectiveRule -> viewModelScope.launch {
+                repository.insert(intent.rule.copy(isEnabled = false))
+            }
+            ReplaceRuleIntent.DisableChineseConverter -> {
+                viewModelScope.launch { readSettingsRepository.setChineseConverterType(0) }
+                _bookState.update { it.copy(chineseConvertActive = false) }
+            }
+            ReplaceRuleIntent.DisableReSegment -> {
+                ReadBook.book?.setReSegment(false)
+                ReadBook.loadContent(false)
+                _bookState.update { it.copy(reSegmentActive = false) }
+            }
+            is ReplaceRuleIntent.ToggleContentProcess -> viewModelScope.launch {
+                bookContentProcessGateway.setEnabled(intent.id, intent.enabled)
+            }
+            is ReplaceRuleIntent.RequestDeleteContentProcess -> _bookState.update {
+                it.copy(contentProcessState = it.contentProcessState.copy(deleteItem = intent.item))
+            }
+            ReplaceRuleIntent.ConfirmDeleteContentProcess -> {
+                val deleteItem = _bookState.value.contentProcessState.deleteItem
+                if (deleteItem != null) {
+                    viewModelScope.launch { bookContentProcessGateway.delete(deleteItem.id) }
+                    _bookState.update {
+                        it.copy(contentProcessState = it.contentProcessState.copy(deleteItem = null))
+                    }
+                }
+            }
+            ReplaceRuleIntent.DismissDeleteContentProcess -> _bookState.update {
+                it.copy(contentProcessState = it.contentProcessState.copy(deleteItem = null))
+            }
         }
         }
     }
     }
 
 
@@ -309,4 +406,84 @@ class ReplaceRuleViewModel(
 
 
     private fun upGroup(oldGroup: String, newGroup: String?) =
     private fun upGroup(oldGroup: String, newGroup: String?) =
         viewModelScope.launch { repository.upGroup(oldGroup, newGroup) }
         viewModelScope.launch { repository.upGroup(oldGroup, newGroup) }
+
+    //region Book-specific methods
+
+    fun emitOpenReplaceEditor(id: Long, pattern: String?) {
+        _effects.tryEmit(ReplaceRuleEffect.OpenReplaceEditor(id, pattern))
+    }
+
+    private fun initBookData(bookUrl: String) {
+        val book = ReadBook.book
+        val textChapter = ReadBook.curTextChapter
+        if (book != null && book.bookUrl == bookUrl) {
+            val effectiveRules = textChapter?.effectiveReplaceRules.orEmpty().toImmutableList()
+            val replaceEnabled = book.getUseReplaceRule(AppConfig.replaceEnableDefault)
+            val chineseConvertActive = readSettingsRepository.currentSettings.chineseConverterType > 0
+            val reSegmentActive = book.getReSegment()
+            _bookState.update {
+                it.copy(
+                    bookUrl = bookUrl,
+                    replaceEnabled = replaceEnabled,
+                    effectiveRules = effectiveRules,
+                    chineseConvertActive = chineseConvertActive,
+                    reSegmentActive = reSegmentActive,
+                )
+            }
+        }
+    }
+
+    private fun toggleReplaceEnable() {
+        ReadBook.book?.let { book ->
+            val enabled = !book.getUseReplaceRule(AppConfig.replaceEnableDefault)
+            book.setUseReplaceRule(enabled)
+            ReadBook.saveRead()
+            _bookState.update { it.copy(replaceEnabled = enabled) }
+        }
+    }
+
+    private fun loadContentProcesses() {
+        val book = ReadBook.book ?: return
+        val chapterIndex = ReadBook.durChapterIndex
+        _bookState.update {
+            it.copy(contentProcessState = it.contentProcessState.copy(isLoading = true, errorMessage = null))
+        }
+        viewModelScope.launch(Dispatchers.IO) {
+            runCatching {
+                bookContentProcessGateway.getForChapter(book.bookUrl, chapterIndex)
+                    .mapNotNull { it.toContentProcessItemUi() }
+                    .toImmutableList()
+            }.onSuccess { items ->
+                _bookState.update {
+                    it.copy(contentProcessState = it.contentProcessState.copy(isLoading = false, items = items))
+                }
+            }.onFailure { error ->
+                _bookState.update {
+                    it.copy(contentProcessState = it.contentProcessState.copy(
+                        isLoading = false,
+                        errorMessage = error.localizedMessage
+                    ))
+                }
+            }
+        }
+    }
+
+    private fun BookContentProcess.toContentProcessItemUi(): ContentProcessItemUi? {
+        val anchor = GSON.fromJsonObject<TextProcessAnchor>(anchorJson).getOrNull()
+            ?: return null
+        val action = GSON.fromJsonObject<TextProcessAction>(actionJson).getOrNull()
+            ?: return null
+        return ContentProcessItemUi(
+            id = id,
+            kind = kind,
+            actionType = action.type,
+            enabled = enabled && status == BookContentProcess.STATUS_ACTIVE,
+            chapterIndex = chapterIndex ?: anchor.chapterIndex,
+            selectedText = anchor.selectedText,
+            replacementText = action.replacement ?: action.text.orEmpty(),
+            createdAt = createdAt,
+        )
+    }
+
+    //endregion
 }
 }

+ 14 - 1
app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt

@@ -49,6 +49,7 @@ data class RuleEditFields(
     val name: String = "",
     val name: String = "",
     val rule1: String = "",
     val rule1: String = "",
     val rule2: String = "",
     val rule2: String = "",
+    val rule3: String = "",
     val extra: String = ""
     val extra: String = ""
 )
 )
 
 
@@ -69,6 +70,7 @@ fun <T> RuleEditSheet(
     title: String,
     title: String,
     label1: String,
     label1: String,
     label2: String,
     label2: String,
+    label3: String? = null,
     onDismissRequest: () -> Unit,
     onDismissRequest: () -> Unit,
     onSave: (T) -> Unit,
     onSave: (T) -> Unit,
     onCopy: (T) -> Unit,
     onCopy: (T) -> Unit,
@@ -84,6 +86,7 @@ fun <T> RuleEditSheet(
     var name by remember(show, rule) { mutableStateOf(initialFields.name) }
     var name by remember(show, rule) { mutableStateOf(initialFields.name) }
     var rule1 by remember(show, rule) { mutableStateOf(initialFields.rule1) }
     var rule1 by remember(show, rule) { mutableStateOf(initialFields.rule1) }
     var rule2 by remember(show, rule) { mutableStateOf(initialFields.rule2) }
     var rule2 by remember(show, rule) { mutableStateOf(initialFields.rule2) }
+    var rule3 by remember(show, rule) { mutableStateOf(initialFields.rule3) }
 
 
     var showMenu by remember(show, rule) { mutableStateOf(false) }
     var showMenu by remember(show, rule) { mutableStateOf(false) }
 
 
@@ -97,7 +100,7 @@ fun <T> RuleEditSheet(
     val exampleIsEmptyStr = stringResource(R.string.example_is_empty)
     val exampleIsEmptyStr = stringResource(R.string.example_is_empty)
     val invalidRegexStr = stringResource(R.string.invalid_regex)
     val invalidRegexStr = stringResource(R.string.invalid_regex)
 
 
-    fun getCurrentEntity() = fromFields(RuleEditFields(name, rule1, rule2), rule)
+    fun getCurrentEntity() = fromFields(RuleEditFields(name, rule1, rule2, rule3), rule)
 
 
     fun runTest() {
     fun runTest() {
         if (testRunning) return
         if (testRunning) return
@@ -174,6 +177,7 @@ fun <T> RuleEditSheet(
                                     name = fields.name
                                     name = fields.name
                                     rule1 = fields.rule1
                                     rule1 = fields.rule1
                                     rule2 = fields.rule2
                                     rule2 = fields.rule2
+                                    rule3 = fields.rule3
                                 }
                                 }
                             }
                             }
                             showMenu = false
                             showMenu = false
@@ -216,6 +220,15 @@ fun <T> RuleEditSheet(
                     label = label2,
                     label = label2,
                     minLines = 3
                     minLines = 3
                 )
                 )
+                label3?.let { label ->
+                    AppTextField(
+                        modifier = Modifier.fillMaxWidth(),
+                        value = rule3,
+                        onValueChange = { rule3 = it },
+                        backgroundColor = LegadoTheme.colorScheme.surface,
+                        label = label
+                    )
+                }
 
 
                 if (showTestButton) {
                 if (showTestButton) {
                     // Test error
                     // Test error

+ 3 - 3
app/src/main/res/layout/dialog_read_info.xml

@@ -84,7 +84,7 @@
             app:max="30"
             app:max="30"
             app:min="8"
             app:min="8"
             app:orientation="horizontal"
             app:orientation="horizontal"
-            app:title="@string/header_font_size" />
+            app:title="@string/title_font_size" />
 
 
     </LinearLayout>
     </LinearLayout>
 
 
@@ -99,14 +99,14 @@
             android:layout_width="wrap_content"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_height="wrap_content"
             style="@style/Widget.Material3Expressive.Button.OutlinedButton"
             style="@style/Widget.Material3Expressive.Button.OutlinedButton"
-            android:text="@string/header_color" />
+            android:text="@string/title_color" />
 
 
         <io.legado.app.ui.widget.AccentColorButton
         <io.legado.app.ui.widget.AccentColorButton
             android:id="@+id/btn_footer_color"
             android:id="@+id/btn_footer_color"
             android:layout_width="wrap_content"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_height="wrap_content"
             style="@style/Widget.Material3Expressive.Button.OutlinedButton"
             style="@style/Widget.Material3Expressive.Button.OutlinedButton"
-            android:text="@string/footer_color" />
+            android:text="@string/title_color" />
 
 
         <io.legado.app.ui.widget.AccentColorButton
         <io.legado.app.ui.widget.AccentColorButton
             android:id="@+id/btn_divider_color"
             android:id="@+id/btn_divider_color"

+ 14 - 9
app/src/main/res/values-zh-rCN/strings.xml

@@ -356,6 +356,10 @@
     <string name="padding_left">左边距</string>
     <string name="padding_left">左边距</string>
     <string name="padding_right">右边距</string>
     <string name="padding_right">右边距</string>
     <string name="padding_format">上: %1$d  下: %2$d  左: %3$d  右: %4$d</string>
     <string name="padding_format">上: %1$d  下: %2$d  左: %3$d  右: %4$d</string>
+    <string name="body_padding">正文边距</string>
+    <string name="title_padding">标题边距</string>
+    <string name="header_padding">页眉边距</string>
+    <string name="footer_padding">页脚边距</string>
     <string name="check_book_source">校验书源</string>
     <string name="check_book_source">校验书源</string>
     <string name="check_select_source">校验所选</string>
     <string name="check_select_source">校验所选</string>
     <string name="progress_show">%1$s      进度 %2$d/%3$d</string>
     <string name="progress_show">%1$s      进度 %2$d/%3$d</string>
@@ -662,7 +666,6 @@
     <string name="bg_color">背景颜色</string>
     <string name="bg_color">背景颜色</string>
     <string name="use_bg_image">使用背景图片</string>
     <string name="use_bg_image">使用背景图片</string>
     <string name="text_color">正文</string>
     <string name="text_color">正文</string>
-    <string name="footer_font_size">页脚字号</string>
     <string name="select_image">选择图片</string>
     <string name="select_image">选择图片</string>
     <string name="group_manage">分组管理</string>
     <string name="group_manage">分组管理</string>
     <string name="group_add">新建分组</string>
     <string name="group_add">新建分组</string>
@@ -755,6 +758,8 @@
     <string name="import_default_rule">导入默认规则</string>
     <string name="import_default_rule">导入默认规则</string>
     <string name="name">名称</string>
     <string name="name">名称</string>
     <string name="regex">正则</string>
     <string name="regex">正则</string>
+    <string name="chapter_rule">章节正则</string>
+    <string name="volume_rule">分卷正则</string>
     <string name="highlight_rule_config">高亮规则</string>
     <string name="highlight_rule_config">高亮规则</string>
     <string name="new_rule">新增规则</string>
     <string name="new_rule">新增规则</string>
     <string name="edit_rule">编辑规则</string>
     <string name="edit_rule">编辑规则</string>
@@ -864,6 +869,7 @@
     <string name="auto_change_source">自动换源</string>
     <string name="auto_change_source">自动换源</string>
     <string name="text_full_justify">文字两端对齐</string>
     <string name="text_full_justify">文字两端对齐</string>
     <string name="text_bottom_justify">文字底部对齐</string>
     <string name="text_bottom_justify">文字底部对齐</string>
+    <string name="text_alignment">对齐</string>
     <string name="auto_page_speed">自动翻页速度</string>
     <string name="auto_page_speed">自动翻页速度</string>
     <string name="sort_by_url">地址排序</string>
     <string name="sort_by_url">地址排序</string>
     <string name="backup_summary">本地和 WebDav 一起备份</string>
     <string name="backup_summary">本地和 WebDav 一起备份</string>
@@ -911,7 +917,7 @@
     <string name="read_config_regex_desc">根据正则表达式更换字体与颜色</string>
     <string name="read_config_regex_desc">根据正则表达式更换字体与颜色</string>
     <string name="read_config_header_footer_desc">配置页眉页脚模块、显示规则和颜色</string>
     <string name="read_config_header_footer_desc">配置页眉页脚模块、显示规则和颜色</string>
     <string name="read_config_header_footer_padding_desc">调整页眉页脚上下左右边距</string>
     <string name="read_config_header_footer_padding_desc">调整页眉页脚上下左右边距</string>
-    <string name="read_config_divider_line">分割线</string>
+    <string name="read_config_divider_line">分隔线</string>
     <string name="group_name">分组名称</string>
     <string name="group_name">分组名称</string>
     <string name="note_content">备注</string>
     <string name="note_content">备注</string>
     <string name="replace_enable_default_t">默认启用替换净化</string>
     <string name="replace_enable_default_t">默认启用替换净化</string>
@@ -976,7 +982,7 @@
     <string name="next_page">下一页</string>
     <string name="next_page">下一页</string>
     <string name="prev_page">上一页</string>
     <string name="prev_page">上一页</string>
     <string name="non_action">无操作</string>
     <string name="non_action">无操作</string>
-    <string name="body_title">正文标题</string>
+    <string name="body_title">标题</string>
     <string name="show_hide">显示/隐藏</string>
     <string name="show_hide">显示/隐藏</string>
     <string name="header_footer">信息</string>
     <string name="header_footer">信息</string>
     <string name="rule_subscription">规则</string>
     <string name="rule_subscription">规则</string>
@@ -1548,7 +1554,7 @@
     <string name="theme_config_reset_success">已恢复默认主题配置</string>
     <string name="theme_config_reset_success">已恢复默认主题配置</string>
     <string name="input_value_range">输入数值(%1$d-%2$d)</string>
     <string name="input_value_range">输入数值(%1$d-%2$d)</string>
     <string name="change_icon_success">图标已修改</string>
     <string name="change_icon_success">图标已修改</string>
-    <string name="replace_rule_title_setting">配置替换规则</string>
+    <string name="replace_rule_title_setting">替换规则</string>
     <string name="more_setting">更多</string>
     <string name="more_setting">更多</string>
     <string name="read_chapter_total">共 %d 章</string>
     <string name="read_chapter_total">共 %d 章</string>
     <string name="read_chapter_index">已读 %d 章</string>
     <string name="read_chapter_index">已读 %d 章</string>
@@ -1801,6 +1807,9 @@
 	<string name="subtitle_scale">次行缩放</string>
 	<string name="subtitle_scale">次行缩放</string>
 	<string name="subtitle_margin">次行距离</string>
 	<string name="subtitle_margin">次行距离</string>
 	<string name="heading_spacing">标题段距</string>
 	<string name="heading_spacing">标题段距</string>
+	<string name="title_segment">分段</string>
+	<string name="title_spacing">间距</string>
+	<string name="title_dedup">去重</string>
     <string name="follow_read_background">跟随阅读背景</string>
     <string name="follow_read_background">跟随阅读背景</string>
     <string name="bookshelf_total_count">共%1$d本</string>
     <string name="bookshelf_total_count">共%1$d本</string>
     <string name="bookshelf_selected_count">已选%1$d本</string>
     <string name="bookshelf_selected_count">已选%1$d本</string>
@@ -1849,11 +1858,7 @@
     <string name="border_style">边框样式</string>
     <string name="border_style">边框样式</string>
     <string name="solid_border">实线</string>
     <string name="solid_border">实线</string>
     <string name="dashed_border">虚线</string>
     <string name="dashed_border">虚线</string>
-    <string name="header_color">页眉</string>
-    <string name="footer_color">页脚</string>
-    <string name="header_font">设置字体</string>
-    <string name="footer_font">页脚字体</string>
-    <string name="header_font_size">设置字号</string>
+    <string name="apply_header_style">应用页眉字体样式</string>
     <string name="source_tab_start">启动</string>
     <string name="source_tab_start">启动</string>
     <string name="r_startHtml">启动页html文本</string>
     <string name="r_startHtml">启动页html文本</string>
     <string name="r_startStyle">启动页style文本</string>
     <string name="r_startStyle">启动页style文本</string>

+ 1 - 7
app/src/main/res/values-zh-rHK/strings.xml

@@ -594,7 +594,6 @@
     <string name="bg_image">背景圖片</string>
     <string name="bg_image">背景圖片</string>
     <string name="bg_color">背景顏色</string>
     <string name="bg_color">背景顏色</string>
     <string name="text_color">文字顏色</string>
     <string name="text_color">文字顏色</string>
-    <string name="footer_font_size">页脚字号</string>
     <string name="select_image">選擇圖片</string>
     <string name="select_image">選擇圖片</string>
     <string name="group_manage">分組管理</string>
     <string name="group_manage">分組管理</string>
     <string name="group_select">分組選擇</string>
     <string name="group_select">分組選擇</string>
@@ -843,7 +842,7 @@
     <string name="next_page">下一頁</string>
     <string name="next_page">下一頁</string>
     <string name="prev_page">上一頁</string>
     <string name="prev_page">上一頁</string>
     <string name="non_action">無操作</string>
     <string name="non_action">無操作</string>
-    <string name="body_title">正文標題</string>
+    <string name="body_title">標題</string>
     <string name="show_hide">顯示/隱藏</string>
     <string name="show_hide">顯示/隱藏</string>
     <string name="header_footer">頁眉<![CDATA[&]]>頁腳</string>
     <string name="header_footer">頁眉<![CDATA[&]]>頁腳</string>
     <string name="rule_subscription">規則訂閱</string>
     <string name="rule_subscription">規則訂閱</string>
@@ -1338,11 +1337,6 @@
     <string name="border_style">边框样式</string>
     <string name="border_style">边框样式</string>
     <string name="solid_border">实线</string>
     <string name="solid_border">实线</string>
     <string name="dashed_border">虚线</string>
     <string name="dashed_border">虚线</string>
-    <string name="header_color">页眉</string>
-    <string name="footer_color">页脚</string>
-    <string name="header_font">设置字体</string>
-    <string name="footer_font">页脚字体</string>
-    <string name="header_font_size">设置字号</string>
     <string name="source_tab_start">启动</string>
     <string name="source_tab_start">启动</string>
     <string name="r_startHtml">启动页html文本</string>
     <string name="r_startHtml">启动页html文本</string>
     <string name="r_startStyle">启动页style文本</string>
     <string name="r_startStyle">启动页style文本</string>

+ 1 - 7
app/src/main/res/values-zh-rTW/strings.xml

@@ -603,7 +603,6 @@
     <string name="bg_image">背景圖片</string>
     <string name="bg_image">背景圖片</string>
     <string name="bg_color">背景顏色</string>
     <string name="bg_color">背景顏色</string>
     <string name="text_color">文字顏色</string>
     <string name="text_color">文字顏色</string>
-    <string name="footer_font_size">页脚字号</string>
     <string name="select_image">選擇圖片</string>
     <string name="select_image">選擇圖片</string>
     <string name="group_manage">分組管理</string>
     <string name="group_manage">分組管理</string>
     <string name="group_select">分組選擇</string>
     <string name="group_select">分組選擇</string>
@@ -847,7 +846,7 @@
     <string name="next_page">下一頁</string>
     <string name="next_page">下一頁</string>
     <string name="prev_page">上一頁</string>
     <string name="prev_page">上一頁</string>
     <string name="non_action">無操作</string>
     <string name="non_action">無操作</string>
-    <string name="body_title">正文標題</string>
+    <string name="body_title">標題</string>
     <string name="show_hide">顯示/隱藏</string>
     <string name="show_hide">顯示/隱藏</string>
     <string name="header_footer">頁首<![CDATA[&]]>頁尾</string>
     <string name="header_footer">頁首<![CDATA[&]]>頁尾</string>
     <string name="rule_subscription">規則訂閱</string>
     <string name="rule_subscription">規則訂閱</string>
@@ -1342,11 +1341,6 @@
     <string name="border_style">边框样式</string>
     <string name="border_style">边框样式</string>
     <string name="solid_border">实线</string>
     <string name="solid_border">实线</string>
     <string name="dashed_border">虚线</string>
     <string name="dashed_border">虚线</string>
-    <string name="header_color">页眉</string>
-    <string name="footer_color">页脚</string>
-    <string name="header_font">设置字体</string>
-    <string name="footer_font">页脚字体</string>
-    <string name="header_font_size">设置字号</string>
     <string name="source_tab_start">启动</string>
     <string name="source_tab_start">启动</string>
     <string name="r_startHtml">启动页html文本</string>
     <string name="r_startHtml">启动页html文本</string>
     <string name="r_startStyle">启动页style文本</string>
     <string name="r_startStyle">启动页style文本</string>

+ 13 - 8
app/src/main/res/values/strings.xml

@@ -386,6 +386,10 @@
     <string name="padding_left">Left</string>
     <string name="padding_left">Left</string>
     <string name="padding_right">Right</string>
     <string name="padding_right">Right</string>
     <string name="padding_format">Top: %1$d  Bottom: %2$d  Left: %3$d  Right: %4$d</string>
     <string name="padding_format">Top: %1$d  Bottom: %2$d  Left: %3$d  Right: %4$d</string>
+    <string name="body_padding">Body Padding</string>
+    <string name="title_padding">Title Padding</string>
+    <string name="header_padding">Header Padding</string>
+    <string name="footer_padding">Footer Padding</string>
     <string name="check_book_source">Check book sources</string>
     <string name="check_book_source">Check book sources</string>
     <string name="check_select_source">Check the selected source</string>
     <string name="check_select_source">Check the selected source</string>
     <string name="progress_show">%1$s      Progress %2$d/%3$d</string>
     <string name="progress_show">%1$s      Progress %2$d/%3$d</string>
@@ -692,12 +696,7 @@
     <string name="bg_color">Background color</string>
     <string name="bg_color">Background color</string>
     <string name="use_bg_image">Use Background Image</string>
     <string name="use_bg_image">Use Background Image</string>
     <string name="text_color">Text Color</string>
     <string name="text_color">Text Color</string>
-    <string name="header_color">Header</string>
-    <string name="footer_color">Footer</string>
-    <string name="header_font">Header Font</string>
-    <string name="footer_font">Footer Font</string>
-    <string name="header_font_size">Header Font Size</string>
-    <string name="footer_font_size">Footer Font Size</string>
+    <string name="apply_header_style">Apply Header Font Style</string>
     <string name="select_image">Select a picture</string>
     <string name="select_image">Select a picture</string>
     <string name="group_manage">Group Management</string>
     <string name="group_manage">Group Management</string>
     <string name="group_add">New Group</string>
     <string name="group_add">New Group</string>
@@ -790,6 +789,8 @@
     <string name="import_default_rule">Import default rules</string>
     <string name="import_default_rule">Import default rules</string>
     <string name="name">Name</string>
     <string name="name">Name</string>
     <string name="regex">Regex</string>
     <string name="regex">Regex</string>
+    <string name="chapter_rule">Chapter Regex</string>
+    <string name="volume_rule">Volume Regex</string>
     <string name="highlight_rule_config">Highlight Rules</string>
     <string name="highlight_rule_config">Highlight Rules</string>
     <string name="new_rule">New Rule</string>
     <string name="new_rule">New Rule</string>
     <string name="edit_rule">Edit Rule</string>
     <string name="edit_rule">Edit Rule</string>
@@ -898,6 +899,7 @@
     <string name="auto_change_source">Change Source Automatically</string>
     <string name="auto_change_source">Change Source Automatically</string>
     <string name="text_full_justify">Text Justified</string>
     <string name="text_full_justify">Text Justified</string>
     <string name="text_bottom_justify">Text Align Bottom</string>
     <string name="text_bottom_justify">Text Align Bottom</string>
+    <string name="text_alignment">Alignment</string>
     <string name="auto_page_speed">Auto scroll speed</string>
     <string name="auto_page_speed">Auto scroll speed</string>
     <string name="sort_by_url">Sort by URL</string>
     <string name="sort_by_url">Sort by URL</string>
     <string name="backup_summary">Backup the local and WebDAV simultaneously</string>
     <string name="backup_summary">Backup the local and WebDAV simultaneously</string>
@@ -1069,7 +1071,7 @@
     <string name="content_src">Content source code</string>
     <string name="content_src">Content source code</string>
     <string name="list_src">List source code</string>
     <string name="list_src">List source code</string>
     <string name="title_font_size">Font Size</string>
     <string name="title_font_size">Font Size</string>
-    <string name="title_color">Title Color</string>
+    <string name="title_color">Color</string>
     <string name="reset_to_body_color">Reset to body text color</string>
     <string name="reset_to_body_color">Reset to body text color</string>
     <string name="title_margin_top">Margin Top</string>
     <string name="title_margin_top">Margin Top</string>
     <string name="title_margin_bottom">Margin Bottom</string>
     <string name="title_margin_bottom">Margin Bottom</string>
@@ -1609,7 +1611,7 @@
     <string name="theme_config_no_font_files">No font files</string>
     <string name="theme_config_no_font_files">No font files</string>
     <string name="input_value_range">Enter value (%1$d-%2$d)</string>
     <string name="input_value_range">Enter value (%1$d-%2$d)</string>
     <string name="change_icon_success">Icon Updated</string>
     <string name="change_icon_success">Icon Updated</string>
-    <string name="replace_rule_title_setting">Configure Replace Rules</string>
+    <string name="replace_rule_title_setting">Replace Rules</string>
     <string name="more_setting">More Settings</string>
     <string name="more_setting">More Settings</string>
     <string name="read_chapter_total">Total %d Chapters</string>
     <string name="read_chapter_total">Total %d Chapters</string>
     <string name="read_chapter_index">Read %d Chapters</string>
     <string name="read_chapter_index">Read %d Chapters</string>
@@ -1856,6 +1858,9 @@
     <string name="filter_show_not_in_shelf_only">Show Only Books Not in Shelf</string>
     <string name="filter_show_not_in_shelf_only">Show Only Books Not in Shelf</string>
     <string name="follow_read_background">Follow background</string>
     <string name="follow_read_background">Follow background</string>
     <string name="heading_spacing">Heading spacing</string>
     <string name="heading_spacing">Heading spacing</string>
+    <string name="title_segment">Segment</string>
+    <string name="title_spacing">Spacing</string>
+    <string name="title_dedup">Dedup</string>
     <string name="layout_setting">Layout settings</string>
     <string name="layout_setting">Layout settings</string>
     <string name="rule_segment">Rule</string>
     <string name="rule_segment">Rule</string>
     <string name="split_title">Split title</string>
     <string name="split_title">Split title</string>

+ 13 - 0
app/src/test/java/io/legado/app/ui/book/toc/TocHierarchyTest.kt

@@ -41,6 +41,19 @@ class TocHierarchyTest {
         assertEquals(listOf(0, 3, 4), filterCollapsedToc(items, setOf(0)).map { it.chapter.index })
         assertEquals(listOf(0, 3, 4), filterCollapsedToc(items, setOf(0)).map { it.chapter.index })
     }
     }
 
 
+    @Test
+    fun txtVolumesWithProperLevels_collapseUntilNextVolume() {
+        val items = listOf(
+            chapter(0, "第一卷", level = 0, isVolume = true),
+            chapter(1, "第一章", level = 1),
+            chapter(2, "第二章", level = 1),
+            chapter(3, "第二卷", level = 0, isVolume = true),
+            chapter(4, "第三章", level = 1),
+        ).map { TocDomainItem(it, it.title, DownloadState.LOCAL) }
+
+        assertEquals(listOf(0, 3, 4), filterCollapsedToc(items, setOf(0)).map { it.chapter.index })
+    }
+
     @Test
     @Test
     fun reverse_reversesSiblingsAtEveryLevelAndPreservesPreorder() {
     fun reverse_reversesSiblingsAtEveryLevelAndPreservesPreorder() {
         val reversed = chapters().reverseTocHierarchy()
         val reversed = chapters().reverseTocHierarchy()