├── .husky ├── pre-push ├── pre-commit └── post-merge ├── .env.standalone ├── tests ├── fixture │ ├── invalid.png │ ├── 1px.jpg │ ├── 1px.png │ ├── 2px.jpg │ ├── 2px.png │ ├── 1px_alpha.jpg │ ├── 1px_alpha.png │ ├── 2px_alpha.jpg │ ├── 2px_alpha.png │ └── index.ts ├── e2e │ ├── types.ts │ ├── components │ │ ├── _PageObjectBase.ts │ │ ├── SettingsSection.ts │ │ ├── Convertlist.ts │ │ ├── ConvertItem.ts │ │ ├── InputFileListHeader.ts │ │ └── InputFileList.ts │ ├── constants │ │ └── params.ts │ ├── util │ │ └── testUtils.ts │ └── specs │ │ └── settings.spec.ts ├── unit │ ├── components │ │ ├── common │ │ │ ├── form │ │ │ │ ├── VFormButton.spec.ts │ │ │ │ ├── VFormSelectBox.spec.ts │ │ │ │ ├── VFormRadio.spec.ts │ │ │ │ ├── VFormCheckBox.spec.ts │ │ │ │ ├── VFormInput.spec.ts │ │ │ │ └── VFormFileInputDrop.spec.ts │ │ │ ├── VClosableItem.spec.ts │ │ │ ├── VHintBalloon.spec.ts │ │ │ └── VAccordionContent.spec.ts │ │ ├── settings │ │ │ ├── ColorSelector.spec.ts │ │ │ ├── LanguageSelector.spec.ts │ │ │ └── LinkList.spec.ts │ │ ├── MainHeader.spec.ts │ │ ├── InputErrorList.spec.ts │ │ └── convert │ │ │ └── ScaledImageList │ │ │ ├── ItemListView.spec.ts │ │ │ └── ItemGridView.spec.ts │ ├── __mocks__ │ │ └── models │ │ │ └── InputImageData.ts │ ├── guards │ │ └── form.spec.ts │ ├── core │ │ ├── plugins │ │ │ └── i18n.spec.ts │ │ ├── services │ │ │ ├── colorService.spec.ts │ │ │ └── image │ │ │ │ └── entryService.spec.ts │ │ └── utils │ │ │ └── ogp.spec.ts │ ├── composables │ │ ├── useColor.spec.ts │ │ ├── useScaleSettings.spec.ts │ │ ├── useI18n.spec.ts │ │ ├── useDisplayStyle.spec.ts │ │ ├── useI18nTextKey.spec.ts │ │ └── useGlobalError.spec.ts │ ├── vitest.setup.ts │ ├── algorithm │ │ └── helpers.ts │ └── stores │ │ ├── outputPathStore.spec.ts │ │ └── errorStore.spec.ts └── utils │ └── imageTestHelper.ts ├── src-tauri ├── build.rs ├── icons │ ├── icon.ico │ └── logo.png ├── .gitignore ├── src │ ├── main.rs │ └── lib.rs ├── Cargo.toml ├── capabilities │ └── default.json ├── resources │ ├── README_ja.txt │ └── README_en.txt └── tauri.conf.json ├── src ├── constants │ ├── displayStyle.ts │ ├── imageFile.ts │ ├── link.ts │ ├── icon.ts │ └── form.ts ├── algorithm │ ├── index.ts │ └── Nearestneighbor.ts ├── @types │ ├── link.ts │ ├── form.ts │ ├── error.ts │ └── convert.ts ├── assets │ ├── variables.scss │ └── theme.scss ├── core │ ├── @types │ │ ├── global.d.ts │ │ ├── i18n.ts │ │ ├── vue.d.ts │ │ ├── github.ts │ │ ├── color.ts │ │ └── xBRjs.d.ts │ ├── config │ │ ├── colors │ │ │ ├── dark.json │ │ │ ├── blue_dark.json │ │ │ ├── red_dark.json │ │ │ ├── blue.json │ │ │ ├── gray.json │ │ │ ├── green.json │ │ │ ├── green_dark.json │ │ │ └── red.json │ │ └── i18n │ │ │ └── cn.json │ ├── plugins │ │ ├── i18n.ts │ │ └── meta.ts │ ├── constants │ │ ├── i18n.ts │ │ └── color.ts │ ├── services │ │ ├── colorService.ts │ │ ├── image │ │ │ ├── entryService.ts │ │ │ ├── entryBatchService.ts │ │ │ └── convertService.ts │ │ └── i18nService.ts │ ├── infrastructure │ │ └── storage.ts │ ├── utils │ │ └── ogp.ts │ └── system.ts ├── vite-env.d.ts ├── guards │ └── form.ts ├── components │ ├── settings │ │ ├── SettingsSection.vue │ │ ├── LinkList.vue │ │ ├── LanguageSelector.vue │ │ └── ColorSelector.vue │ ├── common │ │ ├── form │ │ │ ├── VFormButton.vue │ │ │ ├── VFormFileInputDrop.vue │ │ │ ├── VFormRadio.vue │ │ │ ├── VFormCheckBox.vue │ │ │ ├── VFormFileInput.vue │ │ │ ├── VFormSelectBox.vue │ │ │ └── VFormInput.vue │ │ ├── VClosableItem.vue │ │ ├── VHintBalloon.vue │ │ └── VAccordionContent.vue │ ├── MainHeader.vue │ ├── HowToUseSection.vue │ ├── convert │ │ ├── ConvertSection.vue │ │ └── ScaledImageList │ │ │ └── DirectorySelector.vue │ └── InputErrorList.vue ├── utils │ ├── imageItemUtils.ts │ ├── imageUtils.ts │ └── fileUtils.ts ├── composables │ ├── useScaleSettings.ts │ ├── useColor.ts │ ├── useGlobalError.ts │ ├── useDisplayStyle.ts │ ├── useI18n.ts │ ├── useI18nTextKey.ts │ ├── useImageCheckable.ts │ └── useFormFileInput.ts ├── models │ ├── errors │ │ ├── FileError.ts │ │ ├── UnknownError.ts │ │ ├── _ErrorBase.ts │ │ ├── InputError.ts │ │ └── ScaleError.ts │ └── __mocks__ │ │ └── InputImageData.ts ├── main.ts ├── stores │ ├── errorStore.ts │ ├── outputPathStore.ts │ ├── convertStore.ts │ └── scaledImageStore.ts └── App.vue ├── public ├── logo.png ├── banner.png └── favicon.ico ├── .env ├── .vscode └── extensions.json ├── .github ├── ISSUE_TEMPLATE │ ├── config.yaml │ ├── featire_request.yaml │ └── bug_report.yaml └── workflows │ ├── pr-check.yaml │ ├── test-and-deploy.yaml │ ├── weekly-dep-update.yaml │ ├── ci-common.yaml │ └── release-build.yaml ├── tsconfig.node.json ├── .gitignore ├── playwright.config.ts ├── tsconfig.json ├── LICENSE ├── index.html ├── vite ├── config │ └── pwa.ts └── plugins │ └── license.ts ├── vitest.config.ts ├── vite.config.ts ├── README.md ├── eslint.config.ts └── package.json /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run test 2 | -------------------------------------------------------------------------------- /.env.standalone: -------------------------------------------------------------------------------- 1 | VITE_IS_STANDALONE=true -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint-staged 2 | -------------------------------------------------------------------------------- /tests/fixture/invalid.png: -------------------------------------------------------------------------------- 1 | this is invalid image data 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src/constants/displayStyle.ts: -------------------------------------------------------------------------------- 1 | export const StorageKey = "display-style"; 2 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/public/logo.png -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_IS_STANDALONE=false 2 | VITE_GIT_ORG=irokaru 3 | VITE_GIT_REPO=pixel-scaler 4 | -------------------------------------------------------------------------------- /public/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/public/banner.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /tests/fixture/1px.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/tests/fixture/1px.jpg -------------------------------------------------------------------------------- /tests/fixture/1px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/tests/fixture/1px.png -------------------------------------------------------------------------------- /tests/fixture/2px.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/tests/fixture/2px.jpg -------------------------------------------------------------------------------- /tests/fixture/2px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/tests/fixture/2px.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/src-tauri/icons/logo.png -------------------------------------------------------------------------------- /src/algorithm/index.ts: -------------------------------------------------------------------------------- 1 | export { nearestNeighbor } from "./Nearestneighbor"; 2 | export { xBR } from "./xBR"; 3 | -------------------------------------------------------------------------------- /tests/fixture/1px_alpha.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/tests/fixture/1px_alpha.jpg -------------------------------------------------------------------------------- /tests/fixture/1px_alpha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/tests/fixture/1px_alpha.png -------------------------------------------------------------------------------- /tests/fixture/2px_alpha.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/tests/fixture/2px_alpha.jpg -------------------------------------------------------------------------------- /tests/fixture/2px_alpha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irokaru/pixel-scaler/HEAD/tests/fixture/2px_alpha.png -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /gen/schemas 5 | -------------------------------------------------------------------------------- /src/@types/link.ts: -------------------------------------------------------------------------------- 1 | export type Link = { 2 | url: string; 3 | icon: IconDefinition; 4 | textKey: string; 5 | }; 6 | -------------------------------------------------------------------------------- /src/assets/variables.scss: -------------------------------------------------------------------------------- 1 | $tablet-width: 768px; 2 | $tablet-height: 1024px; 3 | $smartphone-width: 528px; 4 | $smartphone-height: 896px; 5 | -------------------------------------------------------------------------------- /tests/e2e/types.ts: -------------------------------------------------------------------------------- 1 | export type ConvertOpts = { 2 | scaleSizePercent: number; 3 | originalPixelSize: number; 4 | scaleMode: string; 5 | }; 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "tauri-apps.tauri-vscode", 5 | "rust-lang.rust-analyzer" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/core/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | import { IconDefinition as FAIconDefinition } from "@fortawesome/fontawesome-common-types"; 2 | 3 | declare global { 4 | type IconDefinition = FAIconDefinition; 5 | } 6 | -------------------------------------------------------------------------------- /src/core/@types/i18n.ts: -------------------------------------------------------------------------------- 1 | export type LanguageBaseKey = "ja" | "en" | "cn" | "es" | "tr"; 2 | export type LanguageKeyForUnite = "ja" | "en" | "cn"; 3 | export type LanguageKey = LanguageBaseKey | LanguageKeyForUnite; 4 | -------------------------------------------------------------------------------- /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 | 4 | fn main() { 5 | app_lib::run(); 6 | } 7 | -------------------------------------------------------------------------------- /src/@types/form.ts: -------------------------------------------------------------------------------- 1 | export type ScaleModeType = "smooth" | "nearest"; 2 | 3 | export type ScaleParameterType = Record<"Min" | "Max" | "Default", number>; 4 | 5 | export type ResultDisplayStyleType = "grid" | "list"; 6 | -------------------------------------------------------------------------------- /src/core/@types/vue.d.ts: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; 2 | 3 | declare module "vue" { 4 | interface GlobalComponents { 5 | FontAwesomeIcon: typeof FontAwesomeIcon; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/e2e/components/_PageObjectBase.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "@playwright/test"; 2 | 3 | export abstract class PageObjectBase { 4 | constructor(protected readonly page: Page) { 5 | this.page = page; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/core/@types/github.ts: -------------------------------------------------------------------------------- 1 | export type Tag = { 2 | name: string; 3 | zipball_url: string; 4 | tarball_url: string; 5 | commit: { 6 | sha: string; 7 | url: string; 8 | }; 9 | node_id: string; 10 | }; 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Questions & Support 4 | url: https://github.com/irokaru/pixel-scaler/discussions 5 | about: Please use GitHub Discussions for general questions and support. 6 | -------------------------------------------------------------------------------- /src/core/config/colors/dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "font": "#ccc", 3 | "background": "#333", 4 | "edgeBright": "rgba(70, 70, 70, 0.75)", 5 | "edgeShadow": "rgba(0, 0, 0, 0.75)", 6 | "scrollbarBackground": "#fff", 7 | "scrollbarShadow": "#777", 8 | "scrollbarThumb": "#1c1c1c" 9 | } 10 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | CHANGED_FILES=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD) 2 | 3 | # TODO: confirm the operation 4 | if echo "$CHANGED_FILES" | grep -qE 'package(-lock)?\.json'; then 5 | echo "Run \"npm ci\" because package.json or package-lock.json changed" 6 | npm ci 7 | fi 8 | -------------------------------------------------------------------------------- /src/core/config/colors/blue_dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "font": "#dfdfff", 3 | "background": "#080815", 4 | "edgeBright": "rgba(40, 40, 70, 0.75)", 5 | "edgeShadow": "rgba(0, 0, 0, 0.75)", 6 | "scrollbarBackground": "#fff", 7 | "scrollbarShadow": "#777", 8 | "scrollbarThumb": "#1c1c7c" 9 | } 10 | -------------------------------------------------------------------------------- /src/core/config/colors/red_dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "font": "#ffdfdf", 3 | "background": "#150808", 4 | "edgeBright": "rgba(70, 40, 40, 0.75)", 5 | "edgeShadow": "rgba(0, 0, 0, 0.75)", 6 | "scrollbarBackground": "#fff", 7 | "scrollbarShadow": "#777", 8 | "scrollbarThumb": "#7c1c1c" 9 | } 10 | -------------------------------------------------------------------------------- /src/core/config/colors/blue.json: -------------------------------------------------------------------------------- 1 | { 2 | "font": "#2d2d33", 3 | "background": "#e0e0ff", 4 | "edgeBright": "rgba(255, 255, 255, 0.75)", 5 | "edgeShadow": "rgba(167, 167, 192, 0.75)", 6 | "scrollbarBackground": "#fff", 7 | "scrollbarShadow": "#777", 8 | "scrollbarThumb": "#7f7fff" 9 | } 10 | -------------------------------------------------------------------------------- /src/core/config/colors/gray.json: -------------------------------------------------------------------------------- 1 | { 2 | "font": "#333333", 3 | "background": "#e0e0e0", 4 | "edgeBright": "rgba(255, 255, 255, 0.75)", 5 | "edgeShadow": "rgba(192, 192, 192, 0.75)", 6 | "scrollbarBackground": "#fff", 7 | "scrollbarShadow": "#777", 8 | "scrollbarThumb": "#7f7f7f" 9 | } 10 | -------------------------------------------------------------------------------- /src/core/config/colors/green.json: -------------------------------------------------------------------------------- 1 | { 2 | "font": "#2d332d", 3 | "background": "#cfe8cf", 4 | "edgeBright": "rgba(255, 255, 255, 0.75)", 5 | "edgeShadow": "rgba(167, 192, 167, 0.75)", 6 | "scrollbarBackground": "#fff", 7 | "scrollbarShadow": "#777", 8 | "scrollbarThumb": "#55ad55" 9 | } 10 | -------------------------------------------------------------------------------- /src/core/config/colors/green_dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "font": "#dfffdf", 3 | "background": "#081508", 4 | "edgeBright": "rgba(40, 70, 40, 0.75)", 5 | "edgeShadow": "rgba(0, 0, 0, 0.75)", 6 | "scrollbarBackground": "#fff", 7 | "scrollbarShadow": "#777", 8 | "scrollbarThumb": "#1c7c1c" 9 | } 10 | -------------------------------------------------------------------------------- /src/core/config/colors/red.json: -------------------------------------------------------------------------------- 1 | { 2 | "font": "#332d2d", 3 | "background": "#ffe0e0", 4 | "edgeBright": "rgba(255, 255, 255, 0.75)", 5 | "edgeShadow": "rgba(192, 167, 167, 0.75)", 6 | "scrollbarBackground": "#fff", 7 | "scrollbarShadow": "#777", 8 | "scrollbarThumb": "#ff7f7f" 9 | } 10 | -------------------------------------------------------------------------------- /src/@types/error.ts: -------------------------------------------------------------------------------- 1 | export type ErrorParams = Record; 2 | export type ErrorKind = "file" | "scale" | "input" | "unknown"; 3 | 4 | export type CustomErrorObject = { 5 | uuid: string; 6 | code: string; 7 | params: ErrorParams; 8 | kind: K; 9 | }; 10 | -------------------------------------------------------------------------------- /.github/workflows/pr-check.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request Check 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | test: 9 | uses: ./.github/workflows/ci-common.yaml 10 | with: 11 | with-coverage: false 12 | with-e2e: false 13 | secrets: 14 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | interface ImportMetaEnv { 3 | readonly APP_VERSION: string; 4 | readonly VITE_IS_STANDALONE: string; 5 | readonly VITE_IS_UNITE: string; 6 | readonly VITE_GIT_ORG: string; 7 | readonly VITE_GIT_REPO: string; 8 | } 9 | 10 | interface ImportMeta { 11 | readonly env: ImportMetaEnv; 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "emitDeclarationOnly": true 10 | }, 11 | "include": ["vite.config.ts", "vitest.config.ts", "vite"] 12 | } 13 | -------------------------------------------------------------------------------- /src/guards/form.ts: -------------------------------------------------------------------------------- 1 | import { ResultDisplayStyleType } from "@/@types/form"; 2 | import { ResultDisplayStyles } from "@/constants/form"; 3 | 4 | export const isResultDisplayStyle = ( 5 | value: unknown, 6 | ): value is ResultDisplayStyleType => { 7 | return ( 8 | typeof value === "string" && 9 | (value === ResultDisplayStyles.Grid || value === ResultDisplayStyles.List) 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /.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 | coverage 12 | dist 13 | dist-ssr 14 | *.local 15 | playwright-report 16 | test-results 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /src/core/@types/color.ts: -------------------------------------------------------------------------------- 1 | export type ColorSettings = { 2 | font: string; 3 | background: string; 4 | edgeBright: string; 5 | edgeShadow: string; 6 | scrollbarBackground: string; 7 | scrollbarShadow: string; 8 | scrollbarThumb: string; 9 | }; 10 | 11 | export type ColorKey = 12 | | "red" 13 | | "blue" 14 | | "green" 15 | | "gray" 16 | | "red_dark" 17 | | "blue_dark" 18 | | "green_dark" 19 | | "dark"; 20 | -------------------------------------------------------------------------------- /src/components/settings/SettingsSection.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /src/constants/imageFile.ts: -------------------------------------------------------------------------------- 1 | export const AcceptedTypes: MIMEType[] = [ 2 | "image/png", 3 | "image/jpeg", 4 | "image/gif", 5 | ] as const; 6 | 7 | export const PickerOpts: OpenFilePickerOptions = { 8 | types: [ 9 | { 10 | description: "Images", 11 | accept: { 12 | "image/*": [".png", ".gif", ".jpeg", ".jpg"], 13 | }, 14 | }, 15 | ], 16 | excludeAcceptAllOption: true, 17 | multiple: true, 18 | } as const; 19 | -------------------------------------------------------------------------------- /src/components/common/form/VFormButton.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | -------------------------------------------------------------------------------- /src/components/MainHeader.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | -------------------------------------------------------------------------------- /src/utils/imageItemUtils.ts: -------------------------------------------------------------------------------- 1 | import { ImageCheckList, ImageEntry } from "@/@types/convert"; 2 | 3 | export const isAllUnchecked = ( 4 | items: ImageEntry[], 5 | checkedMap: ImageCheckList, 6 | ) => items.every((item) => !checkedMap[item.image.uuid]); 7 | 8 | export const getCheckedItems = ( 9 | items: ImageEntry[], 10 | checkedMap: ImageCheckList, 11 | ) => { 12 | return isAllUnchecked(items, checkedMap) 13 | ? items 14 | : items.filter((item) => checkedMap[item.image.uuid]); 15 | }; 16 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 2 | pub fn run() { 3 | tauri::Builder::default() 4 | .setup(|app| { 5 | if cfg!(debug_assertions) { 6 | app.handle().plugin( 7 | tauri_plugin_log::Builder::default() 8 | .level(log::LevelFilter::Info) 9 | .build(), 10 | )?; 11 | } 12 | Ok(()) 13 | }) 14 | .plugin(tauri_plugin_dialog::init()) 15 | .plugin(tauri_plugin_fs::init()) 16 | .run(tauri::generate_context!()) 17 | .expect("error while running tauri application"); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/settings/LinkList.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | 19 | 25 | -------------------------------------------------------------------------------- /src/components/HowToUseSection.vue: -------------------------------------------------------------------------------- 1 | 25 | -------------------------------------------------------------------------------- /src/composables/useScaleSettings.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue"; 2 | 3 | import { ScaleModeType } from "@/@types/form"; 4 | import { 5 | OriginalPixelSize, 6 | ScaleModes, 7 | ScaleSizePercent, 8 | } from "@/constants/form"; 9 | 10 | const useScaleSettings = () => { 11 | const originalPixelSize = ref(OriginalPixelSize.Default); 12 | const scaleMode = ref(ScaleModes[0].value); 13 | const scaleSizePercent = ref(ScaleSizePercent.Default); 14 | 15 | return { originalPixelSize, scaleMode, scaleSizePercent }; 16 | }; 17 | 18 | export default useScaleSettings; 19 | -------------------------------------------------------------------------------- /src/core/plugins/i18n.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from "vue-i18n"; 2 | 3 | import { 4 | getAllLanguage, 5 | loadLanguageKeyInStorage, 6 | } from "@/core/services/i18nService"; 7 | 8 | import { DefaultLanguage } from "../constants/i18n"; 9 | 10 | /** 11 | * The Vue I18n instance. 12 | */ 13 | export const vueI18n = createI18n({ 14 | locale: loadLanguageKeyInStorage(), 15 | fallbackLocale: DefaultLanguage, 16 | messages: getAllLanguage(), 17 | globalInjection: true, 18 | legacy: false, 19 | }); 20 | 21 | export const vueI18nLocales = vueI18n.global.availableLocales; 22 | export type vueI18nLocales = (typeof vueI18nLocales)[number]; 23 | -------------------------------------------------------------------------------- /src/composables/useColor.ts: -------------------------------------------------------------------------------- 1 | import { ref, watch } from "vue"; 2 | 3 | import { ColorKey } from "@/core/@types/color"; 4 | import { 5 | loadColorKeyInStorage, 6 | saveColorKey, 7 | } from "@/core/services/colorService"; 8 | 9 | const useColor = () => { 10 | const themeColorKey = ref(loadColorKeyInStorage()); 11 | 12 | watch( 13 | themeColorKey, 14 | (newColorKey) => { 15 | saveColorKey(newColorKey); 16 | document.documentElement.dataset.colorTheme = newColorKey; 17 | }, 18 | { 19 | immediate: true, 20 | }, 21 | ); 22 | 23 | return { themeColorKey }; 24 | }; 25 | 26 | export default useColor; 27 | -------------------------------------------------------------------------------- /src/core/constants/i18n.ts: -------------------------------------------------------------------------------- 1 | import cn from "@/core/config/i18n/cn.json" with { type: "json" }; 2 | import en from "@/core/config/i18n/en.json" with { type: "json" }; 3 | import es from "@/core/config/i18n/es.json" with { type: "json" }; 4 | import ja from "@/core/config/i18n/ja.json" with { type: "json" }; 5 | import tr from "@/core/config/i18n/tr.json" with { type: "json" }; 6 | 7 | export const StorageKey = "language"; 8 | export const DefaultLanguage = "en"; 9 | 10 | export const Languages = { 11 | ja, 12 | en, 13 | cn, 14 | es, 15 | tr, 16 | } as const; 17 | 18 | export const LanguagesForUnite = { 19 | ja, 20 | en, 21 | cn, 22 | } as const; 23 | -------------------------------------------------------------------------------- /src/components/settings/LanguageSelector.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 22 | -------------------------------------------------------------------------------- /src/models/errors/FileError.ts: -------------------------------------------------------------------------------- 1 | import { CustomErrorBase } from "./_ErrorBase"; 2 | 3 | type FileErrorCode = "duplicate-image"; 4 | type FileErrorParam = Record>; 5 | interface FileErrorParams extends FileErrorParam { 6 | "duplicate-image": { filename: string }; 7 | } 8 | 9 | export class FileError< 10 | C extends FileErrorCode = FileErrorCode, 11 | > extends CustomErrorBase { 12 | readonly kind = "file" as const; 13 | 14 | constructor( 15 | public code: C, 16 | public params: FileErrorParams[C], 17 | ) { 18 | super(code, params); 19 | this.name = "PixelScalerFileError"; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/unit/components/common/form/VFormButton.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import VFormButton from "@/components/common/form/VFormButton.vue"; 4 | 5 | describe("VFormButton Component", () => { 6 | test("renders slot content", () => { 7 | const wrapper = mount(VFormButton, { 8 | slots: { 9 | default: "Click Me", 10 | }, 11 | }); 12 | expect(wrapper.html()).toContain("Click Me"); 13 | }); 14 | 15 | test("emits click event when clicked", async () => { 16 | const wrapper = mount(VFormButton); 17 | await wrapper.trigger("click"); 18 | expect(wrapper.emitted()).toHaveProperty("click"); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/models/errors/UnknownError.ts: -------------------------------------------------------------------------------- 1 | import { CustomErrorBase } from "./_ErrorBase"; 2 | 3 | export class UnknownError extends CustomErrorBase< 4 | "unknown", 5 | { message: string }, 6 | "unknown" 7 | > { 8 | readonly kind = "unknown" as const; 9 | constructor(error: unknown) { 10 | const message = 11 | error instanceof Error 12 | ? UnknownError.toJSON(error) 13 | : JSON.stringify(error); 14 | super("unknown", { message }); 15 | this.name = "PixelScalerUnknownError"; 16 | } 17 | 18 | protected static toJSON(error: Error) { 19 | return JSON.stringify({ 20 | name: error.name, 21 | message: error.message, 22 | stack: error.stack, 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/composables/useGlobalError.ts: -------------------------------------------------------------------------------- 1 | import { computed } from "vue"; 2 | 3 | import { useErrorStore } from "@/stores/errorStore"; 4 | 5 | const useGlobalError = () => { 6 | const errorStore = useErrorStore(); 7 | 8 | const GlobalErrors = computed(() => errorStore.errors); 9 | 10 | const addError = (error: unknown) => { 11 | errorStore.addError(error); 12 | }; 13 | 14 | const clearErrors = () => { 15 | errorStore.clearErrors(); 16 | }; 17 | 18 | const deleteOneError = (uuid: string) => { 19 | errorStore.deleteOneError(uuid); 20 | }; 21 | 22 | return { 23 | GlobalErrors, 24 | addError, 25 | clearErrors, 26 | deleteOneError, 27 | }; 28 | }; 29 | 30 | export default useGlobalError; 31 | -------------------------------------------------------------------------------- /src/composables/useDisplayStyle.ts: -------------------------------------------------------------------------------- 1 | import { watch, ref } from "vue"; 2 | 3 | import { ResultDisplayStyleType } from "@/@types/form"; 4 | import { StorageKey } from "@/constants/displayStyle"; 5 | import { 6 | getLocalStorage, 7 | setLocalStorage, 8 | } from "@/core/infrastructure/storage"; 9 | import { isResultDisplayStyle } from "@/guards/form"; 10 | 11 | const useDisplayStyle = () => { 12 | const stored = getLocalStorage(StorageKey); 13 | const displayStyle = ref( 14 | isResultDisplayStyle(stored) ? stored : "grid", 15 | ); 16 | watch(displayStyle, (newValue) => { 17 | setLocalStorage(StorageKey, newValue); 18 | }); 19 | 20 | return { displayStyle }; 21 | }; 22 | 23 | export default useDisplayStyle; 24 | -------------------------------------------------------------------------------- /src/@types/convert.ts: -------------------------------------------------------------------------------- 1 | import { CustomErrorObject } from "./error"; 2 | 3 | import type { ScaleModeType } from "./form"; 4 | 5 | export type PSImageDataSettingType = { 6 | scaleSizePercent: number; 7 | scaleMode: ScaleModeType; 8 | }; 9 | 10 | export type PSImageDataObject = { 11 | uuid: string; 12 | data: File; 13 | imageData: ImageData; 14 | width: number; 15 | height: number; 16 | originalPixelSize: number; 17 | url: string; 18 | status: PSImageStatus; 19 | }; 20 | 21 | export type PSImageStatus = "loaded" | "scaled"; 22 | 23 | export type ImageEntry = { 24 | image: PSImageDataObject; 25 | settings: PSImageDataSettingType; 26 | errors: CustomErrorObject<"scale">[]; 27 | }; 28 | 29 | export type ImageCheckList = Record; 30 | -------------------------------------------------------------------------------- /src/composables/useI18n.ts: -------------------------------------------------------------------------------- 1 | import { readonly, watch, ref } from "vue"; 2 | 3 | import { vueI18n, vueI18nLocales } from "@/core/plugins/i18n"; 4 | import { saveLanguageKey } from "@/core/services/i18nService"; 5 | 6 | const useI18n = () => { 7 | const languageKey = ref(vueI18n.global.locale); 8 | 9 | const updateLanguageKey = (key: vueI18nLocales) => { 10 | saveLanguageKey(key); 11 | languageKey.value = key; 12 | }; 13 | 14 | watch( 15 | languageKey, 16 | (newLanguageKey: vueI18nLocales) => { 17 | document.documentElement.lang = newLanguageKey; 18 | updateLanguageKey(newLanguageKey); 19 | }, 20 | { immediate: true }, 21 | ); 22 | 23 | return { languageKey, languageKeys: readonly(vueI18nLocales) }; 24 | }; 25 | 26 | export default useI18n; 27 | -------------------------------------------------------------------------------- /src/core/plugins/meta.ts: -------------------------------------------------------------------------------- 1 | import { watchEffect, Plugin } from "vue"; 2 | 3 | import { createOrChangeOgpValues } from "@/core/utils/ogp"; 4 | 5 | import { vueI18n } from "./i18n"; 6 | 7 | const metaPlugin = { 8 | install() { 9 | watchEffect(() => { 10 | document.title = vueI18n.global.t("title"); 11 | createOrChangeOgpValues([ 12 | { property: "og:title", content: vueI18n.global.t("title") }, 13 | { property: "og:site_name", content: vueI18n.global.t("title") }, 14 | { 15 | property: "og:description", 16 | content: vueI18n.global.t("ogp-description"), 17 | }, 18 | { property: "og:locale", content: vueI18n.global.locale.value }, 19 | ]); 20 | }); 21 | }, 22 | } satisfies Plugin; 23 | 24 | export default metaPlugin; 25 | -------------------------------------------------------------------------------- /src/models/errors/_ErrorBase.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | 3 | import { ErrorKind, CustomErrorObject, ErrorParams } from "@/@types/error"; 4 | export abstract class CustomErrorBase< 5 | Code extends string, 6 | Params extends ErrorParams, 7 | Kind extends ErrorKind = ErrorKind, 8 | > extends Error { 9 | readonly uuid: string; 10 | abstract readonly kind: Kind; 11 | 12 | constructor( 13 | public code: Code, 14 | public params: Params, 15 | ) { 16 | super(code); 17 | this.name = "PixelScalerError"; 18 | this.uuid = uuidv4(); 19 | } 20 | 21 | public toObject(): CustomErrorObject { 22 | return { 23 | uuid: this.uuid, 24 | code: `error.${this.kind}.${this.code}`, 25 | params: this.params, 26 | kind: this.kind, 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/unit/components/common/VClosableItem.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import VClosableItem from "@/components/common/VClosableItem.vue"; 4 | 5 | describe("VClosableItem Component", () => { 6 | test("renders slot content", () => { 7 | const wrapper = mount(VClosableItem, { 8 | slots: { 9 | default: "
Test Content
", 10 | }, 11 | }); 12 | 13 | expect(wrapper.html()).toContain("Test Content"); 14 | }); 15 | 16 | test("emits 'close' event when close button is clicked", async () => { 17 | const wrapper = mount(VClosableItem); 18 | 19 | const closeBtn = wrapper.find(".close-btn span"); 20 | await closeBtn.trigger("click"); 21 | 22 | expect(wrapper.emitted()).toHaveProperty("close"); 23 | expect(wrapper.emitted("close")?.length).toBe(1); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/common/form/VFormFileInputDrop.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 31 | -------------------------------------------------------------------------------- /src/models/errors/InputError.ts: -------------------------------------------------------------------------------- 1 | import { CustomErrorBase } from "./_ErrorBase"; 2 | 3 | type InputErrorCode = 4 | | "invalid-image-type" 5 | | "encoding-error" 6 | | "file-not-found" 7 | | "canvas-is-unsupported"; 8 | type InputErrorParam = Record>; 9 | interface InputErrorParams extends InputErrorParam { 10 | "invalid-image-type": { filename: string }; 11 | "encoding-error": { filename: string }; 12 | "file-not-found": { filename: string }; 13 | "canvas-is-unsupported": { filename: string }; 14 | } 15 | 16 | export class InputError< 17 | C extends InputErrorCode = InputErrorCode, 18 | > extends CustomErrorBase { 19 | readonly kind = "input" as const; 20 | 21 | constructor(code: C, params: InputErrorParams[C]) { 22 | super(code, params); 23 | this.name = "PixelScalerInputError"; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/unit/__mocks__/models/InputImageData.ts: -------------------------------------------------------------------------------- 1 | import { ImageEntry } from "@/@types/convert"; 2 | import { ScaleMode } from "@/constants/form"; 3 | import { PSImageData } from "@/models/InputImageData"; 4 | 5 | const defaultProps = { image: {}, settings: {}, errors: [] }; 6 | 7 | export const dummyImageEntry = async ( 8 | props: { 9 | image?: Partial; 10 | settings?: Partial; 11 | errors?: ImageEntry["errors"]; 12 | } = defaultProps, 13 | ): Promise => { 14 | const imageData = await PSImageData.init( 15 | new File([], props.image?.data?.name ?? "image.png"), 16 | ); 17 | return { 18 | image: { ...imageData.toObject(), ...props.image }, 19 | settings: { 20 | scaleSizePercent: 100, 21 | scaleMode: ScaleMode.Smooth, 22 | ...props.settings, 23 | }, 24 | errors: props.errors || [], 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | export default defineConfig({ 4 | testDir: "./tests/e2e", 5 | timeout: 30 * 1000, 6 | retries: process.env.CI ? 2 : 0, 7 | workers: process.env.CI ? 1 : undefined, 8 | reporter: "html", 9 | use: { 10 | baseURL: "http://localhost:5173", 11 | trace: "on-first-retry", 12 | headless: true, 13 | }, 14 | projects: [ 15 | { 16 | name: "chromium", 17 | use: { ...devices["Desktop Chrome"] }, 18 | }, 19 | { 20 | name: "firefox", 21 | use: { ...devices["Desktop Firefox"] }, 22 | }, 23 | { 24 | name: "webkit", 25 | use: { ...devices["Desktop Safari"] }, 26 | }, 27 | ], 28 | webServer: [ 29 | { 30 | command: "npm run dev -- --mode=e2e", 31 | url: "http://localhost:5173", 32 | reuseExistingServer: !process.env.CI, 33 | }, 34 | ], 35 | }); 36 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pixel-scaler" 3 | version = "1.1.0" 4 | description = "A pixel art upscaling tool" 5 | authors = ["irokaru"] 6 | license = "MIT" 7 | repository = "https://github.com/irokaru/pixel-scaler" 8 | edition = "2021" 9 | rust-version = "1.77.2" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [lib] 14 | name = "app_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2.0.5", features = [] } 19 | 20 | [dependencies] 21 | serde_json = "1.0" 22 | serde = { version = "1.0", features = ["derive"] } 23 | log = "0.4" 24 | tauri = { version = "2.3.1", features = ["devtools"] } 25 | tauri-plugin-log = "2.0.0-rc" 26 | tauri-plugin-fs = "2.0.0" 27 | tauri-plugin-dialog = "2.2.2" 28 | 29 | [profile.release] 30 | strip = true # Automatically strip symbols from the binary. 31 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "enables the default permissions", 5 | "windows": ["main"], 6 | "permissions": [ 7 | "core:default", 8 | "dialog:allow-open", 9 | { 10 | "identifier": "fs:allow-write-file", 11 | "allow": [ 12 | { 13 | "path": "*/**" 14 | } 15 | ] 16 | }, 17 | { 18 | "identifier": "fs:scope", 19 | "allow": [ 20 | { 21 | "path": "*/**" 22 | } 23 | ] 24 | }, 25 | { 26 | "identifier": "fs:allow-mkdir", 27 | "allow": [ 28 | { 29 | "path": "*/**" 30 | } 31 | ] 32 | }, 33 | { 34 | "identifier": "fs:allow-exists", 35 | "allow": [ 36 | { 37 | "path": "*/**" 38 | } 39 | ] 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /tests/unit/components/settings/ColorSelector.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import ColorSelector from "@/components/settings/ColorSelector.vue"; 4 | import { getAllColors } from "@/core/services/colorService"; 5 | 6 | describe("ColorSelector Component", () => { 7 | test("renders the correct number of color boxes", () => { 8 | const wrapper = mount(ColorSelector); 9 | 10 | const colorBoxes = wrapper.findAll(".color-box"); 11 | expect(colorBoxes.length).toBe(Object.keys(getAllColors()).length); 12 | }); 13 | 14 | test("updates the modelValue when a color box is clicked", async () => { 15 | const wrapper = mount(ColorSelector); 16 | 17 | const colorBox = wrapper.findAll(".color-box").at(5); 18 | await colorBox?.trigger("click"); 19 | 20 | wrapper.vm.$nextTick(); 21 | 22 | expect(document.documentElement.dataset.colorTheme).toBe("blue_dark"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { library } from "@fortawesome/fontawesome-svg-core"; 2 | import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; 3 | import { createPinia } from "pinia"; 4 | import { createApp } from "vue"; 5 | import { createGtag } from "vue-gtag"; 6 | 7 | import "@/assets/global.scss"; 8 | 9 | import { FontAwesomeIcons } from "@/constants/icon"; 10 | import { vueI18n } from "@/core/plugins/i18n"; 11 | import metaPlugin from "@/core/plugins/meta"; 12 | 13 | import App from "./App.vue"; 14 | import { isWeb } from "./core/system"; 15 | 16 | library.add(...Object.values(FontAwesomeIcons)); 17 | 18 | const app = createApp(App); 19 | const pinia = createPinia(); 20 | 21 | app.component("FontAwesomeIcon", FontAwesomeIcon); 22 | app.use(vueI18n); 23 | app.use(metaPlugin); 24 | app.use(pinia); 25 | if (isWeb()) { 26 | const gtag = createGtag({ tagId: "G-1KZRGEYWQ7" }); 27 | app.use(gtag); 28 | } 29 | 30 | app.mount("#app"); 31 | -------------------------------------------------------------------------------- /src/components/settings/ColorSelector.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 24 | 25 | 33 | -------------------------------------------------------------------------------- /tests/unit/guards/form.spec.ts: -------------------------------------------------------------------------------- 1 | import { ResultDisplayStyles } from "@/constants/form"; 2 | import { isResultDisplayStyle } from "@/guards/form"; 3 | 4 | describe("isResultDisplayStyle", () => { 5 | test.each([ 6 | { input: ResultDisplayStyles.Grid, expected: true }, 7 | { input: ResultDisplayStyles.List, expected: true }, 8 | { input: "grid", expected: true }, 9 | { input: "list", expected: true }, 10 | { input: "GRID", expected: false }, 11 | { input: "LIST", expected: false }, 12 | { input: "table", expected: false }, 13 | { input: "", expected: false }, 14 | { input: null, expected: false }, 15 | { input: undefined, expected: false }, 16 | { input: 123, expected: false }, 17 | { input: {}, expected: false }, 18 | { input: [], expected: false }, 19 | ])("returns $expected for input: $input", ({ input, expected }) => { 20 | expect(isResultDisplayStyle(input)).toBe(expected); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /.github/workflows/test-and-deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Test and Deploy 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | test: 9 | uses: ./.github/workflows/ci-common.yaml 10 | with: 11 | with-coverage: true 12 | with-e2e: true 13 | secrets: 14 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 15 | 16 | build: 17 | needs: test 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - uses: actions/setup-node@v4 23 | id: setup_node 24 | with: 25 | node-version: 22 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Build application 31 | run: npm run build 32 | 33 | - name: Deploy to gh-pages 34 | uses: peaceiris/actions-gh-pages@v4 35 | with: 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | publish_dir: ./dist 38 | publish_branch: gh-pages 39 | -------------------------------------------------------------------------------- /tests/fixture/index.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "node:fs"; 2 | import path from "node:path"; 3 | 4 | export const Png1px = readFileSync( 5 | path.resolve(import.meta.dirname, "./1px.png"), 6 | ); 7 | export const Png1pxAlpha = readFileSync( 8 | path.resolve(import.meta.dirname, "./1px_alpha.png"), 9 | ); 10 | export const Jpg1px = readFileSync( 11 | path.resolve(import.meta.dirname, "./1px.jpg"), 12 | ); 13 | export const Jpg1pxAlpha = readFileSync( 14 | path.resolve(import.meta.dirname, "./1px_alpha.jpg"), 15 | ); 16 | 17 | export const Png2px = readFileSync( 18 | path.resolve(import.meta.dirname, "./2px.png"), 19 | ); 20 | export const Png2pxAlpha = readFileSync( 21 | path.resolve(import.meta.dirname, "./2px_alpha.png"), 22 | ); 23 | export const Jpg2px = readFileSync( 24 | path.resolve(import.meta.dirname, "./2px.jpg"), 25 | ); 26 | export const Jpg2pxAlpha = readFileSync( 27 | path.resolve(import.meta.dirname, "./2px_alpha.jpg"), 28 | ); 29 | -------------------------------------------------------------------------------- /tests/e2e/components/SettingsSection.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "@playwright/test"; 2 | 3 | import { PageObjectBase } from "./_PageObjectBase"; 4 | 5 | export class SettingsSection extends PageObjectBase { 6 | // FIXME: VITE_IS_UNITE is a temporary solution for the Unite version. 7 | static readonly languages = process.env.VITE_IS_UNITE 8 | ? ["ja", "en", "cn"] 9 | : ["ja", "en", "cn", "es", "tr"]; 10 | 11 | async clickColorButton(buttonName: string) { 12 | const colorSection = this.page.locator("#colors-setting"); 13 | const button = colorSection.getByTitle(buttonName, { exact: true }); 14 | await expect(button).toBeVisible(); 15 | await button.click(); 16 | } 17 | 18 | async clickLanguageButton(buttonName: string) { 19 | const languageSection = this.page.locator("#languages-setting"); 20 | const button = languageSection.getByText(buttonName); 21 | await expect(button).toBeVisible(); 22 | await button.click(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/unit/core/plugins/i18n.spec.ts: -------------------------------------------------------------------------------- 1 | import { nextTick } from "vue"; 2 | 3 | import useI18n from "@/composables/useI18n"; 4 | import { DefaultLanguage } from "@/core/constants/i18n"; 5 | import { vueI18nLocales } from "@/core/plugins/i18n"; 6 | 7 | describe("i18n", () => { 8 | beforeEach(() => { 9 | localStorage.setItem("i18n", DefaultLanguage); 10 | }); 11 | 12 | test.each<{ 13 | description: string; 14 | newLanguageKey: vueI18nLocales; 15 | expectedLanguageKey: vueI18nLocales; 16 | }>([ 17 | { 18 | description: 19 | "should update the language when updateLanguageKey is called", 20 | newLanguageKey: "ja", 21 | expectedLanguageKey: "ja", 22 | }, 23 | ])("$description", async ({ newLanguageKey, expectedLanguageKey }) => { 24 | const { languageKey } = useI18n(); 25 | languageKey.value = newLanguageKey; 26 | await nextTick(); 27 | expect(languageKey.value).toBe(expectedLanguageKey); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/unit/composables/useColor.spec.ts: -------------------------------------------------------------------------------- 1 | import { nextTick } from "vue"; 2 | 3 | import useColor from "@/composables/useColor"; 4 | import { ColorKey } from "@/core/@types/color"; 5 | 6 | describe("useColor", () => { 7 | const DEFAULT_COLOR = "red"; 8 | beforeEach(() => { 9 | localStorage.setItem("color", DEFAULT_COLOR); 10 | }); 11 | 12 | test.each<{ 13 | description: string; 14 | newColorKey: ColorKey; 15 | expecterColorSetting: string; 16 | }>([ 17 | { 18 | description: 19 | "should update the color settings when updateColorKey is called", 20 | newColorKey: "blue", 21 | expecterColorSetting: "blue", 22 | }, 23 | ])("$description", async ({ newColorKey, expecterColorSetting }) => { 24 | const { themeColorKey } = useColor(); 25 | themeColorKey.value = newColorKey; 26 | await nextTick(); 27 | expect(document.documentElement.dataset.colorTheme).toEqual( 28 | expecterColorSetting, 29 | ); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/components/common/VClosableItem.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | 24 | 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | "paths": { 9 | "@/*": ["./src/*"] 10 | }, 11 | 12 | "types": ["vitest/globals", "@types/wicg-file-system-access"], 13 | 14 | /* Bundler mode */ 15 | "moduleResolution": "bundler", 16 | "allowImportingTsExtensions": true, 17 | "resolveJsonModule": true, 18 | "esModuleInterop": true, 19 | "isolatedModules": true, 20 | "incremental": false, 21 | "noEmit": true, 22 | "jsx": "preserve", 23 | 24 | /* Linting */ 25 | "strict": true, 26 | "noUnusedLocals": true, 27 | "noUnusedParameters": true, 28 | "noFallthroughCasesInSwitch": true 29 | }, 30 | "include": ["**/*.ts", "**/*.vue", "**/*.json"], 31 | "exclude": ["node_modules", "dist", "src-tauri"], 32 | "references": [{ "path": "./tsconfig.node.json" }] 33 | } 34 | -------------------------------------------------------------------------------- /src/composables/useI18nTextKey.ts: -------------------------------------------------------------------------------- 1 | import { computed, Ref } from "vue"; 2 | 3 | const useI18nTextKey = (isAnyChecked: Ref) => { 4 | const convertText = computed(() => 5 | isAnyChecked.value ? "form.convert-selected" : "form.convert-all", 6 | ); 7 | const deleteText = computed(() => 8 | isAnyChecked.value ? "delete-selected" : "delete-all", 9 | ); 10 | const applyText = computed(() => 11 | isAnyChecked.value ? "form.apply-selected" : "form.apply-all", 12 | ); 13 | const downloadZipText = computed(() => 14 | isAnyChecked.value 15 | ? "convert.download-zip-selected" 16 | : "convert.download-zip-all", 17 | ); 18 | const outputFileText = computed(() => 19 | isAnyChecked.value 20 | ? "convert.output-file-selected" 21 | : "convert.output-file-all", 22 | ); 23 | 24 | return { 25 | convertText, 26 | deleteText, 27 | applyText, 28 | downloadZipText, 29 | outputFileText, 30 | }; 31 | }; 32 | 33 | export default useI18nTextKey; 34 | -------------------------------------------------------------------------------- /src/components/convert/ConvertSection.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 31 | -------------------------------------------------------------------------------- /src/models/errors/ScaleError.ts: -------------------------------------------------------------------------------- 1 | import { CustomErrorBase } from "./_ErrorBase"; 2 | 3 | type ScaleErrorCode = 4 | | "invalid-image-size" 5 | | "unsupported-scale-size" 6 | | "duplicate-image-and-settings"; 7 | type ScaleErrorParam = Record>; 8 | interface ScaleErrorParams extends ScaleErrorParam { 9 | "invalid-image-size": { filename: string; originalPixelSize: number }; 10 | "unsupported-scale-size": { scaleSizePercent: number }; 11 | "duplicate-image-and-settings": { 12 | filename: string; 13 | scaleSizePercent: number; 14 | scaleMode: string; 15 | }; 16 | } 17 | 18 | export class ScaleError< 19 | C extends ScaleErrorCode = ScaleErrorCode, 20 | > extends CustomErrorBase { 21 | readonly kind = "scale" as const; 22 | 23 | constructor( 24 | public code: C, 25 | public params: ScaleErrorParams[C], 26 | ) { 27 | super(code, params); 28 | this.name = "PixelScalerScaleError"; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/unit/components/settings/LanguageSelector.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { nextTick } from "vue"; 3 | 4 | import LanguageSelector from "@/components/settings/LanguageSelector.vue"; 5 | import useI18n from "@/composables/useI18n"; 6 | import { vueI18nLocales } from "@/core/plugins/i18n"; 7 | 8 | describe("LanguageSelector.vue", () => { 9 | test("renders all language options", () => { 10 | const wrapper = mount(LanguageSelector); 11 | const languageOptions = wrapper.findAll(".box"); 12 | expect(languageOptions.length).toBe(vueI18nLocales.length); 13 | }); 14 | 15 | test("updates the languageKey when a language is clicked", async () => { 16 | const { languageKey } = useI18n(); 17 | const wrapper = mount(LanguageSelector); 18 | const languageOptions = wrapper.findAll(".box"); 19 | 20 | for (const option of languageOptions) { 21 | await option.trigger("click"); 22 | await nextTick(); 23 | expect(languageKey.value).toBe(option.text()); 24 | } 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/core/services/colorService.ts: -------------------------------------------------------------------------------- 1 | import { ColorKey, ColorSettings } from "@/core/@types/color"; 2 | import { 3 | Colors, 4 | ColorKeys, 5 | StorageKey, 6 | DefaultColorKeyName, 7 | } from "@/core/constants/color"; 8 | import { 9 | existsLocalStorage, 10 | getLocalStorage, 11 | setLocalStorage, 12 | } from "@/core/infrastructure/storage"; 13 | 14 | export const getAllColors = (): Record => { 15 | return Colors; 16 | }; 17 | 18 | export const loadColorKeyInStorage = (): ColorKey => { 19 | if (!existsLocalStorage(StorageKey)) { 20 | return DefaultColorKeyName; 21 | } 22 | 23 | const key = getLocalStorage(StorageKey) as string; 24 | 25 | return existsColorKey(key) ? (key as ColorKey) : DefaultColorKeyName; 26 | }; 27 | 28 | export const saveColorKey = (key: ColorKey) => { 29 | if (existsColorKey(key)) { 30 | setLocalStorage(StorageKey, key); 31 | } 32 | }; 33 | 34 | export const existsColorKey = (key: string): key is ColorKey => { 35 | return (ColorKeys as readonly string[]).includes(key); 36 | }; 37 | -------------------------------------------------------------------------------- /src/stores/errorStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { computed, ref } from "vue"; 3 | 4 | import { CustomErrorObject } from "@/@types/error"; 5 | import { CustomErrorBase } from "@/models/errors/_ErrorBase"; 6 | import { UnknownError } from "@/models/errors/UnknownError"; 7 | 8 | export const useErrorStore = defineStore("error", () => { 9 | const errors = ref([]); 10 | 11 | const hasErrors = computed(() => errors.value.length > 0); 12 | 13 | const addError = (error: unknown): void => { 14 | if (error instanceof CustomErrorBase) { 15 | errors.value.push(error.toObject()); 16 | } else { 17 | errors.value.push(new UnknownError(error).toObject()); 18 | } 19 | }; 20 | 21 | const clearErrors = (): void => { 22 | errors.value = []; 23 | }; 24 | 25 | const deleteOneError = (uuid: string): void => { 26 | errors.value = errors.value.filter((error) => error.uuid !== uuid); 27 | }; 28 | 29 | return { 30 | errors, 31 | hasErrors, 32 | addError, 33 | clearErrors, 34 | deleteOneError, 35 | }; 36 | }); 37 | -------------------------------------------------------------------------------- /src-tauri/resources/README_ja.txt: -------------------------------------------------------------------------------- 1 | Pixel Scaler - A Pixel Art Upscaling Tool 2 | ========================================= 3 | 4 | 概要 5 | ---- 6 | Pixel Scaler は、ピクセルアートを高品質に拡大するツールです。 7 | xBR を使用し、イラストのような滑らかな仕上がりに変換します。 8 | 9 | 対応形式: 10 | - gif / jpeg / png 11 | 12 | 使用方法 13 | -------- 14 | 1. Pixel Scaler を起動します。 15 | 2. 拡大したい画像を選択、またはドラッグ&ドロップで追加します。 16 | 3. 拡大率(100%~10000%)を入力します。 17 | 4. 「Pixel Size」を指定します。 18 | - 元のピクセルアート:`1` を入力 19 | - すでに拡大済みの画像:その拡大倍率を入力(例: 2倍 → 2) 20 | 5. 「変換」ボタンを押します。 21 | 6. 拡大後の画像が表示されます。 22 | 7. 「出力」または「全出力」で保存できます。 23 | 24 | ヒント 25 | ------ 26 | - オリジナルサイズのピクセルアートが最も綺麗に変換されます。 27 | - 通常の低解像度イラスト(ピクセルアートではない画像)は綺麗に拡大できません。 28 | - 拡大率を100%にすると、アンチエイリアス(ぼかし)処理が適用されます。 29 | - 「Pixel Size」の設定が画像の実際のピクセルサイズと合っていないと、変換に失敗することがあります。 30 | 31 | ライセンスと著作権 32 | ------------------- 33 | 変換後の画像は、常識的な範囲内で、個人・商用を問わず自由に使用できます。 34 | 35 | 使用ライブラリ: 36 | - xBRjs (c) 2020 Josep del Rio 37 | https://github.com/joseprio/xBRjs 38 | 39 | 配布元: 40 | ------- 41 | https://github.com/irokaru/pixel-scaler/releases 42 | 43 | 44 | 著作権: 45 | ------- 46 | (c) 2025 ののの茶屋 47 | https://nononotyaya.net/ 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 irokaru 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 | -------------------------------------------------------------------------------- /tests/unit/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; 2 | import { config } from "@vue/test-utils"; 3 | 4 | beforeEach(() => { 5 | config.global.components = { 6 | FontAwesomeIcon, 7 | }; 8 | }); 9 | 10 | if (globalThis.ImageData === undefined) { 11 | globalThis.ImageData = class { 12 | width: number; 13 | height: number; 14 | data: Uint8ClampedArray; 15 | colorSpace: PredefinedColorSpace; 16 | constructor(width: number, height: number) { 17 | this.width = width; 18 | this.height = height; 19 | this.data = new Uint8ClampedArray(width * height * 4); 20 | this.colorSpace = "srgb"; 21 | } 22 | } as unknown as typeof ImageData; 23 | } 24 | 25 | vi.mock("@tauri-apps/api/core", () => ({ 26 | invoke: vi.fn().mockResolvedValue("/home/user"), 27 | })); 28 | vi.mock("@tauri-apps/api/path", () => ({ 29 | normalize: vi.fn().mockImplementation((path) => Promise.resolve(path)), 30 | })); 31 | vi.mock("@tauri-apps/plugin-dialog", () => ({ 32 | open: vi.fn(), 33 | })); 34 | vi.mock("@tauri-apps/plugin-fs", () => ({ 35 | exists: vi.fn().mockResolvedValue(true), 36 | })); 37 | -------------------------------------------------------------------------------- /src/constants/link.ts: -------------------------------------------------------------------------------- 1 | import { Link } from "@/@types/link"; 2 | 3 | import { FontAwesomeIcons } from "./icon"; 4 | 5 | export const links: Link[] = [ 6 | { 7 | url: "https://x.com/intent/post?text=%E3%81%B4%E3%81%8F%E3%81%9B%E3%82%8B%20%E3%81%99%E3%81%91%E3%82%90%E3%82%89%E3%81%81&hashtags=%E3%81%B4%E3%81%8F%E3%81%9B%E3%82%8B%E3%81%99%E3%81%91%E3%82%90%E3%82%89%E3%81%81&hashtags=PiXelScaLer&url=https%3A%2F%2Firokaru.github.io%2Fpixel-scaler", 8 | icon: FontAwesomeIcons["fa-share-nodes"], 9 | textKey: "link.share-on-x", 10 | }, 11 | { 12 | url: "https://twitter.com/irokaru", 13 | icon: FontAwesomeIcons["fa-x-twitter"], 14 | textKey: "link.developer", 15 | }, 16 | { 17 | url: "https://github.com/irokaru/pixel-scaler", 18 | icon: FontAwesomeIcons["fa-github-alt"], 19 | textKey: "link.repository", 20 | }, 21 | { 22 | url: "https://nononotyaya.booth.pm/items/2517679", 23 | icon: FontAwesomeIcons["fa-images"], 24 | textKey: "link.booth", 25 | }, 26 | { 27 | url: "./THIRD_PARTY_LICENSES", 28 | icon: FontAwesomeIcons["fa-balance-scale"], 29 | textKey: "link.license", 30 | }, 31 | ] as const; 32 | -------------------------------------------------------------------------------- /tests/unit/components/MainHeader.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import MainHeader from "@/components/MainHeader.vue"; 4 | import * as system from "@/core/system"; 5 | 6 | const isUniteMock = vi.spyOn(system, "isUnite"); 7 | 8 | describe("MainHeader.vue", () => { 9 | const factory = (props = {}) => { 10 | return mount(MainHeader, { 11 | props: { 12 | ...props, 13 | }, 14 | global: { 15 | mocks: { 16 | $t: (key: string) => key, 17 | }, 18 | }, 19 | }); 20 | }; 21 | 22 | test("renders the image when isUnite() returns true", () => { 23 | isUniteMock.mockReturnValue(true); 24 | 25 | const wrapper = factory(); 26 | 27 | expect(wrapper.find("img").exists()).toBe(true); 28 | expect(wrapper.find("img").attributes("src")).toBe("/banner.png"); 29 | }); 30 | 31 | test("renders the title text when isUnite() returns false", () => { 32 | isUniteMock.mockReturnValue(false); 33 | 34 | const wrapper = factory(); 35 | 36 | expect(wrapper.find("h1").text()).toBe("title"); 37 | expect(wrapper.find("img").exists()).toBe(false); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ぴくせる すけゐらぁ 16 | 17 | 18 | 19 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 38 | -------------------------------------------------------------------------------- /src/core/infrastructure/storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sets the value of a key in the local storage. 3 | * @param key - The key to set. 4 | * @param value - The value to set for the key. 5 | */ 6 | export const setLocalStorage = (key: string, value: string) => { 7 | localStorage.setItem(key, value); 8 | }; 9 | 10 | /** 11 | * Retrieves the value associated with the specified key from the local storage. 12 | * @param key - The key of the value to retrieve. 13 | * @returns The value associated with the specified key, or null if the key does not exist. 14 | */ 15 | export const getLocalStorage = (key: string) => { 16 | return localStorage.getItem(key); 17 | }; 18 | 19 | /** 20 | * Removes an item from the local storage based on the provided key. 21 | * @param key - The key of the item to be removed. 22 | */ 23 | export const removeLocalStorage = (key: string) => { 24 | localStorage.removeItem(key); 25 | }; 26 | 27 | /** 28 | * Checks if a value exists in the local storage. 29 | * @param key - The key to check in the local storage. 30 | * @returns A boolean indicating whether the value exists in the local storage. 31 | */ 32 | export const existsLocalStorage = (key: string) => { 33 | return localStorage.getItem(key) !== null; 34 | }; 35 | -------------------------------------------------------------------------------- /src/core/services/image/entryService.ts: -------------------------------------------------------------------------------- 1 | import { ImageEntry, PSImageDataSettingType } from "@/@types/convert"; 2 | import { PSImageData, PSImageDataSetting } from "@/models/InputImageData"; 3 | 4 | /** 5 | * Create an ImageEntry from a File with specified settings 6 | */ 7 | export const createImageEntry = async ( 8 | file: File, 9 | opts: { originalPixelSize: number } & PSImageDataSettingType, 10 | ): Promise => { 11 | const inputImageData = await PSImageData.init(file); 12 | inputImageData.originalPixelSize = opts.originalPixelSize; 13 | 14 | const settings = new PSImageDataSetting(opts); 15 | 16 | return { 17 | image: inputImageData.toObject(), 18 | settings, 19 | errors: [], 20 | }; 21 | }; 22 | 23 | /** 24 | * Check if a URL already exists in the entry list 25 | */ 26 | export const isDuplicateUrl = ( 27 | url: string, 28 | existingEntries: ImageEntry[], 29 | ): boolean => { 30 | return existingEntries.some((entry) => entry.image.url === url); 31 | }; 32 | 33 | /** 34 | * Find an entry by UUID 35 | */ 36 | export const findEntryByUuid = ( 37 | uuid: string, 38 | entries: ImageEntry[], 39 | ): ImageEntry | undefined => { 40 | return entries.find((entry) => entry.image.uuid === uuid); 41 | }; 42 | -------------------------------------------------------------------------------- /tests/e2e/components/Convertlist.ts: -------------------------------------------------------------------------------- 1 | import { expect, Locator, Page } from "@playwright/test"; 2 | 3 | import { ResultDisplayStyleType } from "@/@types/form"; 4 | 5 | import { PageObjectBase } from "./_PageObjectBase"; 6 | 7 | export class ConvertList extends PageObjectBase { 8 | public readonly convertList: Locator; 9 | 10 | constructor(protected readonly page: Page) { 11 | super(page); 12 | this.convertList = this.page.locator(".scaled-image-list"); 13 | } 14 | 15 | async exists() { 16 | await expect(this.convertList).toBeVisible(); 17 | } 18 | 19 | async notExists() { 20 | await expect(this.convertList).not.toBeVisible(); 21 | } 22 | 23 | async clickDownloadZipButton() { 24 | await this.convertList 25 | .locator(".scaled-image-list__ctrl") 26 | .locator("svg.fa-file-zipper") 27 | .click(); 28 | } 29 | 30 | async clickDeleteAllButton() { 31 | await this.convertList 32 | .locator(".scaled-image-list__ctrl") 33 | .locator("svg.fa-trash") 34 | .click(); 35 | } 36 | 37 | async selectDisplayStyle(style: ResultDisplayStyleType) { 38 | await this.convertList 39 | .locator(".scaled-image-list__ctrl") 40 | .locator("label", { hasText: style }) 41 | .check(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/unit/components/settings/LinkList.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import LinkList from "@/components/settings/LinkList.vue"; 4 | import { links } from "@/constants/link"; 5 | 6 | describe("LinkList.vue", () => { 7 | const factory = (props = {}) => { 8 | return mount(LinkList, { 9 | props: { 10 | ...props, 11 | }, 12 | global: { 13 | mocks: { 14 | $t: (msg: string) => msg, 15 | }, 16 | }, 17 | }); 18 | }; 19 | 20 | test("renders all links correctly", () => { 21 | const wrapper = factory(); 22 | 23 | const linkElements = wrapper.findAll("a"); 24 | expect(linkElements.length).toBe(links.length); 25 | 26 | for (const [index, link] of links.entries()) { 27 | const linkElement = linkElements[index]; 28 | expect(linkElement.attributes("href")).toBe(link.url); 29 | expect(linkElement.text()).toContain(wrapper.vm.$t(link.textKey)); 30 | } 31 | }); 32 | 33 | test("opens links in a new tab", () => { 34 | const wrapper = factory(); 35 | 36 | const linkElements = wrapper.findAll("a"); 37 | for (const linkElement of linkElements) { 38 | expect(linkElement.attributes("target")).toBe("_blank"); 39 | } 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/core/constants/color.ts: -------------------------------------------------------------------------------- 1 | import blue from "@/core/config/colors/blue.json" with { type: "json" }; 2 | import blue_dark from "@/core/config/colors/blue_dark.json" with { type: "json" }; 3 | import dark from "@/core/config/colors/dark.json" with { type: "json" }; 4 | import gray from "@/core/config/colors/gray.json" with { type: "json" }; 5 | import green from "@/core/config/colors/green.json" with { type: "json" }; 6 | import green_dark from "@/core/config/colors/green_dark.json" with { type: "json" }; 7 | import red from "@/core/config/colors/red.json" with { type: "json" }; 8 | import red_dark from "@/core/config/colors/red_dark.json" with { type: "json" }; 9 | 10 | import { ColorKey, ColorSettings } from "../@types/color"; 11 | 12 | export const StorageKey = "color"; 13 | export const DefaultColorKeyName: ColorKey = "red"; 14 | 15 | export const ColorKeys: ColorKey[] = [ 16 | "red", 17 | "blue", 18 | "green", 19 | "gray", 20 | "red_dark", 21 | "blue_dark", 22 | "green_dark", 23 | "dark", 24 | ] as const; 25 | 26 | /** 27 | * Represents a collection of colors. 28 | */ 29 | export const Colors: Record = { 30 | red, 31 | blue, 32 | green, 33 | gray, 34 | red_dark, 35 | blue_dark, 36 | green_dark, 37 | dark, 38 | } as const; 39 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "productName": "PiXel ScaLer", 4 | "version": "1.1.0", 5 | "identifier": "net.nononotyaya.pixel-scaler", 6 | "build": { 7 | "frontendDist": "../dist", 8 | "devUrl": "http://localhost:5173", 9 | "beforeDevCommand": "npm run dev -- --mode standalone", 10 | "beforeBuildCommand": "npm run build:standalone" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "PiXel ScaLer", 16 | "width": 800, 17 | "height": 800, 18 | "minWidth": 800, 19 | "minHeight": 800, 20 | "resizable": true, 21 | "fullscreen": false, 22 | "dragDropEnabled": false, 23 | "devtools": false 24 | } 25 | ], 26 | "security": { 27 | "csp": null, 28 | "capabilities": [] 29 | } 30 | }, 31 | "bundle": { 32 | "active": true, 33 | "resources": { 34 | "resources/README_en.txt": "README_en.txt", 35 | "resources/README_ja.txt": "README_ja.txt" 36 | }, 37 | "windows": { 38 | "nsis": { 39 | "installMode": "perMachine" 40 | } 41 | }, 42 | "targets": "all", 43 | "icon": ["../public/logo.png", "../public/favicon.ico"] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/unit/components/common/VHintBalloon.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import VHintBalloon from "@/components/common/VHintBalloon.vue"; 4 | 5 | describe("VHintBalloon Component", () => { 6 | test.each<"top" | "bottom">(["top", "bottom"])( 7 | "renders the component and applies the correct pos class when position is %s", 8 | (position) => { 9 | const wrapper = mount(VHintBalloon, { 10 | props: { 11 | position, 12 | }, 13 | slots: { 14 | default: "Test Hint Content", 15 | }, 16 | }); 17 | 18 | expect(wrapper.find(".hint-balloon").exists()).toBe(true); 19 | expect(wrapper.find(".hint-balloon-content").text()).toBe( 20 | "Test Hint Content", 21 | ); 22 | expect(wrapper.find(".hint-balloon-content").classes()).toContain( 23 | `hint-balloon-content-position--${position}`, 24 | ); 25 | }, 26 | ); 27 | 28 | test("renders the FontAwesomeIcon correctly", () => { 29 | const wrapper = mount(VHintBalloon, { 30 | props: { 31 | position: "top", 32 | }, 33 | }); 34 | 35 | expect(wrapper.find(".hint-icon").exists()).toBe(true); 36 | expect(wrapper.findComponent({ name: "FontAwesomeIcon" }).exists()).toBe( 37 | true, 38 | ); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tests/unit/composables/useScaleSettings.spec.ts: -------------------------------------------------------------------------------- 1 | import useScaleSettings from "@/composables/useScaleSettings"; 2 | import { 3 | OriginalPixelSize, 4 | ScaleModes, 5 | ScaleSizePercent, 6 | } from "@/constants/form"; 7 | 8 | describe("useScaleSettings", () => { 9 | test("should initialize originalPixelSize with the default value", () => { 10 | const { originalPixelSize } = useScaleSettings(); 11 | expect(originalPixelSize.value).toBe(OriginalPixelSize.Default); 12 | }); 13 | 14 | test("should initialize scaleMode with the first value in ScaleModes", () => { 15 | const { scaleMode } = useScaleSettings(); 16 | expect(scaleMode.value).toBe(ScaleModes[0].value); 17 | }); 18 | 19 | test("should initialize scaleSizePercent with the default value", () => { 20 | const { scaleSizePercent } = useScaleSettings(); 21 | expect(scaleSizePercent.value).toBe(ScaleSizePercent.Default); 22 | }); 23 | 24 | test("should return refs that are reactive", () => { 25 | const { originalPixelSize, scaleMode, scaleSizePercent } = 26 | useScaleSettings(); 27 | 28 | originalPixelSize.value = 10; 29 | scaleMode.value = ScaleModes[1].value; 30 | scaleSizePercent.value = 50; 31 | 32 | expect(originalPixelSize.value).toBe(10); 33 | expect(scaleMode.value).toBe(ScaleModes[1].value); 34 | expect(scaleSizePercent.value).toBe(50); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/e2e/components/ConvertItem.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page, expect } from "@playwright/test"; 2 | 3 | import { PageObjectBase } from "./_PageObjectBase"; 4 | 5 | export class ConvertItem extends PageObjectBase { 6 | protected readonly convertItem: Locator; 7 | constructor( 8 | protected readonly page: Page, 9 | public readonly fileName: string, 10 | opts: { 11 | scaleSizePercent: number; 12 | originalPixelSize: number; 13 | scaleMode: string; 14 | }, 15 | ) { 16 | super(page); 17 | this.convertItem = this.page 18 | .locator(".scaled-image-list") 19 | .locator(".scaled-image-list-item") 20 | .filter({ 21 | has: this.page.locator( 22 | `[name="checked-scaled-${opts.scaleSizePercent}-${opts.originalPixelSize}-${opts.scaleMode}-${this.fileName}"]`, 23 | ), 24 | }); 25 | } 26 | 27 | async exists() { 28 | await expect(this.convertItem).toBeVisible(); 29 | } 30 | 31 | async notExists() { 32 | await expect(this.convertItem).not.toBeVisible(); 33 | } 34 | 35 | async clickCheckBox() { 36 | await this.convertItem.getByRole("checkbox").click(); 37 | } 38 | 39 | async clickDownloadButton() { 40 | await this.convertItem.locator("svg.fa-download").click(); 41 | } 42 | 43 | async clickDeleteButton() { 44 | await this.convertItem.locator("svg.fa-trash").click(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/core/utils/ogp.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Changes the value of the Open Graph Protocol (OGP) meta tag. 3 | * 4 | * @param property - The property attribute of the meta tag. 5 | * @param content - The new content value for the meta tag. 6 | */ 7 | export const createOrChangeOgpValue = (property: string, content: string) => { 8 | const meta = document.querySelector(`meta[property="${property}"]`); 9 | meta 10 | ? meta.setAttribute("content", content) 11 | : createOgpTag(property, content); 12 | }; 13 | 14 | /** 15 | * Changes the value of the Open Graph Protocol (OGP) meta tags. 16 | * 17 | * @param values - An array of objects containing the property and content values. 18 | */ 19 | export const createOrChangeOgpValues = ( 20 | values: { property: string; content: string }[], 21 | ) => { 22 | for (const { property, content } of values) { 23 | createOrChangeOgpValue(property, content); 24 | } 25 | }; 26 | 27 | /** 28 | * Creates a new Open Graph Protocol (OGP) meta tag with the specified property and content values. 29 | * 30 | * @param property - The property attribute of the meta tag. 31 | * @param content - The content attribute of the meta tag. 32 | */ 33 | const createOgpTag = (property: string, content: string) => { 34 | const meta = document.createElement("meta"); 35 | meta.setAttribute("property", property); 36 | meta.setAttribute("content", content); 37 | document.head.append(meta); 38 | }; 39 | -------------------------------------------------------------------------------- /tests/e2e/constants/params.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OriginalPixelSize, 3 | ScaleMode, 4 | ScaleSizePercent, 5 | } from "@/constants/form"; 6 | 7 | export const ConvertOptsList = [ 8 | { 9 | scaleSizePercent: 300, 10 | originalPixelSize: OriginalPixelSize.Default, 11 | scaleMode: ScaleMode.Smooth, 12 | }, 13 | { 14 | scaleSizePercent: ScaleSizePercent.Default, 15 | originalPixelSize: 2, 16 | scaleMode: ScaleMode.Smooth, 17 | }, 18 | { 19 | scaleSizePercent: ScaleSizePercent.Default, 20 | originalPixelSize: OriginalPixelSize.Default, 21 | scaleMode: ScaleMode.Nearest, 22 | }, 23 | { 24 | scaleSizePercent: 300, 25 | originalPixelSize: OriginalPixelSize.Default, 26 | scaleMode: ScaleMode.Nearest, 27 | }, 28 | { 29 | scaleSizePercent: ScaleSizePercent.Default, 30 | originalPixelSize: 2, 31 | scaleMode: ScaleMode.Nearest, 32 | }, 33 | { scaleSizePercent: 300, originalPixelSize: 2, scaleMode: ScaleMode.Nearest }, 34 | ] satisfies { 35 | scaleSizePercent: number; 36 | originalPixelSize: number; 37 | scaleMode: string; 38 | }[]; 39 | 40 | export const ScaleSizePercentParams = { 41 | valid: [100, 200, 500], 42 | invalidMin: [-1, 99, 99.9, 100.9], 43 | invalidMax: [10_001, 10_000.2, 100_000], 44 | }; 45 | 46 | export const OriginalPixelSizeParams = { 47 | valid: [1, 2, 3], 48 | invalidMin: [-1, 0], 49 | invalidMax: [101, 100.2, 100_000], 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/common/form/VFormRadio.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 43 | 44 | 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/featire_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea or improvement for the app. 3 | labels: [enhancement] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | **Thanks for your suggestion!** 9 | Please describe your idea clearly and in detail. 10 | 11 | - type: input 12 | id: title 13 | attributes: 14 | label: Feature title 15 | placeholder: e.g. Add ZIP download for selected images 16 | validations: 17 | required: true 18 | 19 | - type: textarea 20 | id: description 21 | attributes: 22 | label: Feature description 23 | placeholder: Describe the feature or improvement you are suggesting. 24 | validations: 25 | required: true 26 | 27 | - type: textarea 28 | id: usecase 29 | attributes: 30 | label: Use case 31 | placeholder: Describe how you would use this feature and why it is helpful. 32 | validations: 33 | required: false 34 | 35 | - type: textarea 36 | id: alternatives 37 | attributes: 38 | label: Alternatives considered 39 | placeholder: If you have tried any workarounds or other solutions, describe them here. 40 | validations: 41 | required: false 42 | 43 | - type: checkboxes 44 | id: agree 45 | attributes: 46 | label: Confirmation 47 | options: 48 | - label: I have checked existing issues to avoid duplicates. 49 | required: true 50 | -------------------------------------------------------------------------------- /src/components/common/VHintBalloon.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 24 | 25 | 66 | -------------------------------------------------------------------------------- /tests/unit/components/common/form/VFormSelectBox.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import VFormSelectBox from "@/components/common/form/VFormSelectBox.vue"; 4 | 5 | describe("VFormSelectBox Component", () => { 6 | const options = [ 7 | { label: "Option 1", value: 1 }, 8 | { label: "Option 2", value: 2 }, 9 | { label: "Option 3", value: 3 }, 10 | ]; 11 | 12 | test("renders options correctly", () => { 13 | const wrapper = mount(VFormSelectBox, { 14 | props: { 15 | id: "test-select", 16 | name: "test-select", 17 | options, 18 | modelValue: 2, 19 | }, 20 | }); 21 | 22 | const renderedOptions = wrapper.findAll("option"); 23 | expect(renderedOptions.length).toBe(options.length); 24 | 25 | for (const [index, option] of options.entries()) { 26 | expect(renderedOptions[index].text()).toBe(option.label); 27 | expect(renderedOptions[index].attributes("value")).toBe( 28 | option.value.toString(), 29 | ); 30 | } 31 | }); 32 | 33 | test("binds modelValue correctly", async () => { 34 | const wrapper = mount(VFormSelectBox, { 35 | props: { 36 | id: "test-select", 37 | name: "test-select", 38 | options, 39 | modelValue: 2, 40 | }, 41 | }); 42 | 43 | const select = wrapper.find("select"); 44 | expect((select.element as HTMLSelectElement).value).toBe("2"); 45 | 46 | await select.setValue("3"); 47 | expect(wrapper.emitted()["update:modelValue"][0]).toEqual([3]); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/core/system.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Retrieves the version of the application. 3 | * @returns The version of the application as a string. 4 | */ 5 | export const getAppCurrentVersion = (): string => { 6 | return import.meta.env.APP_VERSION; 7 | }; 8 | 9 | /** 10 | * Returns the language code of the user's browser. 11 | * @returns The language code of the user's browser. 12 | */ 13 | export const getBrowserLanguage = (): string => { 14 | return ( 15 | (globalThis.navigator.languages && globalThis.navigator.languages[0]) || 16 | globalThis.navigator.language 17 | ); 18 | }; 19 | 20 | /** 21 | * Checks if the application is running in standalone mode. 22 | * @returns A boolean value indicating whether the application is running in standalone mode. 23 | */ 24 | export const isStandalone = (): boolean => { 25 | return ( 26 | "VITE_IS_STANDALONE" in import.meta.env && 27 | import.meta.env.VITE_IS_STANDALONE === "true" 28 | ); 29 | }; 30 | 31 | /** 32 | * Checks if the code is running in a web environment. 33 | * @returns {boolean} Returns true if the code is running in a web environment, false otherwise. 34 | */ 35 | export const isWeb = (): boolean => { 36 | return !isStandalone(); 37 | }; 38 | 39 | /** 40 | * Checks if the application is running in RPG Maker Unite mode. 41 | * @returns {boolean} Returns true if the application is running in RPG Maker Unite mode. 42 | */ 43 | export const isUnite = (): boolean => { 44 | return ( 45 | "VITE_IS_UNITE" in import.meta.env && 46 | import.meta.env.VITE_IS_UNITE === "true" 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/constants/icon.ts: -------------------------------------------------------------------------------- 1 | import { faGithubAlt, faXTwitter } from "@fortawesome/free-brands-svg-icons"; 2 | import { 3 | faAngleDown, 4 | faBalanceScale, 5 | faCircleQuestion, 6 | faDownload, 7 | faExclamationTriangle, 8 | faFileExport, 9 | faFileZipper, 10 | faFolderOpen, 11 | faGrip, 12 | faImages, 13 | faList, 14 | faMagnifyingGlass, 15 | faMaximize, 16 | faRotate, 17 | faShareNodes, 18 | faSliders, 19 | faTerminal, 20 | faTimesCircle, 21 | faTrash, 22 | } from "@fortawesome/free-solid-svg-icons"; 23 | 24 | export const FontAwesomeIcons = { 25 | // NOTE: for form 26 | "fa-folder-open": faFolderOpen, 27 | "fa-angle-down": faAngleDown, 28 | "fa-maximize": faMaximize, 29 | "fa-terminal": faTerminal, 30 | "fa-magnifying-glass": faMagnifyingGlass, 31 | "fa-sliders": faSliders, 32 | // NOTE: for link 33 | "fa-share-nodes": faShareNodes, 34 | "fa-x-twitter": faXTwitter, 35 | "fa-github-alt": faGithubAlt, 36 | "fa-images": faImages, 37 | "fa-balance-scale": faBalanceScale, 38 | // NOTE: for closable component 39 | "fa-times-circle": faTimesCircle, 40 | // NOTE: for file list item 41 | "fa-rotate": faRotate, 42 | "fa-trash": faTrash, 43 | "fa-triangle-exclamation": faExclamationTriangle, 44 | // NOTE: for convert list item 45 | "fa-grid": faGrip, 46 | "fa-list": faList, 47 | "fa-download": faDownload, 48 | "fa-file-export": faFileExport, 49 | "fa-file-zipper": faFileZipper, 50 | // NOTE: for balloon 51 | "fa-circle-question": faCircleQuestion, 52 | } as const satisfies Record; 53 | -------------------------------------------------------------------------------- /src-tauri/resources/README_en.txt: -------------------------------------------------------------------------------- 1 | Pixel Scaler - A Pixel Art Upscaling Tool 2 | ========================================= 3 | 4 | Overview 5 | -------- 6 | Pixel Scaler is a tool that upscales pixel art with high quality. 7 | It uses the xBR algorithm to convert pixel art into a smooth, illustration-like style. 8 | 9 | Supported formats: 10 | - gif / jpeg / png 11 | 12 | How to Use 13 | ---------- 14 | 1. Launch Pixel Scaler. 15 | 2. Select or drag & drop the image you want to upscale. 16 | 3. Enter the desired scale percentage (100%–10000%). 17 | 4. Set the "Pixel Size": 18 | - For original pixel art: enter `1` 19 | - For already upscaled images: enter the scale factor (e.g., 2x → 2) 20 | 5. Click the "Convert" button. 21 | 6. The upscaled image will appear. 22 | 7. Click "Output" or "Output All" to save it. 23 | 24 | Tips 25 | ---- 26 | - Best results are achieved by using original-size pixel art. 27 | - Low-resolution illustrations (non-pixel art) will not upscale cleanly. 28 | - Setting the scale to 100% applies anti-aliasing to the image. 29 | - If the "Pixel Size" does not match the actual size in the image, conversion may fail. 30 | 31 | License and Copyright 32 | --------------------- 33 | You may use the converted images freely for personal or commercial purposes within reasonable bounds. 34 | 35 | Used Library: 36 | - xBRjs (c) 2020 Josep del Rio 37 | https://github.com/joseprio/xBRjs 38 | 39 | Download 40 | -------- 41 | https://github.com/irokaru/pixel-scaler/releases 42 | 43 | Copyright 44 | --------- 45 | (c) 2025 ののの茶屋 46 | https://nononotyaya.net/ 47 | -------------------------------------------------------------------------------- /src/constants/form.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ResultDisplayStyleType, 3 | ScaleModeType, 4 | ScaleParameterType, 5 | } from "@/@types/form"; 6 | 7 | import { FontAwesomeIcons } from "./icon"; 8 | 9 | export const ScaleMode = { 10 | Smooth: "smooth", 11 | Nearest: "nearest", 12 | } as const satisfies Record; 13 | 14 | // NOTE: label is i18n key 15 | export const ScaleModes = [ 16 | { label: "form.scale-modes.smooth", value: ScaleMode.Smooth }, 17 | { label: "form.scale-modes.nearest", value: ScaleMode.Nearest }, 18 | ] as const satisfies { 19 | label: string; 20 | value: ScaleModeType; 21 | }[]; 22 | 23 | export const ScaleSizePercent = { 24 | Min: 100, 25 | Max: 10_000, 26 | Default: 200, 27 | } as const satisfies ScaleParameterType; 28 | 29 | export const OriginalPixelSize = { 30 | Min: 1, 31 | Max: 100, 32 | Default: 1, 33 | } as const satisfies ScaleParameterType; 34 | 35 | export const ResultDisplayStyles = { 36 | Grid: "grid", 37 | List: "list", 38 | } as const satisfies Record; 39 | 40 | export const ResultDisplayStyleOptions = [ 41 | { 42 | prefixIcon: "fa-grid", 43 | label: "form.result-display-style.grid", 44 | value: ResultDisplayStyles.Grid, 45 | }, 46 | { 47 | prefixIcon: "fa-list", 48 | label: "form.result-display-style.list", 49 | value: ResultDisplayStyles.List, 50 | }, 51 | ] as const satisfies { 52 | prefixIcon: keyof typeof FontAwesomeIcons; 53 | label: string; 54 | value: ResultDisplayStyleType; 55 | }[]; 56 | 57 | export const OutputPathStorageKey = "output-path"; 58 | -------------------------------------------------------------------------------- /src/components/common/form/VFormCheckBox.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 28 | 29 | 73 | -------------------------------------------------------------------------------- /src/composables/useImageCheckable.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref, Ref, watch } from "vue"; 2 | 3 | import { ImageCheckList, PSImageDataObject } from "@/@types/convert"; 4 | 5 | const useImageCheckable = ( 6 | modelValue: Ref, 7 | ) => { 8 | const checkedMap = ref({}); 9 | 10 | const allChecked = computed({ 11 | get: () => 12 | modelValue.value.length > 0 && 13 | modelValue.value.every((item) => checkedMap.value[item.image.uuid]), 14 | set: (val: boolean) => { 15 | for (const item of modelValue.value) { 16 | checkedMap.value[item.image.uuid] = val; 17 | } 18 | }, 19 | }); 20 | 21 | const toggleAllChecked = () => { 22 | allChecked.value = !allChecked.value; 23 | }; 24 | 25 | const isAnyChecked = computed(() => 26 | Object.values(checkedMap.value).some(Boolean), 27 | ); 28 | 29 | watch( 30 | modelValue, 31 | (newList) => { 32 | for (const item of newList) { 33 | if (!(item.image.uuid in checkedMap.value)) { 34 | checkedMap.value[item.image.uuid] = false; 35 | } 36 | } 37 | 38 | const uuids = new Set(newList.map((item) => item.image.uuid)); 39 | for (const uuid of Object.keys(checkedMap.value)) { 40 | if (!uuids.has(uuid)) { 41 | delete checkedMap.value[uuid]; 42 | } 43 | } 44 | }, 45 | { immediate: true, deep: true }, 46 | ); 47 | 48 | return { 49 | checkedMap, 50 | allChecked, 51 | toggleAllChecked, 52 | isAnyChecked, 53 | }; 54 | }; 55 | 56 | export default useImageCheckable; 57 | -------------------------------------------------------------------------------- /tests/utils/imageTestHelper.ts: -------------------------------------------------------------------------------- 1 | import { ImageEntry } from "@/@types/convert"; 2 | import { ScaleMode } from "@/constants/form"; 3 | import { PSImageData } from "@/models/InputImageData"; 4 | 5 | /** 6 | * Creates a 1x1 red PNG file for testing purposes. 7 | * This helper is used in browser environment tests where Canvas API is available. 8 | * 9 | * @returns A File object containing a valid 1x1 PNG image 10 | */ 11 | export const create1pxPngFile = (): File => { 12 | const canvas = document.createElement("canvas"); 13 | canvas.width = 1; 14 | canvas.height = 1; 15 | const ctx = canvas.getContext("2d")!; 16 | ctx.fillStyle = "red"; 17 | ctx.fillRect(0, 0, 1, 1); 18 | 19 | const dataURL = canvas.toDataURL("image/png"); 20 | const base64Data = dataURL.split(",")[1]; 21 | const binaryString = atob(base64Data); 22 | const bytes = new Uint8Array(binaryString.length); 23 | for (let i = 0; i < binaryString.length; i++) { 24 | bytes[i] = binaryString.codePointAt(i) ?? 0; 25 | } 26 | 27 | return new File([bytes], "test.png", { type: "image/png" }); 28 | }; 29 | 30 | /** 31 | * Creates a complete ImageEntry for testing purposes. 32 | * This helper initializes a PSImageData from a 1x1 PNG file. 33 | * 34 | * @returns A Promise resolving to an ImageEntry with default settings 35 | */ 36 | export const createImageEntry = async (): Promise => { 37 | const file = create1pxPngFile(); 38 | const imageData = await PSImageData.init(file); 39 | imageData.originalPixelSize = 1; 40 | return { 41 | image: imageData.toObject(), 42 | settings: { scaleSizePercent: 100, scaleMode: ScaleMode.Smooth }, 43 | errors: [], 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /vite/config/pwa.ts: -------------------------------------------------------------------------------- 1 | import { VitePWAOptions } from "vite-plugin-pwa"; 2 | 3 | export const pwaConfig: Partial = { 4 | registerType: "autoUpdate" as const, 5 | workbox: { 6 | globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"], 7 | runtimeCaching: [ 8 | { 9 | urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, 10 | handler: "CacheFirst" as const, 11 | options: { 12 | cacheName: "google-fonts-cache", 13 | expiration: { 14 | maxEntries: 10, 15 | maxAgeSeconds: 60 * 60 * 24 * 365, 16 | }, 17 | }, 18 | }, 19 | { 20 | urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, 21 | handler: "CacheFirst" as const, 22 | options: { 23 | cacheName: "gstatic-fonts-cache", 24 | expiration: { 25 | maxEntries: 10, 26 | maxAgeSeconds: 60 * 60 * 24 * 365, 27 | }, 28 | }, 29 | }, 30 | ], 31 | }, 32 | includeAssets: ["favicon.ico", "logo.png", "banner.png"], 33 | manifest: { 34 | name: "ぴくせる すけゐらぁ", 35 | short_name: "PiXel ScaLer", 36 | description: "ドット絵をイラスト調にリサイズできるやつです", 37 | theme_color: "#ffffff", 38 | background_color: "#ffffff", 39 | display: "standalone" as const, 40 | orientation: "portrait" as const, 41 | scope: "/", 42 | start_url: "/", 43 | categories: ["graphics", "utilities"], 44 | icons: [ 45 | { 46 | src: "logo.png", 47 | sizes: "256x256", 48 | type: "image/png", 49 | purpose: "any maskable", 50 | }, 51 | ], 52 | }, 53 | devOptions: { 54 | enabled: false, 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /src/utils/imageUtils.ts: -------------------------------------------------------------------------------- 1 | export const imageDataToFile = ( 2 | imageData: ImageData, 3 | filename: string, 4 | fileType: string, 5 | ): Promise => { 6 | const canvas = document.createElement("canvas"); 7 | canvas.width = imageData.width; 8 | canvas.height = imageData.height; 9 | 10 | const ctx = canvas.getContext("2d"); 11 | if (!ctx) { 12 | throw new Error("could not get 2d context"); 13 | } 14 | ctx.putImageData(imageData, 0, 0); 15 | 16 | return new Promise((resolve, reject) => { 17 | canvas.toBlob((blob) => { 18 | if (!blob) { 19 | reject(new Error("could not create blob")); 20 | return; 21 | } 22 | resolve(new File([blob], filename, { type: fileType })); 23 | }, fileType); 24 | }); 25 | }; 26 | 27 | export const resizeImageData = async ( 28 | imageData: ImageData, 29 | width: number, 30 | height: number, 31 | imageSmoothingEnabled = true, 32 | ): Promise => { 33 | const resizeWidth = Math.floor(width); 34 | const resizeHeight = Math.floor(height); 35 | 36 | const imageBitmap = await createImageBitmap(imageData); 37 | const canvas = document.createElement("canvas"); 38 | canvas.width = resizeWidth; 39 | canvas.height = resizeHeight; 40 | 41 | const ctx = canvas.getContext("2d"); 42 | if (!ctx) { 43 | throw new Error("could not get 2d context"); 44 | } 45 | ctx.imageSmoothingEnabled = imageSmoothingEnabled; 46 | 47 | ctx.scale(resizeWidth / imageData.width, resizeHeight / imageData.height); 48 | ctx.drawImage(imageBitmap, 0, 0); 49 | 50 | return ctx.getImageData(0, 0, resizeWidth, resizeHeight); 51 | }; 52 | 53 | export const revokeObjectURL = (url: string) => { 54 | URL.revokeObjectURL(url); 55 | }; 56 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { fileURLToPath } from "node:url"; 4 | 5 | import vue from "@vitejs/plugin-vue"; 6 | import { playwright } from "@vitest/browser-playwright"; 7 | import tsconfigPaths from "vite-tsconfig-paths"; 8 | import { coverageConfigDefaults, defineConfig } from "vitest/config"; 9 | 10 | export default defineConfig({ 11 | plugins: [vue(), tsconfigPaths()], 12 | test: { 13 | globals: true, 14 | projects: [ 15 | { 16 | extends: true, 17 | test: { 18 | name: "unit", 19 | setupFiles: ["tests/unit/vitest.setup.ts"], 20 | environment: "happy-dom", 21 | include: ["tests/unit/**/*.spec.ts"], 22 | exclude: ["tests/unit/**/*.browser.spec.ts"], 23 | }, 24 | }, 25 | { 26 | extends: true, 27 | test: { 28 | name: "browser", 29 | include: ["tests/unit/**/*.browser.spec.ts"], 30 | browser: { 31 | enabled: true, 32 | provider: playwright(), 33 | headless: true, 34 | instances: [{ browser: "chromium" }], 35 | }, 36 | }, 37 | }, 38 | ], 39 | reporters: process.env.GITHUB_ACTIONS 40 | ? [["junit", { outputFile: "coverage/test-report.junit.xml" }]] 41 | : [], 42 | coverage: { 43 | exclude: [ 44 | "src-tauri", 45 | "tests/e2e", 46 | "playwright-report", 47 | "playwright.config.ts", 48 | "**/__mocks__", 49 | ...coverageConfigDefaults.exclude, 50 | ], 51 | }, 52 | }, 53 | resolve: { 54 | alias: { 55 | "@": fileURLToPath(new URL("src", import.meta.url)), 56 | }, 57 | }, 58 | }); 59 | -------------------------------------------------------------------------------- /tests/e2e/components/InputFileListHeader.ts: -------------------------------------------------------------------------------- 1 | import { PageObjectBase } from "./_PageObjectBase"; 2 | 3 | export class InputFileListHeader extends PageObjectBase { 4 | async clickConvertAllButton() { 5 | const fileInputArea = this.page.getByTestId("file-input-area"); 6 | await fileInputArea.locator("svg.fa-images").click(); 7 | } 8 | 9 | async clickDeleteAllButton() { 10 | const fileInputArea = this.page.getByTestId("file-input-area"); 11 | await fileInputArea.locator("svg.fa-trash").click(); 12 | } 13 | 14 | async clickApplyAllButton() { 15 | const fileInputArea = this.page.getByTestId("file-input-area"); 16 | await fileInputArea.locator("svg.fa-sliders").click(); 17 | } 18 | 19 | async changeScaleSizePercent(percent: number) { 20 | const fileInputArea = this.page.getByTestId("file-input-area"); 21 | const scaleSizePercentInput = fileInputArea.getByRole("spinbutton", { 22 | name: "scale", 23 | }); 24 | await scaleSizePercentInput.fill(String(percent)); 25 | await scaleSizePercentInput.blur(); 26 | } 27 | 28 | async changeOriginalPixelSize(size: number) { 29 | const fileInputArea = this.page.getByTestId("file-input-area"); 30 | const originalPixelSizeInput = fileInputArea.getByRole("spinbutton", { 31 | name: "pixel", 32 | }); 33 | await originalPixelSizeInput.fill(String(size)); 34 | await originalPixelSizeInput.blur(); 35 | } 36 | 37 | async changeScaleMode(scaleMode: string) { 38 | const fileInputArea = this.page.getByTestId("file-input-area"); 39 | const scaleModeInput = fileInputArea.getByRole("combobox", { 40 | name: "mode", 41 | }); 42 | await scaleModeInput.selectOption(scaleMode); 43 | await scaleModeInput.blur(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/common/form/VFormFileInput.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 66 | -------------------------------------------------------------------------------- /src/components/convert/ScaledImageList/DirectorySelector.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 39 | 40 | 62 | -------------------------------------------------------------------------------- /src/core/services/image/entryBatchService.ts: -------------------------------------------------------------------------------- 1 | import { ImageCheckList, ImageEntry } from "@/@types/convert"; 2 | import { getCheckedItems, isAllUnchecked } from "@/utils/imageItemUtils"; 3 | import { revokeObjectURL } from "@/utils/imageUtils"; 4 | 5 | /** 6 | * Filter entries based on checked state 7 | * If all items are unchecked, returns empty array (all items will be processed) 8 | * Otherwise, returns only checked items 9 | */ 10 | export const filterEntriesByChecked = ( 11 | entries: ImageEntry[], 12 | checkedMap: ImageCheckList, 13 | ): ImageEntry[] => { 14 | const allUnchecked = isAllUnchecked(entries, checkedMap); 15 | 16 | if (allUnchecked) { 17 | return entries; 18 | } 19 | 20 | return getCheckedItems(entries, checkedMap); 21 | }; 22 | 23 | /** 24 | * Get entries that should be kept (not deleted) based on checked state 25 | */ 26 | export const getUncheckedEntries = ( 27 | entries: ImageEntry[], 28 | checkedMap: ImageCheckList, 29 | ): ImageEntry[] => { 30 | const allUnchecked = isAllUnchecked(entries, checkedMap); 31 | 32 | if (allUnchecked) { 33 | return []; 34 | } 35 | 36 | return entries.filter((entry) => !checkedMap[entry.image.uuid]); 37 | }; 38 | 39 | /** 40 | * Revoke object URLs for multiple entries 41 | * Failures are silently ignored as recovery is not possible 42 | */ 43 | export const revokeEntryUrls = (entries: ImageEntry[]): void => { 44 | for (const entry of entries) { 45 | try { 46 | revokeObjectURL(entry.image.url); 47 | } catch { 48 | // Ignore errors as recovery is not possible 49 | } 50 | } 51 | }; 52 | 53 | // TODO: Consider migrating downloadString and createZipBlobFromScaledImages 54 | // from utils/fileUtils.ts to a dedicated fileService for better separation of concerns 55 | -------------------------------------------------------------------------------- /src/components/common/VAccordionContent.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 34 | 35 | 67 | -------------------------------------------------------------------------------- /tests/unit/composables/useI18n.spec.ts: -------------------------------------------------------------------------------- 1 | import { nextTick } from "vue"; 2 | 3 | import useI18n from "@/composables/useI18n"; 4 | import { StorageKey } from "@/core/constants/i18n"; 5 | import { getLocalStorage } from "@/core/infrastructure/storage"; 6 | import { vueI18n, vueI18nLocales } from "@/core/plugins/i18n"; 7 | 8 | describe("useI18n", () => { 9 | beforeEach(() => { 10 | localStorage.clear(); 11 | document.documentElement.lang = ""; 12 | vueI18n.global.locale.value = "en"; 13 | }); 14 | 15 | test.each<{ 16 | description: string; 17 | initialLocale: string; 18 | expectedLangAttr: string; 19 | }>([ 20 | { 21 | description: 22 | "sets document lang and localStorage when initialized with 'en'", 23 | initialLocale: "en", 24 | expectedLangAttr: "en", 25 | }, 26 | { 27 | description: 28 | "sets document lang and localStorage when initialized with 'ja'", 29 | initialLocale: "ja", 30 | expectedLangAttr: "ja", 31 | }, 32 | ])("$description", async ({ initialLocale, expectedLangAttr }) => { 33 | vueI18n.global.locale.value = initialLocale as vueI18nLocales; 34 | 35 | const { languageKey } = useI18n(); 36 | 37 | await nextTick(); 38 | 39 | expect(languageKey.value).toBe(initialLocale); 40 | expect(document.documentElement.lang).toBe(expectedLangAttr); 41 | expect(getLocalStorage(StorageKey)).toBe(initialLocale); 42 | }); 43 | 44 | test("updates languageKey and saves to localStorage", async () => { 45 | vueI18n.global.locale.value = "en"; 46 | 47 | const { languageKey } = useI18n(); 48 | 49 | languageKey.value = "ja"; 50 | 51 | await nextTick(); 52 | 53 | expect(document.documentElement.lang).toBe("ja"); 54 | expect(getLocalStorage(StorageKey)).toBe("ja"); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/models/__mocks__/InputImageData.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | 3 | import { PSImageDataObject, PSImageDataSettingType } from "@/@types/convert"; 4 | import { CustomErrorObject } from "@/@types/error"; 5 | import { ScaleModeType } from "@/@types/form"; 6 | 7 | export class PSImageDataSetting implements PSImageDataSettingType { 8 | public scaleSizePercent!: number; 9 | public scaleMode!: ScaleModeType; 10 | 11 | public constructor(settings: PSImageDataSettingType) { 12 | this.scaleSizePercent = settings.scaleSizePercent; 13 | this.scaleMode = settings.scaleMode; 14 | } 15 | } 16 | 17 | export class PSImageData { 18 | public uuid: string = "mock-uuid"; 19 | public readonly data: File; 20 | public imageData: ImageData = new ImageData(1, 1); 21 | public width: number = 1; 22 | public height: number = 1; 23 | public originalPixelSize: number = 1; 24 | public errors: CustomErrorObject<"scale">[] = []; 25 | 26 | protected constructor(data: File) { 27 | this.data = data; 28 | } 29 | 30 | public static async init(data: File): Promise { 31 | const inputImageData = new PSImageData(data); 32 | inputImageData.uuid = uuidv4(); 33 | return inputImageData; 34 | } 35 | 36 | public toUrl(): string { 37 | return `data:image/png;base64,mock-image-url-${this.data.name}`; 38 | } 39 | 40 | public toObject(): PSImageDataObject { 41 | return { 42 | uuid: this.uuid, 43 | data: this.data, 44 | imageData: this.imageData, 45 | width: this.width, 46 | height: this.height, 47 | originalPixelSize: this.originalPixelSize, 48 | url: this.toUrl(), 49 | status: "loaded", 50 | }; 51 | } 52 | 53 | protected async loadImageData(): Promise { 54 | return; 55 | } 56 | } 57 | 58 | export default PSImageData; 59 | -------------------------------------------------------------------------------- /src/utils/fileUtils.ts: -------------------------------------------------------------------------------- 1 | import { BaseDirectory, writeFile } from "@tauri-apps/plugin-fs"; 2 | import { zipSync } from "fflate"; 3 | 4 | import { ImageEntry } from "@/@types/convert"; 5 | import { isWeb } from "@/core/system"; 6 | 7 | import { revokeObjectURL } from "./imageUtils"; 8 | 9 | export const downloadString = async ( 10 | url: string, 11 | fileName: string, 12 | outputPath?: string, 13 | ) => { 14 | if (isWeb()) { 15 | const link = document.createElement("a"); 16 | link.href = url; 17 | link.download = fileName; 18 | link.click(); 19 | } else { 20 | const base64 = url.split(",")[1]; 21 | const bin = atob(base64); 22 | const bytes = new Uint8Array(bin.length); 23 | for (let i = 0; i < bin.length; i++) { 24 | bytes[i] = bin.codePointAt(i) as number; 25 | } 26 | await writeFile(`${outputPath}/${fileName}`, bytes, { 27 | baseDir: BaseDirectory.Home, 28 | }); 29 | } 30 | }; 31 | 32 | export const downloadBlob = (blob: Blob, fileName: string) => { 33 | const url = URL.createObjectURL(blob); 34 | downloadString(url, fileName); 35 | revokeObjectURL(url); 36 | }; 37 | 38 | export const createZipBlobFromScaledImages = async (images: ImageEntry[]) => { 39 | const zipEntries: Record = {}; 40 | 41 | for (const image of images) { 42 | const blob = image.image.data; 43 | const buffer = await blob.arrayBuffer(); 44 | const uint8Array = new Uint8Array(buffer); 45 | 46 | const fileName = image.image.data.name; 47 | const filePath = `${image.settings.scaleMode}/org_${image.image.originalPixelSize}px/x${image.settings.scaleSizePercent}/${fileName}`; 48 | zipEntries[filePath] = uint8Array; 49 | } 50 | 51 | const zipped = zipSync(zipEntries); 52 | return new Blob([new Uint8Array(zipped)], { type: "application/zip" }); 53 | }; 54 | -------------------------------------------------------------------------------- /tests/unit/core/services/colorService.spec.ts: -------------------------------------------------------------------------------- 1 | import { ColorKey } from "@/core/@types/color"; 2 | import { StorageKey } from "@/core/constants/color"; 3 | import { 4 | getAllColors, 5 | loadColorKeyInStorage, 6 | saveColorKey, 7 | } from "@/core/services/colorService"; 8 | 9 | describe("loadColorKeyInStorage", () => { 10 | beforeEach(() => { 11 | localStorage.removeItem(StorageKey); 12 | }); 13 | 14 | test("should return the default color key if local storage does not exist", () => { 15 | const result = loadColorKeyInStorage(); 16 | expect(result).toBe("red"); 17 | }); 18 | 19 | test("should return the color key from local storage if it exists", () => { 20 | localStorage.setItem("color", "blue"); 21 | const result = loadColorKeyInStorage(); 22 | 23 | expect(result).toBe("blue"); 24 | }); 25 | 26 | test("should return the default color key if the color key from local storage does not exist in the colors object", () => { 27 | localStorage.setItem("color", "invalid_color"); 28 | 29 | const result = loadColorKeyInStorage(); 30 | expect(result).toBe("red"); 31 | }); 32 | }); 33 | 34 | describe("saveColorKey", () => { 35 | beforeEach(() => { 36 | localStorage.removeItem(StorageKey); 37 | }); 38 | 39 | test.each(Object.keys(getAllColors()))( 40 | "should set the color key in local storage if the provided key exists in the colors object (%s)", 41 | (key) => { 42 | saveColorKey(key as ColorKey); 43 | expect(localStorage.getItem("color")).toBe(key); 44 | }, 45 | ); 46 | 47 | test("should not set the color key in local storage if the provided key does not exist in the colors object", () => { 48 | const key = "invalid_color"; 49 | saveColorKey(key as ColorKey); 50 | 51 | expect(localStorage.getItem("color")).toBeNull(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/e2e/util/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@playwright/test"; 2 | 3 | import { ConvertOptsList } from "../constants/params"; 4 | import { ConvertOpts } from "../types"; 5 | 6 | type ConversionField = { 7 | name: string; 8 | convert: (opts: ConvertOpts) => Promise; 9 | verify: (opts: ConvertOpts) => Promise; 10 | }; 11 | 12 | export const testConversion = async (field: ConversionField) => { 13 | for (const opts of ConvertOptsList) { 14 | await test.step(`convert ${field.name} with: scale=${opts.scaleSizePercent}%, pixel=${opts.originalPixelSize}, mode=${opts.scaleMode}`, async () => 15 | await field.convert(opts)); 16 | 17 | await test.step(`verify converted ${field.name} conversion result`, async () => 18 | await field.verify(opts)); 19 | } 20 | }; 21 | 22 | type ValidationField = { 23 | name: string; 24 | valid: number[]; 25 | invalidMin: number[]; 26 | invalidMax: number[]; 27 | min: number; 28 | max: number; 29 | set: (value: number) => Promise; 30 | expect: (value: number) => Promise; 31 | }; 32 | 33 | export const testInputValidation = async (field: ValidationField) => { 34 | for (const valid of field.valid) { 35 | await test.step(`input valid ${field.name} (${valid})`, async () => { 36 | await field.set(valid); 37 | await field.expect(valid); 38 | }); 39 | } 40 | 41 | for (const invalid of field.invalidMin) { 42 | await test.step(`input invalid (min) ${field.name} (${invalid})`, async () => { 43 | await field.set(invalid); 44 | await field.expect(field.min); 45 | }); 46 | } 47 | 48 | for (const invalid of field.invalidMax) { 49 | await test.step(`input invalid (max) ${field.name} (${invalid})`, async () => { 50 | await field.set(invalid); 51 | await field.expect(field.max); 52 | }); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /src/algorithm/Nearestneighbor.ts: -------------------------------------------------------------------------------- 1 | import { PSImageDataObject } from "@/@types/convert"; 2 | import { PSImageData } from "@/models/InputImageData"; 3 | import { imageDataToFile, resizeImageData } from "@/utils/imageUtils"; 4 | 5 | /** 6 | * Scales an image using the nearest neighbor algorithm. 7 | * 8 | * @param inputImageData - The input image data object containing the image and its metadata. 9 | * @param scaleSizePercent - The scaling percentage to resize the image. For example, 50 for 50% or 200 for 200%. 10 | * @returns A promise that resolves to a `PSImageData` object containing the resized image data. 11 | * 12 | * @remarks 13 | * This function calculates the new dimensions of the image based on the provided scaling percentage, 14 | * resizes the image using the nearest neighbor algorithm, and converts the resized image data into 15 | * a file format compatible with the `PSImageData` class. 16 | * 17 | * @throws Will throw an error if the resizing or file conversion fails. 18 | */ 19 | export const nearestNeighbor = async ( 20 | inputImageData: PSImageDataObject, 21 | scaleSizePercent: number, 22 | ): Promise => { 23 | const scaledWidth = Math.round( 24 | (inputImageData.width * scaleSizePercent) / 100, 25 | ); 26 | const scaledHeight = Math.round( 27 | (inputImageData.height * scaleSizePercent) / 100, 28 | ); 29 | 30 | const resizedImageData = await resizeImageData( 31 | inputImageData.imageData, 32 | scaledWidth, 33 | scaledHeight, 34 | false, 35 | ); 36 | const resizedFile = await imageDataToFile( 37 | resizedImageData, 38 | inputImageData.data.name, 39 | inputImageData.data.type, 40 | ); 41 | const resizedPSImageData = await PSImageData.init(resizedFile); 42 | resizedPSImageData.originalPixelSize = inputImageData.originalPixelSize; 43 | 44 | return resizedPSImageData; 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/common/form/VFormSelectBox.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | 33 | 75 | -------------------------------------------------------------------------------- /tests/e2e/specs/settings.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | import { ColorKeys } from "@/core/constants/color"; 4 | 5 | import { SettingsSection } from "../components/SettingsSection"; 6 | 7 | test.describe("SettingsSection", () => { 8 | test.beforeEach(async ({ page }) => { 9 | await page.goto("/"); 10 | }); 11 | 12 | test("should change color theme and persist after reload", async ({ 13 | page, 14 | }) => { 15 | const settingsSection = new SettingsSection(page); 16 | const html = page.locator("html"); 17 | 18 | for (const color of ColorKeys) { 19 | await test.step(`change theme to ${color}`, async () => { 20 | await settingsSection.clickColorButton(color); 21 | await expect(html).toHaveAttribute("data-color-theme", color); 22 | }); 23 | } 24 | 25 | await test.step("theme setting should persist after reload", async () => { 26 | await page.reload(); 27 | await expect(html).toHaveAttribute( 28 | "data-color-theme", 29 | ColorKeys.at(-1) ?? "", 30 | ); 31 | }); 32 | }); 33 | 34 | test("should change language and persist after reload", async ({ page }) => { 35 | const settingsSection = new SettingsSection(page); 36 | const html = page.locator("html"); 37 | 38 | for (const language of SettingsSection.languages) { 39 | await test.step(`change language to ${language}`, async () => { 40 | await settingsSection.clickLanguageButton(language); 41 | await expect(html).toHaveAttribute("lang", language); 42 | }); 43 | } 44 | 45 | await test.step("language setting should persist after reload", async () => { 46 | await page.reload(); 47 | await expect(html).toHaveAttribute( 48 | "lang", 49 | SettingsSection.languages.at(-1) ?? "", 50 | ); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/core/services/i18nService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getLocalStorage, 3 | setLocalStorage, 4 | } from "@/core/infrastructure/storage"; 5 | import { getBrowserLanguage, isUnite } from "@/core/system"; 6 | 7 | import { LanguageKey } from "../@types/i18n"; 8 | import { 9 | DefaultLanguage, 10 | Languages, 11 | LanguagesForUnite, 12 | StorageKey, 13 | } from "../constants/i18n"; 14 | 15 | /** 16 | * Retrieves all available languages based on the current environment. 17 | * If the environment is "unite", it returns languages for unite, otherwise it returns default languages. 18 | * 19 | * @returns An array of available languages. 20 | */ 21 | export const getAllLanguage = () => { 22 | return isUnite() ? LanguagesForUnite : Languages; 23 | }; 24 | 25 | /** 26 | * Retrieves the language key to be used for localization. 27 | * If a language is stored in local storage, it is returned. 28 | * Otherwise, the browser language is used. 29 | * The returned language key is validated to ensure it is supported. 30 | * 31 | * @returns The valid language key to be used for localization. 32 | */ 33 | export const loadLanguageKeyInStorage = () => { 34 | const storedLanguage = getLocalStorage(StorageKey); 35 | const languageKey = storedLanguage ?? getBrowserLanguage(); 36 | return getValidLanguageKey(languageKey); 37 | }; 38 | 39 | /** 40 | * Sets the language key in the local storage if it exists. 41 | * 42 | * @param key - The language key to set. 43 | */ 44 | export const saveLanguageKey = (key: LanguageKey) => { 45 | if (existsLanguageKey(key)) { 46 | setLocalStorage(StorageKey, key); 47 | } 48 | }; 49 | 50 | const getValidLanguageKey = (key: string): LanguageKey => { 51 | return existsLanguageKey(key) ? key : DefaultLanguage; 52 | }; 53 | 54 | const existsLanguageKey = (key: string): key is LanguageKey => { 55 | return Object.keys(getAllLanguage()).includes(key); 56 | }; 57 | -------------------------------------------------------------------------------- /src/core/services/image/convertService.ts: -------------------------------------------------------------------------------- 1 | import { ImageEntry, PSImageDataObject } from "@/@types/convert"; 2 | import { ScaleModeType } from "@/@types/form"; 3 | import { nearestNeighbor, xBR } from "@/algorithm"; 4 | import { ScaleMode } from "@/constants/form"; 5 | import { PSImageData } from "@/models/InputImageData"; 6 | 7 | type ScaleMethod = ( 8 | file: PSImageDataObject, 9 | scaleSizePercent: number, 10 | ) => Promise; 11 | 12 | /** 13 | * Get the scale method based on the scale mode 14 | */ 15 | export const getScaleMethod = (mode: ScaleModeType): ScaleMethod => { 16 | const scaleMethods: Record = { 17 | [ScaleMode.Smooth]: xBR, 18 | [ScaleMode.Nearest]: nearestNeighbor, 19 | }; 20 | return scaleMethods[mode]; 21 | }; 22 | 23 | /** 24 | * Convert an image entry using the specified scale method 25 | */ 26 | export const convertImage = async ( 27 | entry: ImageEntry, 28 | ): Promise => { 29 | const { image, settings } = entry; 30 | const scaleMethod = getScaleMethod(settings.scaleMode); 31 | const scaledFile = await scaleMethod(image, settings.scaleSizePercent); 32 | 33 | const result = scaledFile.toObject(); 34 | result.status = "scaled"; 35 | 36 | return result; 37 | }; 38 | 39 | /** 40 | * Check if an image entry is a duplicate based on name, settings, and pixel size 41 | */ 42 | export const isDuplicateEntry = ( 43 | entry: ImageEntry, 44 | existingList: ImageEntry[], 45 | ): boolean => { 46 | return existingList.some( 47 | (scaledImage) => 48 | scaledImage.image.data.name === entry.image.data.name && 49 | scaledImage.settings.scaleSizePercent === 50 | entry.settings.scaleSizePercent && 51 | scaledImage.image.originalPixelSize === entry.image.originalPixelSize && 52 | scaledImage.settings.scaleMode === entry.settings.scaleMode, 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /tests/unit/components/common/form/VFormRadio.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import VFormRadio from "@/components/common/form/VFormRadio.vue"; 4 | 5 | describe("VFormRadio Component", () => { 6 | test("renders radio options correctly", () => { 7 | const options = [ 8 | { label: "Option 1", value: "option1", checked: false }, 9 | { label: "Option 2", value: "option2", checked: true }, 10 | { label: "Option 3", value: "option3", checked: false }, 11 | ]; 12 | const wrapper = mount(VFormRadio, { 13 | props: { 14 | name: "radioGroup", 15 | options, 16 | modelValue: options[1].value, 17 | }, 18 | }); 19 | 20 | const radioOptions = wrapper.findAll(".radio"); 21 | expect(radioOptions.length).toBe(options.length); 22 | 23 | for (const [index, option] of options.entries()) { 24 | expect(radioOptions[index].text()).toBe(option.label); 25 | expect(radioOptions[index].find("input").element.value).toBe( 26 | option.value, 27 | ); 28 | expect(radioOptions[index].find("input").element.checked).toBe( 29 | option.checked, 30 | ); 31 | } 32 | }); 33 | 34 | test("emits update:modelValue event when radio option is changed", async () => { 35 | const wrapper = mount(VFormRadio, { 36 | props: { 37 | name: "radioGroup", 38 | options: [ 39 | { label: "Option 1", value: "option1" }, 40 | { label: "Option 2", value: "option2" }, 41 | { label: "Option 3", value: "option3" }, 42 | ], 43 | modelValue: "option2", 44 | }, 45 | }); 46 | 47 | const radioOption = wrapper.find(".radio:nth-child(3)"); 48 | await radioOption.find("input").trigger("change"); 49 | 50 | expect(wrapper.emitted("update:modelValue")).toBeTruthy(); 51 | expect(wrapper.emitted("update:modelValue")?.[0]).toEqual(["option3"]); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/stores/outputPathStore.ts: -------------------------------------------------------------------------------- 1 | import { open } from "@tauri-apps/plugin-dialog"; 2 | import { exists } from "@tauri-apps/plugin-fs"; 3 | import { defineStore } from "pinia"; 4 | import { computed, ref, watch } from "vue"; 5 | 6 | import { OutputPathStorageKey } from "@/constants/form"; 7 | import { 8 | getLocalStorage, 9 | setLocalStorage, 10 | } from "@/core/infrastructure/storage"; 11 | import { isWeb } from "@/core/system"; 12 | 13 | const useOutputPathStore = defineStore("outputPathStore", () => { 14 | const outputPath = ref(getLocalStorage(OutputPathStorageKey) || ""); 15 | const error = ref(""); 16 | const isWebVal = isWeb(); 17 | 18 | const hasError = computed(() => error.value !== ""); 19 | 20 | watch(outputPath, (newValue) => { 21 | if (isWebVal) return; 22 | setLocalStorage(OutputPathStorageKey, newValue); 23 | validatePath(newValue); 24 | }); 25 | 26 | const browseDir = async () => { 27 | if (isWebVal) return; 28 | 29 | const selected = await open({ 30 | directory: true, 31 | multiple: false, 32 | defaultPath: outputPath.value || undefined, 33 | title: "Select Output Directory", 34 | }); 35 | 36 | if (selected && typeof selected === "string") { 37 | outputPath.value = selected; 38 | } 39 | }; 40 | 41 | const validatePath = async (value: string) => { 42 | if (isWebVal) return; 43 | 44 | if (!value) { 45 | error.value = "path-selector.empty-path"; 46 | return; 47 | } 48 | 49 | const existsPath = await exists(value); 50 | if (!existsPath) { 51 | error.value = "path-selector.path-does-not-exist"; 52 | return; 53 | } 54 | 55 | // NOTE: Clear error if validation passes 56 | error.value = ""; 57 | }; 58 | 59 | // NOTE: throw callback error when settings watch immediate option 60 | validatePath(outputPath.value); 61 | 62 | return { 63 | outputPath, 64 | error, 65 | hasError, 66 | browseDir, 67 | validatePath, 68 | }; 69 | }); 70 | 71 | export default useOutputPathStore; 72 | -------------------------------------------------------------------------------- /.github/workflows/weekly-dep-update.yaml: -------------------------------------------------------------------------------- 1 | name: Weekly dependency update 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 0" # 毎週日曜日 00:00 UTC(日本時間9:00) 6 | workflow_dispatch: 7 | 8 | jobs: 9 | update-dependencies: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: "22" 20 | 21 | - name: Update dependencies 22 | id: deps 23 | run: | 24 | set -e 25 | npm install -g npm-check-updates 26 | 27 | ncu -u 28 | rm package-lock.json 29 | npm install || { 30 | echo "npm install failed after dependency update" 31 | exit 1 32 | } 33 | 34 | if git diff --quiet; then 35 | echo "has_changes=false" >> $GITHUB_OUTPUT 36 | echo "No dependency updates found." 37 | else 38 | echo "has_changes=true" >> $GITHUB_OUTPUT 39 | fi 40 | 41 | - name: Get current year and week 42 | id: date 43 | run: echo "yearweek=$(date +'%Y%U')" >> $GITHUB_OUTPUT 44 | 45 | - name: Create Pull Request 46 | if: steps.deps.outputs.has_changes == 'true' 47 | uses: peter-evans/create-pull-request@v7 48 | with: 49 | branch: chore/deps-${{ steps.date.outputs.yearweek }} 50 | title: "chore: Weekly dependency update (${{ steps.date.outputs.yearweek }})" 51 | body: | 52 | This PR updates all dependencies to their latest versions. 53 | Automatically generated by GitHub Actions. 54 | labels: dependencies 55 | base: main 56 | commit-message: "chore: weekly dependency update (${{ steps.date.outputs.yearweek }})" 57 | committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 58 | author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 59 | -------------------------------------------------------------------------------- /src/core/@types/xBRjs.d.ts: -------------------------------------------------------------------------------- 1 | declare module "xbr-js/dist/xBRjs.esm.js" { 2 | /** 3 | * blendColors - Determines if new colors will be created. Defaults to true. 4 | * scaleAlpha - Determines whether to upscale the alpha channel using the xBR algorithm. Defaults to false. 5 | */ 6 | type rawOpts = { 7 | blendColors?: boolean; 8 | scaleAlpha?: boolean; 9 | }; 10 | 11 | /** 12 | * Applies 2x scaling using xBR algorithm. 13 | * @param pixelArray - The input pixels in ARGB format. 14 | * @param width - The width of the original image. 15 | * @param height - The height of the original image. 16 | * @param rawOpts - Optional parameters to customize the scaling behavior. 17 | * @returns A new Uint32Array containing the scaled image data. 18 | */ 19 | export function xbr2x( 20 | pixelArray: Uint32Array, 21 | width: number, 22 | height: number, 23 | options?: rawOpts, 24 | ): Uint32Array; 25 | 26 | /** 27 | * Applies 3x scaling using xBR algorithm. 28 | * @param pixelArray - The input pixels in ARGB format. 29 | * @param width - The width of the original image. 30 | * @param height - The height of the original image. 31 | * @param rawOpts - Optional parameters to customize the scaling behavior. 32 | * @returns A new Uint32Array containing the scaled image data. 33 | */ 34 | export function xbr3x( 35 | pixelArray: Uint32Array, 36 | width: number, 37 | height: number, 38 | options?: rawOpts, 39 | ): Uint32Array; 40 | 41 | /** 42 | * Applies 4x scaling using xBR algorithm. 43 | * @param pixelArray - The input pixels in ARGB format. 44 | * @param width - The width of the original image. 45 | * @param height - The height of the original image. 46 | * @param rawOpts - Optional parameters to customize the scaling behavior. 47 | * @returns A new Uint32Array containing the scaled image data. 48 | */ 49 | export function xbr4x( 50 | pixelArray: Uint32Array, 51 | width: number, 52 | height: number, 53 | options?: rawOpts, 54 | ): Uint32Array; 55 | } 56 | -------------------------------------------------------------------------------- /tests/unit/components/common/VAccordionContent.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import VAccordionContent from "@/components/common/VAccordionContent.vue"; 4 | 5 | const headerSlot = "
Header Content
"; 6 | const bodySlot = "
Body Content
"; 7 | 8 | describe("VAccordionContent Component", () => { 9 | test("renders header and body slots", () => { 10 | const wrapper = mount(VAccordionContent, { 11 | slots: { 12 | header: headerSlot, 13 | body: bodySlot, 14 | }, 15 | }); 16 | 17 | expect(wrapper.html()).toContain("Header Content"); 18 | expect(wrapper.html()).toContain("Body Content"); 19 | }); 20 | 21 | test("toggles accordion body visibility on header click", async () => { 22 | const wrapper = mount(VAccordionContent, { 23 | slots: { 24 | header: headerSlot, 25 | body: bodySlot, 26 | }, 27 | }); 28 | 29 | const header = wrapper.find(".accordion-content__header"); 30 | const body = wrapper.find(".accordion-content__body"); 31 | 32 | expect(getComputedStyle(body.element).maxHeight).toBe(""); 33 | 34 | await header.trigger("click"); 35 | expect(getComputedStyle(body.element).maxHeight).not.toBe("0px"); 36 | 37 | await header.trigger("click"); 38 | expect(getComputedStyle(body.element).maxHeight).toBe(""); 39 | }); 40 | 41 | test("rotates the icon when toggled", async () => { 42 | const wrapper = mount(VAccordionContent, { 43 | slots: { 44 | header: headerSlot, 45 | body: bodySlot, 46 | }, 47 | }); 48 | 49 | const icon = wrapper.find( 50 | ".accordion-content__header-icon svg", 51 | ); 52 | 53 | expect(icon.element.classList).not.toContain("fa-rotate-180"); 54 | 55 | await wrapper.find(".accordion-content__header").trigger("click"); 56 | expect(icon.element.classList).toContain("fa-rotate-180"); 57 | 58 | await wrapper.find(".accordion-content__header").trigger("click"); 59 | expect(icon.element.classList).not.toContain("fa-rotate-180"); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/unit/composables/useDisplayStyle.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, beforeEach } from "vitest"; 2 | import { nextTick } from "vue"; 3 | 4 | import { ResultDisplayStyleType } from "@/@types/form"; 5 | import useDisplayStyle from "@/composables/useDisplayStyle"; 6 | import { StorageKey } from "@/constants/displayStyle"; 7 | import { setLocalStorage } from "@/core/infrastructure/storage"; 8 | 9 | describe("useDisplayStyle", () => { 10 | beforeEach(() => { 11 | localStorage.clear(); 12 | }); 13 | 14 | test.each<{ 15 | description: string; 16 | stored: unknown; 17 | localStorageValue: string | null; 18 | expectedInitial: ResultDisplayStyleType; 19 | }>([ 20 | { 21 | description: "returns 'grid' when localStorage has 'grid'", 22 | localStorageValue: "grid", 23 | stored: "grid", 24 | expectedInitial: "grid", 25 | }, 26 | { 27 | description: "returns 'list' when localStorage has 'list'", 28 | localStorageValue: "list", 29 | stored: "list", 30 | expectedInitial: "list", 31 | }, 32 | { 33 | description: "returns default 'grid' when localStorage has unknown value", 34 | localStorageValue: "unknown", 35 | stored: "unknown", 36 | expectedInitial: "grid", 37 | }, 38 | { 39 | description: "returns default 'grid' when localStorage is empty", 40 | localStorageValue: null, 41 | stored: null, 42 | expectedInitial: "grid", 43 | }, 44 | ])("$description", ({ localStorageValue, expectedInitial }) => { 45 | if (localStorageValue !== null) { 46 | setLocalStorage(StorageKey, localStorageValue); 47 | } 48 | 49 | const { displayStyle } = useDisplayStyle(); 50 | 51 | expect(displayStyle.value).toBe(expectedInitial); 52 | }); 53 | 54 | test("saves updated displayStyle to localStorage", async () => { 55 | setLocalStorage(StorageKey, "grid"); 56 | 57 | const { displayStyle } = useDisplayStyle(); 58 | 59 | displayStyle.value = "list"; 60 | await nextTick(); 61 | 62 | expect(localStorage.getItem(StorageKey)).toBe("list"); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/unit/composables/useI18nTextKey.spec.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue"; 2 | 3 | import useI18nTextKey from "@/composables/useI18nTextKey"; 4 | 5 | describe("useI18nTextKey", () => { 6 | test.each<{ 7 | description: string; 8 | isAnyChecked: boolean; 9 | expectedConvertText: string; 10 | expectedDeleteText: string; 11 | expectedApplyText: string; 12 | expectedDownloadZipText: string; 13 | expectedDownloadFileText: string; 14 | }>([ 15 | { 16 | description: "should return correct text keys when isAnyChecked is true", 17 | isAnyChecked: true, 18 | expectedConvertText: "form.convert-selected", 19 | expectedDeleteText: "delete-selected", 20 | expectedApplyText: "form.apply-selected", 21 | expectedDownloadZipText: "convert.download-zip-selected", 22 | expectedDownloadFileText: "convert.output-file-selected", 23 | }, 24 | { 25 | description: "should return correct text keys when isAnyChecked is false", 26 | isAnyChecked: false, 27 | expectedConvertText: "form.convert-all", 28 | expectedDeleteText: "delete-all", 29 | expectedApplyText: "form.apply-all", 30 | expectedDownloadZipText: "convert.download-zip-all", 31 | expectedDownloadFileText: "convert.output-file-all", 32 | }, 33 | ])( 34 | "$description", 35 | ({ 36 | isAnyChecked, 37 | expectedConvertText, 38 | expectedDeleteText, 39 | expectedApplyText, 40 | expectedDownloadZipText, 41 | expectedDownloadFileText, 42 | }) => { 43 | const isChecked = ref(isAnyChecked); 44 | const { 45 | convertText, 46 | deleteText, 47 | applyText, 48 | downloadZipText, 49 | outputFileText, 50 | } = useI18nTextKey(isChecked); 51 | 52 | expect(convertText.value).toBe(expectedConvertText); 53 | expect(deleteText.value).toBe(expectedDeleteText); 54 | expect(applyText.value).toBe(expectedApplyText); 55 | expect(downloadZipText.value).toBe(expectedDownloadZipText); 56 | expect(outputFileText.value).toBe(expectedDownloadFileText); 57 | }, 58 | ); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/unit/algorithm/helpers.ts: -------------------------------------------------------------------------------- 1 | import { PSImageDataObject } from "@/@types/convert"; 2 | import { PSImageData } from "@/models/InputImageData"; 3 | 4 | /** 5 | * Helper function to create mock PSImageDataObject for testing 6 | */ 7 | export const createMockPSImageDataObject = async ( 8 | width: number, 9 | height: number, 10 | originalPixelSize: number = 1, 11 | pattern: "checkerboard" | "pixelart" = "checkerboard", 12 | ): Promise => { 13 | const canvas = document.createElement("canvas"); 14 | canvas.width = width; 15 | canvas.height = height; 16 | const ctx = canvas.getContext("2d")!; 17 | 18 | if (pattern === "checkerboard") { 19 | // Checkerboard pattern for nearest neighbor tests 20 | ctx.fillStyle = "red"; 21 | ctx.fillRect(0, 0, Math.ceil(width / 2), Math.ceil(height / 2)); 22 | ctx.fillStyle = "blue"; 23 | ctx.fillRect(Math.ceil(width / 2), 0, width, Math.ceil(height / 2)); 24 | ctx.fillStyle = "green"; 25 | ctx.fillRect(0, Math.ceil(height / 2), Math.ceil(width / 2), height); 26 | ctx.fillStyle = "yellow"; 27 | ctx.fillRect(Math.ceil(width / 2), Math.ceil(height / 2), width, height); 28 | } else if (pattern === "pixelart") { 29 | // Pixel art pattern for xBR tests 30 | const pixelSize = originalPixelSize; 31 | for (let y = 0; y < height; y += pixelSize) { 32 | for (let x = 0; x < width; x += pixelSize) { 33 | const colorIndex = (x / pixelSize + y / pixelSize) % 4; 34 | const colors = ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"]; 35 | ctx.fillStyle = colors[colorIndex]; 36 | ctx.fillRect(x, y, pixelSize, pixelSize); 37 | } 38 | } 39 | } 40 | 41 | const dataURL = canvas.toDataURL("image/png"); 42 | const base64Data = dataURL.split(",")[1]; 43 | const binaryString = atob(base64Data); 44 | const bytes = new Uint8Array(binaryString.length); 45 | for (let i = 0; i < binaryString.length; i++) { 46 | bytes[i] = binaryString.codePointAt(i) ?? 0; 47 | } 48 | 49 | const file = new File([bytes], `${width}x${height}.png`, { 50 | type: "image/png", 51 | }); 52 | const psImageData = await PSImageData.init(file); 53 | psImageData.originalPixelSize = originalPixelSize; 54 | 55 | return psImageData.toObject(); 56 | }; 57 | -------------------------------------------------------------------------------- /tests/unit/composables/useGlobalError.spec.ts: -------------------------------------------------------------------------------- 1 | import { createPinia, setActivePinia } from "pinia"; 2 | 3 | import useGlobalError from "@/composables/useGlobalError"; 4 | import { FileError } from "@/models/errors/FileError"; 5 | import { ScaleError } from "@/models/errors/ScaleError"; 6 | import { UnknownError } from "@/models/errors/UnknownError"; 7 | import { useErrorStore } from "@/stores/errorStore"; 8 | 9 | describe("useGlobalError", () => { 10 | let errorStore: ReturnType; 11 | 12 | beforeEach(() => { 13 | setActivePinia(createPinia()); 14 | errorStore = useErrorStore(); 15 | }); 16 | 17 | test("should add an error to GlobalErrors", () => { 18 | const { addError, GlobalErrors } = useGlobalError(); 19 | const error = new FileError("duplicate-image", { filename: "test.png" }); 20 | 21 | addError(error); 22 | 23 | expect(GlobalErrors.value).toHaveLength(1); 24 | expect(GlobalErrors.value[0].kind).toBe("file"); 25 | expect(errorStore.errors).toHaveLength(1); 26 | }); 27 | 28 | test("should clear all errors", () => { 29 | const { addError, clearErrors, GlobalErrors } = useGlobalError(); 30 | addError(new UnknownError("unknown")); 31 | addError( 32 | new ScaleError("duplicate-image-and-settings", { 33 | filename: "test.png", 34 | scaleSizePercent: 200, 35 | scaleMode: "smooth", 36 | }), 37 | ); 38 | 39 | clearErrors(); 40 | 41 | expect(GlobalErrors.value).toHaveLength(0); 42 | expect(errorStore.errors).toHaveLength(0); 43 | }); 44 | 45 | test("should delete a specific error by uuid", () => { 46 | const { addError, deleteOneError, GlobalErrors } = useGlobalError(); 47 | const error1 = new UnknownError("unknown"); 48 | const error2 = new ScaleError("duplicate-image-and-settings", { 49 | filename: "test.png", 50 | scaleSizePercent: 200, 51 | scaleMode: "smooth", 52 | }); 53 | 54 | addError(error1); 55 | addError(error2); 56 | 57 | const uuid1 = GlobalErrors.value[0].uuid; 58 | 59 | deleteOneError(uuid1); 60 | 61 | expect(GlobalErrors.value).toHaveLength(1); 62 | expect(GlobalErrors.value[0].uuid).not.toBe(uuid1); 63 | expect(errorStore.errors).toHaveLength(1); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url"; 2 | 3 | import vue from "@vitejs/plugin-vue"; 4 | import { RootNode, TemplateChildNode } from "@vue/compiler-core"; 5 | import Unfonts from "unplugin-fonts/vite"; 6 | import { defineConfig } from "vite"; 7 | import { VitePWA } from "vite-plugin-pwa"; 8 | 9 | import { version } from "./package.json"; 10 | import { pwaConfig } from "./vite/config/pwa"; 11 | import generateLicensePlugin from "./vite/plugins/license"; 12 | 13 | /** 14 | * Removes `data-testid` attributes from a given node's properties. 15 | * This function filters out both static (`data-testid`) and dynamic (`:data-testid`) attributes 16 | * from the `props` array of the provided node, if applicable. 17 | * 18 | * @param node - The node to process, which can be either a `RootNode` or a `TemplateChildNode`. 19 | * If the node's type is not 1, the function will return without making changes. 20 | */ 21 | const removeDataTestAttrs = (node: RootNode | TemplateChildNode) => { 22 | if (node.type !== 1) return; 23 | 24 | node.props = node.props.filter((prop) => { 25 | if (prop.type === 6) return prop.name !== "data-testid"; 26 | if (prop.type === 7) return prop.rawName !== ":data-testid"; 27 | return true; 28 | }); 29 | }; 30 | 31 | // https://vitejs.dev/config/ 32 | export default defineConfig((configEnv) => ({ 33 | plugins: [ 34 | vue({ 35 | template: { 36 | compilerOptions: { 37 | nodeTransforms: 38 | configEnv.mode === "production" ? [removeDataTestAttrs] : [], 39 | }, 40 | }, 41 | }), 42 | Unfonts({ 43 | google: { 44 | families: [ 45 | { 46 | name: "Kosugi", 47 | }, 48 | ], 49 | }, 50 | }), 51 | VitePWA(pwaConfig), 52 | generateLicensePlugin({ 53 | outputDir: "dist", 54 | fileName: "THIRD_PARTY_LICENSES", 55 | }), 56 | ], 57 | define: { 58 | "import.meta.env.APP_VERSION": JSON.stringify(version), 59 | __VUE_OPTIONS_API__: false, 60 | }, 61 | base: "./", 62 | css: { 63 | preprocessorOptions: { 64 | scss: {}, 65 | }, 66 | }, 67 | resolve: { 68 | alias: { 69 | "@": fileURLToPath(new URL("src", import.meta.url)), 70 | }, 71 | }, 72 | })); 73 | -------------------------------------------------------------------------------- /src/components/InputErrorList.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 58 | 59 | 80 | -------------------------------------------------------------------------------- /src/components/common/form/VFormInput.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 65 | 66 | 90 | -------------------------------------------------------------------------------- /tests/unit/components/InputErrorList.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import { CustomErrorObject, ErrorKind } from "@/@types/error"; 4 | import InputErrorList from "@/components/InputErrorList.vue"; 5 | 6 | describe("InputErrorList", () => { 7 | const defaultErrors: CustomErrorObject[] = [ 8 | { uuid: "1", kind: "input", code: "error.required", params: {} }, 9 | { uuid: "2", kind: "input", code: "error.server", params: {} }, 10 | ]; 11 | 12 | const defaultKinds: ErrorKind[] = ["input"]; 13 | 14 | const factory = (props = {}) => { 15 | return mount(InputErrorList, { 16 | props: { 17 | errors: defaultErrors, 18 | kinds: defaultKinds, 19 | ...props, 20 | }, 21 | global: { 22 | mocks: { 23 | $t: (msg: string) => msg, 24 | }, 25 | }, 26 | }); 27 | }; 28 | 29 | test("renders filtered errors based on kinds", () => { 30 | const wrapper = factory(); 31 | 32 | expect(wrapper.findAll(".input-error-list__item").length).toBe(2); 33 | expect(wrapper.text()).toContain("error.required"); 34 | }); 35 | 36 | test("emits deleteOneError when an error is closed", async () => { 37 | const wrapper = factory(); 38 | 39 | const closableItem = wrapper.findComponent({ name: "VClosableItem" }); 40 | await closableItem.vm.$emit("close"); 41 | 42 | const emitted = wrapper.emitted("deleteOneError"); 43 | expect(emitted).toBeTruthy(); 44 | expect(emitted!.length).toBe(1); 45 | expect(emitted![0]).toEqual(["1"]); 46 | }); 47 | 48 | test("emits deleteAllErrors when delete button is clicked", async () => { 49 | const wrapper = factory(); 50 | 51 | await wrapper 52 | .find(".input-error-list__header-button .v-form-button") 53 | .trigger("click"); 54 | expect(wrapper.emitted("deleteAllErrors")).toBeTruthy(); 55 | }); 56 | 57 | test("does not render if there are no filtered errors", () => { 58 | const wrapper = factory({ errors: [] }); 59 | 60 | expect(wrapper.find(".input-error-list").exists()).toBe(false); 61 | }); 62 | 63 | test("does not render if there are kind no matched errors", () => { 64 | const wrapper = factory({ kinds: ["unknown"] }); 65 | 66 | expect(wrapper.find(".input-error-list").exists()).toBe(false); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/composables/useFormFileInput.ts: -------------------------------------------------------------------------------- 1 | const isAcceptable = (file: File, acceptedTypes: MIMEType[]) => 2 | acceptedTypes.includes(file.type as MIMEType); 3 | 4 | const useFormFileInput = (opts: { 5 | acceptedTypes: MIMEType[]; 6 | pickerOpts: FilePickerOptions; 7 | }) => { 8 | const { acceptedTypes, pickerOpts } = opts; 9 | 10 | const hasFileSystemAccess = () => 11 | import.meta.env.MODE !== "e2e" && 12 | "showOpenFilePicker" in globalThis && 13 | Boolean(globalThis.showOpenFilePicker); 14 | 15 | const getFilesFromFilePicker = async () => { 16 | // NOTE: returns FileSystemFileHandle[] if props.pickerOpts.multiple is true and FileHandle if false 17 | const fileHandles = await globalThis 18 | .showOpenFilePicker(pickerOpts) 19 | .catch(() => []); 20 | 21 | const allFiles = await Promise.all( 22 | fileHandles.map((fileHandle) => fileHandle.getFile()), 23 | ); 24 | 25 | const files = allFiles.filter((file) => isAcceptable(file, acceptedTypes)); 26 | const unacceptedFiles = allFiles.filter( 27 | (file) => !isAcceptable(file, acceptedTypes), 28 | ); 29 | 30 | return { files, unacceptedFiles }; 31 | }; 32 | 33 | const getFilesFromEvent = (e: Event) => { 34 | const allFiles = [...((e.target as HTMLInputElement)?.files ?? [])]; 35 | 36 | const files = allFiles.filter((file) => isAcceptable(file, acceptedTypes)); 37 | const unacceptedFiles = allFiles.filter( 38 | (file) => !isAcceptable(file, acceptedTypes), 39 | ); 40 | 41 | return { files, unacceptedFiles }; 42 | }; 43 | 44 | const getFilesFromDragEvent = (e: DragEvent) => { 45 | const files: File[] = []; 46 | const unacceptedFiles: File[] = []; 47 | 48 | const items = e.dataTransfer?.items ?? []; 49 | 50 | for (const item of items) { 51 | if (item.kind !== "file") continue; 52 | 53 | const file = item.getAsFile(); 54 | 55 | if (!file) continue; 56 | 57 | isAcceptable(file, acceptedTypes) 58 | ? files.push(file) 59 | : unacceptedFiles.push(file); 60 | } 61 | 62 | return { files, unacceptedFiles }; 63 | }; 64 | 65 | return { 66 | hasFileSystemAccess, 67 | getFilesFromFilePicker, 68 | getFilesFromEvent, 69 | getFilesFromDragEvent, 70 | }; 71 | }; 72 | 73 | export default useFormFileInput; 74 | -------------------------------------------------------------------------------- /vite/plugins/license.ts: -------------------------------------------------------------------------------- 1 | import fs, { readFileSync } from "node:fs"; 2 | import path from "node:path"; 3 | 4 | import checker from "license-checker"; 5 | 6 | import type { Plugin } from "vite"; 7 | 8 | export default function generateLicensePlugin(options: { 9 | outputDir: string; 10 | fileName: string; 11 | }): Plugin { 12 | const distPath = options.outputDir || "dist"; 13 | const licenseFileName = options.fileName || "LICENSE"; 14 | 15 | return { 16 | name: "vite-plugin-generate-license", 17 | apply: "build", 18 | async closeBundle() { 19 | try { 20 | const packages = await new Promise( 21 | (resolve, reject) => { 22 | checker.init({ start: process.cwd() }, (err, packages) => { 23 | if (err) return reject(err); 24 | resolve(packages); 25 | }); 26 | }, 27 | ); 28 | 29 | let content = `Third-Party Licenses for This Project\n`; 30 | content += `Generated on: ${new Date().toISOString()}\n`; 31 | content += `\n===========================================\n\n`; 32 | 33 | const sorted = Object.entries(packages).sort(([a], [b]) => 34 | a.localeCompare(b), 35 | ); 36 | 37 | for (const [pkgName, info] of sorted) { 38 | content += `Package: ${pkgName}\n`; 39 | content += `License: ${info.licenses}\n`; 40 | if (info.repository) content += `Repository: ${info.repository}\n`; 41 | if (info.publisher) content += `Publisher: ${info.publisher}\n`; 42 | if (info.email) content += `Email: ${info.email}\n`; 43 | if (info.licenseFile) { 44 | const licenseFile = readFileSync(info.licenseFile, "utf8"); 45 | content += `${licenseFile.trim()}\n`; 46 | } 47 | content += "\n" + "-".repeat(80) + "\n\n"; 48 | } 49 | 50 | const outDir = path.resolve(process.cwd(), distPath); 51 | const outFile = path.join(outDir, licenseFileName); 52 | fs.mkdirSync(outDir, { recursive: true }); 53 | fs.writeFileSync(outFile, content, "utf8"); 54 | console.log(`✓ LICENSE file generated: ${outFile}`); 55 | } catch (error) { 56 | console.error("❌ Failed to generate license file:", error); 57 | } 58 | }, 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/ci-common.yaml: -------------------------------------------------------------------------------- 1 | name: Common CI Steps 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | with-coverage: 7 | required: false 8 | type: boolean 9 | default: false 10 | with-e2e: 11 | required: false 12 | type: boolean 13 | default: true 14 | secrets: 15 | CODECOV_TOKEN: 16 | required: true 17 | 18 | jobs: 19 | test: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: actions/setup-node@v4 25 | id: setup_node 26 | with: 27 | node-version: 22 28 | 29 | - uses: actions/cache@v4 30 | id: cache 31 | with: 32 | path: node_modules 33 | key: ${{ runner.arch }}-${{ runner.os }}-node-${{ steps.setup_node.outputs.node-version }}-npm-${{ hashFiles('**/package-lock.json') }} 34 | 35 | - name: Install dependencies 36 | if: steps.cache.outputs.cache-hit != 'true' 37 | run: npm ci 38 | 39 | # FIXME: Failed tests when using Playwright cache 40 | # - uses: actions/cache@v4 41 | # id: playwright-cache 42 | # if: inputs.with-e2e == true 43 | # with: 44 | # path: ~/.cache/ms-playwright 45 | # key: ${{ runner.arch }}-${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }} 46 | 47 | - name: Install Playwright dependencies 48 | # if: inputs.with-e2e == true && steps.playwright-cache.outputs.cache-hit != 'true' 49 | # if: inputs.with-e2e == true 50 | run: npx playwright install --with-deps 51 | 52 | - name: Run unit tests 53 | if: inputs.with-coverage != true 54 | run: npm run test 55 | 56 | - name: Run unit tests with coverage 57 | if: inputs.with-coverage == true 58 | run: npm run test:coverage 59 | 60 | - name: Upload coverage to Codecov 61 | if: inputs.with-coverage == true 62 | uses: codecov/codecov-action@v5 63 | with: 64 | token: ${{ secrets.CODECOV_TOKEN }} 65 | 66 | - name: Upload test results to Codecov 67 | if: ${{ !cancelled() }} && inputs.with-coverage == true 68 | uses: codecov/test-results-action@v1 69 | with: 70 | token: ${{ secrets.CODECOV_TOKEN }} 71 | 72 | - name: Run E2E tests 73 | if: inputs.with-e2e == true 74 | run: npm run test:e2e 75 | -------------------------------------------------------------------------------- /tests/unit/stores/outputPathStore.spec.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@tauri-apps/api/core"; 2 | import * as fs from "@tauri-apps/plugin-fs"; 3 | import { createPinia, setActivePinia } from "pinia"; 4 | import { nextTick } from "vue"; 5 | 6 | import * as storage from "@/core/infrastructure/storage"; 7 | import * as system from "@/core/system"; 8 | import useOutputPathStore from "@/stores/outputPathStore"; 9 | 10 | const HomeDirMock = "/home/user"; 11 | 12 | vi.spyOn(core, "invoke").mockResolvedValue(HomeDirMock); 13 | vi.spyOn(system, "isWeb").mockReturnValue(false); 14 | const existsMock = vi.spyOn(fs, "exists").mockResolvedValue(true); 15 | 16 | describe("outputPathStore", () => { 17 | beforeEach(() => { 18 | setActivePinia(createPinia()); 19 | vi.clearAllMocks(); 20 | }); 21 | 22 | test("should initialize with value from localStorage", () => { 23 | vi.spyOn(storage, "getLocalStorage").mockReturnValueOnce("hogehoge"); 24 | 25 | const store = useOutputPathStore(); 26 | expect(store.outputPath).toBe("hogehoge"); 27 | }); 28 | 29 | describe("error", () => { 30 | test.each<{ description: string; path: string; error: string }>([ 31 | { 32 | description: "should be empty error when outputPath is empty", 33 | path: "", 34 | error: "path-selector.empty-path", 35 | }, 36 | // { 37 | // description: 38 | // "should be not allowed when outputPath is outside of allowed root", 39 | // path: "/outside", 40 | // error: "path-selector.path-not-in-allowed-root", 41 | // }, 42 | { 43 | description: "should be not error when outputPath is valid", 44 | path: HomeDirMock, 45 | error: "", 46 | }, 47 | ])("$description", async ({ path, error }) => { 48 | const store = useOutputPathStore(); 49 | store.outputPath = path; 50 | 51 | await nextTick(); 52 | await new Promise((resolve) => setTimeout(resolve, 1)); 53 | 54 | expect(store.error).toBe(error); 55 | }); 56 | 57 | test("should be no exists when outputPath is no exists path", async () => { 58 | existsMock.mockResolvedValue(false); 59 | const store = useOutputPathStore(); 60 | store.outputPath = HomeDirMock + "/no-exists-path"; 61 | 62 | await nextTick(); 63 | await new Promise((resolve) => setTimeout(resolve, 1)); 64 | 65 | expect(store.error).toBe("path-selector.path-does-not-exist"); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41B Bug Report" 2 | description: Report a reproducible bug or regression. 3 | title: "[Bug]: " 4 | labels: [bug] 5 | assignees: [] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for taking the time to report a bug! Please fill out the following template to help us fix the issue more efficiently. 11 | 12 | - type: input 13 | id: summary 14 | attributes: 15 | label: Brief Summary 16 | description: A short summary of the bug. 17 | placeholder: e.g., "Image download fails in WebKit mode" 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: description 23 | attributes: 24 | label: Description 25 | description: Describe the bug in detail. What did you expect to happen? What happened instead? 26 | placeholder: | 27 | When I clicked the download button in WebKit mode, nothing happened. 28 | Expected a file to be saved, but no download occurred. 29 | validations: 30 | required: true 31 | 32 | - type: checkboxes 33 | id: environment 34 | attributes: 35 | label: Which environment did the issue occur in? 36 | description: Select all that apply. 37 | options: 38 | - label: chromium 39 | - label: firefox 40 | - label: webkit 41 | - label: standalone (Tauri) 42 | 43 | - type: textarea 44 | id: steps 45 | attributes: 46 | label: Steps to Reproduce 47 | description: Provide a clear set of steps to reproduce the behavior. 48 | placeholder: | 49 | 1. Run `npm run dev` 50 | 2. Open the app in WebKit 51 | 3. Click on the download button 52 | 4. Nothing happens 53 | validations: 54 | required: true 55 | 56 | - type: textarea 57 | id: logs 58 | attributes: 59 | label: Relevant Logs or Console Output 60 | description: Please paste any relevant logs or error messages. 61 | render: shell 62 | validations: 63 | required: false 64 | 65 | - type: textarea 66 | id: version 67 | attributes: 68 | label: Environment Information 69 | description: Include browser or runtime versions, OS, and any other relevant configuration. 70 | placeholder: | 71 | - OS: Ubuntu 22.04 72 | - Browser: WebKit 17.4 73 | - Tauri: 2.3.1 74 | - Node.js: 22.0.0 75 | - App Version: 1.1.0 76 | validations: 77 | required: true 78 | -------------------------------------------------------------------------------- /src/stores/convertStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | 3 | import { ImageEntry, ImageCheckList } from "@/@types/convert"; 4 | import { vueI18n } from "@/core/plugins/i18n"; 5 | import { 6 | convertImage, 7 | isDuplicateEntry, 8 | } from "@/core/services/image/convertService"; 9 | import { filterEntriesByChecked } from "@/core/services/image/entryBatchService"; 10 | import { ScaleError } from "@/models/errors/ScaleError"; 11 | import { useErrorStore } from "@/stores/errorStore"; 12 | import { useInputImageStore } from "@/stores/inputImageStore"; 13 | import { useScaledImageStore } from "@/stores/scaledImageStore"; 14 | 15 | export const useConvertStore = defineStore("convert", () => { 16 | const convertOne = async (entry: ImageEntry): Promise => { 17 | const errorStore = useErrorStore(); 18 | const scaledImageStore = useScaledImageStore(); 19 | const { settings } = entry; 20 | 21 | try { 22 | if (isDuplicateEntry(entry, scaledImageStore.entries)) { 23 | throw new ScaleError("duplicate-image-and-settings", { 24 | filename: entry.image.data.name, 25 | scaleSizePercent: settings.scaleSizePercent, 26 | scaleMode: vueI18n.global.t(`form.scale-modes.${settings.scaleMode}`), 27 | }); 28 | } 29 | 30 | const scaledImageData = await convertImage(entry); 31 | const scaledEntry: ImageEntry = { 32 | image: scaledImageData, 33 | settings: { ...settings }, 34 | errors: [], 35 | }; 36 | 37 | scaledImageStore.addEntry(scaledEntry); 38 | } catch (error) { 39 | if (error instanceof ScaleError) { 40 | entry.errors.push(error.toObject()); 41 | } else { 42 | errorStore.addError(error); 43 | } 44 | } 45 | }; 46 | 47 | const convertOneByUuid = async (uuid: string): Promise => { 48 | const inputImageStore = useInputImageStore(); 49 | const entry = inputImageStore.entries.find((e) => e.image.uuid === uuid); 50 | if (!entry) return; 51 | await convertOne(entry); 52 | }; 53 | 54 | const convertAnyChecked = async ( 55 | checkedList: ImageCheckList, 56 | ): Promise => { 57 | const inputImageStore = useInputImageStore(); 58 | const checkedEntries = filterEntriesByChecked( 59 | inputImageStore.entries, 60 | checkedList, 61 | ); 62 | 63 | for (const entry of checkedEntries) { 64 | await convertOne(entry); 65 | } 66 | }; 67 | 68 | return { 69 | convertOne, 70 | convertOneByUuid, 71 | convertAnyChecked, 72 | }; 73 | }); 74 | -------------------------------------------------------------------------------- /tests/unit/components/common/form/VFormCheckBox.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { ref } from "vue"; 3 | 4 | import VCheckbox from "@/components/common/form/VFormCheckBox.vue"; 5 | 6 | describe("VFormCheckbox.vue", () => { 7 | test("renders with correct label and initial state", () => { 8 | const model = ref(false); 9 | const wrapper = mount(VCheckbox, { 10 | props: { 11 | id: "test-checkbox", 12 | name: "test-checkbox", 13 | label: "Test Label", 14 | modelValue: model.value, 15 | }, 16 | }); 17 | 18 | const input = wrapper.find("input"); 19 | const label = wrapper.find("label"); 20 | 21 | expect(input.exists()).toBe(true); 22 | expect(label.text()).toBe("Test Label"); 23 | expect(input.element.checked).toBe(false); 24 | }); 25 | 26 | test.each<{ description: string; initial: boolean; expected: boolean }>([ 27 | { 28 | description: "toggles check state from false to true", 29 | initial: false, 30 | expected: true, 31 | }, 32 | { 33 | description: "toggles check state from true to false", 34 | initial: true, 35 | expected: false, 36 | }, 37 | ])("$description", async ({ initial, expected }) => { 38 | const model = ref(initial); 39 | const wrapper = mount(VCheckbox, { 40 | props: { 41 | id: "test-checkbox", 42 | name: "test-checkbox", 43 | label: "Test Label", 44 | modelValue: model.value, 45 | }, 46 | modelValue: model, 47 | }); 48 | 49 | const input = wrapper.find("input"); 50 | 51 | await input.trigger("click"); 52 | expect(input.element.checked).toBe(expected); 53 | }); 54 | 55 | test.each<{ description: string; disabled: boolean; shouldToggle: boolean }>([ 56 | { 57 | description: "does not toggle when disabled", 58 | disabled: true, 59 | shouldToggle: false, 60 | }, 61 | { 62 | description: "toggles when not disabled", 63 | disabled: false, 64 | shouldToggle: true, 65 | }, 66 | ])("$description", async ({ disabled, shouldToggle }) => { 67 | const model = ref(false); 68 | const wrapper = mount(VCheckbox, { 69 | props: { 70 | id: "test-checkbox", 71 | name: "test-checkbox", 72 | label: "Test Label", 73 | modelValue: model.value, 74 | disabled, 75 | }, 76 | modelValue: model, 77 | }); 78 | 79 | const input = wrapper.find("input"); 80 | 81 | await input.trigger("click"); 82 | expect(input.element.checked).toBe(shouldToggle); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/assets/theme.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --font: #332d2d; 3 | --background: #ffe0e0; 4 | --edge-bright: rgba(255, 255, 255, 0.75); 5 | --edge-shadow: rgba(192, 167, 167, 0.75); 6 | --scrollbar-background: #fff; 7 | --scrollbar-shadow: #777; 8 | --scrollbar-thumb: #ff7f7f; 9 | } 10 | 11 | [data-color-theme="blue"] { 12 | --font: #2d2d33; 13 | --background: #e0e0ff; 14 | --edge-bright: rgba(255, 255, 255, 0.75); 15 | --edge-shadow: rgba(167, 167, 192, 0.75); 16 | --scrollbar-background: #fff; 17 | --scrollbar-shadow: #777; 18 | --scrollbar-thumb: #7f7fff; 19 | } 20 | 21 | [data-color-theme="blue_dark"] { 22 | --font: #dfdfff; 23 | --background: #080815; 24 | --edge-bright: rgba(40, 40, 70, 0.75); 25 | --edge-shadow: rgba(0, 0, 0, 0.75); 26 | --scrollbar-background: #fff; 27 | --scrollbar-shadow: #777; 28 | --scrollbar-thumb: #1c1c7c; 29 | } 30 | 31 | [data-color-theme="dark"] { 32 | --font: #ccc; 33 | --background: #333; 34 | --edge-bright: rgba(70, 70, 70, 0.75); 35 | --edge-shadow: rgba(0, 0, 0, 0.75); 36 | --scrollbar-background: #fff; 37 | --scrollbar-shadow: #777; 38 | --scrollbar-thumb: #1c1c1c; 39 | } 40 | 41 | [data-color-theme="gray"] { 42 | --font: #333333; 43 | --background: #e0e0e0; 44 | --edge-bright: rgba(255, 255, 255, 0.75); 45 | --edge-shadow: rgba(192, 192, 192, 0.75); 46 | --scrollbar-background: #fff; 47 | --scrollbar-shadow: #777; 48 | --scrollbar-thumb: #7f7f7f; 49 | } 50 | 51 | [data-color-theme="green"] { 52 | --font: #2d332d; 53 | --background: #cfe8cf; 54 | --edge-bright: rgba(255, 255, 255, 0.75); 55 | --edge-shadow: rgba(167, 192, 167, 0.75); 56 | --scrollbar-background: #fff; 57 | --scrollbar-shadow: #777; 58 | --scrollbar-thumb: #55ad55; 59 | } 60 | 61 | [data-color-theme="green_dark"] { 62 | --font: #dfffdf; 63 | --background: #081508; 64 | --edge-bright: rgba(40, 70, 40, 0.75); 65 | --edge-shadow: rgba(0, 0, 0, 0.75); 66 | --scrollbar-background: #fff; 67 | --scrollbar-shadow: #777; 68 | --scrollbar-thumb: #1c7c1c; 69 | } 70 | 71 | [data-color-theme="red"] { 72 | --font: #332d2d; 73 | --background: #ffe0e0; 74 | --edge-bright: rgba(255, 255, 255, 0.75); 75 | --edge-shadow: rgba(192, 167, 167, 0.75); 76 | --scrollbar-background: #fff; 77 | --scrollbar-shadow: #777; 78 | --scrollbar-thumb: #ff7f7f; 79 | } 80 | 81 | [data-color-theme="red_dark"] { 82 | --font: #ffdfdf; 83 | --background: #150808; 84 | --edge-bright: rgba(70, 40, 40, 0.75); 85 | --edge-shadow: rgba(0, 0, 0, 0.75); 86 | --scrollbar-background: #fff; 87 | --scrollbar-shadow: #777; 88 | --scrollbar-thumb: #7c1c1c; 89 | } 90 | -------------------------------------------------------------------------------- /tests/unit/components/common/form/VFormInput.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import VFormInput from "@/components/common/form/VFormInput.vue"; 4 | 5 | describe("VFormInput Component", () => { 6 | test.each<{ 7 | description: string; 8 | props: { 9 | type: "number" | "text"; 10 | min: number; 11 | max: number; 12 | allowDecimal?: boolean; 13 | modelValue: number | string; 14 | }; 15 | inputValue: string; 16 | expectedValue: number | string; 17 | }>([ 18 | { 19 | description: "should allow decimal input when allowDecimal is true", 20 | props: { 21 | type: "number", 22 | min: 0, 23 | max: 100, 24 | allowDecimal: true, 25 | modelValue: 0, 26 | }, 27 | inputValue: "12.34", 28 | expectedValue: 12.34, 29 | }, 30 | { 31 | description: "should truncate decimal input when allowDecimal is false", 32 | props: { 33 | type: "number", 34 | min: 0, 35 | max: 100, 36 | allowDecimal: false, 37 | modelValue: 0, 38 | }, 39 | inputValue: "12.34", 40 | expectedValue: 12, 41 | }, 42 | { 43 | description: "should respect max constraint", 44 | props: { 45 | type: "number", 46 | min: 10, 47 | max: 20, 48 | allowDecimal: true, 49 | modelValue: 15, 50 | }, 51 | inputValue: "25", 52 | expectedValue: 20, 53 | }, 54 | { 55 | description: "should respect min constraint", 56 | props: { 57 | type: "number", 58 | min: 10, 59 | max: 20, 60 | allowDecimal: true, 61 | modelValue: 15, 62 | }, 63 | inputValue: "5", 64 | expectedValue: 10, 65 | }, 66 | { 67 | description: 68 | "should handle text input correctly (truncate to max length)", 69 | props: { type: "text", min: 3, max: 5, modelValue: "" }, 70 | inputValue: "hello world", 71 | expectedValue: "hello", 72 | }, 73 | { 74 | description: "should handle text input correctly (not pad to min length)", 75 | props: { type: "text", min: 3, max: 5, modelValue: "" }, 76 | inputValue: "hi", 77 | expectedValue: "hi", 78 | }, 79 | ])("$description", async ({ props, inputValue, expectedValue }) => { 80 | const wrapper = mount(VFormInput, { props }); 81 | const input = wrapper.find("input"); 82 | await input.setValue(inputValue); 83 | input.trigger("blur"); 84 | expect(wrapper.emitted("update:modelValue")?.[0][0]).toBe(expectedValue); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /tests/unit/stores/errorStore.spec.ts: -------------------------------------------------------------------------------- 1 | import { createPinia, setActivePinia } from "pinia"; 2 | 3 | import { UnknownError } from "@/models/errors/UnknownError"; 4 | import { useErrorStore } from "@/stores/errorStore"; 5 | 6 | describe("errorStore", () => { 7 | beforeEach(() => { 8 | setActivePinia(createPinia()); 9 | }); 10 | 11 | describe("addError", () => { 12 | test("should add error to the store", () => { 13 | const store = useErrorStore(); 14 | const error = new UnknownError("Test error"); 15 | 16 | store.addError(error); 17 | 18 | expect(store.errors).toHaveLength(1); 19 | expect(store.errors[0].code).toBe("error.unknown.unknown"); 20 | }); 21 | 22 | test("should add multiple errors", () => { 23 | const store = useErrorStore(); 24 | const error1 = new UnknownError("Error 1"); 25 | const error2 = new UnknownError("Error 2"); 26 | 27 | store.addError(error1); 28 | store.addError(error2); 29 | 30 | expect(store.errors).toHaveLength(2); 31 | expect(store.errors[0].code).toBe("error.unknown.unknown"); 32 | expect(store.errors[1].code).toBe("error.unknown.unknown"); 33 | }); 34 | }); 35 | 36 | describe("deleteOneError", () => { 37 | test("should delete error by uuid", () => { 38 | const store = useErrorStore(); 39 | const error1 = new UnknownError("Error 1"); 40 | const error2 = new UnknownError("Error 2"); 41 | const error3 = new UnknownError("Error 3"); 42 | 43 | store.addError(error1); 44 | store.addError(error2); 45 | store.addError(error3); 46 | 47 | const uuidToDelete = store.errors[1].uuid; 48 | store.deleteOneError(uuidToDelete); 49 | 50 | expect(store.errors).toHaveLength(2); 51 | expect(store.errors.find((e) => e.uuid === uuidToDelete)).toBeUndefined(); 52 | }); 53 | 54 | test("should do nothing if uuid not found", () => { 55 | const store = useErrorStore(); 56 | const error = new UnknownError("Error"); 57 | 58 | store.addError(error); 59 | store.deleteOneError("non-existent-uuid"); 60 | 61 | expect(store.errors).toHaveLength(1); 62 | }); 63 | }); 64 | 65 | describe("clearErrors", () => { 66 | test("should clear all errors", () => { 67 | const store = useErrorStore(); 68 | store.addError(new UnknownError("Error 1")); 69 | store.addError(new UnknownError("Error 2")); 70 | 71 | store.clearErrors(); 72 | 73 | expect(store.errors).toHaveLength(0); 74 | }); 75 | 76 | test("should work when there are no errors", () => { 77 | const store = useErrorStore(); 78 | 79 | store.clearErrors(); 80 | 81 | expect(store.errors).toHaveLength(0); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /tests/unit/core/utils/ogp.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createOrChangeOgpValue, 3 | createOrChangeOgpValues, 4 | } from "@/core/utils/ogp"; 5 | 6 | describe("createOrChangeOgpValue", () => { 7 | beforeEach(() => { 8 | document.head.innerHTML = ""; 9 | }); 10 | 11 | test("should create a new meta tag if it does not exist", () => { 12 | createOrChangeOgpValue("og:title", "My Title"); 13 | const meta = document.querySelector('meta[property="og:title"]'); 14 | expect(meta).not.toBeNull(); 15 | expect(meta?.getAttribute("content")).toBe("My Title"); 16 | }); 17 | 18 | test("should change the content of an existing meta tag", () => { 19 | const meta = document.createElement("meta"); 20 | meta.setAttribute("property", "og:title"); 21 | meta.setAttribute("content", "Old Title"); 22 | document.head.append(meta); 23 | 24 | createOrChangeOgpValue("og:title", "New Title"); 25 | expect(meta.getAttribute("content")).toBe("New Title"); 26 | }); 27 | }); 28 | 29 | describe("createOrChangeOgpValues", () => { 30 | beforeEach(() => { 31 | document.head.innerHTML = ""; 32 | }); 33 | 34 | test("should create new meta tags for each property and content pair", () => { 35 | createOrChangeOgpValues([ 36 | { property: "og:title", content: "Title 1" }, 37 | { property: "og:description", content: "Description 1" }, 38 | { property: "og:image", content: "Image 1" }, 39 | ]); 40 | 41 | const meta1 = document.querySelector('meta[property="og:title"]'); 42 | const meta2 = document.querySelector('meta[property="og:description"]'); 43 | const meta3 = document.querySelector('meta[property="og:image"]'); 44 | 45 | expect(meta1).not.toBeNull(); 46 | expect(meta1?.getAttribute("content")).toBe("Title 1"); 47 | 48 | expect(meta2).not.toBeNull(); 49 | expect(meta2?.getAttribute("content")).toBe("Description 1"); 50 | 51 | expect(meta3).not.toBeNull(); 52 | expect(meta3?.getAttribute("content")).toBe("Image 1"); 53 | }); 54 | 55 | test("should change the content of existing meta tags", () => { 56 | const meta1 = document.createElement("meta"); 57 | meta1.setAttribute("property", "og:title"); 58 | meta1.setAttribute("content", "Old Title"); 59 | document.head.append(meta1); 60 | 61 | const meta2 = document.createElement("meta"); 62 | meta2.setAttribute("property", "og:description"); 63 | meta2.setAttribute("content", "Old Description"); 64 | document.head.append(meta2); 65 | 66 | createOrChangeOgpValues([ 67 | { property: "og:title", content: "New Title" }, 68 | { property: "og:description", content: "New Description" }, 69 | ]); 70 | 71 | expect(meta1.getAttribute("content")).toBe("New Title"); 72 | expect(meta2.getAttribute("content")).toBe("New Description"); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Pixel Scaler Logo 4 | 5 |

A pixel art upscaling tool

6 | 7 | 8 |

9 | Latest Release 10 | Downloads 11 | Coverage 12 | Follow on X 13 |

14 |
15 | 16 | ## Development Environment 17 | 18 | * node (>= 22.x) 19 | * rust (>=1.85.0) 20 | * (required for Tauri development) 21 | 22 | ## Setup for Development (WSL) 23 | 24 | Install dependencies: 25 | 26 | ```sh 27 | npm ci 28 | ``` 29 | 30 | (Optional) If you want to use Tauri: 31 | 32 | ```sh 33 | cargo install tauri-cli 34 | sudo apt install -y libsoup2.4-dev javascriptcoregtk-4.1 libsoup-3.0 webkit2gtk-4.1 \ 35 | libjavascriptcoregtk-4.0-dev libwebkit2gtk-4.0-dev librsvg2-dev 36 | ``` 37 | 38 | ## Available Commands 39 | 40 | | Command | Description | 41 | |-------------------------|------------------------------------------------------------| 42 | | `npm run dev` | Start development server with Vite | 43 | | `npm run dev:tauri` | Start Tauri development server | 44 | | `npm run build` | Build the project for production | 45 | | `npm run build:tauri` | Build the project with tauri | 46 | | `npm run preview` | Preview the built project (requires `npm run build` first) | 47 | | `npm run test` | Run tests | 48 | | `npm run test:watch` | Run tests in watch mode | 49 | | `npm run test:coverage` | Run tests with coverage | 50 | | `npm run test:e2e` | Run tests by playwright | 51 | | `npm run clean` | Remove generated files | 52 | | `npm run lint-staged` | Run linting on staged files | 53 | | `npm run prepare` | Set up Husky for Git hooks | 54 | 55 | ## Scaling Library 56 | 57 | * xBRjs (Copyright 2020 Josep del Rio) 58 | * https://github.com/joseprio/xBRjs 59 | -------------------------------------------------------------------------------- /tests/e2e/components/InputFileList.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | import { expect } from "@playwright/test"; 4 | 5 | import { PageObjectBase } from "./_PageObjectBase"; 6 | import { InputFileItem } from "./InputFileItem"; 7 | 8 | export class InputFileList extends PageObjectBase { 9 | async expectNotUploadedFile() { 10 | const fileInputArea = this.page.getByTestId("file-input-area"); 11 | expect(await fileInputArea.locator("> div").all()).toHaveLength(1); 12 | await expect(fileInputArea.locator("svg.fa-images")).not.toBeVisible(); 13 | await expect(fileInputArea.locator("svg.fa-trash")).not.toBeVisible(); 14 | } 15 | 16 | async expectUploadedFilePresent() { 17 | const fileInputArea = this.page.getByTestId("file-input-area"); 18 | await expect(fileInputArea.locator("> div")).toHaveCount(2, { 19 | timeout: 10_000, 20 | }); 21 | 22 | const inputFileHeader = fileInputArea.locator("> div").nth(0); 23 | await expect(inputFileHeader.locator("svg.fa-images")).toBeVisible(); 24 | await expect(inputFileHeader.locator("svg.fa-trash")).toBeVisible(); 25 | } 26 | 27 | async expectExistsUploadedFilename(fileName: string) { 28 | const fileInputArea = this.page.getByTestId("file-input-area"); 29 | const inputFileList = fileInputArea.locator("> div").nth(1); 30 | await expect(inputFileList).toBeVisible(); 31 | await expect( 32 | inputFileList.locator(".input-file-list-item__main__title", { 33 | hasText: fileName, 34 | }), 35 | ).toBeVisible(); 36 | } 37 | 38 | async clickDeleteAllFilesButton() { 39 | const inputErrorList = this.page.locator("#input-error-list"); 40 | await inputErrorList.locator("svg.fa-trash").click(); 41 | } 42 | 43 | async expectVisibleInputError() { 44 | const inputErrorList = this.page.locator("#input-error-list"); 45 | await expect(inputErrorList).toBeVisible(); 46 | } 47 | 48 | async expectNotVisibleInputError() { 49 | const inputErrorList = this.page.locator("#input-error-list"); 50 | await expect(inputErrorList).not.toBeVisible(); 51 | } 52 | 53 | async expectExistsInputErrorMessage(errorMessage: string) { 54 | const inputErrorList = this.page.locator("#input-error-list"); 55 | await expect(inputErrorList).toBeVisible(); 56 | await expect( 57 | inputErrorList.locator(".input-error-list__item", { 58 | hasText: errorMessage, 59 | }), 60 | ).toBeVisible(); 61 | } 62 | 63 | async uploadImages(filePaths: string[]) { 64 | const fileInput = this.page 65 | .getByTestId("file-input-area") 66 | .locator('input[type="file"]'); 67 | await fileInput.setInputFiles(filePaths); 68 | } 69 | 70 | async uploadAndGetItems(filePaths: string[]) { 71 | await this.uploadImages(filePaths); 72 | return filePaths.map( 73 | (filePath) => new InputFileItem(this.page, path.basename(filePath)), 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/unit/components/common/form/VFormFileInputDrop.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import VFormFileInputDrop from "@/components/common/form/VFormFileInputDrop.vue"; 4 | import { PickerOpts } from "@/constants/imageFile"; 5 | 6 | import { Jpg1px, Png1px } from "../../../../fixture"; 7 | 8 | interface CustomWindow extends Window { 9 | showOpenFilePicker?: (options?: { 10 | multiple?: boolean; 11 | types?: { 12 | description: string; 13 | accept: { [key: string]: string[] }; 14 | }[]; 15 | }) => Promise; 16 | } 17 | 18 | declare let window: CustomWindow; 19 | 20 | describe("VFormFileInputDrop Component", () => { 21 | const acceptedTypes: MIMEType[] = ["image/png", "image/jpeg"]; 22 | const pickerOpts = structuredClone(PickerOpts); 23 | const mockFiles = [ 24 | new File([new Uint8Array(Png1px)], "file1.png", { type: "image/png" }), 25 | new File([new Uint8Array(Jpg1px)], "file2.jpg", { type: "image/jpeg" }), 26 | new File(["content"], "file3.txt", { type: "text/plain" }), 27 | ]; 28 | 29 | beforeEach(() => { 30 | vi.clearAllMocks(); 31 | }); 32 | 33 | test.each([ 34 | { hasFileSystemAccess: true, description: "with File System Access API" }, 35 | { 36 | hasFileSystemAccess: false, 37 | description: "without File System Access API", 38 | }, 39 | ])( 40 | "should handle file drop $description", 41 | async ({ hasFileSystemAccess: fileSystemAccess }) => { 42 | if (fileSystemAccess) { 43 | window.showOpenFilePicker = vi.fn().mockResolvedValue( 44 | mockFiles.map((file) => ({ 45 | getFile: vi.fn().mockResolvedValue(file), 46 | })), 47 | ); 48 | } else { 49 | delete window.showOpenFilePicker; 50 | } 51 | 52 | const wrapper = mount(VFormFileInputDrop, { 53 | slots: { 54 | default: "this is a drop area", 55 | }, 56 | props: { 57 | acceptedTypes, 58 | pickerOpts, 59 | }, 60 | }); 61 | 62 | const div = wrapper.find("div"); 63 | const dataTransfer = new DataTransfer(); 64 | for (const file of mockFiles) dataTransfer.items.add(file); 65 | 66 | await div.trigger("drop", { 67 | dataTransfer, 68 | }); 69 | 70 | const fileChangeEvent = wrapper.emitted("fileChange"); 71 | expect(fileChangeEvent).toBeTruthy(); 72 | expect(fileChangeEvent?.[0][0]).toEqual([ 73 | expect.objectContaining({ name: mockFiles[0].name }), 74 | expect.objectContaining({ name: mockFiles[1].name }), 75 | ]); 76 | 77 | const unacceptedFilesEvent = wrapper.emitted("unacceptedFiles"); 78 | expect(unacceptedFilesEvent).toBeTruthy(); 79 | expect(unacceptedFilesEvent?.[0][0]).toEqual([ 80 | expect.objectContaining({ name: mockFiles[2].name }), 81 | ]); 82 | }, 83 | ); 84 | }); 85 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import eslintPluginVitest from "@vitest/eslint-plugin"; 3 | import eslintConfigPrettier from "eslint-config-prettier/flat"; 4 | import eslintPluginImportX from "eslint-plugin-import-x"; 5 | import eslintPluginUnicorn from "eslint-plugin-unicorn"; 6 | import eslintPluginUnusedImports from "eslint-plugin-unused-imports"; 7 | import globals from "globals"; 8 | import tseslint from "typescript-eslint"; 9 | import vueParser from "vue-eslint-parser"; 10 | 11 | export default tseslint.config( 12 | { 13 | ignores: ["node_modules", "dist", "coverage", "public", "src-tauri"], 14 | }, 15 | { 16 | files: ["**/*.ts", "**/*.vue"], 17 | }, 18 | { 19 | files: ["tests/**"], 20 | ...eslintPluginVitest.configs.recommended, 21 | rules: { 22 | "vitest/consistent-test-it": ["error", { fn: "test" }], 23 | "vitest/no-alias-methods": "warn", 24 | }, 25 | }, 26 | eslint.configs.recommended, 27 | tseslint.configs.recommended, 28 | eslintPluginUnicorn.configs.recommended, 29 | eslintPluginImportX.flatConfigs.recommended, 30 | eslintConfigPrettier, 31 | { 32 | plugins: { 33 | "unused-imports": eslintPluginUnusedImports, 34 | }, 35 | settings: { 36 | "import-x/resolver": { 37 | typescript: {}, 38 | }, 39 | }, 40 | languageOptions: { 41 | ecmaVersion: "latest", 42 | sourceType: "module", 43 | globals: { 44 | ...globals.browser, 45 | // FIXME: more simple way to define globals 46 | MIMEType: "readonly", 47 | FilePickerOptions: "readonly", 48 | }, 49 | parser: vueParser, 50 | parserOptions: { 51 | parser: tseslint.parser, 52 | extraFileExtensions: [".vue"], 53 | }, 54 | }, 55 | rules: { 56 | "@typescript-eslint/no-unused-vars": "off", 57 | "@typescript-eslint/no-unused-expressions": [ 58 | "error", 59 | { allowTernary: true }, 60 | ], 61 | "unused-imports/no-unused-imports": "warn", 62 | "unicorn/consistent-function-scoping": "off", 63 | "unicorn/prevent-abbreviations": "off", 64 | "unicorn/filename-case": "off", 65 | "unicorn/no-array-reduce": "off", 66 | "unicorn/no-null": "off", 67 | "import-x/order": [ 68 | "warn", 69 | { 70 | groups: [ 71 | "builtin", 72 | "external", 73 | "internal", 74 | "parent", 75 | "sibling", 76 | "index", 77 | "object", 78 | "type", 79 | ], 80 | "newlines-between": "always", 81 | pathGroupsExcludedImportTypes: ["builtin"], 82 | pathGroups: [ 83 | { 84 | pattern: "@/**", 85 | group: "internal", 86 | position: "after", 87 | }, 88 | ], 89 | alphabetize: { order: "asc", caseInsensitive: true }, 90 | }, 91 | ], 92 | }, 93 | }, 94 | ); 95 | -------------------------------------------------------------------------------- /tests/unit/components/convert/ScaledImageList/ItemListView.spec.ts: -------------------------------------------------------------------------------- 1 | // tests/components/ScaledImageListItem.spec.ts 2 | import { mount } from "@vue/test-utils"; 3 | 4 | import { ImageEntry } from "@/@types/convert"; 5 | import ItemListView from "@/components/convert/ScaledImageList/ItemListView.vue"; 6 | import { ScaleMode } from "@/constants/form"; 7 | 8 | describe("ScaledImageList/ItemListView", () => { 9 | // helper to build a mock ImageEntry 10 | const makeEntry = (overrides?: Partial): ImageEntry => { 11 | const baseImage = { 12 | uuid: "u1", 13 | data: new File([""], "foo.png"), 14 | imageData: new ImageData(1, 1), 15 | width: 10, 16 | height: 10, 17 | originalPixelSize: 2, 18 | url: "http://example.com/foo.png", 19 | status: "scaled" as const, 20 | }; 21 | const baseSettings = { 22 | scaleSizePercent: 150, 23 | scaleMode: ScaleMode.Smooth, 24 | }; 25 | return { 26 | image: { ...baseImage, ...overrides?.image }, 27 | settings: { ...baseSettings, ...overrides?.settings }, 28 | errors: [], 29 | ...overrides, 30 | }; 31 | }; 32 | 33 | const factory = (props = {}) => { 34 | return mount(ItemListView, { 35 | props: { 36 | scaledImage: makeEntry(), 37 | checked: false, 38 | hasOutputPathError: false, 39 | ...props, 40 | }, 41 | global: { 42 | mocks: { 43 | $t: (msg: string) => msg, 44 | }, 45 | }, 46 | }); 47 | }; 48 | 49 | describe("Rendering", () => { 50 | test("computes checkbox id and name via getId()", () => { 51 | const wrapper = factory(); 52 | 53 | const id = `checked-scaled-150-2-${ScaleMode.Smooth}-foo.png`; 54 | const checkbox = wrapper.find(`input[type="checkbox"]`); 55 | expect(checkbox.attributes("id")).toBe(id); 56 | expect(checkbox.attributes("name")).toBe(id); 57 | }); 58 | 59 | test("displays scale percent, original pixel, and scale mode", () => { 60 | const wrapper = factory(); 61 | 62 | const texts = wrapper.text(); 63 | expect(texts).toContain("150%"); 64 | expect(texts).toContain("2px"); 65 | expect(texts).toContain(ScaleMode.Smooth); 66 | }); 67 | }); 68 | 69 | describe("Emits", () => { 70 | test("emits 'download' when download button clicked", async () => { 71 | const wrapper = factory(); 72 | 73 | await wrapper 74 | .findAllComponents({ name: "VFormButton" }) 75 | .at(0)! 76 | .trigger("click"); 77 | expect(wrapper.emitted("download")).toBeTruthy(); 78 | expect(wrapper.emitted("download")!.length).toBe(1); 79 | }); 80 | 81 | test("emits 'delete' when delete button clicked", async () => { 82 | const wrapper = factory(); 83 | 84 | await wrapper 85 | .findAllComponents({ name: "VFormButton" }) 86 | .at(1)! 87 | .trigger("click"); 88 | expect(wrapper.emitted("delete")).toBeTruthy(); 89 | expect(wrapper.emitted("delete")!.length).toBe(1); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pixel-scaler", 3 | "private": true, 4 | "version": "1.1.0", 5 | "type": "module", 6 | "homepage": "https://irokaru.github.io/pixel-scaler", 7 | "repository": "https://github.com/irokaru/pixel-scaler", 8 | "author": { 9 | "name": "irokaru", 10 | "email": "karuta@nononotyaya.net", 11 | "url": "https://nononotyaya.net/" 12 | }, 13 | "scripts": { 14 | "dev": "vite", 15 | "dev:tauri": "tauri dev", 16 | "build": "vue-tsc -b && vite build && npm run clean", 17 | "build:standalone": "vue-tsc -b && vite build --mode standalone && npm run clean", 18 | "build:tauri": "tauri build", 19 | "preview": "vite preview", 20 | "test": "vitest run", 21 | "test:watch": "vitest --watch", 22 | "test:coverage": "vitest run --coverage", 23 | "test:browser": "npm run test -- --project browser", 24 | "test:browser:watch": "npm run test:watch -- --project browser", 25 | "test:e2e": "playwright test", 26 | "clean": "rm -f *.tsbuildinfo vite.config.d.ts vitest.config.d.ts vite/*/*.d.ts", 27 | "lint-staged": "lint-staged", 28 | "tauri": "tauri", 29 | "prepare": "test -d .husky && husky || echo 'Skipping husky'; playwright install || echo 'Skipping playwright'" 30 | }, 31 | "engines": { 32 | "node": ">=22.0.0" 33 | }, 34 | "dependencies": { 35 | "@fortawesome/fontawesome-svg-core": "^7.1.0", 36 | "@fortawesome/free-brands-svg-icons": "^7.1.0", 37 | "@fortawesome/free-solid-svg-icons": "^7.1.0", 38 | "@fortawesome/vue-fontawesome": "^3.1.2", 39 | "@tauri-apps/plugin-dialog": "^2.4.2", 40 | "@tauri-apps/plugin-fs": "^2.4.4", 41 | "fflate": "^0.8.2", 42 | "license-checker": "^25.0.1", 43 | "pinia": "^3.0.4", 44 | "uuid": "^13.0.0", 45 | "vite-plugin-pwa": "^1.2.0", 46 | "vue": "^3.5.25", 47 | "vue-gtag": "^3.6.3", 48 | "vue-i18n": "^11.2.2", 49 | "vue-tsc": "^3.1.8", 50 | "xbr-js": "^2.0.1" 51 | }, 52 | "devDependencies": { 53 | "@eslint/js": "^9.39.2", 54 | "@pinia/testing": "^1.0.3", 55 | "@playwright/test": "^1.57.0", 56 | "@tauri-apps/cli": "^2.9.6", 57 | "@types/license-checker": "^25.0.6", 58 | "@types/node": "^25.0.2", 59 | "@types/wicg-file-system-access": "^2023.10.7", 60 | "@vitejs/plugin-vue": "^6.0.3", 61 | "@vitest/browser": "^4.0.15", 62 | "@vitest/browser-playwright": "^4.0.15", 63 | "@vitest/coverage-v8": "^4.0.15", 64 | "@vitest/eslint-plugin": "^1.5.2", 65 | "@vue/test-utils": "^2.4.6", 66 | "eslint": "^9.39.2", 67 | "eslint-config-prettier": "^10.1.8", 68 | "eslint-import-resolver-typescript": "^4.4.4", 69 | "eslint-plugin-import-x": "^4.16.1", 70 | "eslint-plugin-unicorn": "^62.0.0", 71 | "eslint-plugin-unused-imports": "^4.3.0", 72 | "eslint-plugin-vue": "^10.6.2", 73 | "globals": "^16.5.0", 74 | "happy-dom": "^20.0.11", 75 | "husky": "^9.1.7", 76 | "jiti": "^2.6.1", 77 | "lint-staged": "^16.2.7", 78 | "prettier": "^3.7.4", 79 | "sass": "^1.96.0", 80 | "typescript": "^5.9.3", 81 | "typescript-eslint": "^8.49.0", 82 | "unplugin-fonts": "^1.4.0", 83 | "vite": "^7.2.7", 84 | "vite-tsconfig-paths": "^5.1.4", 85 | "vitest": "^4.0.15" 86 | }, 87 | "lint-staged": { 88 | "*.{js,ts,vue}": [ 89 | "eslint --fix", 90 | "prettier --write" 91 | ], 92 | "*.{json,css,scss}": [ 93 | "prettier --write" 94 | ] 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/unit/components/convert/ScaledImageList/ItemGridView.spec.ts: -------------------------------------------------------------------------------- 1 | // tests/components/ScaledImageListItem.spec.ts 2 | import { mount } from "@vue/test-utils"; 3 | 4 | import { ImageEntry } from "@/@types/convert"; 5 | import ItemGridView from "@/components/convert/ScaledImageList/ItemGridView.vue"; 6 | import { ScaleMode } from "@/constants/form"; 7 | 8 | describe("ScaledImageList/ItemGridView", () => { 9 | // helper to build a mock ImageEntry 10 | const makeEntry = (overrides?: Partial): ImageEntry => { 11 | const baseImage = { 12 | uuid: "u1", 13 | data: new File([""], "foo.png"), 14 | imageData: new ImageData(1, 1), 15 | width: 10, 16 | height: 10, 17 | originalPixelSize: 2, 18 | url: "http://example.com/foo.png", 19 | status: "scaled" as const, 20 | }; 21 | const baseSettings = { 22 | scaleSizePercent: 150, 23 | scaleMode: ScaleMode.Smooth, 24 | }; 25 | return { 26 | image: { ...baseImage, ...overrides?.image }, 27 | settings: { ...baseSettings, ...overrides?.settings }, 28 | errors: [], 29 | ...overrides, 30 | }; 31 | }; 32 | 33 | const factory = (props = {}) => { 34 | return mount(ItemGridView, { 35 | props: { 36 | scaledImage: makeEntry(), 37 | checked: false, 38 | hasOutputPathError: false, 39 | ...props, 40 | }, 41 | global: { 42 | mocks: { 43 | $t: (msg: string) => msg, 44 | }, 45 | }, 46 | }); 47 | }; 48 | 49 | describe("Rendering", () => { 50 | test("renders the image with correct src and alt", () => { 51 | const wrapper = factory(); 52 | 53 | const img = wrapper.find("img"); 54 | expect(img.exists()).toBe(true); 55 | expect(img.attributes("src")).toBe("http://example.com/foo.png"); 56 | expect(img.attributes("alt")).toBe("foo.png"); 57 | }); 58 | 59 | test("computes checkbox id and name via getId()", () => { 60 | const wrapper = factory(); 61 | 62 | const id = `checked-scaled-150-2-${ScaleMode.Smooth}-foo.png`; 63 | const checkbox = wrapper.find(`input[type="checkbox"]`); 64 | expect(checkbox.attributes("id")).toBe(id); 65 | expect(checkbox.attributes("name")).toBe(id); 66 | }); 67 | 68 | test("displays scale percent, original pixel, and scale mode", () => { 69 | const wrapper = factory(); 70 | 71 | const texts = wrapper.text(); 72 | expect(texts).toContain("150%"); 73 | expect(texts).toContain("2px"); 74 | expect(texts).toContain(ScaleMode.Smooth); 75 | }); 76 | }); 77 | 78 | describe("Emits", () => { 79 | test("emits 'download' when download button clicked", async () => { 80 | const wrapper = factory(); 81 | 82 | await wrapper 83 | .findAllComponents({ name: "VFormButton" }) 84 | .at(0)! 85 | .trigger("click"); 86 | expect(wrapper.emitted("download")).toBeTruthy(); 87 | expect(wrapper.emitted("download")!.length).toBe(1); 88 | }); 89 | 90 | test("emits 'delete' when delete button clicked", async () => { 91 | const wrapper = factory(); 92 | 93 | await wrapper 94 | .findAllComponents({ name: "VFormButton" }) 95 | .at(1)! 96 | .trigger("click"); 97 | expect(wrapper.emitted("delete")).toBeTruthy(); 98 | expect(wrapper.emitted("delete")!.length).toBe(1); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/stores/scaledImageStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { computed, ref } from "vue"; 3 | 4 | import { ImageEntry, ImageCheckList } from "@/@types/convert"; 5 | import { 6 | filterEntriesByChecked, 7 | revokeEntryUrls, 8 | } from "@/core/services/image/entryBatchService"; 9 | import { findEntryByUuid } from "@/core/services/image/entryService"; 10 | import { UnknownError } from "@/models/errors/UnknownError"; 11 | import useOutputPathStore from "@/stores/outputPathStore"; 12 | import { 13 | downloadString, 14 | createZipBlobFromScaledImages, 15 | downloadBlob, 16 | } from "@/utils/fileUtils"; 17 | 18 | export const useScaledImageStore = defineStore("scaledImage", () => { 19 | const entries = ref([]); 20 | 21 | const isEmpty = computed(() => entries.value.length === 0); 22 | 23 | const addEntry = (entry: ImageEntry): void => { 24 | entries.value.push(entry); 25 | }; 26 | 27 | const removeEntry = (uuid: string): void => { 28 | const targetEntry = findEntryByUuid(uuid, entries.value); 29 | if (targetEntry) { 30 | URL.revokeObjectURL(targetEntry.image.url); 31 | entries.value = entries.value.filter( 32 | (entry) => entry.image.uuid !== uuid, 33 | ); 34 | } 35 | }; 36 | 37 | const clearEntryErrors = (uuid: string): void => { 38 | const targetEntry = findEntryByUuid(uuid, entries.value); 39 | if (targetEntry) { 40 | targetEntry.errors = []; 41 | } 42 | }; 43 | 44 | const downloadEntry = (uuid: string): void => { 45 | const targetEntry = findEntryByUuid(uuid, entries.value); 46 | if (!targetEntry) { 47 | throw new UnknownError("ダウンロード対象のエントリーが見つかりません"); 48 | } 49 | const outputPathStore = useOutputPathStore(); 50 | downloadString( 51 | targetEntry.image.url, 52 | targetEntry.image.data.name, 53 | outputPathStore.outputPath, 54 | ); 55 | }; 56 | 57 | const deleteCheckedEntries = (checkedList: ImageCheckList): void => { 58 | const checkedEntries = filterEntriesByChecked(entries.value, checkedList); 59 | revokeEntryUrls(checkedEntries); 60 | const checkedUuids = new Set(checkedEntries.map((e) => e.image.uuid)); 61 | entries.value = entries.value.filter( 62 | (entry) => !checkedUuids.has(entry.image.uuid), 63 | ); 64 | }; 65 | 66 | const downloadCheckedEntries = (checkedList: ImageCheckList): void => { 67 | const outputPathStore = useOutputPathStore(); 68 | const checkedEntries = filterEntriesByChecked(entries.value, checkedList); 69 | for (const entry of checkedEntries) { 70 | downloadString( 71 | entry.image.url, 72 | entry.image.data.name, 73 | outputPathStore.outputPath, 74 | ); 75 | } 76 | }; 77 | 78 | const downloadCheckedEntriesZip = async ( 79 | checkedList: ImageCheckList, 80 | ): Promise => { 81 | const checkedEntries = filterEntriesByChecked(entries.value, checkedList); 82 | const zipBlob = await createZipBlobFromScaledImages(checkedEntries); 83 | downloadBlob(zipBlob, "images.zip"); 84 | }; 85 | 86 | const clearAll = (): void => { 87 | revokeEntryUrls(entries.value); 88 | entries.value = []; 89 | }; 90 | 91 | return { 92 | entries, 93 | isEmpty, 94 | addEntry, 95 | removeEntry, 96 | clearEntryErrors, 97 | downloadEntry, 98 | deleteCheckedEntries, 99 | downloadCheckedEntries, 100 | downloadCheckedEntriesZip, 101 | clearAll, 102 | }; 103 | }); 104 | -------------------------------------------------------------------------------- /src/core/config/i18n/cn.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "PiXel ScaLer", 3 | 4 | "delete": "删除", 5 | "delete-all": "全部删除", 6 | "delete-selected": "删除所选", 7 | 8 | "form": { 9 | "original-pixel-size": "像素", 10 | "original-pixel-size-hint": "对于已放大图片,请指定 1 像素所代表的大小", 11 | 12 | "scale-mode": "模式", 13 | "scale-modes": { 14 | "smooth": "平滑", 15 | "nearest": "简单" 16 | }, 17 | "scale-size-percent": "缩放比例 (%)", 18 | 19 | "result-display-style": { 20 | "grid": "网格", 21 | "list": "列表" 22 | }, 23 | 24 | "input-file-area": "选择或拖放图片 (gif/jpeg/png)", 25 | "convert": "转换", 26 | "convert-all": "全部转换", 27 | "convert-selected": "转换所选", 28 | "apply-all": "全部应用", 29 | "apply-selected": "应用所选", 30 | "delete": "删除" 31 | }, 32 | 33 | "convert": { 34 | "download-zip-all": "下载 ZIP", 35 | "download-zip-selected": "下载所选 ZIP", 36 | "download-file-all": "全部下载", 37 | "download-file-selected": "下载所选", 38 | "output-file-all": "全部输出", 39 | "output-file-selected": "输出所选", 40 | "download": "下载", 41 | "output": "输出" 42 | }, 43 | 44 | "path-selector": { 45 | "title": "选择输出目录", 46 | "output-path": "输出路径", 47 | "select-path": "选择", 48 | "empty-path": "尚未选择输出目录", 49 | "path-not-in-allowed-root": "无法选择主目录之外的路径", 50 | "path-does-not-exist": "所选目录不存在" 51 | }, 52 | 53 | "ogp-description": "一个可将像素画调整为插画风格的应用", 54 | 55 | "error": { 56 | "heading": "发生错误 ({count})", 57 | "file": { 58 | "duplicate-image": "已选择同名文件 ({filename})" 59 | }, 60 | "input": { 61 | "invalid-image-type": "请选择 png、jpeg 或 gif 文件 ({filename})", 62 | "encoding-error": "文件读取失败。可能已损坏或格式不受支持 ({filename})", 63 | "file-not-found": "无法加载文件。如文件已被修改,请重新选择 ({filename})", 64 | "canvas-is-unsupported": "您的浏览器不支持 canvas。请使用最新版本的浏览器" 65 | }, 66 | "scale": { 67 | "invalid-image-size": "像素大小 ({originalPixelSize}px) 与图片尺寸不一致", 68 | "unsupported-scale-size": "无效的缩放比例 ({scaleSizePercent}%)", 69 | "duplicate-image-and-settings": "已存在相同图片及设置 ({scaleSizePercent}%/{scaleMode}) 的转换结果" 70 | }, 71 | "unknown": { 72 | "unknown": "发生未知错误。请将以下信息发送给开发者:{message}" 73 | } 74 | }, 75 | 76 | "what-is": { 77 | "heading": "这是什么", 78 | "steps": [ 79 | "这是一个使用 xBRjs 的图像放大工具,可轻松将像素画转换为插画风格。", 80 | "您可以在合理范围内,自由将转换后的图片用于个人或商业用途。" 81 | ] 82 | }, 83 | 84 | "usage": { 85 | "heading": "使用方法", 86 | "steps": [ 87 | "选择或拖放像素画添加图片", 88 | "在缩放输入框中设置缩放比例(100%~10000%)", 89 | "若放大原始像素画,将“像素”设为 1;若放大已放大图片,请输入对应的缩放倍数", 90 | "点击“转换”按钮", 91 | "生成的插画风格图片将显示在下方", 92 | "点击图片下的“下载”或“下载 ZIP”按钮保存图片", 93 | "祝您使用愉快!" 94 | ] 95 | }, 96 | 97 | "tips": { 98 | "heading": "小贴士", 99 | "steps": [ 100 | "使用原始尺寸的像素画进行转换效果最佳", 101 | "非像素画的低分辨率图片可能无法干净放大", 102 | "将缩放比例设为 100% 可以对像素画进行抗锯齿处理", 103 | "若指定的像素大小与图片实际像素大小不一致,可能会导致转换失败" 104 | ] 105 | }, 106 | 107 | "link": { 108 | "share-on-x": "分享(X)", 109 | "developer": "开发者", 110 | "repository": "代码仓库", 111 | "booth": "Booth", 112 | "license": "许可证" 113 | }, 114 | 115 | "version": { 116 | "checking": "正在检查更新……", 117 | "new-notice": "发现新版本 v.{version}", 118 | "new-download": "请从 BoothGitHub 下载!", 119 | "latest-now": "您使用的是最新版本" 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /.github/workflows/release-build.yaml: -------------------------------------------------------------------------------- 1 | name: Release Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+' # Matches semantic versioning pattern like 1.0.0 7 | 8 | jobs: 9 | build-and-release: 10 | permissions: 11 | contents: write 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - platform: 'macos-latest' 17 | target: 'aarch64-apple-darwin' 18 | name: 'macOS (Apple Silicon)' 19 | - platform: 'macos-latest' 20 | target: 'x86_64-apple-darwin' 21 | name: 'macOS (Intel)' 22 | - platform: 'ubuntu-latest' 23 | name: 'Linux' 24 | - platform: 'windows-latest' 25 | name: 'Windows' 26 | 27 | runs-on: ${{ matrix.platform }} 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup Node.js 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: '22' 36 | cache: 'npm' 37 | 38 | - name: Install dependencies 39 | run: npm ci 40 | 41 | - name: Install Rust toolchain 42 | uses: dtolnay/rust-toolchain@stable 43 | with: 44 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 45 | 46 | - name: Install Linux dependencies 47 | if: matrix.platform == 'ubuntu-latest' 48 | run: | 49 | sudo apt-get update 50 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 51 | 52 | - name: Build and release Tauri app 53 | uses: tauri-apps/tauri-action@v0 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | with: 57 | tagName: ${{ github.ref_name }} 58 | iconPath: 'public/favicon.ico' 59 | releaseName: 'Pixel Scaler v${{ github.ref_name }}' 60 | releaseBody: 'Release builds of Pixel Scaler for Windows, macOS, and Linux.' 61 | releaseDraft: false 62 | prerelease: false 63 | args: ${{ matrix.platform == 'macos-latest' && format('--target {0}', matrix.target) || '' }} 64 | 65 | - name: Create Windows ZIP package 66 | if: matrix.platform == 'windows-latest' 67 | shell: pwsh 68 | run: | 69 | $exePath = Get-ChildItem -Path "src-tauri\target\release\*.exe" -Recurse | Where-Object { $_.Name -match "pixel-scaler.*\.exe" } | Select-Object -First 1 70 | $exeName = $exePath.Name 71 | $exeDir = $exePath.DirectoryName 72 | New-Item -Path "windows-package" -ItemType Directory 73 | Copy-Item $exePath.FullName -Destination "windows-package\$exeName" 74 | if (Test-Path "$exeDir\README_*.txt") { 75 | Copy-Item "$exeDir\README_*.txt" -Destination "windows-package\" 76 | } 77 | if (Test-Path "dist\THIRD_PARTY_LICENSES") { 78 | Copy-Item "dist\THIRD_PARTY_LICENSES" -Destination "windows-package\THIRD_PARTY_LICENSES" 79 | } 80 | $zipName = "PiXel.ScaLer_${{ github.ref_name }}-x64_windows.zip" 81 | Compress-Archive -Path "windows-package\*" -DestinationPath $zipName 82 | 83 | - name: Upload Windows ZIP to release 84 | if: matrix.platform == 'windows-latest' 85 | uses: softprops/action-gh-release@v2 86 | with: 87 | files: PiXel.ScaLer_${{ github.ref_name }}-x64_windows.zip 88 | tag_name: ${{ github.ref_name }} 89 | token: ${{ secrets.GITHUB_TOKEN }} 90 | -------------------------------------------------------------------------------- /tests/unit/core/services/image/entryService.spec.ts: -------------------------------------------------------------------------------- 1 | vi.mock("@/models/InputImageData"); 2 | 3 | import { ScaleMode } from "@/constants/form"; 4 | import { 5 | createImageEntry, 6 | isDuplicateUrl, 7 | findEntryByUuid, 8 | } from "@/core/services/image/entryService"; 9 | 10 | import { dummyImageEntry } from "../../../__mocks__/models/InputImageData"; 11 | 12 | describe("entryService", () => { 13 | describe("createImageEntry", () => { 14 | test("creates an ImageEntry from a File", async () => { 15 | const file = new File([], "test.png", { type: "image/png" }); 16 | const opts = { 17 | originalPixelSize: 100, 18 | scaleSizePercent: 200, 19 | scaleMode: ScaleMode.Smooth, 20 | }; 21 | 22 | const entry = await createImageEntry(file, opts); 23 | 24 | expect(entry.image.data.name).toBe("test.png"); 25 | expect(entry.image.originalPixelSize).toBe(100); 26 | expect(entry.settings.scaleSizePercent).toBe(200); 27 | expect(entry.settings.scaleMode).toBe(ScaleMode.Smooth); 28 | expect(entry.errors).toEqual([]); 29 | }); 30 | 31 | test("sets originalPixelSize on the image data", async () => { 32 | const file = new File([], "test.png", { type: "image/png" }); 33 | const opts = { 34 | originalPixelSize: 50, 35 | scaleSizePercent: 300, 36 | scaleMode: ScaleMode.Nearest, 37 | }; 38 | 39 | const entry = await createImageEntry(file, opts); 40 | 41 | expect(entry.image.originalPixelSize).toBe(50); 42 | }); 43 | }); 44 | 45 | describe("isDuplicateUrl", () => { 46 | test("returns true when URL exists in list", async () => { 47 | const existingEntry = await dummyImageEntry({ 48 | image: { url: "blob:http://example.com/test" }, 49 | }); 50 | const entries = [existingEntry]; 51 | 52 | const result = isDuplicateUrl("blob:http://example.com/test", entries); 53 | 54 | expect(result).toBe(true); 55 | }); 56 | 57 | test("returns false when URL does not exist in list", async () => { 58 | const existingEntry = await dummyImageEntry({ 59 | image: { url: "blob:http://example.com/test1" }, 60 | }); 61 | const entries = [existingEntry]; 62 | 63 | const result = isDuplicateUrl("blob:http://example.com/test2", entries); 64 | 65 | expect(result).toBe(false); 66 | }); 67 | 68 | test("returns false for empty list", () => { 69 | const result = isDuplicateUrl("blob:http://example.com/test", []); 70 | 71 | expect(result).toBe(false); 72 | }); 73 | }); 74 | 75 | describe("findEntryByUuid", () => { 76 | test("finds entry by UUID", async () => { 77 | const entry1 = await dummyImageEntry({ image: { uuid: "uuid-1" } }); 78 | const entry2 = await dummyImageEntry({ image: { uuid: "uuid-2" } }); 79 | const entries = [entry1, entry2]; 80 | 81 | const result = findEntryByUuid("uuid-2", entries); 82 | 83 | expect(result).toBe(entry2); 84 | }); 85 | 86 | test("returns undefined when UUID not found", async () => { 87 | const entry1 = await dummyImageEntry({ image: { uuid: "uuid-1" } }); 88 | const entries = [entry1]; 89 | 90 | const result = findEntryByUuid("uuid-999", entries); 91 | 92 | expect(result).toBeUndefined(); 93 | }); 94 | 95 | test("returns undefined for empty list", () => { 96 | const result = findEntryByUuid("uuid-1", []); 97 | 98 | expect(result).toBeUndefined(); 99 | }); 100 | }); 101 | }); 102 | --------------------------------------------------------------------------------