<!-- savepoint: 2026-05-12 / JSZip 本地化與 sheet-4x4 混淆 -->

# Savepoint：JSZip 本地化與 `sheet-4x4.js` 混淆

> 日期：2026-05-12
> 專案：`d:\www\docker\www\line_cut_tools`（Laravel 11 + Vite 7）
> 主題：把 4×4 頁面唯一的外網依賴（JSZip CDN）換成專案內檔，並對 `sheet-4x4.js` 套用 Vite 端混淆，但保留 JSZip 與全域 API 不混淆。
> 前傳：[`savepoint_2026-05-12_4x4_CropAlpha純前端化.md`](./savepoint_2026-05-12_4x4_CropAlpha純前端化.md)

---

## 1. 一開始是如何提出需求與問題的

1. 第一輪：使用者貼上 [`resources/js/jszip.min.js`](../../resources/js/jszip.min.js) 與 [`resources/views/line-sticker/sheet-4x4.blade.php#L935`](../../resources/views/line-sticker/sheet-4x4.blade.php)，意圖把外網 CDN `<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>` 改為走專案內既有的 `resources/js/jszip.min.js`。
2. 第二輪：詢問「可以混淆 `sheet-4x4.js` 嗎？」——需要評估技術可行性、效能成本與保護價值。
3. 第三輪：使用者選擇「方案 A」——以 Vite 插件 `vite-plugin-javascript-obfuscator` 進行混淆，但**只針對 `sheet-4x4.js`**，且 JSZip 保持原樣。
4. 第四輪：`savepoint` 收尾。

---

## 2. 過程中是如何一步步引導、修正與優化的

### 階段 A：JSZip CDN → 本地

1. **盤點現況**：
   - Blade 第 935 行載入 cdnjs JSZip，第 936 行接 `@vite(['resources/js/sheet-4x4.js'])`。
   - `sheet-4x4.js` 第 1 行為 `const JSZip = window.JSZip;`，**依賴全域**。
   - [`public/build/manifest.json`](../../public/build/manifest.json) 雖然有 `sheet-4x4.js` entry，但實際上是舊版殘留——當前 `vite.config.js` 只有 `app.css` + `app.js` 兩個 input，跑一次 `npm run build` 即會把 `sheet-4x4-*.js` 從 manifest 中移除。
2. **三點修正**：
   - 在 `sheet-4x4.js` 最上方加 side-effect import：`import './jszip.min.js';`，這支 UMD 會自動把 `JSZip` 掛到 `window`，下一行 `const JSZip = window.JSZip` 不必動。
   - 移除 Blade 第 935 行的 CDN `<script>`，只留 `@vite(['resources/js/sheet-4x4.js'])`。
   - 將 `resources/js/sheet-4x4.js` 補進 `vite.config.js` 的 `laravel({ input: [...] })`，否則 `npm run build` 不會輸出 chunk → `@vite()` 在 production 會 500。
3. **驗證**：`npm run build` 成功，產出 `public/build/assets/sheet-4x4-*.js` ≈ 114.35 kB（含 JSZip + 業務邏輯）、`manifest.json` 重新登錄三個 entry。整站不再有任何外網 JS 依賴。

### 階段 B：技術評估「能否混淆」

