├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ └── publish.yml ├── src-tauri ├── build.rs ├── icons │ ├── 32x32.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── .gitignore ├── src │ ├── commands │ │ ├── show_path_in_file_manager.rs │ │ ├── get_config.rs │ │ ├── get_background_dir_relative_path.rs │ │ ├── get_background_dir_abs_path.rs │ │ ├── save_config.rs │ │ ├── mod.rs │ │ ├── open_image.rs │ │ ├── get_image_infos.rs │ │ ├── get_manga_dir_data.rs │ │ ├── generate_background.rs │ │ └── remove_watermark.rs │ ├── errors.rs │ ├── utils.rs │ ├── types.rs │ ├── extensions.rs │ ├── config.rs │ ├── events.rs │ └── main.rs ├── capabilities │ └── default.json ├── tauri.conf.json └── Cargo.toml ├── src ├── main.ts ├── vite-env.d.ts ├── App.vue ├── assets │ └── vue.svg ├── components │ ├── RemoveProgress.vue │ ├── AboutDialog.vue │ ├── MangaDirIndicator.vue │ └── WatermarkCropper.vue ├── utils.ts ├── bindings.ts └── AppContent.vue ├── .prettierrc ├── tsconfig.node.json ├── .gitignore ├── index.html ├── eslint.config.js ├── uno.config.ts ├── tsconfig.json ├── LICENSE ├── package.json ├── vite.config.ts ├── components.d.ts ├── public ├── vite.svg └── tauri.svg ├── auto-imports.d.ts └── README.md /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build(); 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import 'virtual:uno.css' 4 | 5 | createApp(App).mount('#app') 6 | -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-watermark-remover/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "semi": false, 6 | "bracketSameLine": true, 7 | "endOfLine": "auto", 8 | "htmlWhitespaceSensitivity": "ignore" 9 | } 10 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | const component: DefineComponent<{}, {}, any> 6 | export default component 7 | } 8 | -------------------------------------------------------------------------------- /src-tauri/src/commands/show_path_in_file_manager.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | #[tauri::command(async)] 4 | #[specta::specta] 5 | pub fn show_path_in_file_manager(path: &str) { 6 | let path = PathBuf::from(path); 7 | showfile::show_path_in_file_manager(path); 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src-tauri/src/commands/get_config.rs: -------------------------------------------------------------------------------- 1 | use parking_lot::RwLock; 2 | use tauri::State; 3 | 4 | use crate::config::Config; 5 | 6 | #[tauri::command(async)] 7 | #[specta::specta] 8 | #[allow(clippy::needless_pass_by_value)] 9 | pub fn get_config(config: State<'_, RwLock>) -> Config { 10 | config.read().clone() 11 | } 12 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .vscode -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tauri + Vue + Typescript App 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src-tauri/src/commands/get_background_dir_relative_path.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::errors::CommandResult; 4 | use crate::utils; 5 | 6 | #[tauri::command(async)] 7 | #[specta::specta] 8 | pub fn get_background_dir_relative_path( 9 | manga_dir: &str, 10 | width: u32, 11 | height: u32, 12 | ) -> CommandResult { 13 | let relative_path = utils::get_background_dir_relative_path(manga_dir, width, height)?; 14 | Ok(relative_path) 15 | } 16 | -------------------------------------------------------------------------------- /src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:default", 10 | "shell:allow-open", 11 | "dialog:allow-open", 12 | "fs:default", 13 | { 14 | "identifier": "fs:scope", 15 | "allow": [ 16 | { 17 | "path": "$RESOURCE/**" 18 | } 19 | ] 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /src-tauri/src/commands/get_background_dir_abs_path.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use tauri::AppHandle; 4 | 5 | use crate::errors::CommandResult; 6 | use crate::utils; 7 | 8 | #[tauri::command(async)] 9 | #[specta::specta] 10 | #[allow(clippy::needless_pass_by_value)] 11 | pub fn get_background_dir_abs_path( 12 | app: AppHandle, 13 | manga_dir: &str, 14 | width: u32, 15 | height: u32, 16 | ) -> CommandResult { 17 | let abs_path = utils::get_background_dir_abs_path(&app, manga_dir, width, height)?; 18 | Ok(abs_path) 19 | } 20 | -------------------------------------------------------------------------------- /src-tauri/src/commands/save_config.rs: -------------------------------------------------------------------------------- 1 | use parking_lot::RwLock; 2 | use tauri::{AppHandle, State}; 3 | 4 | use crate::config::Config; 5 | use crate::errors::CommandResult; 6 | 7 | #[tauri::command(async)] 8 | #[specta::specta] 9 | #[allow(clippy::needless_pass_by_value)] 10 | pub fn save_config( 11 | app: AppHandle, 12 | config_state: State>, 13 | config: Config, 14 | ) -> CommandResult<()> { 15 | let mut config_state = config_state.write(); 16 | *config_state = config; 17 | config_state.save(&app)?; 18 | Ok(()) 19 | } 20 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import pluginVue from "eslint-plugin-vue"; 5 | 6 | 7 | /** @type {import('eslint').Linter.Config[]} */ 8 | export default [ 9 | {files: ["**/*.{js,mjs,cjs,ts,vue}"]}, 10 | {languageOptions: { globals: globals.browser }}, 11 | pluginJs.configs.recommended, 12 | ...tseslint.configs.recommended, 13 | ...pluginVue.configs["flat/essential"], 14 | {files: ["**/*.vue"], languageOptions: {parserOptions: {parser: tseslint.parser}}}, 15 | ]; -------------------------------------------------------------------------------- /src-tauri/src/errors.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use specta::Type; 3 | 4 | use crate::extensions::AnyhowErrorToStringChain; 5 | 6 | pub type CommandResult = Result; 7 | 8 | #[derive(Debug, Type)] 9 | pub struct CommandError(String); 10 | impl Serialize for CommandError { 11 | fn serialize(&self, serializer: S) -> Result 12 | where 13 | S: serde::Serializer, 14 | { 15 | serializer.serialize_str(&format!("{:#}", self.0)) 16 | } 17 | } 18 | impl From for CommandError { 19 | fn from(err: anyhow::Error) -> Self { 20 | Self(err.to_string_chain()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/RemoveProgress.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 23 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetAttributify, 4 | presetIcons, 5 | presetTypography, 6 | presetUno, 7 | presetWebFonts, 8 | transformerDirectives, 9 | transformerVariantGroup, 10 | } from 'unocss' 11 | 12 | export default defineConfig({ 13 | shortcuts: [ 14 | // ... 15 | ], 16 | theme: { 17 | colors: { 18 | // ... 19 | }, 20 | }, 21 | presets: [ 22 | presetUno(), 23 | presetAttributify(), 24 | presetIcons(), 25 | presetTypography(), 26 | presetWebFonts({ 27 | fonts: { 28 | // ... 29 | }, 30 | }), 31 | ], 32 | transformers: [transformerDirectives(), transformerVariantGroup()], 33 | }) 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 功能请求 2 | description: 想要请求添加某个功能 3 | labels: [enhancement] 4 | title: "[功能请求] 修改我!" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | 为了使我更好地帮助你,请提供以下信息。以及上方的标题 10 | - type: textarea 11 | id: reason 12 | attributes: 13 | label: 原因 14 | description: 为什么想要这个功能 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: desc 19 | attributes: 20 | label: 功能简述 21 | description: 想要个怎样的功能 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: logic 26 | attributes: 27 | label: 功能逻辑 28 | description: 如何互交、如何使用等 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: ref 33 | attributes: 34 | label: 实现参考 35 | description: 该功能可能的实现方式,或者其他已经实现该功能的应用等 -------------------------------------------------------------------------------- /src-tauri/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod prelude { 2 | pub use crate::commands::{ 3 | generate_background::generate_background, 4 | get_background_dir_abs_path::get_background_dir_abs_path, 5 | get_background_dir_relative_path::get_background_dir_relative_path, get_config::get_config, 6 | get_image_infos::get_image_infos, get_manga_dir_data::get_manga_dir_data, 7 | open_image::open_image, remove_watermark::remove_watermark, save_config::save_config, 8 | show_path_in_file_manager::show_path_in_file_manager, 9 | }; 10 | } 11 | 12 | mod generate_background; 13 | mod get_background_dir_abs_path; 14 | mod get_background_dir_relative_path; 15 | mod get_config; 16 | mod get_image_infos; 17 | mod get_manga_dir_data; 18 | mod open_image; 19 | mod remove_watermark; 20 | mod save_config; 21 | mod show_path_in_file_manager; 22 | -------------------------------------------------------------------------------- /src-tauri/src/commands/open_image.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::Context; 4 | 5 | use crate::errors::CommandResult; 6 | use crate::types::{ImageData, ImageInfo}; 7 | 8 | #[tauri::command(async)] 9 | #[specta::specta] 10 | #[allow(clippy::cast_possible_truncation)] 11 | pub fn open_image(path: String) -> CommandResult { 12 | let path = PathBuf::from(path); 13 | let (width, height) = image::image_dimensions(&path) 14 | .context(format!("获取图片 {path:?} 的尺寸失败")) 15 | .map_err(anyhow::Error::from)?; 16 | let data = std::fs::read(&path) 17 | .context(format!("读取图片 {path:?} 失败")) 18 | .map_err(anyhow::Error::from)?; 19 | 20 | let data = ImageData { 21 | info: ImageInfo { 22 | width, 23 | height, 24 | path, 25 | }, 26 | data, 27 | }; 28 | 29 | Ok(data) 30 | } 31 | -------------------------------------------------------------------------------- /src-tauri/src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use tauri::{AppHandle, Manager}; 4 | 5 | pub fn get_background_dir_relative_path( 6 | manga_dir: &str, 7 | width: u32, 8 | height: u32, 9 | ) -> anyhow::Result { 10 | let manga_dir_name = Path::new(manga_dir) 11 | .file_name() 12 | .ok_or(anyhow::anyhow!("获取漫画目录名失败"))? 13 | .to_str() 14 | .ok_or(anyhow::anyhow!("漫画目录名包含非UTF-8字符"))?; 15 | let relative_path = format!("背景水印图/{manga_dir_name}{width}x{height}"); 16 | Ok(PathBuf::from(relative_path)) 17 | } 18 | 19 | pub fn get_background_dir_abs_path( 20 | app: &AppHandle, 21 | manga_dir: &str, 22 | width: u32, 23 | height: u32, 24 | ) -> anyhow::Result { 25 | let resource_dir = app.path().resource_dir()?; 26 | let relative_path = get_background_dir_relative_path(manga_dir, width, height)?; 27 | let abs_path = resource_dir.join(relative_path); 28 | Ok(abs_path) 29 | } 30 | -------------------------------------------------------------------------------- /src-tauri/src/commands/get_image_infos.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use walkdir::WalkDir; 4 | 5 | use crate::{extensions::PathIsImage, types::ImageInfo}; 6 | 7 | #[tauri::command(async)] 8 | #[specta::specta] 9 | #[allow(clippy::cast_possible_truncation)] 10 | pub fn get_image_infos(manga_dir: &str) -> Vec { 11 | // 用于存储图片的信息 12 | let mut image_infos = vec![]; 13 | // 遍历漫画目录下的所有文件,获取图片的信息 14 | WalkDir::new(PathBuf::from(manga_dir)) 15 | .max_depth(2) // 一般第一层目录是章节目录,第二层目录是图片文件 16 | .into_iter() 17 | .filter_map(Result::ok) 18 | .filter_map(|entry| { 19 | let path = entry.into_path(); 20 | if !path.is_file() || !path.is_image() { 21 | return None; 22 | } 23 | let size = image::image_dimensions(&path).ok()?; 24 | Some((path, size)) 25 | }) 26 | .for_each(|(path, size)| { 27 | image_infos.push(ImageInfo { 28 | width: size.0, 29 | height: size.1, 30 | path, 31 | }); 32 | }); 33 | 34 | image_infos 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 kurisu_u 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "bilibili-manga-watermark-remover", 3 | "version": "0.10.0", 4 | "identifier": "com.lanyeeee.bilibili-manga-watermark-remover", 5 | "build": { 6 | "beforeDevCommand": "pnpm dev", 7 | "devUrl": "http://localhost:5005", 8 | "beforeBuildCommand": "pnpm build", 9 | "frontendDist": "../dist" 10 | }, 11 | "app": { 12 | "windows": [ 13 | { 14 | "title": "bilibili-manga-watermark-remover", 15 | "width": 800, 16 | "height": 600 17 | } 18 | ], 19 | "security": { 20 | "csp": null 21 | } 22 | }, 23 | "bundle": { 24 | "active": true, 25 | "targets": "all", 26 | "licenseFile": "../LICENSE", 27 | "icon": [ 28 | "icons/32x32.png", 29 | "icons/128x128.png", 30 | "icons/128x128@2x.png", 31 | "icons/icon.icns", 32 | "icons/icon.ico" 33 | ], 34 | "windows": { 35 | "nsis": { 36 | "installMode": "perMachine", 37 | "displayLanguageSelector": true, 38 | "languages": [ 39 | "SimpChinese" 40 | ] 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bilibili-manga-watermark-remover", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc --noEmit && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "@tauri-apps/api": ">=2.0.0-beta.0", 14 | "@tauri-apps/plugin-dialog": "2.0.0-beta.5", 15 | "@tauri-apps/plugin-fs": "2.0.0-beta.6", 16 | "@tauri-apps/plugin-shell": ">=2.0.0-beta.0", 17 | "@viselect/vue": "^3.6.0", 18 | "prettier": "^3.4.2", 19 | "vue": "^3.3.4" 20 | }, 21 | "devDependencies": { 22 | "@eslint/js": "^9.17.0", 23 | "@tauri-apps/cli": ">=2.0.0-beta.0", 24 | "@vitejs/plugin-vue": "^5.0.5", 25 | "eslint": "^9.17.0", 26 | "eslint-plugin-vue": "^9.32.0", 27 | "globals": "^15.14.0", 28 | "naive-ui": "^2.38.2", 29 | "typescript": "^5.2.2", 30 | "typescript-eslint": "^8.19.1", 31 | "unocss": "^0.61.0", 32 | "unplugin-auto-import": "^0.17.6", 33 | "unplugin-vue-components": "^0.27.2", 34 | "vite": "^5.3.1", 35 | "vue-tsc": "^2.0.22" 36 | }, 37 | "packageManager": "pnpm@9.9.0" 38 | } 39 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import AutoImport from 'unplugin-auto-import/vite' 4 | import Components from 'unplugin-vue-components/vite' 5 | import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' 6 | import UnoCSS from 'unocss/vite' 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(async () => ({ 10 | plugins: [ 11 | vue(), 12 | UnoCSS(), 13 | AutoImport({ 14 | imports: [ 15 | 'vue', 16 | { 17 | 'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'], 18 | }, 19 | ], 20 | }), 21 | Components({ 22 | resolvers: [NaiveUiResolver()], 23 | }), 24 | ], 25 | 26 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 27 | // 28 | // 1. prevent vite from obscuring rust errors 29 | clearScreen: false, 30 | // 2. tauri expects a fixed port, fail if that port is not available 31 | server: { 32 | port: 5005, 33 | strictPort: true, 34 | watch: { 35 | // 3. tell vite to ignore watching `src-tauri` 36 | ignored: ['**/src-tauri/**'], 37 | }, 38 | }, 39 | })) 40 | -------------------------------------------------------------------------------- /src-tauri/src/types.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | 6 | #[derive(Debug, Deserialize, Serialize, Type)] 7 | pub struct RectData { 8 | pub left: u32, 9 | pub top: u32, 10 | pub right: u32, 11 | pub bottom: u32, 12 | } 13 | 14 | #[derive(Debug, Deserialize, Serialize, Type)] 15 | pub struct MangaDirData { 16 | pub width: u32, 17 | pub height: u32, 18 | pub count: u32, 19 | #[serde(rename = "blackBackground")] 20 | pub black_background: Option, 21 | #[serde(rename = "whiteBackground")] 22 | pub white_background: Option, 23 | } 24 | 25 | #[derive(Debug, Deserialize, Serialize, Type)] 26 | pub struct ImageInfo { 27 | pub width: u32, 28 | pub height: u32, 29 | pub path: PathBuf, 30 | } 31 | 32 | #[derive(Debug, Deserialize, Serialize, Type)] 33 | pub struct ImageData { 34 | pub info: ImageInfo, 35 | pub data: Vec, 36 | } 37 | impl ImageData { 38 | pub fn to_image(&self) -> anyhow::Result { 39 | let image = image::load_from_memory(&self.data)?; 40 | Ok(image) 41 | } 42 | } 43 | 44 | #[derive(Debug, Clone, Deserialize, Serialize, Type)] 45 | pub enum ImageFormat { 46 | Jpeg, 47 | Png, 48 | } 49 | -------------------------------------------------------------------------------- /src-tauri/src/extensions.rs: -------------------------------------------------------------------------------- 1 | pub trait AnyhowErrorToStringChain { 2 | /// 将 `anyhow::Error` 转换为chain格式 3 | /// # Example 4 | /// 0: error message 5 | /// 1: error message 6 | /// 2: error message 7 | fn to_string_chain(&self) -> String; 8 | } 9 | 10 | impl AnyhowErrorToStringChain for anyhow::Error { 11 | fn to_string_chain(&self) -> String { 12 | use std::fmt::Write; 13 | self.chain() 14 | .enumerate() 15 | .fold(String::new(), |mut output, (i, e)| { 16 | let _ = writeln!(output, "{i}: {e}"); 17 | output 18 | }) 19 | } 20 | } 21 | 22 | pub trait PathIsImage { 23 | /// 判断路径是否为图片文件 24 | /// # Example 25 | /// ``` 26 | /// use std::path::Path; 27 | /// use tauri_manga::extensions::PathIsImage; 28 | /// 29 | /// let path = Path::new("test.jpg"); 30 | /// assert_eq!(path.is_image(), true); 31 | /// ``` 32 | fn is_image(&self) -> bool; 33 | } 34 | 35 | impl PathIsImage for std::path::Path { 36 | fn is_image(&self) -> bool { 37 | self.extension() 38 | .and_then(|ext| ext.to_str()) 39 | .map(str::to_lowercase) 40 | .is_some_and(|ext| matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "webp" | "gif")) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bilibili-manga-watermark-remover" 3 | version = "0.0.0" 4 | description = "A Tauri App" 5 | authors = ["you"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [build-dependencies] 11 | tauri-build = { version = "2.0.0-rc", features = [] } 12 | 13 | [dependencies] 14 | tauri = { version = "2.0.0-rc", features = ["devtools"] } 15 | tauri-plugin-shell = { version = "2.0.0-rc" } 16 | tauri-plugin-dialog = { version = "2.0.0-rc" } 17 | tauri-plugin-fs = { version = "2.0.0-rc" } 18 | 19 | specta = { version = "2.0.0-rc" } 20 | tauri-specta = { version = "2.0.0-rc", features = ["derive", "typescript"] } 21 | specta-typescript = { version = "0.0.7" } 22 | 23 | serde = { version = "1", features = ["derive"] } 24 | serde_json = "1" 25 | 26 | image = { version = "0.25.1", default-features = false, features = ["jpeg", "png", "webp", "gif"] } 27 | jpeg-encoder = { version = "0.6.0", features = ["simd"] } 28 | 29 | rayon = { version = "1.10" } 30 | walkdir = { version = "2" } 31 | anyhow = { version = "1.0" } 32 | showfile = { version = "0.1.1" } 33 | parking_lot = { version = "0.12.3", features = ["send_guard"] } 34 | 35 | [profile.release] 36 | strip = true # Automatically strip symbols from the binary. 37 | lto = true 38 | codegen-units = 1 39 | panic = "abort" 40 | -------------------------------------------------------------------------------- /components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // Generated by unplugin-vue-components 4 | // Read more: https://github.com/vuejs/core/pull/3399 5 | export {} 6 | 7 | /* prettier-ignore */ 8 | declare module 'vue' { 9 | export interface GlobalComponents { 10 | AboutDialog: typeof import('./src/components/AboutDialog.vue')['default'] 11 | MangaDirIndicator: typeof import('./src/components/MangaDirIndicator.vue')['default'] 12 | NA: typeof import('naive-ui')['NA'] 13 | NButton: typeof import('naive-ui')['NButton'] 14 | NDialog: typeof import('naive-ui')['NDialog'] 15 | NInput: typeof import('naive-ui')['NInput'] 16 | NMessageProvider: typeof import('naive-ui')['NMessageProvider'] 17 | NModal: typeof import('naive-ui')['NModal'] 18 | NModalProvider: typeof import('naive-ui')['NModalProvider'] 19 | NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] 20 | NProgress: typeof import('naive-ui')['NProgress'] 21 | NRadio: typeof import('naive-ui')['NRadio'] 22 | NRadioGroup: typeof import('naive-ui')['NRadioGroup'] 23 | NSpace: typeof import('naive-ui')['NSpace'] 24 | NSwitch: typeof import('naive-ui')['NSwitch'] 25 | NTooltip: typeof import('naive-ui')['NTooltip'] 26 | RemoveProgress: typeof import('./src/components/RemoveProgress.vue')['default'] 27 | WatermarkCropper: typeof import('./src/components/WatermarkCropper.vue')['default'] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | use tauri::{AppHandle, Manager}; 6 | 7 | use crate::types::ImageFormat; 8 | 9 | #[allow(clippy::struct_field_names)] 10 | #[derive(Debug, Clone, Serialize, Deserialize, Type)] 11 | #[serde(rename_all = "camelCase")] 12 | pub struct Config { 13 | pub output_dir: PathBuf, 14 | pub output_format: ImageFormat, 15 | pub output_optimize: bool, 16 | } 17 | 18 | impl Config { 19 | pub fn new(app: &AppHandle) -> anyhow::Result { 20 | let resource_dir = app.path().resource_dir()?; 21 | let config_path = resource_dir.join("config.json"); 22 | let default_config = Config { 23 | output_dir: resource_dir, 24 | output_format: ImageFormat::Jpeg, 25 | output_optimize: false, 26 | }; 27 | let config = if config_path.exists() { 28 | let config_string = std::fs::read_to_string(config_path)?; 29 | serde_json::from_str(&config_string).unwrap_or(default_config) 30 | } else { 31 | default_config 32 | }; 33 | config.save(app)?; 34 | Ok(config) 35 | } 36 | 37 | pub fn save(&self, app: &AppHandle) -> anyhow::Result<()> { 38 | let resource_dir = app.path().resource_dir()?; 39 | let config_path = resource_dir.join("config.json"); 40 | let config_string = serde_json::to_string_pretty(self)?; 41 | std::fs::write(config_path, config_string)?; 42 | Ok(()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 反馈 Bug 2 | description: 反馈遇到的问题 3 | labels: [bug] 4 | title: "[Bug] 修改我!" 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: 在提问之前 9 | description: 请先查看[常见问题](https://github.com/lanyeeee/bilibili-manga-watermark-remover?tab=readme-ov-file#%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98) 10 | options: 11 | - label: 我已经查看了[常见问题](https://github.com/lanyeeee/bilibili-manga-watermark-remover?tab=readme-ov-file#%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98),我确定这个问题不在其中 12 | required: true 13 | - type: markdown 14 | attributes: 15 | value: | 16 | 为了使我更好地帮助你,请提供以下信息。以及修改上方的标题 17 | - type: textarea 18 | id: desc 19 | attributes: 20 | label: 问题描述 21 | description: 发生了什么情况?有什么现状?复现条件是什么? 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: expected 26 | attributes: 27 | label: 预期行为 28 | description: 正常情况下应该发生什么 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: actual 33 | attributes: 34 | label: 实际行为 35 | description: 实际上发生了什么 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: media 40 | attributes: 41 | label: 截图或录屏 42 | description: 问题复现时候的截图或录屏 43 | placeholder: 点击文本框下面小长条可以上传文件 44 | - type: input 45 | id: version 46 | attributes: 47 | label: 工具版本号 48 | placeholder: v0.1.0 49 | validations: 50 | required: true 51 | - type: textarea 52 | id: other 53 | attributes: 54 | label: 其他 55 | description: 其他要补充的内容 56 | placeholder: 其他要补充的内容 57 | validations: 58 | required: false 59 | -------------------------------------------------------------------------------- /src-tauri/src/events.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | use tauri_specta::Event; 6 | 7 | pub mod prelude { 8 | pub use crate::events::{ 9 | RemoveWatermarkEndEvent, RemoveWatermarkErrorEvent, RemoveWatermarkStartEvent, 10 | RemoveWatermarkSuccessEvent, 11 | }; 12 | } 13 | 14 | #[derive(Serialize, Deserialize, Clone, Type)] 15 | #[serde(rename_all = "camelCase")] 16 | pub struct RemoveWatermarkStartEventPayload { 17 | pub dir_path: PathBuf, 18 | pub total: u32, 19 | } 20 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 21 | pub struct RemoveWatermarkStartEvent(pub RemoveWatermarkStartEventPayload); 22 | 23 | #[derive(Serialize, Deserialize, Clone, Type)] 24 | #[serde(rename_all = "camelCase")] 25 | pub struct RemoveWatermarkSuccessEventPayload { 26 | pub dir_path: PathBuf, 27 | pub img_path: PathBuf, 28 | pub current: u32, 29 | } 30 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 31 | pub struct RemoveWatermarkSuccessEvent(pub RemoveWatermarkSuccessEventPayload); 32 | 33 | #[derive(Serialize, Deserialize, Clone, Type)] 34 | #[serde(rename_all = "camelCase")] 35 | pub struct RemoveWatermarkErrorEventPayload { 36 | pub dir_path: PathBuf, 37 | pub img_path: PathBuf, 38 | pub err_msg: String, 39 | } 40 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 41 | pub struct RemoveWatermarkErrorEvent(pub RemoveWatermarkErrorEventPayload); 42 | 43 | #[derive(Serialize, Deserialize, Clone, Type)] 44 | #[serde(rename_all = "camelCase")] 45 | pub struct RemoveWatermarkEndEventPayload { 46 | pub dir_path: PathBuf, 47 | } 48 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 49 | pub struct RemoveWatermarkEndEvent(pub RemoveWatermarkEndEventPayload); 50 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { commands } from './bindings.ts' 2 | import { NotificationApiInjection } from 'naive-ui/es/notification/src/NotificationProvider' 3 | 4 | export async function showPathInFileManager(path: string | undefined) { 5 | if (path === undefined) { 6 | return 7 | } 8 | await commands.showPathInFileManager(path) 9 | } 10 | 11 | export async function getBackgroundDirRelativePath( 12 | mangaDir: string, 13 | width: number, 14 | height: number, 15 | notification: NotificationApiInjection, 16 | ): Promise { 17 | const result = await commands.getBackgroundDirRelativePath(mangaDir, width, height) 18 | if (result.status === 'error') { 19 | notification.error({ title: '获取背景水印图相对路径失败', description: result.error }) 20 | return null 21 | } 22 | return result.data 23 | } 24 | 25 | export async function getBackgroundDirAbsPath( 26 | mangaDir: string, 27 | width: number, 28 | height: number, 29 | notification: NotificationApiInjection, 30 | ): Promise { 31 | const result = await commands.getBackgroundDirAbsPath(mangaDir, width, height) 32 | if (result.status === 'error') { 33 | notification.error({ title: '获取背景水印图绝对路径失败', description: result.error }) 34 | return null 35 | } 36 | return result.data 37 | } 38 | 39 | export async function autoGenerateBackground( 40 | mangaDir: string, 41 | width: number, 42 | height: number, 43 | notification: NotificationApiInjection, 44 | ): Promise { 45 | const result = await commands.generateBackground(mangaDir, null, width, height) 46 | if (result.status === 'error') { 47 | notification.error({ 48 | title: `自动生成背景水印图(${width}x${height})失败`, 49 | description: result.error, 50 | }) 51 | return false 52 | } 53 | return true 54 | } 55 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | #![warn(clippy::unwrap_used)] 4 | 5 | use parking_lot::RwLock; 6 | use tauri::{Context, Manager, Wry}; 7 | 8 | use crate::commands::prelude::*; 9 | use crate::config::Config; 10 | use crate::events::prelude::*; 11 | 12 | mod commands; 13 | mod config; 14 | mod errors; 15 | mod events; 16 | mod extensions; 17 | mod types; 18 | mod utils; 19 | 20 | fn generate_context() -> Context { 21 | tauri::generate_context!() 22 | } 23 | 24 | fn main() { 25 | let builder = tauri_specta::Builder::::new() 26 | .commands(tauri_specta::collect_commands![ 27 | generate_background, 28 | remove_watermark, 29 | open_image, 30 | get_manga_dir_data, 31 | get_image_infos, 32 | show_path_in_file_manager, 33 | get_background_dir_relative_path, 34 | get_background_dir_abs_path, 35 | get_config, 36 | save_config, 37 | ]) 38 | .events(tauri_specta::collect_events![ 39 | RemoveWatermarkStartEvent, 40 | RemoveWatermarkSuccessEvent, 41 | RemoveWatermarkErrorEvent, 42 | RemoveWatermarkEndEvent, 43 | ]); 44 | // 只有在debug模式下才会生成bindings.ts 45 | #[cfg(debug_assertions)] 46 | builder 47 | .export( 48 | specta_typescript::Typescript::default() 49 | .bigint(specta_typescript::BigIntExportBehavior::Number) 50 | .formatter(specta_typescript::formatter::prettier) 51 | .header("// @ts-nocheck"), // 跳过检查 52 | "../src/bindings.ts", 53 | ) 54 | .expect("Failed to export typescript bindings"); 55 | 56 | tauri::Builder::default() 57 | .plugin(tauri_plugin_dialog::init()) 58 | .plugin(tauri_plugin_shell::init()) 59 | .plugin(tauri_plugin_fs::init()) 60 | .invoke_handler(builder.invoke_handler()) 61 | .setup(move |app| { 62 | builder.mount_events(app); 63 | let config = RwLock::new(Config::new(app.handle())?); 64 | app.manage(config); 65 | Ok(()) 66 | }) 67 | .run(generate_context()) 68 | .expect("error while running tauri application"); 69 | } 70 | -------------------------------------------------------------------------------- /src/components/AboutDialog.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 59 | -------------------------------------------------------------------------------- /public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src-tauri/src/commands/get_manga_dir_data.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::PathBuf; 3 | 4 | use tauri::AppHandle; 5 | use walkdir::WalkDir; 6 | 7 | use crate::commands::open_image::open_image; 8 | use crate::errors::CommandResult; 9 | use crate::extensions::PathIsImage; 10 | use crate::types::MangaDirData; 11 | use crate::utils; 12 | 13 | #[tauri::command(async)] 14 | #[specta::specta] 15 | #[allow(clippy::cast_possible_truncation)] 16 | #[allow(clippy::needless_pass_by_value)] 17 | pub fn get_manga_dir_data(app: AppHandle, manga_dir: &str) -> CommandResult> { 18 | // 用于存储不同尺寸的图片的数量 19 | let mut size_count: HashMap<(u32, u32), u32> = HashMap::new(); 20 | // 遍历漫画目录下的所有文件,统计不同尺寸的图片的数量 21 | WalkDir::new(PathBuf::from(manga_dir)) 22 | .max_depth(2) // 一般第一层目录是章节目录,第二层目录是图片文件 23 | .into_iter() 24 | .filter_map(Result::ok) 25 | .filter_map(|entry| { 26 | let path = entry.into_path(); 27 | if !path.is_file() || !path.is_image() { 28 | return None; 29 | } 30 | // imagesize::size(&path).ok() 31 | image::image_dimensions(&path).ok() 32 | }) 33 | .for_each(|size| { 34 | let count = size_count.entry(size).or_insert(0); 35 | *count += 1; 36 | }); 37 | // 将统计结果转换为Vec 38 | let mut manga_dir_data: Vec = size_count 39 | .into_iter() 40 | .map(|((width, height), count)| MangaDirData { 41 | width, 42 | height, 43 | count, 44 | black_background: None, 45 | white_background: None, 46 | }) 47 | .collect(); 48 | // 以count降序排序 49 | manga_dir_data.sort_by(|a, b| b.count.cmp(&a.count)); 50 | // 获取背景水印图的数据 51 | for dir_data in &mut manga_dir_data { 52 | let width = dir_data.width; 53 | let height = dir_data.height; 54 | let background_dir = utils::get_background_dir_abs_path(&app, manga_dir, width, height)?; 55 | let black_background_path = background_dir.join("black.png"); 56 | let white_background_path = background_dir.join("white.png"); 57 | if black_background_path.exists() { 58 | let black_background_path = black_background_path.display().to_string(); 59 | let black_background = open_image(black_background_path)?; 60 | dir_data.black_background = Some(black_background); 61 | } 62 | if white_background_path.exists() { 63 | let white_background_path = white_background_path.display().to_string(); 64 | let white_background = open_image(white_background_path)?; 65 | dir_data.white_background = Some(white_background); 66 | } 67 | } 68 | 69 | Ok(manga_dir_data) 70 | } 71 | -------------------------------------------------------------------------------- /src/components/MangaDirIndicator.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 84 | -------------------------------------------------------------------------------- /auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | export {} 7 | declare global { 8 | const EffectScope: typeof import('vue')['EffectScope'] 9 | const computed: typeof import('vue')['computed'] 10 | const createApp: typeof import('vue')['createApp'] 11 | const customRef: typeof import('vue')['customRef'] 12 | const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] 13 | const defineComponent: typeof import('vue')['defineComponent'] 14 | const effectScope: typeof import('vue')['effectScope'] 15 | const getCurrentInstance: typeof import('vue')['getCurrentInstance'] 16 | const getCurrentScope: typeof import('vue')['getCurrentScope'] 17 | const h: typeof import('vue')['h'] 18 | const inject: typeof import('vue')['inject'] 19 | const isProxy: typeof import('vue')['isProxy'] 20 | const isReactive: typeof import('vue')['isReactive'] 21 | const isReadonly: typeof import('vue')['isReadonly'] 22 | const isRef: typeof import('vue')['isRef'] 23 | const markRaw: typeof import('vue')['markRaw'] 24 | const nextTick: typeof import('vue')['nextTick'] 25 | const onActivated: typeof import('vue')['onActivated'] 26 | const onBeforeMount: typeof import('vue')['onBeforeMount'] 27 | const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] 28 | const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] 29 | const onDeactivated: typeof import('vue')['onDeactivated'] 30 | const onErrorCaptured: typeof import('vue')['onErrorCaptured'] 31 | const onMounted: typeof import('vue')['onMounted'] 32 | const onRenderTracked: typeof import('vue')['onRenderTracked'] 33 | const onRenderTriggered: typeof import('vue')['onRenderTriggered'] 34 | const onScopeDispose: typeof import('vue')['onScopeDispose'] 35 | const onServerPrefetch: typeof import('vue')['onServerPrefetch'] 36 | const onUnmounted: typeof import('vue')['onUnmounted'] 37 | const onUpdated: typeof import('vue')['onUpdated'] 38 | const provide: typeof import('vue')['provide'] 39 | const reactive: typeof import('vue')['reactive'] 40 | const readonly: typeof import('vue')['readonly'] 41 | const ref: typeof import('vue')['ref'] 42 | const resolveComponent: typeof import('vue')['resolveComponent'] 43 | const shallowReactive: typeof import('vue')['shallowReactive'] 44 | const shallowReadonly: typeof import('vue')['shallowReadonly'] 45 | const shallowRef: typeof import('vue')['shallowRef'] 46 | const toRaw: typeof import('vue')['toRaw'] 47 | const toRef: typeof import('vue')['toRef'] 48 | const toRefs: typeof import('vue')['toRefs'] 49 | const toValue: typeof import('vue')['toValue'] 50 | const triggerRef: typeof import('vue')['triggerRef'] 51 | const unref: typeof import('vue')['unref'] 52 | const useAttrs: typeof import('vue')['useAttrs'] 53 | const useCssModule: typeof import('vue')['useCssModule'] 54 | const useCssVars: typeof import('vue')['useCssVars'] 55 | const useDialog: typeof import('naive-ui')['useDialog'] 56 | const useLoadingBar: typeof import('naive-ui')['useLoadingBar'] 57 | const useMessage: typeof import('naive-ui')['useMessage'] 58 | const useNotification: typeof import('naive-ui')['useNotification'] 59 | const useSlots: typeof import('vue')['useSlots'] 60 | const watch: typeof import('vue')['watch'] 61 | const watchEffect: typeof import('vue')['watchEffect'] 62 | const watchPostEffect: typeof import('vue')['watchPostEffect'] 63 | const watchSyncEffect: typeof import('vue')['watchSyncEffect'] 64 | } 65 | // for type re-export 66 | declare global { 67 | // @ts-ignore 68 | export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue' 69 | import('vue') 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # ✨哔哩哔哩漫画去水印工具 6 | 7 | bilibili漫画 哔哩哔哩漫画 B漫 去水印工具(无痕 无损),带图形界面,图形界面基于[Tauri](https://v2.tauri.app/start/) 8 | 9 | 🔽在[Release页面](https://github.com/lanyeeee/bilibili-manga-watermark-remover/releases)可以直接下载 10 | 11 | **如果本项目对你有帮助,欢迎点个 Star⭐ 支持!你的支持是我持续更新维护的动力🙏** 12 | 13 | # 🖼️效果预览 14 | 15 | | 原图 | 去水印 | 16 | | ------------------------------------------------------------ | ------------------------------------------------------------ | 17 | | ![少女终末旅行-原图](https://github.com/user-attachments/assets/832107b5-d40c-4a93-98f1-674bd761abfb) | ![少女终末旅行-去水印](https://github.com/user-attachments/assets/341d0c74-5076-42a8-af7d-304322bc85d2) | 18 | | ![炎拳-原图](https://github.com/user-attachments/assets/a42ab0df-d9a0-43e9-9d0b-46ac98798cd8) | ![炎拳-去水印](https://github.com/user-attachments/assets/6840a1bf-2607-4ee4-88e6-5367019911da) | 19 | 20 | # 📖使用方法 21 | 22 | ### 🧽去水印 23 | 24 | 1. 选择漫画目录,等待自动生成背景水印图完成 25 | 2. 点击开始去水印按钮,等待去水印完成 26 | 3. 前往输出目录查看结果 27 | 28 | 🎥下面的视频是去水印的完整流程 29 | 30 | https://github.com/user-attachments/assets/f7ad65d0-4211-4fe3-b090-419a722b2e45 31 | 32 | ### 🖌️生成背景水印图 33 | 34 | 一般选择漫画目录后,工具会自动为每种尺寸的图片生成黑色和白色的背景水印图 35 | 如果自动生成失败,可以尝试手动截取水印 36 | 37 | 🎥下面的视频演示了 38 | 39 | - 所有尺寸全部重试自动生成 40 | - 单个尺寸重试自动生成 41 | - 单个尺寸手动截取水印 42 | 43 | https://github.com/user-attachments/assets/52666942-27df-4e39-9dc1-dfcbe0461c44 44 | 45 | # ❓常见问题 46 | 47 | - [生成背景水印图失败](https://github.com/lanyeeee/bilibili-manga-watermark-remover/discussions/1) 48 | - [极个别图片去除水印失败](https://github.com/lanyeeee/bilibili-manga-watermark-remover/discussions/5) 49 | - [同一本漫画,正文尺寸相同,但是水印不同](https://github.com/lanyeeee/bilibili-manga-watermark-remover/discussions/8) 50 | - [白色背景水印图被命名为black.png](https://github.com/lanyeeee/bilibili-manga-watermark-remover/discussions/55) 51 | 52 | # 📚哔哩哔哩漫画下载器 53 | 54 | [![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=lanyeeee&repo=bilibili-manga-downloader)](https://github.com/lanyeeee/bilibili-manga-downloader) 55 | 56 | # 🧠去水印原理 57 | 58 | 本工具的去水印算法基于[这个项目](https://github.com/yuchenxi2000/bilibili-watermark/tree/master) 59 | 60 | > B漫给图片添加水印的算法是用一张带alpha通道的水印图叠加到原图上。 61 | > out = in * alpha + watermark * (1 - alpha) 62 | > out是加了水印的图,in是原图,alpha是透明通道,watermark是水印(除透明通道外) 63 | 64 | 因为网上下载的图没有alpha通道,所以需要一张黑背景和一张白背景的水印图把alpha通道算出来 65 | 所以每种尺寸的图片要去水印,都需要对应尺寸的黑背景和白背景水印图各一张 66 | 67 | [核心算法的Python实现](https://github.com/yuchenxi2000/bilibili-watermark/tree/master/B%E6%BC%AB) 68 | 69 | # ⚠️关于被杀毒软件误判为病毒 70 | 71 | 对于个人开发者来说,这个问题几乎是无解的(~~需要数字证书给软件签名,甚至给杀毒软件交保护费~~) 72 | 我能想到的解决办法只有: 73 | 74 | 1. 根据下面的**如何构建(build)**,自行编译 75 | 2. 希望你相信我的承诺,我承诺你在[Release页面](https://github.com/lanyeeee/bilibili-manga-watermark-remover/releases)下载到的所有东西都是安全的 76 | 77 | # 🛠️如何构建(build) 78 | 79 | 构建非常简单,一共就3条命令 80 | ~~前提是你已经安装了Rust、Node、pnpm~~ 81 | 82 | ### 📋前提 83 | 84 | - [Rust](https://www.rust-lang.org/tools/install) 85 | - [Node](https://nodejs.org/en) 86 | - [pnpm](https://pnpm.io/installation) 87 | 88 | ### 📝步骤 89 | 90 | #### 1. 克隆本仓库 91 | 92 | ``` 93 | git clone https://github.com/lanyeeee/bilibili-manga-watermark-remover.git 94 | ``` 95 | 96 | #### 2.安装依赖 97 | 98 | ``` 99 | cd bilibili-manga-watermark-remover 100 | pnpm install 101 | ``` 102 | 103 | #### 3.构建(build) 104 | 105 | ``` 106 | pnpm tauri build 107 | ``` 108 | 109 | # 🤝提交PR 110 | 111 | **PR请提交至`develop`分支** 112 | 113 | **如果想新加一个功能,请先开个`issue`或`discussion`讨论一下,避免无效工作** 114 | 115 | 其他情况的PR欢迎直接提交,比如: 116 | 117 | 1. 🔧对原有功能的改进 118 | 2. 🐛修复BUG 119 | 3. ⚡使用更轻量的库实现原有功能 120 | 4. 📝修订文档 121 | 5. ⬆️升级、更新依赖的PR也会被接受 122 | 123 | # ⚠️免责声明 124 | 125 | - 本工具仅作学习、研究、交流使用,使用本工具的用户应自行承担风险 126 | - 作者不对使用本工具导致的任何损失、法律纠纷或其他后果负责 127 | - 作者不对用户使用本工具的行为负责,包括但不限于用户违反法律或任何第三方权益的行为 128 | 129 | # 💬其他 130 | 131 | 任何使用中遇到的问题、任何希望添加的功能,都欢迎提交issue或开discussion交流,我会尽力解决 132 | -------------------------------------------------------------------------------- /src-tauri/src/commands/generate_background.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::{anyhow, Context}; 4 | use image::RgbImage; 5 | use parking_lot::Mutex; 6 | use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; 7 | use tauri::AppHandle; 8 | use walkdir::WalkDir; 9 | 10 | use crate::errors::CommandResult; 11 | use crate::extensions::PathIsImage; 12 | use crate::types::RectData; 13 | use crate::utils; 14 | 15 | #[tauri::command(async)] 16 | #[specta::specta] 17 | #[allow(clippy::cast_possible_truncation)] 18 | #[allow(clippy::needless_pass_by_value)] 19 | #[allow(clippy::cast_sign_loss)] 20 | #[allow(clippy::cast_precision_loss)] 21 | pub fn generate_background( 22 | app: AppHandle, 23 | manga_dir: &str, 24 | rect_data: Option, 25 | width: u32, 26 | height: u32, 27 | ) -> CommandResult<()> { 28 | let output_dir = utils::get_background_dir_abs_path(&app, manga_dir, width, height)?; 29 | // TODO: 给RectData实现Default trait,以替换下面的代码 30 | let default_rect_data = RectData { 31 | left: (width as f32 * 0.835) as u32, 32 | top: (height as f32 * 0.946) as u32, 33 | right: (width as f32 * 0.994) as u32, 34 | bottom: (height as f32 * 0.994) as u32, 35 | }; 36 | let rect_data = rect_data.unwrap_or(default_rect_data); 37 | 38 | // 保证输出目录存在 39 | std::fs::create_dir_all(&output_dir).context(format!("创建目录 {output_dir:?} 失败"))?; 40 | // 收集尺寸符合width和height的图片的路径 41 | let image_paths = create_image_paths(manga_dir, width, height); 42 | // 用于保存各种符合条件的背景水印图 43 | let backgrounds = Mutex::new(vec![]); 44 | // 用于标记是否找到了黑色和白色背景水印图 45 | let background_pair_found = Mutex::new(false); 46 | // 并发遍历image_paths 47 | let image_paths = image_paths.par_iter(); 48 | image_paths.try_for_each(|path| -> anyhow::Result<()> { 49 | // 如果已经找到了黑色和白色背景水印图,则直接返回 50 | if *background_pair_found.lock() { 51 | return Ok(()); 52 | } 53 | 54 | let mut img = image::open(path) 55 | .context(format!("打开图片 {path:?} 失败"))? 56 | .to_rgb8(); 57 | // 如果图片不满足背景的条件,则直接跳过 58 | if !is_background(&img, &rect_data) { 59 | return Ok(()); 60 | }; 61 | // 获取左上角的颜色 62 | let (left, top) = (rect_data.left, rect_data.top); 63 | let (right, bottom) = (rect_data.right, rect_data.bottom); 64 | let color = *img.get_pixel(left, top); 65 | // 把截图区域外的像素点设置为左上角的颜色 66 | for (x, y, pixel) in img.enumerate_pixels_mut() { 67 | if x < left || x > right || y < top || y > bottom { 68 | *pixel = color; 69 | } 70 | } 71 | let mut backgrounds = backgrounds.lock(); 72 | backgrounds.push(img); 73 | // 按照像素值排序,保证黑色背景水印图在前,白色背景水印图在后 74 | backgrounds.sort_by(|a, b| { 75 | let a_color = a.get_pixel(0, 0); 76 | let b_color = b.get_pixel(0, 0); 77 | a_color[0].cmp(&b_color[0]) 78 | }); 79 | if backgrounds.len() < 2 { 80 | return Ok(()); 81 | } 82 | 83 | let black = &backgrounds[0]; 84 | let white = &backgrounds[backgrounds.len() - 1]; 85 | // 如果黑色和白色背景水印图的像素值差异大于50,则认为找到了黑色和白色背景水印图 86 | let black_color = black.get_pixel(0, 0); 87 | let white_color = white.get_pixel(0, 0); 88 | if white_color[0] - black_color[0] > 50 { 89 | *background_pair_found.lock() = true; 90 | } 91 | 92 | Ok(()) 93 | })?; 94 | 95 | let backgrounds = std::mem::take(&mut *backgrounds.lock()); 96 | let background_pair_found = std::mem::take(&mut *background_pair_found.lock()); 97 | // 如果有第一张背景水印图,则将其保存为黑色背景 98 | if let Some(black) = backgrounds.first() { 99 | let black_output_path = output_dir.join("black.png"); 100 | black 101 | .save(&black_output_path) 102 | .context(format!("保存图片 {black_output_path:?} 失败",))?; 103 | } 104 | // 如果找到了黑色和白色背景水印图 105 | if background_pair_found { 106 | // 把最后一张背景水印图保存为白色背景 107 | let white = &backgrounds[backgrounds.len() - 1]; 108 | let white_output_path = output_dir.join("white.png"); 109 | white 110 | .save(&white_output_path) 111 | .context(format!("保存图片 {white_output_path:?} 失败",))?; 112 | } 113 | 114 | if backgrounds.is_empty() { 115 | return Err(anyhow!("找不到尺寸为({width}x{height})的背景水印图\n").into()); 116 | } else if !background_pair_found { 117 | return Err(anyhow!("只找到一张尺寸为({width}x{height})的背景水印图\n").into()); 118 | }; 119 | 120 | Ok(()) 121 | } 122 | 123 | /// 遍历`manga_dir`目录下的所有图片文件,收集尺寸符合`width`和`height`的图片的路径 124 | #[allow(clippy::cast_possible_truncation)] 125 | fn create_image_paths(manga_dir: &str, width: u32, height: u32) -> Vec { 126 | let image_paths: Vec = WalkDir::new(PathBuf::from(manga_dir)) 127 | .max_depth(2) // 一般第一层目录是章节目录,第二层目录是图片文件 128 | .into_iter() 129 | .filter_map(Result::ok) 130 | .filter_map(|entry| { 131 | let path = entry.into_path(); 132 | if !path.is_file() || !path.is_image() { 133 | return None; 134 | } 135 | // 只收集尺寸符合width和height的图片的路径 136 | let (img_width, img_height) = image::image_dimensions(&path).ok()?; 137 | if img_width == width && img_height == height { 138 | Some(path) 139 | } else { 140 | None 141 | } 142 | }) 143 | .collect(); 144 | image_paths 145 | } 146 | 147 | /// 检查图片`img`是否满足背景的条件 148 | #[allow(clippy::cast_precision_loss)] 149 | fn is_background(img: &RgbImage, rect_data: &RectData) -> bool { 150 | let (left, top) = (rect_data.left, rect_data.top); 151 | let (right, bottom) = (rect_data.right, rect_data.bottom); 152 | let inside_rect = |x: u32, y: u32| x >= left && x <= right && y >= top && y <= bottom; 153 | // 获取左上角的颜色 154 | let color = *img.get_pixel(left, top); 155 | let [r, g, b] = color.0; 156 | // 如果r,g,b通道之间不相等,则不满足背景的条件 157 | if r != g || g != b { 158 | return false; 159 | } 160 | // 如果截图区域的左右两边的颜色有一个与左上角的颜色不同,则不满足背景的条件 161 | for y in top..=bottom { 162 | if img.get_pixel(left, y) != &color || img.get_pixel(right, y) != &color { 163 | return false; 164 | } 165 | } 166 | // 如果截图区域的上下两边的颜色有一个与左上角的颜色不同,则不满足背景的条件 167 | for x in left..=right { 168 | if img.get_pixel(x, top) != &color || img.get_pixel(x, bottom) != &color { 169 | return false; 170 | } 171 | } 172 | // 统计rect_data区域内color颜色的像素点数量 173 | let color_count = img 174 | .enumerate_pixels() 175 | .filter(|(x, y, &pixel)| inside_rect(*x, *y) && pixel == color) 176 | .count(); 177 | // 如果rect_data区域内的像素点数量大于总数的90%,则不满足背景的条件 178 | if color_count as f32 / ((right - left + 1) * (bottom - top + 1)) as f32 > 0.9 { 179 | return false; 180 | } 181 | true 182 | } 183 | -------------------------------------------------------------------------------- /src/bindings.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. 3 | 4 | /** user-defined commands **/ 5 | 6 | 7 | export const commands = { 8 | async generateBackground(mangaDir: string, rectData: RectData | null, width: number, height: number) : Promise> { 9 | try { 10 | return { status: "ok", data: await TAURI_INVOKE("generate_background", { mangaDir, rectData, width, height }) }; 11 | } catch (e) { 12 | if(e instanceof Error) throw e; 13 | else return { status: "error", error: e as any }; 14 | } 15 | }, 16 | async removeWatermark(mangaDir: string, outputDir: string, format: ImageFormat, optimize: boolean, backgroundsData: ([ImageData, ImageData])[]) : Promise> { 17 | try { 18 | return { status: "ok", data: await TAURI_INVOKE("remove_watermark", { mangaDir, outputDir, format, optimize, backgroundsData }) }; 19 | } catch (e) { 20 | if(e instanceof Error) throw e; 21 | else return { status: "error", error: e as any }; 22 | } 23 | }, 24 | async openImage(path: string) : Promise> { 25 | try { 26 | return { status: "ok", data: await TAURI_INVOKE("open_image", { path }) }; 27 | } catch (e) { 28 | if(e instanceof Error) throw e; 29 | else return { status: "error", error: e as any }; 30 | } 31 | }, 32 | async getMangaDirData(mangaDir: string) : Promise> { 33 | try { 34 | return { status: "ok", data: await TAURI_INVOKE("get_manga_dir_data", { mangaDir }) }; 35 | } catch (e) { 36 | if(e instanceof Error) throw e; 37 | else return { status: "error", error: e as any }; 38 | } 39 | }, 40 | async getImageInfos(mangaDir: string) : Promise { 41 | return await TAURI_INVOKE("get_image_infos", { mangaDir }); 42 | }, 43 | async showPathInFileManager(path: string) : Promise { 44 | await TAURI_INVOKE("show_path_in_file_manager", { path }); 45 | }, 46 | async getBackgroundDirRelativePath(mangaDir: string, width: number, height: number) : Promise> { 47 | try { 48 | return { status: "ok", data: await TAURI_INVOKE("get_background_dir_relative_path", { mangaDir, width, height }) }; 49 | } catch (e) { 50 | if(e instanceof Error) throw e; 51 | else return { status: "error", error: e as any }; 52 | } 53 | }, 54 | async getBackgroundDirAbsPath(mangaDir: string, width: number, height: number) : Promise> { 55 | try { 56 | return { status: "ok", data: await TAURI_INVOKE("get_background_dir_abs_path", { mangaDir, width, height }) }; 57 | } catch (e) { 58 | if(e instanceof Error) throw e; 59 | else return { status: "error", error: e as any }; 60 | } 61 | }, 62 | async getConfig() : Promise { 63 | return await TAURI_INVOKE("get_config"); 64 | }, 65 | async saveConfig(config: Config) : Promise> { 66 | try { 67 | return { status: "ok", data: await TAURI_INVOKE("save_config", { config }) }; 68 | } catch (e) { 69 | if(e instanceof Error) throw e; 70 | else return { status: "error", error: e as any }; 71 | } 72 | } 73 | } 74 | 75 | /** user-defined events **/ 76 | 77 | 78 | export const events = __makeEvents__<{ 79 | removeWatermarkEndEvent: RemoveWatermarkEndEvent, 80 | removeWatermarkErrorEvent: RemoveWatermarkErrorEvent, 81 | removeWatermarkStartEvent: RemoveWatermarkStartEvent, 82 | removeWatermarkSuccessEvent: RemoveWatermarkSuccessEvent 83 | }>({ 84 | removeWatermarkEndEvent: "remove-watermark-end-event", 85 | removeWatermarkErrorEvent: "remove-watermark-error-event", 86 | removeWatermarkStartEvent: "remove-watermark-start-event", 87 | removeWatermarkSuccessEvent: "remove-watermark-success-event" 88 | }) 89 | 90 | /** user-defined constants **/ 91 | 92 | 93 | 94 | /** user-defined types **/ 95 | 96 | export type CommandError = string 97 | export type Config = { outputDir: string; outputFormat: ImageFormat; outputOptimize: boolean } 98 | export type ImageData = { info: ImageInfo; data: number[] } 99 | export type ImageFormat = "Jpeg" | "Png" 100 | export type ImageInfo = { width: number; height: number; path: string } 101 | export type MangaDirData = { width: number; height: number; count: number; blackBackground: ImageData | null; whiteBackground: ImageData | null } 102 | export type RectData = { left: number; top: number; right: number; bottom: number } 103 | export type RemoveWatermarkEndEvent = RemoveWatermarkEndEventPayload 104 | export type RemoveWatermarkEndEventPayload = { dirPath: string } 105 | export type RemoveWatermarkErrorEvent = RemoveWatermarkErrorEventPayload 106 | export type RemoveWatermarkErrorEventPayload = { dirPath: string; imgPath: string; errMsg: string } 107 | export type RemoveWatermarkStartEvent = RemoveWatermarkStartEventPayload 108 | export type RemoveWatermarkStartEventPayload = { dirPath: string; total: number } 109 | export type RemoveWatermarkSuccessEvent = RemoveWatermarkSuccessEventPayload 110 | export type RemoveWatermarkSuccessEventPayload = { dirPath: string; imgPath: string; current: number } 111 | 112 | /** tauri-specta globals **/ 113 | 114 | import { 115 | invoke as TAURI_INVOKE, 116 | Channel as TAURI_CHANNEL, 117 | } from "@tauri-apps/api/core"; 118 | import * as TAURI_API_EVENT from "@tauri-apps/api/event"; 119 | import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; 120 | 121 | type __EventObj__ = { 122 | listen: ( 123 | cb: TAURI_API_EVENT.EventCallback, 124 | ) => ReturnType>; 125 | once: ( 126 | cb: TAURI_API_EVENT.EventCallback, 127 | ) => ReturnType>; 128 | emit: T extends null 129 | ? (payload?: T) => ReturnType 130 | : (payload: T) => ReturnType; 131 | }; 132 | 133 | export type Result = 134 | | { status: "ok"; data: T } 135 | | { status: "error"; error: E }; 136 | 137 | function __makeEvents__>( 138 | mappings: Record, 139 | ) { 140 | return new Proxy( 141 | {} as unknown as { 142 | [K in keyof T]: __EventObj__ & { 143 | (handle: __WebviewWindow__): __EventObj__; 144 | }; 145 | }, 146 | { 147 | get: (_, event) => { 148 | const name = mappings[event as keyof T]; 149 | 150 | return new Proxy((() => {}) as any, { 151 | apply: (_, __, [window]: [__WebviewWindow__]) => ({ 152 | listen: (arg: any) => window.listen(name, arg), 153 | once: (arg: any) => window.once(name, arg), 154 | emit: (arg: any) => window.emit(name, arg), 155 | }), 156 | get: (_, command: keyof __EventObj__) => { 157 | switch (command) { 158 | case "listen": 159 | return (arg: any) => TAURI_API_EVENT.listen(name, arg); 160 | case "once": 161 | return (arg: any) => TAURI_API_EVENT.once(name, arg); 162 | case "emit": 163 | return (arg: any) => TAURI_API_EVENT.emit(name, arg); 164 | } 165 | }, 166 | }); 167 | }, 168 | }, 169 | ); 170 | } 171 | -------------------------------------------------------------------------------- /src/components/WatermarkCropper.vue: -------------------------------------------------------------------------------- 1 | 199 | 200 | 217 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: "publish" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | REPO_NAME: bilibili-manga-watermark-remover 10 | 11 | jobs: 12 | get-version: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | VERSION: ${{ steps.get_version.outputs.VERSION }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Get version number 20 | id: get_version 21 | run: | 22 | VERSION=$(jq -r '.version' src-tauri/tauri.conf.json) 23 | echo "VERSION=$VERSION" >> $GITHUB_OUTPUT 24 | 25 | windows-build: 26 | needs: get-version 27 | env: 28 | VERSION: ${{ needs.get-version.outputs.VERSION }} 29 | outputs: 30 | VERSION: ${{ env.VERSION }} 31 | runs-on: windows-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - name: Setup node 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: lts/* 39 | 40 | - name: Install Rust stable 41 | uses: dtolnay/rust-toolchain@stable 42 | 43 | - name: Install pnpm 44 | uses: pnpm/action-setup@v4 45 | with: 46 | run_install: false 47 | 48 | - name: Install frontend dependencies 49 | run: pnpm install 50 | 51 | - name: Build tauri app 52 | uses: tauri-apps/tauri-action@v0 53 | 54 | - name: Create artifacts directory 55 | run: mkdir -p artifacts 56 | 57 | - name: Copy nsis to release assets 58 | run: cp src-tauri/target/release/bundle/nsis/${{ env.REPO_NAME }}_${{ env.VERSION }}_x64-setup.exe artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_windows_x64.exe 59 | 60 | - name: Zip portable to release assets 61 | run: | 62 | cd src-tauri/target/release 63 | 7z a -tzip ../../../artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_windows_x64_portable.zip ${{ env.REPO_NAME }}.exe 64 | 65 | - name: Upload artifacts 66 | uses: actions/upload-artifact@v4 67 | with: 68 | name: windows-assets 69 | path: artifacts/* 70 | 71 | linux-build: 72 | needs: get-version 73 | env: 74 | VERSION: ${{ needs.get-version.outputs.VERSION }} 75 | outputs: 76 | VERSION: ${{ env.VERSION }} 77 | runs-on: ubuntu-24.04 78 | steps: 79 | - name: install dependencies 80 | run: | 81 | sudo apt-get update 82 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 83 | - uses: actions/checkout@v4 84 | 85 | - name: Setup node 86 | uses: actions/setup-node@v4 87 | with: 88 | node-version: lts/* 89 | 90 | - name: Install Rust stable 91 | uses: dtolnay/rust-toolchain@stable 92 | 93 | - name: Install pnpm 94 | uses: pnpm/action-setup@v4 95 | with: 96 | run_install: false 97 | 98 | - name: Install frontend dependencies 99 | run: pnpm install 100 | 101 | - name: Build tauri app 102 | uses: tauri-apps/tauri-action@v0 103 | 104 | - name: Create artifacts directory 105 | run: mkdir -p artifacts 106 | 107 | - name: Copy deb to release assets 108 | run: cp src-tauri/target/release/bundle/deb/${{ env.REPO_NAME }}_${{ env.VERSION }}_amd64.deb artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_linux_amd64.deb 109 | 110 | - name: Copy rpm to release assets 111 | run: cp src-tauri/target/release/bundle/rpm/${{ env.REPO_NAME }}-${{ env.VERSION }}-1.x86_64.rpm artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_linux_amd64.rpm 112 | 113 | - name: Zip portable to release assets 114 | run: | 115 | cd src-tauri/target/release 116 | tar -czf ../../../artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_linux_amd64_portable.tar.gz ${{ env.REPO_NAME }} 117 | 118 | - name: Upload artifacts 119 | uses: actions/upload-artifact@v4 120 | with: 121 | name: linux-assets 122 | path: artifacts/* 123 | 124 | macos-build: 125 | needs: get-version 126 | env: 127 | VERSION: ${{ needs.get-version.outputs.VERSION }} 128 | outputs: 129 | VERSION: ${{ env.VERSION }} 130 | strategy: 131 | fail-fast: false 132 | matrix: 133 | arch: [ aarch64, x86_64 ] 134 | runs-on: macos-latest 135 | steps: 136 | - uses: actions/checkout@v4 137 | 138 | - name: Setup node 139 | uses: actions/setup-node@v4 140 | with: 141 | node-version: lts/* 142 | 143 | - name: Install Rust stable 144 | uses: dtolnay/rust-toolchain@stable 145 | with: 146 | targets: ${{ matrix.arch }}-apple-darwin 147 | 148 | - name: Install pnpm 149 | uses: pnpm/action-setup@v4 150 | with: 151 | run_install: false 152 | 153 | - name: Install frontend dependencies 154 | run: pnpm install 155 | 156 | - name: Build tauri app 157 | uses: tauri-apps/tauri-action@v0 158 | with: 159 | args: --target ${{ matrix.arch }}-apple-darwin 160 | 161 | - name: Create artifacts directory 162 | run: mkdir -p artifacts 163 | 164 | - name: Copy dmg to release assets 165 | env: 166 | ARCH_ALIAS: ${{ matrix.arch == 'x86_64' && 'x64' || matrix.arch }} 167 | run: cp src-tauri/target/${{ matrix.arch }}-apple-darwin/release/bundle/dmg/${{ env.REPO_NAME }}_${{ env.VERSION }}_${{ env.ARCH_ALIAS }}.dmg artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_macos_${{ matrix.arch }}.dmg 168 | 169 | - name: Upload artifacts 170 | uses: actions/upload-artifact@v4 171 | with: 172 | name: macos-assets-${{ matrix.arch }} 173 | path: artifacts/* 174 | 175 | create-release: 176 | needs: [ windows-build, linux-build, macos-build ] 177 | runs-on: ubuntu-latest 178 | permissions: 179 | contents: write 180 | steps: 181 | - name: Download Windows assets 182 | uses: actions/download-artifact@v4 183 | with: 184 | name: windows-assets 185 | path: artifacts/windows 186 | 187 | - name: Download Linux assets 188 | uses: actions/download-artifact@v4 189 | with: 190 | name: linux-assets 191 | path: artifacts/linux 192 | 193 | - name: Download macOS aarch64 assets 194 | uses: actions/download-artifact@v4 195 | with: 196 | name: macos-assets-aarch64 197 | path: artifacts/macos-aarch64 198 | 199 | - name: Download macOS x86_64 assets 200 | uses: actions/download-artifact@v4 201 | with: 202 | name: macos-assets-x86_64 203 | path: artifacts/macos-x86_64 204 | 205 | - name: List files in artifacts directory 206 | run: ls -R artifacts 207 | 208 | - name: Create GitHub Release 209 | id: create_release 210 | uses: softprops/action-gh-release@v2 211 | env: 212 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 213 | with: 214 | name: Desktop App v${{ needs.windows-build.outputs.VERSION }} 215 | body: | 216 | Take a look at the assets to download and install this app. 217 | files: | 218 | artifacts/windows/* 219 | artifacts/linux/* 220 | artifacts/macos-aarch64/* 221 | artifacts/macos-x86_64/* 222 | draft: true 223 | prerelease: false -------------------------------------------------------------------------------- /src/AppContent.vue: -------------------------------------------------------------------------------- 1 | 219 | 220 | 311 | -------------------------------------------------------------------------------- /src-tauri/src/commands/remove_watermark.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::io::BufWriter; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use anyhow::{anyhow, Context}; 6 | use image::codecs::png::PngEncoder; 7 | use image::{Rgb, RgbImage}; 8 | use parking_lot::Mutex; 9 | use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; 10 | use tauri::AppHandle; 11 | use tauri_specta::Event; 12 | use walkdir::WalkDir; 13 | 14 | use crate::errors::CommandResult; 15 | use crate::events; 16 | use crate::extensions::PathIsImage; 17 | use crate::types::{ImageData, ImageFormat}; 18 | 19 | #[tauri::command(async)] 20 | #[specta::specta] 21 | #[allow(clippy::needless_pass_by_value)] 22 | pub fn remove_watermark( 23 | app: AppHandle, 24 | manga_dir: &str, 25 | output_dir: &str, 26 | format: ImageFormat, 27 | optimize: bool, 28 | backgrounds_data: Vec<(ImageData, ImageData)>, 29 | ) -> CommandResult<()> { 30 | let manga_dir = PathBuf::from(manga_dir); 31 | let manga_dir_without_name = manga_dir 32 | .parent() 33 | .ok_or(anyhow!("漫画目录 {manga_dir:?} 的父目录不存在"))?; 34 | let output_dir = PathBuf::from(output_dir); 35 | // (width, height) => (black, white) 36 | let backgrounds = create_backgrounds(&backgrounds_data)?; 37 | // dir => [img_path1, img_path2, ...] 38 | let dir_map = create_dir_map(&manga_dir); 39 | // dir => (current, total) 40 | let dir_progress = create_dir_progress(&app, &dir_map)?; 41 | // 使用Mutex包装dir_progress,用于并发更新目录的进度 42 | let dir_progress = Mutex::new(dir_progress); 43 | // 使用rayon的并行迭代器,并行处理每个目录 44 | let dir_map = dir_map.par_iter(); 45 | dir_map.try_for_each(|entry| -> anyhow::Result<()> { 46 | let (dir, img_paths) = entry; 47 | // 使用rayon的并行迭代器,并行处理每个目录下的图片 48 | let img_paths = img_paths.par_iter(); 49 | img_paths.try_for_each(|img_path| -> anyhow::Result<()> { 50 | // 获取相对路径(漫画名/章节名/图片名) 51 | let relative_path = img_path 52 | .strip_prefix(manga_dir_without_name) 53 | .context(format!( 54 | "{manga_dir_without_name:?} 不是 {img_path:?} 的父目录" 55 | ))?; 56 | // 构建输出图片的路径(输出目录/漫画名/章节名/图片名) 57 | let out_image_path = output_dir.join(relative_path); 58 | // 获取图片的尺寸 59 | let (width, height) = image::image_dimensions(img_path) 60 | .context(format!("获取图片 {img_path:?} 的尺寸失败"))?; 61 | if let Some((black, white)) = backgrounds.get(&(width, height)) { 62 | // 在backgrounds中找到了黑色背景和白色背景的水印图片,可以去除水印 63 | let mut img = image::open(img_path) 64 | .context(format!("打开图片 {img_path:?} 失败"))? 65 | .to_rgb8(); 66 | 67 | remove_image_watermark(black, white, &mut img); 68 | 69 | save_image(&img, &out_image_path, &format, optimize) 70 | .context(format!("保存图片 {out_image_path:?} 失败"))?; 71 | } else { 72 | // 否则,直接复制图片到输出目录 73 | if let Some(parent) = out_image_path.parent() { 74 | // 保证输出目录存在 75 | std::fs::create_dir_all(parent).context(format!("创建目录 {parent:?} 失败"))?; 76 | } 77 | std::fs::copy(img_path, &out_image_path) 78 | .context(format!("复制图片 {img_path:?} 到 {out_image_path:?} 失败"))?; 79 | } 80 | // 更新目录的进度 81 | let (current, total) = { 82 | let mut dir_progress = dir_progress.lock(); 83 | let (current, total) = dir_progress 84 | .get_mut(dir) 85 | .ok_or(anyhow!("目录 {dir:?} 的进度不存在"))?; 86 | *current += 1; 87 | (*current, *total) 88 | }; 89 | // 发送RemoveWatermarkSuccessEvent事件 90 | let payload = events::RemoveWatermarkSuccessEventPayload { 91 | dir_path: dir.clone(), 92 | img_path: out_image_path.clone(), 93 | current, 94 | }; 95 | let event = events::RemoveWatermarkSuccessEvent(payload); 96 | event.emit(&app)?; 97 | // 如果当前图片是目录下的最后一张图片,则发送RemoveWatermarkEndEvent事件 98 | if current == total { 99 | let payload = events::RemoveWatermarkEndEventPayload { 100 | dir_path: dir.clone(), 101 | }; 102 | let event = events::RemoveWatermarkEndEvent(payload); 103 | event.emit(&app)?; 104 | } 105 | 106 | Ok(()) 107 | })?; 108 | Ok(()) 109 | })?; 110 | 111 | Ok(()) 112 | } 113 | 114 | /// 构建一个`HashMap`,`key`是目录的路径,`value`是该目录下的所有图片文件的路径 115 | #[allow(clippy::cast_possible_truncation)] 116 | fn create_dir_progress<'a>( 117 | app: &AppHandle, 118 | dir_map: &'a HashMap>, 119 | ) -> anyhow::Result> { 120 | let dir_progress: HashMap<&PathBuf, (u32, u32)> = dir_map 121 | .keys() 122 | .map(|dir| { 123 | let total = dir_map[dir].len() as u32; 124 | // 发送RemoveWatermarkStartEvent事件 125 | let payload = events::RemoveWatermarkStartEventPayload { 126 | dir_path: dir.clone(), 127 | total, 128 | }; 129 | let event = events::RemoveWatermarkStartEvent(payload); 130 | event.emit(app).map_err(anyhow::Error::from)?; 131 | 132 | Ok((dir, (0, total))) 133 | }) 134 | .collect::>>()?; 135 | Ok(dir_progress) 136 | } 137 | 138 | /// 构建一个`HashMap`,`key`是目录的路径,`value`是该目录下的所有图片文件的路径 139 | fn create_dir_map(manga_dir: &PathBuf) -> HashMap> { 140 | let mut dir_map: HashMap> = HashMap::new(); 141 | // 遍历manga_dir目录下的所有文件和子目录 142 | WalkDir::new(manga_dir) 143 | .into_iter() 144 | .filter_map(Result::ok) 145 | .filter_map(|entry| { 146 | let path = entry.into_path(); 147 | if !path.is_file() || !path.is_image() { 148 | return None; 149 | } 150 | let parent = path.parent()?.to_path_buf(); 151 | Some((path, parent)) 152 | }) 153 | .for_each(|(path, parent)| dir_map.entry(parent).or_default().push(path)); 154 | dir_map 155 | } 156 | 157 | /// 构建一个`HashMap`,`key`是背景水印图的尺寸,`value`是黑色背景和白色背景水印图 158 | fn create_backgrounds( 159 | backgrounds_data: &[(ImageData, ImageData)], 160 | ) -> anyhow::Result> { 161 | let backgrounds = backgrounds_data 162 | .iter() 163 | .map(|(black_data, white_data)| { 164 | let black = black_data 165 | .to_image() 166 | .context(format!("黑色背景水印图 {:?} 转换失败", black_data.info.path))? 167 | .to_rgb8(); 168 | let white = white_data 169 | .to_image() 170 | .context(format!("白色背景水印图 {:?} 转换失败", white_data.info.path))? 171 | .to_rgb8(); 172 | if black.dimensions() != white.dimensions() { 173 | return Err(anyhow!( 174 | "黑色背景和白色背景水印图的尺寸不一致,黑色背景水印图的尺寸是 ({}x{}),白色背景水印图的尺寸是 ({}x{})", 175 | black.width(), 176 | black.height(), 177 | white.width(), 178 | white.height(), 179 | )); 180 | } 181 | Ok(((black.width(), black.height()), (black, white))) 182 | }) 183 | .collect::>>()?; 184 | Ok(backgrounds) 185 | } 186 | 187 | /// 去除`img`的水印 188 | #[allow(clippy::cast_possible_truncation)] 189 | #[allow(clippy::cast_lossless)] 190 | #[allow(clippy::cast_sign_loss)] 191 | fn remove_image_watermark(black: &RgbImage, white: &RgbImage, img: &mut RgbImage) { 192 | if img.width() != white.width() || img.height() != white.height() { 193 | return; 194 | } 195 | // 遍历图片的每个像素点 196 | let [black_in_r, black_in_g, black_in_b] = black.get_pixel(0, 0).0.map(|x| x as f64); 197 | for (x, y, img_pixel) in img.enumerate_pixels_mut() { 198 | let [out_r, out_g, out_b] = img_pixel.0.map(|x| x as f64); 199 | let [black_out_r, black_out_g, black_out_b] = black.get_pixel(x, y).0.map(|x| x as f64); 200 | let [white_out_r, white_out_g, white_out_b] = white.get_pixel(x, y).0.map(|x| x as f64); 201 | 202 | let in_r = (out_r - black_out_r) / ((white_out_r - black_out_r) / 255.0) + black_in_r; 203 | let in_g = (out_g - black_out_g) / ((white_out_g - black_out_g) / 255.0) + black_in_g; 204 | let in_b = (out_b - black_out_b) / ((white_out_b - black_out_b) / 255.0) + black_in_b; 205 | // 将f64转换为u8自带clamp功能 206 | let watermark_removed_r = in_r.round() as u8; 207 | let watermark_removed_g = in_g.round() as u8; 208 | let watermark_removed_b = in_b.round() as u8; 209 | // 将去除水印后的像素点赋值给img 210 | *img_pixel = Rgb([ 211 | watermark_removed_r, 212 | watermark_removed_g, 213 | watermark_removed_b, 214 | ]); 215 | } 216 | } 217 | 218 | /// 保存图片`img`到指定路径`path`,`format`为图片格式,`optimize`为true时会检查图片是否为灰度图像,如果是则保存为luma8图片 219 | #[allow(clippy::cast_possible_truncation)] 220 | fn save_image( 221 | img: &RgbImage, 222 | path: &Path, 223 | format: &ImageFormat, 224 | optimize: bool, 225 | ) -> anyhow::Result<()> { 226 | // 保证输出目录存在 227 | if let Some(parent) = path.parent() { 228 | std::fs::create_dir_all(parent).context(format!("创建目录 {parent:?} 失败"))?; 229 | } 230 | 231 | match format { 232 | ImageFormat::Jpeg => { 233 | save_jpg_image(img, path, optimize)?; 234 | } 235 | ImageFormat::Png => { 236 | save_png_image(img, path, optimize)?; 237 | } 238 | } 239 | Ok(()) 240 | } 241 | 242 | /// 保存jpg图片`img`到指定路径`path`, `optimize`为true时会检查图片是否为灰度图像,如果是则保存为luma8图片 243 | #[allow(clippy::cast_possible_truncation)] 244 | fn save_jpg_image(img: &RgbImage, path: &Path, optimize: bool) -> anyhow::Result<()> { 245 | let (width, height) = (img.width() as u16, img.height() as u16); 246 | // 保证后缀为jpg 247 | let path = path.with_extension("jpg"); 248 | // 保存去除水印后的图片,使用jpeg_encoder库的Encoder处理jpg效率更高 249 | let encoder = jpeg_encoder::Encoder::new_file(&path, 95)?; 250 | if optimize && is_grey_image(img) { 251 | let luma = image::DynamicImage::ImageRgb8(img.clone()).into_luma8(); 252 | encoder 253 | .encode(luma.as_raw(), width, height, jpeg_encoder::ColorType::Luma) 254 | .context(format!("编码luma8图片 {path:?} 失败"))?; 255 | } else { 256 | encoder 257 | .encode(img.as_raw(), width, height, jpeg_encoder::ColorType::Rgb) 258 | .context(format!("编码rgb图片 {path:?} 失败"))?; 259 | } 260 | Ok(()) 261 | } 262 | 263 | /// 保存png图片`img`到指定路径`path`, `optimize`为true时会检查图片是否为灰度图像,如果是则保存为luma8图片 264 | #[allow(clippy::cast_possible_truncation)] 265 | fn save_png_image(img: &RgbImage, path: &Path, optimize: bool) -> anyhow::Result<()> { 266 | // 保证后缀为png 267 | let path = path.with_extension("png"); 268 | let png_file = std::fs::File::create(&path)?; 269 | let buffered_file_writer = BufWriter::new(png_file); 270 | let encoder = PngEncoder::new(buffered_file_writer); 271 | if optimize && is_grey_image(img) { 272 | let luma = image::DynamicImage::ImageRgb8(img.clone()).into_luma8(); 273 | luma.write_with_encoder(encoder) 274 | .context(format!("编码luma8图片 {path:?} 失败"))?; 275 | } else { 276 | img.write_with_encoder(encoder) 277 | .context(format!("编码rgb图片 {path:?} 失败"))?; 278 | } 279 | Ok(()) 280 | } 281 | 282 | fn is_grey_image(img: &RgbImage) -> bool { 283 | img.pixels().all(|pixel| { 284 | let [r, g, b] = pixel.0; 285 | r == g && g == b 286 | }) 287 | } 288 | --------------------------------------------------------------------------------