Adult woman typing on a laptop at a desk with a smartphone and documents.

【シェア】PDF結合&暗号化ツール V1.0(オフライン版)

Windows向けの完全オフラインPDF結合&暗号化ツールです。複数のPDFを結合し、AES-256暗号化で安全に保護。オンラインにアップロードせず、個人や社内の重要ファイルを安全に管理できます。

作者:Pecezen
プラットフォーム:Windows
言語:Go

✅ ツール概要

これは 完全オフライン の PDF 結合&暗号化ツールです。

  • 登録不要
  • ファイルアップロード不要
  • 専門知識不要

わずか数ステップで複数の PDF を1つに結合し、AES-256 暗号化 で保護することができます。
大切なファイルを外部に漏らさず安全に扱えます。

⭐ なぜこのツールが必要か?

こんな状況に遭遇したことはありませんか?

📎 PDF ファイルが多すぎて、メールで送信したりアップロードするのが面倒
🔐 契約書や身分証明書など、オンラインサービスに預けたくない
🧾 スキャン後のフォルダがすべて PDF ファイルでいっぱい
😵 オンラインツールはファイルサイズ制限がある、ログインが必要、動作が遅い

このツールは、まさにこうした状況を解決するために作られています。

🔧 主な機能

📂 PDF 結合
  • 複数の PDF を追加可能
  • フォルダ丸ごと追加可能
  • 結合順序を自由に調整
  • ファイル間に区切りページを挿入可能

🔐 PDF 暗号化(AES-256)
  • 結合後、ワンクリックで暗号化
  • AES-256 暗号化強度を使用
  • パスワード強度をリアルタイム表示
    • ❌ 弱い
    • ⚠ 普通
    • ✅ 安全

📊 処理状況が一目でわかる
  • 進行状況をリアルタイムで表示、処理が止まったか心配なし
  • 完了後に表示される情報:
    • 保存場所
    • 出力ファイルサイズ
🛡 「オフライン」が重要な理由

🔒 ファイルはどのサーバーにもアップロードされません
🔒 サードパーティプラットフォームを経由しません
🔒 記録が残らず、バックアップや解析もされません

👉 ファイルはあなたのコンピュータ上にのみ存在します

これは特に以下のような場合に重要です:

  • 契約書
  • 身分証明書
  • 社内文書

🖥 使い方(とても簡単です)

1️⃣ 「PDFを追加」または「フォルダを追加」をクリック
2️⃣ 必要に応じて結合順序を調整
3️⃣ 出力先とファイル名を選択
4️⃣ (オプション)暗号化パスワードを設定
5️⃣ 「結合&処理開始」をクリック → 完了

🖥️ 操作画面

💾 Windows 実行ファイルのダウンロード

ダウンロード先

ダウンロード後は圧縮ファイル形式です。
解凍パスワード:pecezen.org

🔗ソースコード(Source Code)
package main

import (
	"fmt"
	"io"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"unicode"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/container"
	"fyne.io/fyne/v2/dialog"
	"fyne.io/fyne/v2/layout"
	"fyne.io/fyne/v2/storage"
	"fyne.io/fyne/v2/theme"
	"fyne.io/fyne/v2/widget"

	"github.com/pdfcpu/pdfcpu/pkg/api"
	"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
)

// 偏好設定 Key
const prefLangKey = "user_language"

// I18n 翻譯資料結構
type i18n struct {
	windowTitle     string
	addBtn          string
	addFolderBtn    string
	removeBtn       string
	moveUp          string
	moveDown        string
	outPlaceholder  string
	chooseOut       string
	dividerCheck    string
	encryptCheck    string
	mergeBtn        string
	passwordPlace   string
	sectionMerge    string
	sectionEnc      string
	langLabel       string
	aboutBtn        string
	aboutTitle      string
	aboutContent    string
	msgSuccess      string
	msgErrMinFile   string
	msgErrNoOut     string
	msgErrNoPW      string
	outputSizeLabel string
	strengthWeak    string
	strengthMedium  string
	strengthStrong  string
	processing      string
	// 新增:加密/保護檔案提示
	msgEncryptedListHeader string
}