- 列出 Vite 預設已用 esbuild minify（短名 + 去註解 + 去死碼），達到「右鍵看不太懂」程度。
- 進階混淆工具：`javascript-obfuscator`（4.x）。可選功能：`stringArray`、`controlFlowFlattening`、`deadCodeInjection`、`debugProtection`、`selfDefending`、`identifierNamesGenerator`。
- 重要提醒（已寫進回覆給使用者）：
  - 混淆 ≠ 安全：可被 [de4js](https://lelinhtinh.github.io/de4js/) 大致還原結構；本頁無 API key、無 token、無付費邏輯，**保護價值僅止於「擋低成本抄襲切圖演算法」**。
  - 效能成本：本檔 `detectBgColor` / `autoDetect` / `removeBackground` / `autotrimCanvas` 是 CPU-bound 像素掃描，**不可開 `controlFlowFlattening`**（會慢 1.5~4 倍）。
  - 體積成本：全套混淆會讓本檔長到 250 kB+，行動裝置不友善。
- 不要混淆 `jszip.min.js`：已是 minified 第三方檔，再混淆只會大幅膨脹且毫無保護價值（原始碼開源）。

### 階段 C：實作方案 A（Vite 插件）

1. `npm i -D vite-plugin-javascript-obfuscator`（裝到 v3.1.0，內含 `javascript-obfuscator ^4.1.0`、`anymatch ~3.1.3`）。
2. **檢查插件原始碼**確認運作機制（`d:\www\docker\www\line_cut_tools\node_modules\vite-plugin-javascript-obfuscator\dist\index.es.js`）：
   - 用 `transform(src, id)` hook 處理「**原始檔**」，不是處理已 bundle 的 chunk。
   - `enforce: "post"` → 在其他 plugin transform 之後跑。
   - `include` / `exclude` 走 `anymatch`（字串會被 `path.resolve('.', s)` 轉成絕對路徑）。
   - 結論：`include: ['resources/js/sheet-4x4.js']` 只會混淆這個檔案的原始內容，`jszip.min.js` 雖然之後被同一 chunk bundle，**完全不會被處理**。
3. 在 `vite.config.js` 加上 plugin，採「**平衡強度**」設定：
   - **開**：`compact`、`identifierNamesGenerator: 'mangled'`、`stringArray + base64 + threshold 0.75`、`splitStrings (chunk=8)`
   - **關**：`controlFlowFlattening`、`deadCodeInjection`、`debugProtection`、`selfDefending`、`renameGlobals`、`numbersToExpressions`
   - **`apply: 'build'`**：dev 模式（HMR）不跑混淆，source 保持可讀
4. **關鍵設計：保留全域 API**
   - Blade 11 個 `onclick="xxx()"` 仰賴全域函式：`toggleRmbg`、`autoDetect`、`copyLinePrompt`、`doCrop`、`resetSel`、`clearAll`、`downloadAll`、`closeLb`、`closeLinePromptDialog`、`confirmCopyLinePrompt`、`onPromptBackdrop`。
   - `sheet-4x4.js` 第 767~779 行已有 `Object.assign(window, { ... })` 把這 11 個函式以**原名**綁到 `window`。
   - 配合 `renameGlobals: false`，混淆後 build 結尾仍是 `Object.assign(window,{toggleRmbg:me,autoDetect:Ae,doCrop:ke,downloadAll:Be,...})` → HTML `onclick` 可正常觸發。

### 階段 D：驗證

1. **`npm run build` 成功**：
   - 混淆前 `sheet-4x4-*.js`：114.35 kB（gzip 36.53 kB）
   - 混淆後 `sheet-4x4-Tdpwcs7i.js`：**119.71 kB**（gzip **39.87 kB**）→ +4.7% / +9.1%
   - build 時間：0.7 s → 1.0 s
2. **JSZip 未被混淆**：grep 檔內仍有 `MODULE_NOT_FOUND`、`pako`、`base64` 等 JSZip 原始字串 ✅
3. **sheet-4x4 邏輯有被混淆**：檔尾為 `t(427)` 形式的 stringArray 取值，函式名變成 `Ae`、`Be`、`ke` ✅
4. **全域 API 對得起來**：grep Blade 11 個 `onclick="xxx()"` 全部都在 `Object.assign(window, {...})` 內 ✅

---

## 3. 最後是如何產出目前這個結果的（現況）

### View 層

```933:937:resources\views\line-sticker\sheet-4x4.blade.php
<span id="scrollHint" style="display:none"></span>

@vite(['resources/js/sheet-4x4.js'])
</body>
</html>
```

- 第 935 行 CDN `<script>` 已移除，整頁外網依賴只剩 Google Fonts（CSS）。

### JS 層

```1:3:resources\js\sheet-4x4.js
import './jszip.min.js';

const JSZip = window.JSZip;
```

```767:779:resources\js\sheet-4x4.js
Object.assign(window, {
  toggleRmbg,
  autoDetect,
  copyLinePrompt,
  doCrop,
  resetSel,
  clearAll,
  downloadAll,
  closeLb,
  closeLinePromptDialog,
  confirmCopyLinePrompt,
  onPromptBackdrop,
});
```

- 第 1 行 side-effect import 讓 UMD JSZip 在打包後也能掛到 `window.JSZip`。
- 第 767~779 行的 `Object.assign(window, {...})` 是**混淆後仍能讓 HTML `onclick=` 呼叫**的關鍵。

### 建置層

```1:46:vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
import obfuscator from 'vite-plugin-javascript-obfuscator';

export default defineConfig({
    plugins: [
        laravel({
            input: [
                'resources/css/app.css',
                'resources/js/app.js',
                'resources/js/sheet-4x4.js',
            ],
            refresh: true,
        }),
        tailwindcss(),
        obfuscator({
            apply: 'build',
            include: ['resources/js/sheet-4x4.js'],
            exclude: [/node_modules/, /jszip\.min\.js$/],
            options: {
                compact: true,
                identifierNamesGenerator: 'mangled',
                renameGlobals: false,
                stringArray: true,
                stringArrayEncoding: ['base64'],
                stringArrayThreshold: 0.75,
                splitStrings: true,
                splitStringsChunkLength: 8,
                numbersToExpressions: false,
                controlFlowFlattening: false,
                deadCodeInjection: false,
                debugProtection: false,
                disableConsoleOutput: false,
                selfDefending: false,
                target: 'browser',
            },
        }),
    ],
    server: {
        watch: {
            ignored: ['**/storage/framework/views/**'],
        },
    },
});
```

### 套件層

- 新增 `devDependencies`：`vite-plugin-javascript-obfuscator ^3.1.0`（連帶 `javascript-obfuscator ^4.1.0`、`anymatch ~3.1.3`，共 +98 packages）。

### 維運注意事項

- **日後如果在 `sheet-4x4.js` 新增「要被 HTML `onclick=` 呼叫」的函式**，**一定要加進 `Object.assign(window, {...})` 清單**，否則 dev 正常但 production build 後該按鈕會 ReferenceError。
- `npm run dev` 不會混淆，可正常 debug；只有 `npm run build` 會輸出混淆檔。
- 若日後要追蹤 production error，可在 `obfuscator.options` 加 `sourceMap: true, sourceMapMode: 'separate'`，並把 `.map` 放到非公開位置或 Sentry。
- 若要再加強強度，把 `controlFlowFlattening: false` 改為 `true`（代價：執行慢 1.5~4 倍、檔案大 2 倍），目前**不建議**因像素掃描效能。

---

## 可直接複製使用、能夠重現相同結果的提示詞

```text
我有一個 Laravel 11 + Vite 7 專案，目前 `/line-sticker/sheet-4x4` 是純前端 4×4 切圖工具，業務邏輯在 `resources/js/sheet-4x4.js`，用 `@vite()` 載入，並從 cdnjs 載入 JSZip 3.10.1 CDN。
專案內已有 `resources/js/jszip.min.js`（UMD 版）。
請依以下需求改造：

1. 把 cdnjs 的 JSZip CDN 改為走專案內 `resources/js/jszip.min.js`：
   - 在 `resources/js/sheet-4x4.js` 最上方加 `import './jszip.min.js';`（side-effect，會把 JSZip 掛到 window）。
   - 移除 blade 對應的 `<script src="https://cdnjs..."></script>`。
   - 在 `vite.config.js` 把 `resources/js/sheet-4x4.js` 加進 `laravel({ input: [...] })`，否則 `npm run build` 不會輸出 chunk，`@vite()` 在 production 會壞。

2. 對 `sheet-4x4.js` 套用 Vite 端混淆（plugin: vite-plugin-javascript-obfuscator 3.x）：
   - 安裝：`npm i -D vite-plugin-javascript-obfuscator`
   - `include: ['resources/js/sheet-4x4.js']`，`exclude: [/node_modules/, /jszip\.min\.js$/]`，`apply: 'build'`
   - options：採「平衡強度」=  compact + identifierNamesGenerator:'mangled' + renameGlobals:false + stringArray + stringArrayEncoding:['base64'] + stringArrayThreshold:0.75 + splitStrings(chunk=8)；**關閉** controlFlowFlattening / deadCodeInjection / debugProtection / selfDefending / numbersToExpressions（因頁面有像素掃描，CPU-bound，控制流平坦化會嚴重拖慢效能）。
   - 必須保留 JSZip 不被混淆（已有 exclude 雙保險，且該檔是 import 進來、不會走 plugin 的 transform hook）。

3. 為了讓混淆後的程式仍能被 HTML `onclick="xxx()"` 呼叫，要求 `sheet-4x4.js` 在最末加一段：
   `Object.assign(window, { toggleRmbg, autoDetect, copyLinePrompt, doCrop, resetSel, clearAll, downloadAll, closeLb, closeLinePromptDialog, confirmCopyLinePrompt, onPromptBackdrop });`
   配合混淆 options `renameGlobals: false`，這段 key 名會被保留為原名。

4. 驗證：
   - `npm run build` 成功且輸出 `public/build/assets/sheet-4x4-*.js`。
   - 該 chunk 開頭仍是可讀的 JSZip 原碼（grep 找得到 MODULE_NOT_FOUND / pako / base64 等字串）。
   - 該 chunk 結尾的 sheet-4x4 邏輯為短名 + stringArray 形式（grep 不應找到 detectBgColor / autoDetect / removeBackground 等原始函式名作為定義；它們只應作為 `Object.assign` 內的 key 出現）。
   - 全部 11 個 blade `onclick` 函式（toggleRmbg / autoDetect / copyLinePrompt / doCrop / resetSel / clearAll / downloadAll / closeLb / closeLinePromptDialog / confirmCopyLinePrompt / onPromptBackdrop）都已包在 Object.assign 內。
   - 體積成本：混淆前後增加 < 10% 為合格（甜蜜點 +4~5% gzip ≈ +9%）。

5. 提醒使用者：
   - 混淆 ≠ 安全；本頁無 API key/付費邏輯，僅止於擋低成本抄襲。
   - 日後在 `sheet-4x4.js` 新增需要被 HTML `onclick` 呼叫的函式，**一定要加進 `Object.assign(window, {...})`**，否則 dev 正常但 production build 會 ReferenceError。
   - 開發時 `npm run dev` 不混淆（apply:'build'），不影響 debug。
```