var translations = map[string]i18n{
	"繁體中文": {
		windowTitle:     "PDF 合併與加密工具 V1.0",
		addBtn:          "加入 PDF",
		addFolderBtn:    "加入資料夾",
		removeBtn:       "移除",
		moveUp:          "上移",
		moveDown:        "下移",
		outPlaceholder:  "輸出檔名 (如: merged.pdf)",
		chooseOut:       "儲存位置",
		dividerCheck:    "在文件間插入分隔頁",
		encryptCheck:    "合併後啟用 AES-256 加密",
		mergeBtn:        "開始合併與處理",
		passwordPlace:   "請輸入加密密碼",
		sectionMerge:    "1. 輸出與合併選項",
		sectionEnc:      "2. 加密設定",
		langLabel:       "語言:",
		aboutBtn:        "關於",
		aboutTitle:      "關於此程式",
		aboutContent:    "作者: pecezen.org\nCopyright © 2026 pecezen.org. All rights reserved.\n\n【第三方內容聲明】\n本程式核心處理引擎採用 pdfcpu 開源專案。\npdfcpu 依據 Apache License 2.0 授權使用。\nCopyright © 2026 pdfcpu authors.\n\n【免責聲明】\n作者不對因使用本程式所造成的任何資料遺失、損壞或其他後果負責。",
		msgSuccess:      "操作成功!",
		msgErrMinFile:   "請至少加入 2 個 PDF 檔案。",
		msgErrNoOut:     "請先指定輸出位置。",
		msgErrNoPW:      "啟用加密時,必須輸入密碼。",
		outputSizeLabel: "輸出檔案大小: ",
		strengthWeak:    "❌ 太弱",
		strengthMedium:  "⚠ 普通",
		strengthStrong:  "✅ 安全",
		processing:      "正在處理中,請稍候...",
		// 新增
		msgEncryptedListHeader: "以下檔案無法合併(可能已加密或受保護):",
	},
	"English": {
		windowTitle:     "PDF Merge & Encrypt Tool V1.0",
		addBtn:          "Add PDF",
		addFolderBtn:    "Add Folder",
		removeBtn:       "Remove",
		moveUp:          "Up",
		moveDown:        "Down",
		outPlaceholder:  "Output name (e.g. merged.pdf)",
		chooseOut:       "Save As...",
		dividerCheck:    "Insert divider pages",
		encryptCheck:    "Enable AES-256 Encryption",
		mergeBtn:        "Run Process",
		passwordPlace:   "Enter Password",
		sectionMerge:    "1. Merge Settings",
		sectionEnc:      "2. Security Settings",
		langLabel:       "Language:",
		aboutBtn:        "About",
		aboutTitle:      "About",
		aboutContent:    "Author: pecezen.org\nCopyright © 2026 pecezen.org. All rights reserved.\n\n[Third-party Attribution]\nThis tool uses the pdfcpu library.\npdfcpu is licensed under the Apache License 2.0.\nCopyright © 2026 pdfcpu authors.\n\n[Disclaimer]\nThe author is not responsible for any data loss, damage, or other consequences caused by the use of this program.",
		msgSuccess:      "Success!",
		msgErrMinFile:   "Add at least 2 PDF files.",
		msgErrNoOut:     "Please specify output location.",
		msgErrNoPW:      "Password required for encryption.",
		outputSizeLabel: "Output Size: ",
		strengthWeak:    "❌ Too Weak",
		strengthMedium:  "⚠ Medium",
		strengthStrong:  "✅ Strong",
		processing:      "Processing, please wait...",
		// 新增
		msgEncryptedListHeader: "The following file(s) cannot be merged (encrypted/protected):",
	},
	"日文": {
		windowTitle:     "PDF結合・暗号化ツール V1.0",
		addBtn:          "PDFを追加",
		addFolderBtn:    "フォルダを追加",
		removeBtn:       "削除",
		moveUp:          "上へ",
		moveDown:        "下へ",
		outPlaceholder:  "出力ファイル名",
		chooseOut:       "保存先",
		dividerCheck:    "仕切りページを掃入",
		encryptCheck:    "AES-256暗号化を有効にする",
		mergeBtn:        "結合と実行",
		passwordPlace:   "パスワード",
		sectionMerge:    "1. 結合設定",
		sectionEnc:      "2. セキュリティ設定",
		langLabel:       "言語:",
		aboutBtn:        "情報",
		aboutTitle:      "このアプリについて",
		aboutContent:    "著者: pecezen.org\nCopyright © 2026 pecezen.org. All rights reserved.\n\n【サードパーティ製ライブラリの声明】\n本プログラムのコア處理引擎は pdfcpu を採用しています。\npdfcpu は Apache License 2.0 に基づいて使用されています。\nCopyright © 2026 pdfcpu authors.\n\n【免責事項】\n作者は、本プログラムの使用によって生じたデータの紛失、破損、その他の結果について一切の責任を負いません。",
		msgSuccess:      "成功しました!",
		msgErrMinFile:   "2つ以上のPDFファイルを追加してください。",
		msgErrNoOut:     "保存先を指定してください。",
		msgErrNoPW:      "パスワードが必要です。",
		outputSizeLabel: "出力サイズ: ",
		strengthWeak:    "❌ 弱い",
		strengthMedium:  "⚠ 普通",
		strengthStrong:  "✅ 強固",
		processing:      "処理中、しばらくお待ちください...",
		// 新增
		msgEncryptedListHeader: "次のファイルは結合できません(暗号化/保護されています):",
	},
	"Deutsch": {
		windowTitle:     "PDF Zusammenführen & Verschlüsseln V1.0",
		addBtn:          "PDF hinzufügen",
		addFolderBtn:    "Ordner hinzufügen",
		removeBtn:       "Entfernen",
		moveUp:          "Nach oben",
		moveDown:        "Nach unten",
		outPlaceholder:  "Dateiname für Ausgabe",
		chooseOut:       "Speichern unter...",
		dividerCheck:    "Trennblätter einfügen",
		encryptCheck:    "AES-256-Verschlüsselung aktivieren",
		mergeBtn:        "Prozess starten",
		passwordPlace:   "Passwort eingeben",
		sectionMerge:    "1. Zusammenführen-Einstellungen",
		sectionEnc:      "2. Sicherheitseinstellungen",
		langLabel:       "Sprache:",
		aboutBtn:        "Über",
		aboutTitle:      "Über diese App",
		aboutContent:    "Autor: pecezen.org\nCopyright © 2026 pecezen.org. All rights reserved.\n\n[Dritthersteller-Attributierung]\nDieses Tool verwendet die pdfcpu-Bibliothek.\npdfcpu ist unter der Apache License 2.0 lizenziert.\nCopyright © 2026 pdfcpu authors.\n\n[Haftungsausschluss]\nDer Autor haftet nicht für Datenverlust, Schäden oder andere Folgen, die durch die Nutzung dieses Programms entstehen.",
		msgSuccess:      "Erfolg!",
		msgErrMinFile:   "Mindestens 2 PDF-Dateien hinzufügen.",
		msgErrNoOut:     "Bitte Ausgabeort angeben.",
		msgErrNoPW:      "Passwort für Verschlüsselung erforderlich.",
		outputSizeLabel: "Ausgabegröße: ",
		strengthWeak:    "❌ Zu schwach",
		strengthMedium:  "⚠ Mittel",
		strengthStrong:  "✅ Stark",
		processing:      "Verarbeitung, bitte warten...",
		// 新增
		msgEncryptedListHeader: "Folgende Datei(en) können nicht zusammengeführt werden (verschlüsselt/geschützt):",
	},
}

func formatFileSize(size int64) string {
	if size < 1024 {
		return fmt.Sprintf("%d B", size)
	} else if size < 1024*1024 {
		return fmt.Sprintf("%.2f KB", float64(size)/1024)
	}
	return fmt.Sprintf("%.2f MB", float64(size)/(1024*1024))
}

func checkPasswordStrength(pw string, t i18n) string {
	if len(pw) == 0 {
		return ""
	}
	if len(pw) < 6 {
		return t.strengthWeak
	}
	var hasNumber, hasUpper, hasSpecial bool
	for _, char := range pw {
		switch {
		case unicode.IsNumber(char):
			hasNumber = true
		case unicode.IsUpper(char):
			hasUpper = true
		case unicode.IsPunct(char) || unicode.IsSymbol(char):
			hasSpecial = true
		}
	}
	if len(pw) >= 10 && hasNumber && hasUpper && hasSpecial {
		return t.strengthStrong
	}
	return t.strengthMedium
}

func cleanPath(p string) string {
	res := p
	if runtime.GOOS == "windows" {
		if len(res) > 3 && res[0] == '/' && res[2] == ':' {
			res = res[1:]
		}
	}
	res = filepath.FromSlash(res)
	if absPath, err := filepath.Abs(res); err == nil {
		return absPath
	}
	return res
}

// 安全地刪除零位元組檔案(避免誤刪已有內容的檔案)
func removeIfEmpty(p string) {
	if p == "" {
		return
	}
	if fi, err := os.Stat(p); err == nil && fi.Size() == 0 {
		_ = os.Remove(p)
	}
}

func moveFile(src, dst string) error {
	if _, err := os.Stat(dst); err == nil {
		_ = os.Remove(dst)
	}
	if err := os.Rename(src, dst); err == nil {
		return nil
	}
	// 跨磁碟或不同裝置時,改用 copy + 刪除
	sf, err := os.Open(src)
	if err != nil {
		return err
	}
	defer sf.Close()
	df, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer df.Close()
	if _, err = io.Copy(df, sf); err != nil {
		return err
	}
	_ = sf.Close()
	return os.Remove(src)
}

// 只複製(不刪來源)
func copyFile(src, dst string) error {
	if _, err := os.Stat(dst); err == nil {
		_ = os.Remove(dst)
	}
	sf, err := os.Open(src)
	if err != nil {
		return err
	}
	defer sf.Close()
	df, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer df.Close()
	_, err = io.Copy(df, sf)
	return err
}

// 嘗試判斷檔案是否為加密/受保護(或不可讀),以避免合併時直接噴錯。
// 這裡以 Validate 嘗試,若錯誤訊息包含 encrypt/password/protect 等關鍵字,就視為無法合併。
func isEncryptedOrProtected(path string) bool {
	// 傳入 nil 讓 pdfcpu 使用預設設定(無密碼)
	err := api.ValidateFile(path, nil)
	if err == nil {
		return false
	}
	msg := strings.ToLower(err.Error())
	if strings.Contains(msg, "encrypt") ||
		strings.Contains(msg, "password") ||
		strings.Contains(msg, "protected") ||
		strings.Contains(msg, "permission") {
		return true
	}
	// 其他 validation 錯誤也視為無法處理,避免後續直接噴錯
	return true
}

func runPdfProcess(files []string, finalOut string, divider, encrypt bool, pw string) error {
	// 單檔案例:若需加密,直接加密單檔;若不加密,外層邏輯不會允許執行(維持原規則)
	if len(files) == 1 {
		single := files[0]
		if encrypt {
			pw = strings.TrimSpace(pw)
			if pw == "" {
				return fmt.Errorf("Password required")
			}
			tempEnc, err := os.CreateTemp("", "pdf_enc_*.pdf")
			if err != nil {
				return err
			}
			encPath := tempEnc.Name()
			tempEnc.Close()
			defer os.Remove(encPath)

			encConf := model.NewAESConfiguration(pw, pw, 256)
			if err := api.EncryptFile(single, encPath, encConf); err != nil {
				return fmt.Errorf("Encrypt Error: %v", err)
			}
			return moveFile(encPath, finalOut)
		}
		// 非加密且單檔:理論上外層不會呼叫到這裡;若呼叫,保守作法採複製
		return copyFile(single, finalOut)
	}

	// 多檔案例:先合併,再視需要加密
	tempMerge, err := os.CreateTemp("", "pdf_merge_*.pdf")
	if err != nil {
		return err
	}
	mergePath := tempMerge.Name()
	tempMerge.Close()
	defer os.Remove(mergePath)

	if err := api.MergeCreateFile(files, mergePath, divider, nil); err != nil {
		return fmt.Errorf("Merge Error: %v", err)
	}

	sourceToMove := mergePath

	if encrypt {
		pw = strings.TrimSpace(pw)
		if pw == "" {
			return fmt.Errorf("Password required")
		}
		tempEnc, err := os.CreateTemp("", "pdf_enc_*.pdf")
		if err != nil {
			return err
		}
		encPath := tempEnc.Name()
		tempEnc.Close()
		defer os.Remove(encPath)

		encConf := model.NewAESConfiguration(pw, pw, 256)
		if err := api.EncryptFile(mergePath, encPath, encConf); err != nil {
			return fmt.Errorf("Encrypt Error: %v", err)
		}
		sourceToMove = encPath
	}

	return moveFile(sourceToMove, finalOut)
}

func main() {
	a := app.NewWithID("org.pecezen.pdftool.v1")
	w := a.NewWindow("PDF Tool V1.0")
	w.Resize(fyne.NewSize(850, 650))

	// 設定視窗圖示
	if iconRes, err := fyne.LoadResourceFromPath("icon.png"); err == nil {
		w.SetIcon(iconRes)
	}

	files := make([]string, 0, 16)
	selectedIndex := -1
	savedLang := a.Preferences().StringWithFallback(prefLangKey, "繁體中文")
	currentLang := savedLang

	outEntry := widget.NewEntry()
	passwordEntry := widget.NewEntry()
	passwordEntry.Password = true
	passwordEntry.Disable()

	strengthLabel := widget.NewLabel("")
	sizeResultLabel := widget.NewLabel("")
	progressIndicator := widget.NewProgressBarInfinite()
	progressIndicator.Hide()

	dividerCheck := widget.NewCheck("", nil)
	encryptCheck := widget.NewCheck("", func(checked bool) {
		if checked {
			passwordEntry.Enable()
		} else {
			passwordEntry.Disable()
			strengthLabel.SetText("")
		}
	})

	passwordEntry.OnChanged = func(s string) {
		if encryptCheck.Checked {
			strengthLabel.SetText(checkPasswordStrength(s, translations[currentLang]))
		}
	}

	list := widget.NewList(
		func() int { return len(files) },
		func() fyne.CanvasObject { return widget.NewLabel("template") },
		func(i widget.ListItemID, o fyne.CanvasObject) {
			o.(*widget.Label).SetText(fmt.Sprintf("%d. %s", i+1, filepath.Base(files[i])))
		},
	)

	addBtn := widget.NewButtonWithIcon("", theme.ContentAddIcon(), nil)
	addFolderBtn := widget.NewButtonWithIcon("", theme.FolderOpenIcon(), nil)
	removeBtn := widget.NewButtonWithIcon("", theme.DeleteIcon(), nil)
	upBtn := widget.NewButtonWithIcon("", theme.MoveUpIcon(), nil)
	downBtn := widget.NewButtonWithIcon("", theme.MoveDownIcon(), nil)
	chooseOutBtn := widget.NewButtonWithIcon("", theme.DocumentSaveIcon(), nil)
	mergeBtn := widget.NewButtonWithIcon("", theme.ConfirmIcon(), nil)
	aboutBtn := widget.NewButtonWithIcon("", theme.InfoIcon(), nil)

	mergeLabel := widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
	encryptLabel := widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
	langLabelPrefix := widget.NewLabel("")

	refreshUI := func(langKey string) {
		currentLang = langKey
		t := translations[langKey]
		w.SetTitle(t.windowTitle)
		addBtn.SetText(t.addBtn)
		addFolderBtn.SetText(t.addFolderBtn)
		removeBtn.SetText(t.removeBtn)
		upBtn.SetText(t.moveUp)
		downBtn.SetText(t.moveDown)
		outEntry.SetPlaceHolder(t.outPlaceholder)
		chooseOutBtn.SetText(t.chooseOut)
		dividerCheck.Text = t.dividerCheck
		dividerCheck.Refresh()
		encryptCheck.Text = t.encryptCheck
		encryptCheck.Refresh()
		mergeBtn.SetText(t.mergeBtn)
		aboutBtn.SetText(t.aboutBtn)
		passwordEntry.SetPlaceHolder(t.passwordPlace)
		mergeLabel.SetText(t.sectionMerge)
		encryptLabel.SetText(t.sectionEnc)
		langLabelPrefix.SetText(t.langLabel)
	}

	mergeBtn.OnTapped = func() {
		t := translations[currentLang]

		// 放行條件:
		// - 多檔:需 >= 2
		// - 單檔:若勾選加密,允許;若未勾選加密,仍需 >= 2
		if !((len(files) >= 2) || (encryptCheck.Checked && len(files) == 1)) {
			dialog.ShowInformation("Info", t.msgErrMinFile, w)
			return
		}

		finalOut := strings.TrimSpace(outEntry.Text)
		if finalOut == "" || finalOut == ".pdf" {
			dialog.ShowInformation("Info", t.msgErrNoOut, w)
			return
		}
		if encryptCheck.Checked && strings.TrimSpace(passwordEntry.Text) == "" {
			dialog.ShowInformation("Info", t.msgErrNoPW, w)
			return
		}

		// === 新增:事前掃描無法合併的檔案(加密/保護/不可讀) ===
		filesToCheck := files
		badFiles := make([]string, 0, len(filesToCheck))
		for _, f := range filesToCheck {
			if isEncryptedOrProtected(f) {
				badFiles = append(badFiles, filepath.Base(f))
			}
		}
		if len(badFiles) > 0 {
			// 顯示清單並中止,不進入處理
			msg := t.msgEncryptedListHeader + "\n"
			for _, name := range badFiles {
				msg += "- " + name + "\n"
			}
			dialog.ShowInformation("Info", msg, w)
			return
		}
		// === 事前掃描結束 ===

		mergeBtn.Disable()
		progressIndicator.Show()
		sizeResultLabel.SetText(t.processing)

		go func() {
			err := runPdfProcess(files, finalOut, dividerCheck.Checked, encryptCheck.Checked, passwordEntry.Text)
			var sizeStr string
			if err == nil {
				if fInfo, statErr := os.Stat(finalOut); statErr == nil {
					sizeStr = formatFileSize(fInfo.Size())
				}
			}

			fyne.Do(func() {
				if err != nil {
					dialog.ShowError(err, w)
				} else {
					sizeResultLabel.SetText(t.outputSizeLabel + sizeStr)
					dialog.ShowInformation("OK", t.msgSuccess+"\n"+finalOut, w)
				}
				mergeBtn.Enable()
				progressIndicator.Hide()
			})
		}()
	}

	addBtn.OnTapped = func() {
		fd := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
			if err != nil || r == nil {
				return
			}
			path := cleanPath(r.URI().Path())
			if strings.EqualFold(filepath.Ext(path), ".pdf") {
				files = append(files, path)
				list.Refresh()
			}
		}, w)
		fd.SetFilter(storage.NewExtensionFileFilter([]string{".pdf"}))
		fd.Show()
	}

	addFolderBtn.OnTapped = func() {
		dialog.ShowFolderOpen(func(lu fyne.ListableURI, err error) {
			if err != nil || lu == nil {
				return
			}
			entries, _ := lu.List()
			for _, entry := range entries {
				path := cleanPath(entry.Path())
				if strings.EqualFold(filepath.Ext(path), ".pdf") {
					files = append(files, path)
				}
			}
			list.Refresh()
		}, w)
	}

	removeBtn.OnTapped = func() {
		if selectedIndex < 0 || selectedIndex >= len(files) {
			return
		}
		files = append(files[:selectedIndex], files[selectedIndex+1:]...)
		selectedIndex = -1
		list.Refresh()
	}

	upBtn.OnTapped = func() {
		if selectedIndex <= 0 {
			return
		}
		files[selectedIndex-1], files[selectedIndex] = files[selectedIndex], files[selectedIndex-1]
		selectedIndex--
		list.Refresh()
		list.Select(selectedIndex)
	}

	downBtn.OnTapped = func() {
		if selectedIndex < 0 || selectedIndex >= len(files)-1 {
			return
		}
		files[selectedIndex+1], files[selectedIndex] = files[selectedIndex], files[selectedIndex+1]
		selectedIndex++
		list.Refresh()
		list.Select(selectedIndex)
	}

	chooseOutBtn.OnTapped = func() {
		fs := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) {
			if err != nil || wc == nil {
				return
			}
			// 使用者實際在 SaveDialog 選到的原始路徑(可能未含 .pdf)
			raw := cleanPath(wc.URI().Path())
			out := raw
			if !strings.HasSuffix(strings.ToLower(out), ".pdf") {
				out += ".pdf"
			}
			// 關閉 Writer(必要),但此步可能會在 raw 位置建立零位元組檔案
			_ = wc.Close()

			// UI 顯示最終輸出路徑(含 .pdf)
			outEntry.SetText(out)

			// 修正:刪除 SaveDialog 可能建立的空白占位檔(0 bytes)
			removeIfEmpty(raw)
			if out != raw {
				removeIfEmpty(out)
			}
		}, w)
		fs.SetFileName("merged.pdf")
		fs.Show()
	}

	aboutBtn.OnTapped = func() {
		t := translations[currentLang]
		dialog.ShowInformation(t.aboutTitle, t.aboutContent, w)
	}

	langOptions := []string{"繁體中文", "English", "日文", "Deutsch"}
	langSelect := widget.NewSelect(langOptions, func(s string) {
		a.Preferences().SetString(prefLangKey, s)
		refreshUI(s)
	})
	langSelect.Selected = savedLang
	langContainer := container.NewHBox(langLabelPrefix, langSelect)

	toolbar := container.NewHBox(addBtn, addFolderBtn, removeBtn, upBtn, downBtn, layout.NewSpacer(), langContainer, aboutBtn)

	settings := container.NewVBox(
		widget.NewSeparator(),
		mergeLabel,
		container.NewBorder(nil, nil, nil, chooseOutBtn, outEntry),
		dividerCheck,
		widget.NewSeparator(),
		encryptLabel,
		encryptCheck,
		container.NewVBox(passwordEntry, strengthLabel),
		widget.NewSeparator(),
		progressIndicator,
		sizeResultLabel,
		mergeBtn,
	)

	list.OnSelected = func(id widget.ListItemID) { selectedIndex = int(id) }

	refreshUI(savedLang)
	w.SetContent(container.NewBorder(
		toolbar, nil, nil, nil,
		container.NewHSplit(list, container.NewPadded(settings)),
	))

	w.ShowAndRun()
}
⚠️ 免責事項(Disclaimer)

このソフトウェアの使用によって発生したデータの損失、破損、その他の結果について、作者は一切責任を負いません。


プライバシーと安全を守るために、PeceZen のツールは「追跡なし・広告なし・完全オフライン」を大切にしています。
もしこれらのツールが、あなたの貴重な時間を節約する助けになったのであれば、コーヒー一杯のご支援で、サーバーの維持やツールの継続的な更新を支えていただければ幸いです。
[ ☕ コーヒーを一杯おごる ]

返信を残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です