Przeglądaj źródła

Extract PDF Tool from Productivity module

Creates a new standalone `PDF Tool` web app with its own `init.agi`, UI, and JS modules (`common`, `editor`, `convert`, `chops`) for PDF editing, image conversion, and chop management. Moves related assets and `pdf-lib` into the new module path, including a legacy chop-storage migration from `Productivity` keys. Removes the old `Productivity` app/tool files and updates the Text editor’s PDF export comment to reference PDF Tool.
Toby Chui 2 tygodni temu
rodzic
commit
0afc79abcf

+ 0 - 0
src/web/Productivity/img/desktop_icon.png → src/web/PDF Tool/img/desktop_icon.png


+ 0 - 0
src/web/Productivity/img/desktop_icon.psd → src/web/PDF Tool/img/desktop_icon.psd


+ 0 - 0
src/web/Productivity/img/module_icon.png → src/web/PDF Tool/img/module_icon.png


+ 0 - 0
src/web/Productivity/img/module_icon.svg → src/web/PDF Tool/img/module_icon.svg


+ 488 - 0
src/web/PDF Tool/index.html

@@ -0,0 +1,488 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>PDF Tool</title>
+    <link rel="icon" type="image/svg+xml" href="img/module_icon.svg">
+    <script src="../script/jquery.min.js"></script>
+    <script src="../script/ao_module.js"></script>
+    <style>
+        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+        :root {
+            --bg: #F2F2F7;
+            --sidebar-bg: #EFEFF4;
+            --text: #1C1C1E;
+            --sub: #6B6B70;
+            --accent: #007AFF;
+            --card-bg: #FFFFFF;
+            --border: #D1D1D6;
+            --inp-bg: #FFFFFF;
+            --progress-bg: #E5E5EA;
+            --danger: #FF3B30;
+        }
+
+        @media (prefers-color-scheme: dark) {
+            :root {
+                --bg: #1C1C1E; --sidebar-bg: #2C2C2E; --text: #F2F2F7; --sub: #8E8E93;
+                --card-bg: #2C2C2E; --border: #48484A; --inp-bg: #3A3A3C;
+                --progress-bg: #3A3A3C; --accent: #0A84FF;
+            }
+        }
+
+        /* ArozOS system theme override (wins over the media query) */
+        html[data-theme="dark"] {
+            --bg: #1C1C1E; --sidebar-bg: #2C2C2E; --text: #F2F2F7; --sub: #8E8E93;
+            --card-bg: #2C2C2E; --border: #48484A; --inp-bg: #3A3A3C;
+            --progress-bg: #3A3A3C; --accent: #0A84FF;
+        }
+        html[data-theme="light"] {
+            --bg: #F2F2F7; --sidebar-bg: #EFEFF4; --text: #1C1C1E; --sub: #6B6B70;
+            --card-bg: #FFFFFF; --border: #D1D1D6; --inp-bg: #FFFFFF;
+            --progress-bg: #E5E5EA; --accent: #007AFF;
+        }
+
+        html, body {
+            width: 100%; height: 100%; overflow: hidden;
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+            background: var(--bg); color: var(--text);
+        }
+
+        /* ── App shell ──────────────────────────────────────────────── */
+        .pe-root {
+            position: relative;
+            display: flex; flex-direction: column;
+            width: 100%; height: 100%;
+            overflow: hidden;
+        }
+
+        .pe-toolbar {
+            display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
+            padding: 8px 12px;
+            border-bottom: 1px solid var(--border);
+            background: var(--card-bg);
+            flex-shrink: 0;
+        }
+        .pe-sep { width: 1px; height: 20px; background: var(--border); margin: 0 3px; }
+        .pe-spacer { flex: 1; }
+        .pe-fname {
+            font-size: 12px; color: var(--sub);
+            max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+        }
+
+        .pe-tbtn {
+            display: inline-flex; align-items: center; gap: 5px;
+            padding: 6px 10px; border-radius: 7px;
+            border: 1px solid var(--border); background: var(--card-bg);
+            color: var(--text); font-size: 12.5px; font-weight: 600;
+            font-family: inherit; cursor: pointer;
+        }
+        .pe-tbtn:hover { background: rgba(0,0,0,0.05); }
+        .pe-tbtn:disabled { opacity: 0.4; cursor: default; }
+        .pe-tbtn:disabled:hover { background: var(--card-bg); }
+        .pe-tbtn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+        .pe-tbtn.primary:hover { opacity: 0.9; background: var(--accent); }
+        .pe-tbtn.primary:disabled:hover { background: var(--accent); }
+
+        .pe-iconbtn {
+            width: 28px; height: 28px; border-radius: 6px;
+            border: 1px solid var(--border); background: var(--card-bg);
+            color: var(--text); cursor: pointer;
+            display: inline-flex; align-items: center; justify-content: center;
+        }
+        .pe-iconbtn:hover { background: rgba(0,0,0,0.05); }
+        .pe-iconbtn:disabled { opacity: 0.4; cursor: default; }
+
+        .pe-page-lbl { font-size: 12px; color: var(--sub); min-width: 70px; text-align: center; }
+        .pe-zoom {
+            font-size: 12.5px; padding: 5px 6px; border-radius: 7px;
+            border: 1px solid var(--border); background: var(--card-bg);
+            color: var(--text); font-family: inherit; cursor: pointer;
+        }
+        .pe-zoom:disabled { opacity: 0.4; cursor: default; }
+
+        /* ── Stage: Fit sizes the page so it never needs scrolling ──── */
+        .pe-stage {
+            position: relative; flex: 1; min-height: 0;
+            display: flex; overflow: hidden;
+            background: var(--progress-bg);
+        }
+        .pe-stage.zoomed { overflow: auto; }
+        .pe-stage.dragover::after {
+            content: 'Drop a PDF here';
+            position: absolute; inset: 10px; z-index: 20;
+            border: 2px dashed var(--accent); border-radius: 12px;
+            background: rgba(0,122,255,0.09);
+            display: flex; align-items: center; justify-content: center;
+            font-size: 15px; font-weight: 600; color: var(--accent);
+            pointer-events: none;
+        }
+        .pe-empty {
+            margin: auto; text-align: center; color: var(--sub);
+            font-size: 13.5px; line-height: 1.6; padding: 20px;
+        }
+
+        .pe-wrap { margin: auto; position: relative; box-shadow: 0 4px 18px rgba(0,0,0,0.22); flex-shrink: 0; }
+        .pe-wrap canvas { display: block; }
+        .pe-overlay { position: absolute; inset: 0; }
+
+        .pe-ov { position: absolute; box-sizing: border-box; cursor: move; border: 1px solid transparent; }
+        .pe-ov.selected { border-color: var(--accent); }
+        .pe-ov.pe-text {
+            padding: 1px 2px; white-space: pre; line-height: 1.25;
+            font-family: 'Helvetica Neue', Arial, sans-serif; outline: none; min-width: 12px;
+        }
+        .pe-ov.pe-text[contenteditable="true"] { cursor: text; background: rgba(0,122,255,0.06); white-space: pre-wrap; }
+        .pe-ov.pe-img img { display: block; width: 100%; height: 100%; pointer-events: none; -webkit-user-drag: none; }
+
+        .pe-handle {
+            position: absolute; right: -6px; bottom: -6px;
+            width: 13px; height: 13px; border-radius: 50%;
+            background: var(--accent); border: 2px solid #fff;
+            cursor: nwse-resize; display: none;
+        }
+        .pe-ov.selected .pe-handle { display: block; }
+        .pe-del {
+            position: absolute; right: -9px; top: -9px;
+            width: 18px; height: 18px; border-radius: 50%;
+            background: var(--danger); color: #fff; border: 2px solid #fff;
+            cursor: pointer; display: none; align-items: center; justify-content: center; padding: 0;
+        }
+        .pe-ov.selected .pe-del { display: flex; }
+
+        /* ── Floating text property bar ─────────────────────────────── */
+        .pe-props {
+            position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
+            z-index: 30; display: none; align-items: center; gap: 16px;
+            padding: 8px 14px; background: var(--card-bg);
+            border: 1px solid var(--border); border-radius: 10px;
+            box-shadow: 0 6px 22px rgba(0,0,0,0.18);
+        }
+        .pe-props.show { display: flex; }
+        .pe-props label { font-size: 12px; color: var(--sub); display: flex; align-items: center; gap: 7px; }
+        .pe-props input[type=color] {
+            width: 30px; height: 26px; border: 1px solid var(--border);
+            border-radius: 6px; background: none; cursor: pointer; padding: 0;
+        }
+        .pe-props input[type=range] { accent-color: var(--accent); }
+
+        /* ── Toast: pill at the bottom centre, fades itself out ─────── */
+        .pe-toast {
+            position: absolute; bottom: 18px; left: 50%;
+            transform: translateX(-50%) translateY(10px);
+            z-index: 60; max-width: 86%;
+            opacity: 0; pointer-events: none;
+            transition: opacity 0.18s ease, transform 0.18s ease;
+        }
+        .pe-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+        /* lift clear of the text property bar when it is showing */
+        .pe-root.props-open .pe-toast { bottom: 76px; }
+
+        .pe-pill {
+            display: block; padding: 9px 18px; border-radius: 999px;
+            background: rgba(28,28,30,0.94); color: #fff;
+            font-size: 13px; line-height: 1.35;
+            box-shadow: 0 6px 24px rgba(0,0,0,0.34);
+            max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+        }
+        .pe-pill.err { background: rgba(199,42,36,0.96); }
+        .pe-pill strong { font-weight: 700; }
+
+        /* ── Dropdown menu ──────────────────────────────────────────── */
+        .pe-menu {
+            position: absolute; z-index: 70; min-width: 212px;
+            background: var(--card-bg); border: 1px solid var(--border);
+            border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,0.22);
+            padding: 5px;
+        }
+        .pe-menu-item {
+            display: flex; align-items: center; gap: 9px; width: 100%;
+            padding: 8px 10px; border: none; background: none; border-radius: 7px;
+            color: var(--text); font-size: 13px; font-family: inherit;
+            text-align: left; cursor: pointer;
+        }
+        .pe-menu-item:hover:not(:disabled) { background: rgba(128,128,128,0.2); }
+        .pe-menu-item:disabled { opacity: 0.4; cursor: default; }
+        .pe-menu-ico {
+            width: 15px; flex-shrink: 0; color: var(--sub);
+            display: inline-flex; align-items: center; justify-content: center;
+        }
+        .pe-menu-sep { height: 1px; background: var(--border); margin: 4px 6px; }
+
+        /* ── Split button (Save + caret) ────────────────────────────── */
+        .pe-split { display: inline-flex; }
+        .pe-split .pe-tbtn:first-child {
+            border-top-right-radius: 0; border-bottom-right-radius: 0;
+        }
+        .pe-split .pe-tbtn:last-child {
+            border-top-left-radius: 0; border-bottom-left-radius: 0;
+            border-left: 1px solid rgba(255,255,255,0.32);
+            padding-left: 7px; padding-right: 7px;
+        }
+
+        .result-box { padding: 11px 14px; border-radius: 10px; font-size: 13px; line-height: 1.5; margin-top: 10px; }
+        .result-ok { background: rgba(48,176,110,0.12); border: 1px solid rgba(48,176,110,0.28); color: #1B7A43; }
+        .result-err { background: rgba(255,59,48,0.1); border: 1px solid rgba(255,59,48,0.25); color: #C0392B; }
+        @media (prefers-color-scheme: dark) {
+            .result-ok { color: #4ADE80; }
+            .result-err { color: #FF6B6B; }
+        }
+        html[data-theme="dark"] .result-ok { color: #4ADE80; }
+        html[data-theme="dark"] .result-err { color: #FF6B6B; }
+
+        /* ── Stamp menu ─────────────────────────────────────────────── */
+        .pe-stamp-menu {
+            position: absolute; z-index: 50;
+            background: var(--card-bg); border: 1px solid var(--border);
+            border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,0.18);
+            padding: 10px; width: 236px; max-height: 300px; overflow-y: auto; display: none;
+        }
+        .pe-stamp-menu.show { display: block; }
+        .pe-stamp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+        .pe-stamp-item {
+            border: 1px solid var(--border); border-radius: 7px; padding: 6px; cursor: pointer;
+            display: flex; flex-direction: column; gap: 4px; align-items: center;
+            background: repeating-conic-gradient(#0000000d 0% 25%, transparent 0% 50%) 50% / 14px 14px;
+        }
+        .pe-stamp-item:hover { border-color: var(--accent); }
+        .pe-stamp-item img { max-width: 100%; height: 54px; object-fit: contain; }
+        .pe-stamp-item span {
+            font-size: 11px; color: var(--text); max-width: 100%;
+            overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+        }
+        .pe-stamp-empty { font-size: 12px; color: var(--sub); text-align: center; padding: 12px 6px; line-height: 1.5; }
+
+        /* ── Modal ──────────────────────────────────────────────────── */
+        .pt-modal-back {
+            position: fixed; inset: 0; z-index: 900;
+            background: rgba(0,0,0,0.42);
+            display: flex; align-items: center; justify-content: center;
+            padding: 24px;
+        }
+        .pt-modal {
+            background: var(--bg); color: var(--text);
+            border: 1px solid var(--border); border-radius: 14px;
+            box-shadow: 0 18px 48px rgba(0,0,0,0.32);
+            width: 100%; max-width: 480px; max-height: 100%;
+            display: flex; flex-direction: column; overflow: hidden;
+        }
+        .pt-modal-hd {
+            display: flex; align-items: center; justify-content: space-between;
+            padding: 13px 16px; border-bottom: 1px solid var(--border);
+            background: var(--card-bg); flex-shrink: 0;
+        }
+        .pt-modal-hd span { font-size: 14px; font-weight: 700; }
+        .pt-modal-x {
+            border: none; background: none; color: var(--sub); cursor: pointer;
+            width: 26px; height: 26px; border-radius: 6px;
+            display: inline-flex; align-items: center; justify-content: center;
+        }
+        .pt-modal-x:hover { background: rgba(0,0,0,0.07); color: var(--text); }
+        .pt-modal-bd { padding: 16px; overflow-y: auto; flex: 1; }
+        .pt-modal-ft {
+            display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap;
+            padding: 11px 16px; border-top: 1px solid var(--border);
+            background: var(--card-bg); flex-shrink: 0;
+        }
+
+        /* ── Form controls used inside dialogs ──────────────────────── */
+        .pt-btn {
+            padding: 7px 14px; border-radius: 8px;
+            border: 1px solid var(--border); background: var(--card-bg);
+            color: var(--text); font-size: 13px; font-weight: 600;
+            font-family: inherit; cursor: pointer;
+        }
+        .pt-btn:hover { background: rgba(0,0,0,0.05); }
+        .pt-btn:disabled { opacity: 0.45; cursor: default; }
+        .pt-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+        .pt-btn.primary:hover { opacity: 0.9; }
+        .pt-btn.tiny { padding: 4px 9px; font-size: 12px; }
+
+        .pt-iconbtn {
+            border: none; background: none; cursor: pointer;
+            padding: 4px; border-radius: 5px; color: var(--sub);
+            display: inline-flex; align-items: center; justify-content: center;
+        }
+        .pt-iconbtn:hover { background: rgba(0,0,0,0.07); color: var(--text); }
+        .pt-iconbtn.danger:hover { color: var(--danger); background: rgba(255,59,48,0.1); }
+
+        .pt-field { margin-bottom: 15px; }
+        .pt-label {
+            display: block; font-size: 11px; font-weight: 700; color: var(--sub);
+            text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px;
+        }
+        .pt-hint { font-size: 12.5px; color: var(--sub); line-height: 1.5; margin-bottom: 10px; }
+        .pt-row { display: flex; align-items: center; gap: 9px; }
+        .pt-path { font-size: 12.5px; color: var(--sub); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+        .pt-radios { display: flex; gap: 14px; }
+        .pt-radios label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
+        .pt-radios input { accent-color: var(--accent); cursor: pointer; }
+        .pt-field input[type=range] { accent-color: var(--accent); }
+
+        .pt-input, .pt-select {
+            width: 100%; padding: 8px 10px;
+            background: var(--inp-bg); border: 1px solid var(--border);
+            border-radius: 8px; font-size: 13px; color: var(--text);
+            font-family: inherit; outline: none;
+        }
+        .pt-input:focus, .pt-select:focus { border-color: var(--accent); }
+
+        .pt-empty-box {
+            padding: 20px 16px; text-align: center;
+            font-size: 12.5px; color: var(--sub);
+            border: 1.5px dashed var(--border); border-radius: 10px;
+        }
+
+        .pt-pathbox {
+            padding: 9px 11px; border-radius: 8px;
+            background: var(--inp-bg); border: 1px solid var(--border);
+            font-size: 12.5px; color: var(--text); word-break: break-all;
+            font-family: 'SF Mono', Consolas, monospace;
+        }
+
+        /* ── Chop grid ──────────────────────────────────────────────── */
+        .chop-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 11px; }
+        .chop-card {
+            background: var(--card-bg); border: 1px solid var(--border);
+            border-radius: 10px; padding: 9px;
+            display: flex; flex-direction: column; gap: 7px;
+        }
+        .chop-thumb {
+            width: 100%; height: 84px; border-radius: 7px;
+            background: repeating-conic-gradient(#0000000d 0% 25%, transparent 0% 50%) 50% / 16px 16px;
+            display: flex; align-items: center; justify-content: center; overflow: hidden;
+        }
+        .chop-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
+        .chop-name { font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+        .chop-card-btns { display: flex; gap: 4px; align-items: center; }
+        .chop-card-btns .pt-btn { flex: 1; }
+
+        /* ── Image list (Create PDF from images) ────────────────────── */
+        .pt-imglist { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
+        .pt-imgrow {
+            display: flex; align-items: center; gap: 10px;
+            padding: 7px 10px; border-bottom: 1px solid var(--border);
+            background: var(--card-bg);
+        }
+        .pt-imgrow:last-child { border-bottom: none; }
+        .pt-thumb {
+            width: 34px; height: 34px; border-radius: 5px;
+            object-fit: cover; background: var(--progress-bg); flex-shrink: 0;
+        }
+        .pt-imgname { flex: 1; font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+        .pt-imgbtns { display: flex; gap: 2px; }
+    </style>
+</head>
+<body>
+
+<div class="pe-root" id="peRoot">
+    <div class="pe-toolbar">
+        <button class="pe-tbtn primary" id="peOpen" title="Open a document">
+            <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M1.2 3.2h3.4l1.3 1.6h6.9v6.4H1.2z"/><path d="M1.2 6h11.6"/>
+            </svg> Open
+            <svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3.8 L5 6.6 L8 3.8"/></svg>
+        </button>
+        <span class="pe-fname" id="peFileName"></span>
+
+        <div class="pe-sep"></div>
+
+        <button class="pe-iconbtn" id="peAddText" title="Insert a text box" aria-label="Insert a text box" disabled>
+            <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
+                <path d="M2 3.2V2h10v1.2M7 2v10M5 12h4"/>
+            </svg>
+        </button>
+        <button class="pe-iconbtn" id="peAddImg" title="Insert an image from the server" aria-label="Insert an image from the server" disabled>
+            <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                <rect x="1.5" y="2.5" width="11" height="9" rx="1.3"/><circle cx="4.7" cy="5.5" r="1"/>
+                <path d="M1.5 10 L5 6.5 L7.5 8.5 L10 5.5 L12.5 9"/>
+            </svg>
+        </button>
+        <button class="pe-iconbtn" id="peUpload" title="Insert an image from this device" aria-label="Insert an image from this device" disabled>
+            <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M7 9V2.5M4.3 5.2 7 2.5l2.7 2.7"/><path d="M2 9.5v1.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V9.5"/>
+            </svg>
+        </button>
+        <button class="pe-iconbtn" id="peAddStamp" title="Place a saved company chop" aria-label="Place a saved company chop" disabled>
+            <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M7 1.5c-1.7 0-2.6 1.3-2.2 2.9.2.9.4 1.6-.3 2.1H9.5c-.7-.5-.5-1.2-.3-2.1C9.6 2.8 8.7 1.5 7 1.5Z"/>
+                <rect x="2.5" y="8.5" width="9" height="1.8" rx="0.6"/><line x1="1.8" y1="12.2" x2="12.2" y2="12.2"/>
+            </svg>
+        </button>
+
+        <div class="pe-sep"></div>
+
+        <button class="pe-iconbtn" id="pePrev" title="Previous page" aria-label="Previous page" disabled>
+            <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 2 L4 6.5 L8.5 11"/></svg>
+        </button>
+        <span class="pe-page-lbl" id="pePageLbl">—</span>
+        <button class="pe-iconbtn" id="peNext" title="Next page" aria-label="Next page" disabled>
+            <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 2 L9 6.5 L4.5 11"/></svg>
+        </button>
+
+        <div class="pe-sep"></div>
+
+        <select class="pe-zoom" id="peZoom" title="Zoom" disabled>
+            <option value="fit" selected>Fit</option>
+            <option value="0.5">50%</option>
+            <option value="0.75">75%</option>
+            <option value="1">100%</option>
+            <option value="1.25">125%</option>
+            <option value="1.5">150%</option>
+            <option value="2">200%</option>
+        </select>
+
+        <div class="pe-spacer"></div>
+
+        <span class="pe-split">
+            <button class="pe-tbtn primary" id="peSave" title="Save the edited PDF" disabled>
+                <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                    <path d="M2 2h7.5L12 4.5V12H2z"/><path d="M4.5 2v3h4V2"/><rect x="4.5" y="7.5" width="5" height="3.5"/>
+                </svg> Save
+            </button>
+            <button class="pe-tbtn primary" id="peSaveMenu" title="More save options" aria-label="More save options" disabled>
+                <svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3.8 L5 6.6 L8 3.8"/></svg>
+            </button>
+        </span>
+    </div>
+
+    <div class="pe-stage" id="peStage">
+        <div class="pe-empty" id="peEmpty">
+            No PDF open.<br>Use <strong>Open</strong> to choose a document, or drag one in from the File Manager.
+        </div>
+    </div>
+
+    <div class="pe-props" id="peProps">
+        <label>Font size
+            <input type="range" id="peFontSize" min="8" max="120" value="24" style="width:120px">
+            <span id="peFontVal" style="color:var(--text);font-weight:600;min-width:34px">24px</span>
+        </label>
+        <label>Colour <input type="color" id="peColor" value="#d0021b"></label>
+    </div>
+
+    <div class="pe-toast" id="ptToast"></div>
+
+    <input type="file" id="peUploadInput" accept="image/*" style="display:none">
+    <input type="file" id="peOpenDeviceInput" accept="application/pdf,.pdf" style="display:none">
+    <div class="pe-stamp-menu" id="peStampMenu"></div>
+</div>
+
+<script src="js/common.js"></script>
+<script src="js/chops.js"></script>
+<script src="js/convert.js"></script>
+<script src="js/editor.js"></script>
+<script>
+/* ── ArozOS system theme binding ── */
+function applyAozTheme(theme) {
+    document.documentElement.setAttribute('data-theme', theme === 'dark' ? 'dark' : 'light');
+}
+
+ao_module_onThemeChanged(applyAozTheme);
+
+$.get(ao_root + 'system/file_system/preference?key=file_explorer/theme', function (data) {
+    applyAozTheme(data === 'darkTheme' ? 'dark' : 'light');
+});
+</script>
+</body>
+</html>

+ 23 - 0
src/web/PDF Tool/init.agi

@@ -0,0 +1,23 @@
+/*
+    PDF Tool
+    Edit PDF documents: insert text, images and company chops,
+    export pages as images and build PDFs from images.
+*/
+
+var moduleLaunchInfo = {
+    Name: "PDF Tool",
+    Desc: "Edit PDFs with text, images and company chops",
+    Group: "Office",
+    IconPath: "PDF Tool/img/module_icon.svg",
+    Version: "1.0",
+    StartDir: "PDF Tool/index.html",
+    SupportFW: true,
+    LaunchFWDir: "PDF Tool/index.html",
+    InitFWSize: [1100, 720],
+    SupportEmb: true,
+    LaunchEmb: "PDF Tool/index.html",
+    InitEmbSize: [1100, 720],
+    SupportedExt: [".pdf"]
+}
+
+registerModule(JSON.stringify(moduleLaunchInfo));

+ 189 - 0
src/web/PDF Tool/js/chops.js

@@ -0,0 +1,189 @@
+/*
+    PDF Tool — company chop / stamp library
+
+    Chop PNGs live in the user's hidden app-data folder; only small metadata
+    (id / name / path) goes into preference storage, which is capped at 1 MB and
+    travels over a query string.
+*/
+
+(function (T) {
+    'use strict';
+
+    var DIR = 'user:/.appdata/PDF Tool/Chops/';
+    var MOD = 'PDF Tool', KEY = 'chops';
+    var LEGACY_MOD = 'Productivity'; // pre-rename storage key, migrated on first load
+
+    /*
+        The in-memory cache is the source of truth once loaded.
+        ao_module_storage.setStorage() fires an async write and returns immediately
+        without confirming completion, so reading the value straight back after a
+        save() can race the write and return stale data.
+    */
+    var cache = null;
+    var pending = [];
+    var fetching = false;
+
+    function parse(raw) {
+        if (!raw) { return []; }
+        try { return JSON.parse(raw) || []; } catch (e) { return []; }
+    }
+
+    function flush() {
+        var queued = pending;
+        pending = [];
+        queued.forEach(function (fn) { fn(cache.slice()); });
+    }
+
+    function load(cb) {
+        if (cache !== null) { cb(cache.slice()); return; }
+        pending.push(cb);
+        if (fetching) { return; }
+        fetching = true;
+
+        ao_module_storage.loadStorage(MOD, KEY, function (raw) {
+            var arr = parse(raw);
+            if (arr.length) {
+                cache = arr;
+                fetching = false;
+                flush();
+                return;
+            }
+            // Nothing under the new key — carry any chops saved before the rename forward.
+            ao_module_storage.loadStorage(LEGACY_MOD, KEY, function (legacyRaw) {
+                var legacy = parse(legacyRaw);
+                cache = legacy;
+                fetching = false;
+                if (legacy.length) {
+                    ao_module_storage.setStorage(MOD, KEY, JSON.stringify(legacy));
+                }
+                flush();
+            });
+        });
+    }
+
+    function save(arr, cb) {
+        cache = arr.slice();
+        ao_module_storage.setStorage(MOD, KEY, JSON.stringify(cache));
+        if (cb) { cb(cache.slice()); }
+    }
+
+    function add(srcPath, name, onDone, onErr) {
+        fetch(T.mediaUrl(srcPath)).then(function (r) {
+            if (!r.ok) { throw new Error('Cannot read source image'); }
+            return r.blob();
+        }).then(function (blob) {
+            var fname = 'chop_' + Date.now() + '.png';
+            ao_module_uploadFile(new File([blob], fname, { type: 'image/png' }), DIR, function () {
+                load(function (arr) {
+                    arr.push({ id: 'c' + Date.now(), name: name || 'Chop', path: DIR + fname });
+                    save(arr, function () { if (onDone) { onDone(arr); } });
+                });
+            }, undefined, function (status) {
+                if (onErr) { onErr('Upload failed (' + status + ')'); }
+            });
+        }).catch(function (e) {
+            if (onErr) { onErr(e.message || String(e)); }
+        });
+    }
+
+    function remove(id, onDone) {
+        load(function (arr) {
+            save(arr.filter(function (c) { return c.id !== id; }), onDone);
+        });
+    }
+
+    function rename(id, name, onDone) {
+        load(function (arr) {
+            arr.forEach(function (c) { if (c.id === id) { c.name = name; } });
+            save(arr, onDone);
+        });
+    }
+
+    T.Chops = { DIR: DIR, load: load, save: save, add: add, remove: remove, rename: rename };
+
+    /* ── Manager dialog ─────────────────────────────────────────────── */
+
+    T.openChopManager = function (onChanged) {
+        var dlg = T.modal({
+            title: 'Chop Library',
+            bodyHtml:
+                '<p class="pt-hint">Load your company stamp as a transparent <strong>PNG</strong>. ' +
+                'Saved chops can be placed on any page from the <strong>Stamp</strong> button.</p>' +
+                '<button class="pt-btn primary" id="chopAdd">Add Chop (PNG)</button>' +
+                '<div id="chopMsg"></div>' +
+                '<div id="chopList" style="margin-top:14px"></div>',
+            actions: [{ label: 'Done', primary: false, onClick: function (c) { c.close(); } }]
+        });
+
+        var listEl = dlg.body.querySelector('#chopList');
+        var msgEl = dlg.body.querySelector('#chopMsg');
+
+        function render() {
+            T.Chops.load(function (chops) {
+                if (!chops.length) {
+                    listEl.innerHTML = '<div class="pt-empty-box">No chops yet. Add a transparent PNG of your company stamp to get started.</div>';
+                    return;
+                }
+                var html = '<div class="chop-grid">';
+                chops.forEach(function (c) {
+                    html += '<div class="chop-card">' +
+                        '<div class="chop-thumb"><img src="' + T.mediaUrl(c.path) + '" alt=""></div>' +
+                        '<div class="chop-name">' + T.escHtml(c.name) + '</div>' +
+                        '<div class="chop-card-btns">' +
+                            '<button class="pt-btn tiny" data-rename="' + c.id + '">Rename</button>' +
+                            '<button class="pt-iconbtn danger" data-remove="' + c.id + '" title="Remove">' + T.svg.x + '</button>' +
+                        '</div>' +
+                    '</div>';
+                });
+                listEl.innerHTML = html + '</div>';
+
+                Array.prototype.forEach.call(listEl.querySelectorAll('[data-remove]'), function (btn) {
+                    btn.addEventListener('click', function () {
+                        T.Chops.remove(this.getAttribute('data-remove'), function () {
+                            render();
+                            if (onChanged) { onChanged(); }
+                        });
+                    });
+                });
+                Array.prototype.forEach.call(listEl.querySelectorAll('[data-rename]'), function (btn) {
+                    btn.addEventListener('click', function () {
+                        var id = this.getAttribute('data-rename');
+                        var card = this.closest('.chop-card');
+                        var current = card ? card.querySelector('.chop-name').textContent : '';
+                        var name = prompt('Chop name', current);
+                        if (name != null && name.trim() !== '') {
+                            T.Chops.rename(id, name.trim(), function () {
+                                render();
+                                if (onChanged) { onChanged(); }
+                            });
+                        }
+                    });
+                });
+            });
+        }
+
+        dlg.body.querySelector('#chopAdd').addEventListener('click', function () {
+            ao_module_openFileSelector('_ptChopAddCb', 'user:/', 'file', false, { filter: ['png'] });
+        });
+
+        window._ptChopAddCb = function (files) {
+            if (!files || !files.length) { return; }
+            var f = files[0];
+            if (!/\.png$/i.test(f.filename)) {
+                msgEl.innerHTML = '<div class="result-box result-err">Please choose a <strong>PNG</strong> image (transparent background recommended).</div>';
+                return;
+            }
+            msgEl.innerHTML = '<div class="pt-hint" style="margin-top:8px">Importing chop…</div>';
+            T.Chops.add(f.filepath, f.filename.replace(/\.png$/i, ''), function () {
+                msgEl.innerHTML = '';
+                render();
+                if (onChanged) { onChanged(); }
+            }, function (err) {
+                msgEl.innerHTML = '<div class="result-box result-err"><strong>Error:</strong> ' + T.escHtml(String(err)) + '</div>';
+            });
+        };
+
+        render();
+    };
+
+})(window.PDFTool);

+ 294 - 0
src/web/PDF Tool/js/common.js

@@ -0,0 +1,294 @@
+/*
+    PDF Tool — shared helpers
+
+    Virtual-path utilities, byte/canvas helpers, lazy loaders for the two PDF
+    libraries, and the toast + modal primitives used by the editor and dialogs.
+*/
+
+window.PDFTool = window.PDFTool || {};
+
+(function (T) {
+    'use strict';
+
+    /* ── Virtual path helpers ───────────────────────────────────────── */
+
+    T.dirOf = function (vpath) {
+        var p = vpath.split('/');
+        p.pop();
+        return p.join('/');
+    };
+
+    T.basenameNoExt = function (vpath) {
+        var n = vpath.split('/').pop();
+        var d = n.lastIndexOf('.');
+        return d > 0 ? n.slice(0, d) : n;
+    };
+
+    T.mediaUrl = function (vpath) {
+        return '/media?file=' + encodeURIComponent(vpath);
+    };
+
+    T.escHtml = function (s) {
+        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+    };
+
+    /* ── Transfer helpers ───────────────────────────────────────────── */
+
+    T.uploadBlob = function (blob, filename, targetDir) {
+        return new Promise(function (resolve, reject) {
+            ao_module_uploadFile(new File([blob], filename, { type: blob.type }),
+                targetDir, resolve, undefined, reject);
+        });
+    };
+
+    T.fetchBytes = function (url) {
+        return fetch(url).then(function (r) {
+            if (!r.ok) { throw new Error('HTTP ' + r.status); }
+            return r.arrayBuffer();
+        }).then(function (buf) { return new Uint8Array(buf); });
+    };
+
+    T.canvasToBytes = function (c, mime, quality) {
+        return new Promise(function (resolve, reject) {
+            c.toBlob(function (blob) {
+                if (!blob) { reject(new Error('Canvas export failed')); return; }
+                var fr = new FileReader();
+                fr.onload = function (e) { resolve(new Uint8Array(e.target.result)); };
+                fr.onerror = reject;
+                fr.readAsArrayBuffer(blob);
+            }, mime || 'image/png', quality);
+        });
+    };
+
+    T.loadImage = function (src) {
+        return new Promise(function (resolve, reject) {
+            var img = new Image();
+            img.onload = function () { resolve(img); };
+            img.onerror = function () { reject(new Error('Could not load image')); };
+            img.src = src;
+        });
+    };
+
+    /* ── Library loaders (both are lazy; pdf.js is shared with PDF Viewer) ── */
+
+    var pdfJsReady = false;
+
+    T.loadPdfJs = function () {
+        return new Promise(function (resolve, reject) {
+            if (pdfJsReady || typeof pdfjsLib !== 'undefined') {
+                if (!pdfJsReady) {
+                    pdfjsLib.GlobalWorkerOptions.workerSrc = '../PDF Viewer/js/pdf.worker.js';
+                    pdfJsReady = true;
+                }
+                resolve();
+                return;
+            }
+            var s = document.createElement('script');
+            s.src = '../PDF Viewer/js/pdf.js';
+            s.onload = function () {
+                pdfjsLib.GlobalWorkerOptions.workerSrc = '../PDF Viewer/js/pdf.worker.js';
+                pdfJsReady = true;
+                resolve();
+            };
+            s.onerror = function () { reject(new Error('Failed to load the PDF renderer')); };
+            document.head.appendChild(s);
+        });
+    };
+
+    /* pdf-lib (vendored, MIT) — writes real PDF content instead of rasterising */
+    T.loadPdfLib = function () {
+        return new Promise(function (resolve, reject) {
+            if (typeof PDFLib !== 'undefined') { resolve(); return; }
+            var s = document.createElement('script');
+            s.src = 'lib/pdf-lib.min.js';
+            s.onload = function () { resolve(); };
+            s.onerror = function () { reject(new Error('Failed to load pdf-lib')); };
+            document.head.appendChild(s);
+        });
+    };
+
+    /*
+        Fetch an image and normalise it to bytes a PDF can hold directly.
+        PNG and JPEG pass through untouched; anything else is drawn to a canvas and
+        re-encoded as PNG so transparency survives.
+
+        Returns {bytes, isPng}. Kept separate from embedding so a caller can embed
+        the same source more than once and get an independent PDF image object each
+        time (see the editor: every placement is its own layer).
+    */
+    T.imageBytesFor = function (src) {
+        return T.fetchBytes(src).then(function (bytes) {
+            if (bytes[0] === 0x89 && bytes[1] === 0x50) { return { bytes: bytes, isPng: true }; }
+            if (bytes[0] === 0xFF && bytes[1] === 0xD8) { return { bytes: bytes, isPng: false }; }
+            return T.loadImage(src).then(function (img) {
+                var c = document.createElement('canvas');
+                c.width = img.naturalWidth;
+                c.height = img.naturalHeight;
+                c.getContext('2d').drawImage(img, 0, 0);
+                return T.canvasToBytes(c, 'image/png').then(function (png) {
+                    return { bytes: png, isPng: true };
+                });
+            });
+        });
+    };
+
+    /* Embed an image into a pdf-lib document as a fresh, independent image object. */
+    T.embedImage = function (pdfDoc, src) {
+        return T.imageBytesFor(src).then(function (r) {
+            return r.isPng ? pdfDoc.embedPng(r.bytes) : pdfDoc.embedJpg(r.bytes);
+        });
+    };
+
+    /* ── Toast ──────────────────────────────────────────────────────── */
+
+    /* A pill at the bottom centre of the stage. Always fades itself out. */
+    T.toast = function (html, isError) {
+        var el = document.getElementById('ptToast');
+        if (!el) { return; }
+        el.innerHTML = '<div class="pe-pill' + (isError ? ' err' : '') + '">' + html + '</div>';
+        el.classList.add('show');
+        clearTimeout(el._t);
+        el._t = setTimeout(function () { el.classList.remove('show'); }, isError ? 6000 : 4000);
+    };
+
+    T.hideToast = function () {
+        var el = document.getElementById('ptToast');
+        if (el) {
+            clearTimeout(el._t);
+            el.classList.remove('show');
+        }
+    };
+
+    /* ── Dropdown menu ──────────────────────────────────────────────── */
+
+    /*
+        PDFTool.menu(anchorEl, [{label, icon, onClick, disabled}, {separator:true}])
+        Positions itself under the anchor inside .pe-root and closes on outside
+        click or Escape.
+    */
+    T.menu = function (anchor, items) {
+        var root = document.getElementById('peRoot');
+        var existing = root.querySelector('.pe-menu');
+        if (existing) { existing.parentNode.removeChild(existing); }
+
+        var menu = document.createElement('div');
+        menu.className = 'pe-menu';
+
+        items.forEach(function (it) {
+            if (it.separator) {
+                var hr = document.createElement('div');
+                hr.className = 'pe-menu-sep';
+                menu.appendChild(hr);
+                return;
+            }
+            var b = document.createElement('button');
+            b.className = 'pe-menu-item';
+            b.disabled = !!it.disabled;
+            b.innerHTML = '<span class="pe-menu-ico">' + (it.icon || '') + '</span><span>' + T.escHtml(it.label) + '</span>';
+            b.addEventListener('click', function () {
+                close();
+                if (it.onClick) { it.onClick(); }
+            });
+            menu.appendChild(b);
+        });
+
+        function close() {
+            if (menu.parentNode) { menu.parentNode.removeChild(menu); }
+            document.removeEventListener('mousedown', onDoc, true);
+            document.removeEventListener('keydown', onKey);
+        }
+        function onDoc(e) {
+            if (!menu.contains(e.target) && !anchor.contains(e.target)) { close(); }
+        }
+        function onKey(e) { if (e.key === 'Escape') { close(); } }
+
+        root.appendChild(menu);
+
+        // Position under the anchor, clamped to the app's right edge
+        var a = anchor.getBoundingClientRect();
+        var r = root.getBoundingClientRect();
+        var left = a.left - r.left;
+        menu.style.top = (a.bottom - r.top + 6) + 'px';
+        menu.style.left = Math.max(6, Math.min(left, r.width - menu.offsetWidth - 6)) + 'px';
+
+        setTimeout(function () {
+            document.addEventListener('mousedown', onDoc, true);
+            document.addEventListener('keydown', onKey);
+        }, 0);
+
+        return { close: close };
+    };
+
+    /* ── Modal ──────────────────────────────────────────────────────── */
+
+    /*
+        PDFTool.modal({
+            title:      "Dialog title",
+            bodyHtml:   "<p>…</p>",
+            actions:    [{label:"Save", primary:true, onClick:function(ctx){…}}],
+            dismissible:false,          // disables backdrop click + Escape
+            onOpen:     function(ctx){} // ctx = {body, footer, close}
+        })
+    */
+    T.modal = function (opts) {
+        var back = document.createElement('div');
+        back.className = 'pt-modal-back';
+        back.innerHTML =
+            '<div class="pt-modal" role="dialog" aria-modal="true">' +
+                '<div class="pt-modal-hd">' +
+                    '<span>' + T.escHtml(opts.title || '') + '</span>' +
+                    '<button class="pt-modal-x" title="Close" aria-label="Close">' +
+                        '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">' +
+                        '<line x1="1.5" y1="1.5" x2="10.5" y2="10.5"/><line x1="10.5" y1="1.5" x2="1.5" y2="10.5"/></svg>' +
+                    '</button>' +
+                '</div>' +
+                '<div class="pt-modal-bd"></div>' +
+                '<div class="pt-modal-ft"></div>' +
+            '</div>';
+
+        var body = back.querySelector('.pt-modal-bd');
+        var foot = back.querySelector('.pt-modal-ft');
+        if (opts.bodyHtml) { body.innerHTML = opts.bodyHtml; }
+
+        function close() {
+            if (back.parentNode) { back.parentNode.removeChild(back); }
+            document.removeEventListener('keydown', onKey);
+        }
+
+        function onKey(e) {
+            if (e.key === 'Escape' && opts.dismissible !== false) { close(); }
+        }
+
+        var ctx = { body: body, footer: foot, close: close };
+
+        (opts.actions || []).forEach(function (a) {
+            var b = document.createElement('button');
+            b.className = 'pt-btn' + (a.primary ? ' primary' : '');
+            b.textContent = a.label;
+            b.addEventListener('click', function () {
+                if (a.onClick) { a.onClick({ body: body, footer: foot, close: close, button: b }); }
+            });
+            foot.appendChild(b);
+            if (a.id) { b.id = a.id; }
+        });
+
+        back.querySelector('.pt-modal-x').addEventListener('click', close);
+        back.addEventListener('mousedown', function (e) {
+            if (e.target === back && opts.dismissible !== false) { close(); }
+        });
+        document.addEventListener('keydown', onKey);
+
+        document.body.appendChild(back);
+        if (opts.onOpen) { opts.onOpen(ctx); }
+        return ctx;
+    };
+
+    /* ── Small inline icons ─────────────────────────────────────────── */
+
+    T.svg = {
+        up: '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 8.5 L6.5 4 L10.5 8.5"/></svg>',
+        down: '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4.5 L6.5 9 L10.5 4.5"/></svg>',
+        x: '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="2" y1="2" x2="11" y2="11"/><line x1="11" y1="2" x2="2" y2="11"/></svg>'
+    };
+
+})(window.PDFTool);

+ 359 - 0
src/web/PDF Tool/js/convert.js

@@ -0,0 +1,359 @@
+/*
+    PDF Tool — conversion dialogs
+
+    "Export Images"  : renders the open document's pages to JPEG/PNG, including
+                       any overlays currently placed (what you see is what you get).
+    "From Images"    : builds a brand new PDF out of a list of images via pdf-lib,
+                       either onto page-sized "base plates" or pages matching each
+                       image. The result can be opened straight in the editor
+                       without ever touching the server.
+*/
+
+(function (T) {
+    'use strict';
+
+    var FONT = "'Helvetica Neue', Arial, sans-serif";
+
+    /* Page sizes in PDF points (72 per inch) */
+    var PAGE_PRESETS = {
+        auto: null,
+        a4p: [595.28, 841.89],
+        a4l: [841.89, 595.28],
+        a3p: [841.89, 1190.55],
+        a3l: [1190.55, 841.89],
+        letterp: [612, 792],
+        letterl: [792, 612],
+        legalp: [612, 1008]
+    };
+
+    /* Draw the editor's overlays onto an export canvas at its own resolution. */
+    function drawOverlays(cx, overlays, W, H, imgMap) {
+        (overlays || []).forEach(function (o) {
+            if (o.type === 'text') {
+                var fontPx = o.fontFrac * H;
+                cx.fillStyle = o.color || '#000';
+                cx.textBaseline = 'top';
+                cx.font = fontPx + 'px ' + FONT;
+                String(o.t).split('\n').forEach(function (ln, i) {
+                    cx.fillText(ln, o.fx * W, o.fy * H + i * fontPx * 1.25);
+                });
+            } else if (o.type === 'image' && imgMap[o.src]) {
+                cx.drawImage(imgMap[o.src], o.fx * W, o.fy * H, o.fwFrac * W, o.fhFrac * H);
+            }
+        });
+    }
+
+    function preloadOverlayImages(overlaysByPage) {
+        var srcs = {};
+        Object.keys(overlaysByPage || {}).forEach(function (k) {
+            (overlaysByPage[k] || []).forEach(function (o) {
+                if (o.type === 'image') { srcs[o.src] = 1; }
+            });
+        });
+        var map = {};
+        return Promise.all(Object.keys(srcs).map(function (src) {
+            return T.loadImage(src).then(function (img) { map[src] = img; },
+                function () { /* skip an image that will not load */ });
+        })).then(function () { return map; });
+    }
+
+    /* ── Export pages as images ─────────────────────────────────────── */
+
+    T.openExportImages = function (doc) {
+        if (!doc || !doc.pdf) { return; }
+        if (!doc.savePath) {
+            T.toast('Save this document first so the images have somewhere to go.', true);
+            return;
+        }
+
+        var dlg = T.modal({
+            title: 'Export Pages as Images',
+            bodyHtml:
+                '<div class="pt-field">' +
+                    '<span class="pt-label">Format</span>' +
+                    '<div class="pt-radios">' +
+                        '<label><input type="radio" name="exFmt" value="jpg" checked> JPEG</label>' +
+                        '<label><input type="radio" name="exFmt" value="png"> PNG</label>' +
+                    '</div>' +
+                '</div>' +
+                '<div class="pt-field" id="exQField">' +
+                    '<span class="pt-label">JPEG quality: <span id="exQVal">90</span>%</span>' +
+                    '<input type="range" id="exQ" min="50" max="100" value="90" style="width:100%">' +
+                '</div>' +
+                '<div class="pt-field">' +
+                    '<span class="pt-label">Resolution</span>' +
+                    '<select class="pt-select" id="exScale">' +
+                        '<option value="1">Standard (1x)</option>' +
+                        '<option value="2" selected>High (2x)</option>' +
+                        '<option value="3">Very high (3x)</option>' +
+                    '</select>' +
+                '</div>' +
+                '<p class="pt-hint">Saved next to the document. Text, images and chops you have placed are included.</p>' +
+                '<div id="exProg"></div>',
+            actions: [
+                { label: 'Cancel', onClick: function (c) { c.close(); } },
+                { label: 'Export', primary: true, onClick: run }
+            ]
+        });
+
+        var qField = dlg.body.querySelector('#exQField');
+        var qRange = dlg.body.querySelector('#exQ');
+        qRange.addEventListener('input', function () {
+            dlg.body.querySelector('#exQVal').textContent = this.value;
+        });
+        Array.prototype.forEach.call(dlg.body.querySelectorAll('input[name=exFmt]'), function (r) {
+            r.addEventListener('change', function () {
+                qField.style.display = this.value === 'jpg' ? '' : 'none';
+            });
+        });
+
+        function run(ctx) {
+            var fmt = dlg.body.querySelector('input[name=exFmt]:checked').value;
+            var quality = parseInt(qRange.value, 10) / 100;
+            var scale = parseFloat(dlg.body.querySelector('#exScale').value);
+            var mime = fmt === 'png' ? 'image/png' : 'image/jpeg';
+            var ext = fmt === 'png' ? '.png' : '.jpg';
+            var outDir = T.dirOf(doc.savePath);
+            var base = T.basenameNoExt(doc.savePath);
+            var prog = dlg.body.querySelector('#exProg');
+
+            ctx.button.disabled = true;
+            prog.innerHTML = '<div class="pt-hint">Preparing…</div>';
+
+            preloadOverlayImages(doc.overlays).then(function (imgMap) {
+                var n = doc.pdf.numPages;
+
+                function fail(err) {
+                    prog.innerHTML = '<div class="result-box result-err"><strong>Error:</strong> ' +
+                        T.escHtml(String(err && err.message ? err.message : err)) + '</div>';
+                    ctx.button.disabled = false;
+                }
+
+                function next(i) {
+                    if (i > n) {
+                        prog.innerHTML = '<div class="result-box result-ok"><strong>' + n +
+                            ' image' + (n !== 1 ? 's' : '') + '</strong> saved to ' + T.escHtml(outDir) + '</div>';
+                        ctx.button.disabled = false;
+                        return;
+                    }
+                    prog.innerHTML = '<div class="pt-hint">Rendering page ' + i + ' of ' + n + '…</div>';
+
+                    doc.pdf.getPage(i).then(function (page) {
+                        var vp = page.getViewport({ scale: scale });
+                        var c = document.createElement('canvas');
+                        c.width = vp.width;
+                        c.height = vp.height;
+                        var cx = c.getContext('2d');
+                        if (fmt !== 'png') {
+                            cx.fillStyle = '#FFF';
+                            cx.fillRect(0, 0, c.width, c.height);
+                        }
+                        page.render({ canvasContext: cx, viewport: vp }).promise.then(function () {
+                            drawOverlays(cx, doc.overlays[i], c.width, c.height, imgMap);
+                            c.toBlob(function (blob) {
+                                T.uploadBlob(blob, base + '_page' + i + ext, outDir)
+                                    .then(function () { next(i + 1); }, fail);
+                            }, mime, quality);
+                        }, fail);
+                    }, fail);
+                }
+
+                next(1);
+            });
+        }
+    };
+
+    /* ── Build a PDF from images ────────────────────────────────────── */
+
+    T.openImagesToPdf = function () {
+        var images = [];
+        var overrideDir = null;
+
+        var dlg = T.modal({
+            title: 'Create PDF from Images',
+            bodyHtml:
+                '<div class="pt-field">' +
+                    '<span class="pt-label">Images</span>' +
+                    '<div id="i2pList"></div>' +
+                    '<button class="pt-btn" id="i2pAdd" style="margin-top:8px">Add Images…</button>' +
+                '</div>' +
+                '<div class="pt-field">' +
+                    '<span class="pt-label">Page size</span>' +
+                    '<select class="pt-select" id="i2pSize">' +
+                        '<option value="auto" selected>Match each image</option>' +
+                        '<option value="a4p">A4 portrait</option>' +
+                        '<option value="a4l">A4 landscape</option>' +
+                        '<option value="a3p">A3 portrait</option>' +
+                        '<option value="a3l">A3 landscape</option>' +
+                        '<option value="letterp">Letter portrait</option>' +
+                        '<option value="letterl">Letter landscape</option>' +
+                        '<option value="legalp">Legal portrait</option>' +
+                    '</select>' +
+                    '<p class="pt-hint" style="margin:6px 0 0">Images are scaled to fit and centred on the page. ' +
+                    'Nothing is cropped and the aspect ratio is kept.</p>' +
+                '</div>' +
+                '<div class="pt-field">' +
+                    '<span class="pt-label">Output filename</span>' +
+                    '<input type="text" class="pt-input" id="i2pName" placeholder="output.pdf">' +
+                '</div>' +
+                '<div class="pt-field">' +
+                    '<span class="pt-label">Save to folder</span>' +
+                    '<div class="pt-row">' +
+                        '<button class="pt-btn" id="i2pDir">Choose…</button>' +
+                        '<span class="pt-path" id="i2pDirLbl">Same folder as the first image</span>' +
+                    '</div>' +
+                '</div>' +
+                '<div id="i2pProg"></div>',
+            actions: [
+                { label: 'Cancel', onClick: function (c) { c.close(); } },
+                { label: 'Open in Editor', onClick: openInEditor },
+                { label: 'Save to Server', primary: true, onClick: saveToServer }
+            ]
+        });
+
+        var listEl = dlg.body.querySelector('#i2pList');
+        var progEl = dlg.body.querySelector('#i2pProg');
+
+        function renderList() {
+            if (!images.length) {
+                listEl.innerHTML = '<div class="pt-empty-box">No images added yet.</div>';
+                return;
+            }
+            var html = '<div class="pt-imglist">';
+            images.forEach(function (img, idx) {
+                html += '<div class="pt-imgrow">' +
+                    '<img class="pt-thumb" src="' + T.mediaUrl(img.filepath) + '" alt="">' +
+                    '<span class="pt-imgname">' + T.escHtml(img.filename) + '</span>' +
+                    '<span class="pt-imgbtns">' +
+                        (idx > 0 ? '<button class="pt-iconbtn" data-mv="-1" data-i="' + idx + '" title="Move up">' + T.svg.up + '</button>' : '') +
+                        (idx < images.length - 1 ? '<button class="pt-iconbtn" data-mv="1" data-i="' + idx + '" title="Move down">' + T.svg.down + '</button>' : '') +
+                        '<button class="pt-iconbtn danger" data-rm="' + idx + '" title="Remove">' + T.svg.x + '</button>' +
+                    '</span>' +
+                '</div>';
+            });
+            listEl.innerHTML = html + '</div>';
+
+            Array.prototype.forEach.call(listEl.querySelectorAll('[data-mv]'), function (b) {
+                b.addEventListener('click', function () {
+                    var i = parseInt(this.getAttribute('data-i'), 10);
+                    var j = i + parseInt(this.getAttribute('data-mv'), 10);
+                    if (j < 0 || j >= images.length) { return; }
+                    var tmp = images[i]; images[i] = images[j]; images[j] = tmp;
+                    renderList();
+                });
+            });
+            Array.prototype.forEach.call(listEl.querySelectorAll('[data-rm]'), function (b) {
+                b.addEventListener('click', function () {
+                    images.splice(parseInt(this.getAttribute('data-rm'), 10), 1);
+                    renderList();
+                });
+            });
+        }
+
+        dlg.body.querySelector('#i2pAdd').addEventListener('click', function () {
+            ao_module_openFileSelector('_ptI2pAddCb', 'user:/', 'file', true,
+                { filter: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'] });
+        });
+        dlg.body.querySelector('#i2pDir').addEventListener('click', function () {
+            ao_module_openFileSelector('_ptI2pDirCb', 'user:/', 'folder', false);
+        });
+
+        window._ptI2pAddCb = function (files) {
+            if (!files || !files.length) { return; }
+            files.forEach(function (f) {
+                if (!images.some(function (x) { return x.filepath === f.filepath; })) {
+                    images.push({ filepath: f.filepath, filename: f.filename });
+                }
+            });
+            renderList();
+        };
+        window._ptI2pDirCb = function (files) {
+            if (!files || !files.length) { return; }
+            overrideDir = files[0].filepath;
+            dlg.body.querySelector('#i2pDirLbl').textContent = overrideDir;
+        };
+
+        function outputName() {
+            var name = dlg.body.querySelector('#i2pName').value.trim() || 'output';
+            return /\.pdf$/i.test(name) ? name : name + '.pdf';
+        }
+
+        /* Build the document in memory and hand back its bytes. */
+        function build(ctx) {
+            if (!images.length) {
+                progEl.innerHTML = '<div class="result-box result-err">Add at least one image first.</div>';
+                return Promise.reject(null);
+            }
+            var preset = PAGE_PRESETS[dlg.body.querySelector('#i2pSize').value] || null;
+
+            ctx.button.disabled = true;
+            progEl.innerHTML = '<div class="pt-hint">Loading pdf-lib…</div>';
+
+            return T.loadPdfLib().then(function () {
+                return PDFLib.PDFDocument.create();
+            }).then(function (pdfDoc) {
+                return images.reduce(function (chain, im, i) {
+                    return chain.then(function () {
+                        progEl.innerHTML = '<div class="pt-hint">Adding image ' + (i + 1) + ' of ' + images.length + '…</div>';
+                        return T.embedImage(pdfDoc, T.mediaUrl(im.filepath)).then(function (emb) {
+                            if (!preset) {
+                                // One page per image, exactly the image's own size
+                                var p = pdfDoc.addPage([emb.width, emb.height]);
+                                p.drawImage(emb, { x: 0, y: 0, width: emb.width, height: emb.height });
+                                return;
+                            }
+                            // Fixed base plate: scale to fit inside, keep aspect, centre it
+                            var pw = preset[0], ph = preset[1];
+                            var s = Math.min(pw / emb.width, ph / emb.height);
+                            var w = emb.width * s, h = emb.height * s;
+                            var page = pdfDoc.addPage([pw, ph]);
+                            page.drawImage(emb, { x: (pw - w) / 2, y: (ph - h) / 2, width: w, height: h });
+                        });
+                    });
+                }, Promise.resolve()).then(function () {
+                    progEl.innerHTML = '<div class="pt-hint">Writing PDF…</div>';
+                    return pdfDoc.save();
+                });
+            }).catch(function (err) {
+                if (err) {
+                    progEl.innerHTML = '<div class="result-box result-err"><strong>Error:</strong> ' +
+                        T.escHtml(String(err.message || err)) + '</div>';
+                }
+                ctx.button.disabled = false;
+                throw err;
+            });
+        }
+
+        /* Straight into the editor — never round-trips through the server */
+        function openInEditor(ctx) {
+            build(ctx).then(function (bytes) {
+                ctx.button.disabled = false;
+                ctx.close();
+                T.openBytes(bytes, outputName(), null);
+            }).catch(function () { /* message already shown */ });
+        }
+
+        function saveToServer(ctx) {
+            var name = outputName();
+            build(ctx).then(function (bytes) {
+                var outDir = overrideDir || T.dirOf(images[0].filepath);
+                progEl.innerHTML = '<div class="pt-hint">Uploading…</div>';
+                return T.uploadBlob(new Blob([bytes], { type: 'application/pdf' }), name, outDir)
+                    .then(function () {
+                        progEl.innerHTML = '<div class="result-box result-ok"><strong>' + T.escHtml(name) +
+                            '</strong> created in ' + T.escHtml(outDir) + '</div>';
+                        ctx.button.disabled = false;
+                    });
+            }).catch(function (err) {
+                if (err) {
+                    progEl.innerHTML = '<div class="result-box result-err"><strong>Error:</strong> ' +
+                        T.escHtml(String(err.message || err)) + '</div>';
+                }
+                ctx.button.disabled = false;
+            });
+        }
+
+        renderList();
+    };
+
+})(window.PDFTool);

+ 775 - 0
src/web/PDF Tool/js/editor.js

@@ -0,0 +1,775 @@
+/*
+    PDF Tool — editor
+
+    Renders the open document with pdf.js and lets the user place text, images
+    and chops on top. Saving re-opens the ORIGINAL bytes with pdf-lib and draws
+    the overlays as real PDF content, so pages keep their vector text and pages
+    without overlays are left untouched.
+
+    The pristine source bytes are held in memory for the life of the document, so
+    saving repeatedly (or overwriting the source file) never re-applies overlays
+    on top of an already-stamped copy.
+*/
+
+(function (T) {
+    'use strict';
+
+    var FONT = "'Helvetica Neue', Arial, sans-serif";
+    var TEXT_BASELINE_RATIO = 0.8; // top of a line to its baseline, as a fraction of font size
+
+    /*
+        doc.srcBytes — pristine original, never mutated
+        doc.savePath — where it lives on the server, or null for a document that
+                       was built in memory (From Images) and not saved yet
+    */
+    var doc = {
+        pdf: null, srcBytes: null, savePath: null, filename: '',
+        page: 1, num: 0, overlays: {}
+    };
+    T.doc = doc;
+
+    var zoom = 'fit'; // 'fit' or a numeric scale (1 = 100%)
+    var sel = null;
+    var resizeTimer = null;
+    var canvas, overlayEl, stageEl, propsEl, stampMenu, zoomEl, fileNameEl, rootEl;
+    var editButtons = [];
+
+    function $(id) { return document.getElementById(id); }
+
+    function enableEditing(on) {
+        editButtons.forEach(function (b) { b.disabled = !on; });
+    }
+
+    /* ── Selection ──────────────────────────────────────────────────── */
+
+    function selectOverlay(o, el) {
+        clearSelection();
+        sel = { o: o, el: el };
+        el.classList.add('selected');
+        if (o.type === 'text') {
+            propsEl.classList.add('show');
+            rootEl.classList.add('props-open'); // lifts the toast clear of the bar
+            var px = Math.round(o.fontFrac * canvas.height);
+            $('peFontSize').value = px;
+            $('peFontVal').textContent = px + 'px';
+            $('peColor').value = o.color || '#000000';
+        }
+    }
+
+    function clearSelection() {
+        if (sel) { sel.el.classList.remove('selected'); }
+        sel = null;
+        propsEl.classList.remove('show');
+        rootEl.classList.remove('props-open');
+    }
+
+    /* ── Overlay elements ───────────────────────────────────────────── */
+
+    function makeOverlayEl(o) {
+        var el = document.createElement('div');
+        el.className = 'pe-ov ' + (o.type === 'text' ? 'pe-text' : 'pe-img');
+        el.style.left = (o.fx * canvas.width) + 'px';
+        el.style.top = (o.fy * canvas.height) + 'px';
+
+        if (o.type === 'text') {
+            el.textContent = o.t;
+            el.style.fontSize = (o.fontFrac * canvas.height) + 'px';
+            el.style.color = o.color || '#000';
+            el.addEventListener('dblclick', function () {
+                el.setAttribute('contenteditable', 'true');
+                el.focus();
+            });
+            el.addEventListener('blur', function () {
+                el.removeAttribute('contenteditable');
+                o.t = el.textContent;
+                o.fx = el.offsetLeft / canvas.width;
+                o.fy = el.offsetTop / canvas.height;
+            });
+        } else {
+            el.style.width = (o.fwFrac * canvas.width) + 'px';
+            el.style.height = (o.fhFrac * canvas.height) + 'px';
+            var img = document.createElement('img');
+            img.src = o.src;
+            el.appendChild(img);
+            var handle = document.createElement('div');
+            handle.className = 'pe-handle';
+            el.appendChild(handle);
+            wireResize(handle, el, o);
+        }
+
+        var del = document.createElement('button');
+        del.className = 'pe-del';
+        del.innerHTML = '<svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round"><line x1="1" y1="1" x2="8" y2="8"/><line x1="8" y1="1" x2="1" y2="8"/></svg>';
+        del.addEventListener('mousedown', function (e) { e.stopPropagation(); });
+        del.addEventListener('click', function (e) {
+            e.stopPropagation();
+            var arr = doc.overlays[doc.page] || [];
+            var i = arr.indexOf(o);
+            if (i !== -1) { arr.splice(i, 1); }
+            clearSelection();
+            el.parentNode.removeChild(el);
+        });
+        el.appendChild(del);
+
+        wireDrag(el, o);
+        el.addEventListener('mousedown', function () { selectOverlay(o, el); });
+        return el;
+    }
+
+    function wireDrag(el, o) {
+        el.addEventListener('mousedown', function (e) {
+            if (el.getAttribute('contenteditable') === 'true') { return; }
+            if (e.target.classList.contains('pe-handle')) { return; }
+            e.preventDefault();
+            var startX = e.clientX, startY = e.clientY;
+            var baseL = el.offsetLeft, baseT = el.offsetTop;
+            function mv(ev) {
+                var nl = Math.max(0, Math.min(canvas.width - 6, baseL + (ev.clientX - startX)));
+                var nt = Math.max(0, Math.min(canvas.height - 6, baseT + (ev.clientY - startY)));
+                el.style.left = nl + 'px';
+                el.style.top = nt + 'px';
+                o.fx = nl / canvas.width;
+                o.fy = nt / canvas.height;
+            }
+            function up() {
+                document.removeEventListener('mousemove', mv);
+                document.removeEventListener('mouseup', up);
+            }
+            document.addEventListener('mousemove', mv);
+            document.addEventListener('mouseup', up);
+        });
+    }
+
+    function wireResize(handle, el, o) {
+        handle.addEventListener('mousedown', function (e) {
+            e.preventDefault();
+            e.stopPropagation();
+            var startX = e.clientX;
+            var startW = el.offsetWidth;
+            var aspect = el.offsetHeight / el.offsetWidth;
+            function mv(ev) {
+                var nw = Math.max(18, Math.min(canvas.width, startW + (ev.clientX - startX)));
+                var nh = nw * aspect;
+                el.style.width = nw + 'px';
+                el.style.height = nh + 'px';
+                o.fwFrac = nw / canvas.width;
+                o.fhFrac = nh / canvas.height;
+            }
+            function up() {
+                document.removeEventListener('mousemove', mv);
+                document.removeEventListener('mouseup', up);
+            }
+            document.addEventListener('mousemove', mv);
+            document.addEventListener('mouseup', up);
+        });
+    }
+
+    function addOverlay(o) {
+        if (!doc.overlays[doc.page]) { doc.overlays[doc.page] = []; }
+        doc.overlays[doc.page].push(o);
+        if (overlayEl) {
+            var el = makeOverlayEl(o);
+            overlayEl.appendChild(el);
+            selectOverlay(o, el);
+        }
+    }
+
+    /* ── Rendering (Fit fills the stage; a numeric zoom scrolls) ────── */
+
+    function pageScale(v1) {
+        if (zoom === 'fit') {
+            var availW = Math.max(80, stageEl.clientWidth - 28);
+            var availH = Math.max(80, stageEl.clientHeight - 28);
+            return Math.min(availW / v1.width, availH / v1.height, 4);
+        }
+        return zoom;
+    }
+
+    function renderPage(n) {
+        if (!doc.pdf) { return; }
+        clearSelection();
+        doc.pdf.getPage(n).then(function (page) {
+            var v1 = page.getViewport({ scale: 1 });
+            var vp = page.getViewport({ scale: pageScale(v1) });
+
+            stageEl.classList.toggle('zoomed', zoom !== 'fit');
+            stageEl.innerHTML = '';
+
+            var wrap = document.createElement('div');
+            wrap.className = 'pe-wrap';
+            wrap.style.width = vp.width + 'px';
+            wrap.style.height = vp.height + 'px';
+
+            canvas = document.createElement('canvas');
+            canvas.width = vp.width;
+            canvas.height = vp.height;
+            overlayEl = document.createElement('div');
+            overlayEl.className = 'pe-overlay';
+            wrap.appendChild(canvas);
+            wrap.appendChild(overlayEl);
+            stageEl.appendChild(wrap);
+
+            overlayEl.addEventListener('mousedown', function (e) {
+                if (e.target === overlayEl) { clearSelection(); }
+            });
+
+            page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise.then(function () {
+                (doc.overlays[n] || []).forEach(function (o) { overlayEl.appendChild(makeOverlayEl(o)); });
+            });
+
+            $('pePageLbl').textContent = n + ' / ' + doc.num;
+            $('pePrev').disabled = (n <= 1);
+            $('peNext').disabled = (n >= doc.num);
+        });
+    }
+
+    /* ── Opening ────────────────────────────────────────────────────── */
+
+    /*
+        Open a document from raw bytes. savePath is the file it came from, or null
+        when it only exists in memory (built by "From Images" and not saved yet).
+    */
+    function openBytes(bytes, filename, savePath) {
+        doc.srcBytes = bytes;
+        doc.savePath = savePath || null;
+        doc.filename = filename;
+        doc.overlays = {};
+        doc.page = 1;
+        zoom = 'fit';
+        zoomEl.value = 'fit';
+
+        fileNameEl.textContent = filename + (doc.savePath ? '' : ' (unsaved)');
+        fileNameEl.title = doc.savePath || filename + ' — not saved yet';
+        T.hideToast();
+        stageEl.classList.remove('zoomed');
+        stageEl.innerHTML = '<div class="pe-empty">Opening…</div>';
+        ao_module_setWindowTitle(filename + ' - PDF Tool');
+
+        return T.loadPdfJs().then(function () {
+            // pdf.js may transfer the buffer to its worker, so hand it a copy
+            return pdfjsLib.getDocument({ data: bytes.slice(0) }).promise;
+        }).then(function (pdf) {
+            doc.pdf = pdf;
+            doc.num = pdf.numPages;
+            doc.page = 1;
+            enableEditing(true);
+            renderPage(1);
+        }).catch(function (err) {
+            stageEl.innerHTML = '<div class="pe-empty">Could not open this PDF:<br>' +
+                T.escHtml(String(err && err.message ? err.message : err)) + '</div>';
+        });
+    }
+
+    T.openBytes = openBytes;
+
+    function openPdf(filepath, filename) {
+        stageEl.innerHTML = '<div class="pe-empty">Loading…</div>';
+        return T.fetchBytes(T.mediaUrl(filepath)).then(function (bytes) {
+            return openBytes(bytes, filename || filepath.split('/').pop(), filepath);
+        }).catch(function (err) {
+            stageEl.innerHTML = '<div class="pe-empty">Could not read this file:<br>' +
+                T.escHtml(String(err && err.message ? err.message : err)) + '</div>';
+        });
+    }
+
+    function placeImageFromSrc(src) {
+        if (!canvas) { return; }
+        T.loadImage(src).then(function (probe) {
+            var dispW = Math.min(150, canvas.width * 0.32);
+            var dispH = dispW * (probe.naturalHeight / probe.naturalWidth);
+            addOverlay({
+                type: 'image', src: src, fx: 0.4, fy: 0.4,
+                fwFrac: dispW / canvas.width, fhFrac: dispH / canvas.height
+            });
+        }).catch(function () {
+            T.toast('Could not load that image.', true);
+        });
+    }
+
+    /* ── Stamp picker ───────────────────────────────────────────────── */
+
+    function openStampMenu(anchor) {
+        T.Chops.load(function (chops) {
+            if (!chops.length) {
+                stampMenu.innerHTML = '<div class="pe-stamp-empty">No chops saved yet.<br>' +
+                    '<button class="pt-btn tiny" id="peStampManage" style="margin-top:8px">Open Chop Library</button></div>';
+            } else {
+                var html = '<div class="pe-stamp-grid">';
+                chops.forEach(function (c) {
+                    html += '<div class="pe-stamp-item" data-src="' + T.mediaUrl(c.path) + '">' +
+                        '<img src="' + T.mediaUrl(c.path) + '" alt=""><span>' + T.escHtml(c.name) + '</span></div>';
+                });
+                html += '</div><button class="pt-btn tiny" id="peStampManage" style="margin-top:9px;width:100%">Manage chops…</button>';
+                stampMenu.innerHTML = html;
+
+                Array.prototype.forEach.call(stampMenu.querySelectorAll('.pe-stamp-item'), function (it) {
+                    it.addEventListener('click', function () {
+                        placeImageFromSrc(this.getAttribute('data-src'));
+                        stampMenu.classList.remove('show');
+                    });
+                });
+            }
+            var manage = stampMenu.querySelector('#peStampManage');
+            if (manage) {
+                manage.addEventListener('click', function () {
+                    stampMenu.classList.remove('show');
+                    T.openChopManager();
+                });
+            }
+            stampMenu.style.left = anchor.offsetLeft + 'px';
+            stampMenu.style.top = (anchor.offsetTop + anchor.offsetHeight + 6) + 'px';
+            stampMenu.classList.add('show');
+        });
+    }
+
+    /* ── Building the edited document ───────────────────────────────── */
+
+    function rasteriseText(o, fontPt) {
+        var lines = String(o.t).split('\n');
+        var probe = document.createElement('canvas').getContext('2d');
+        probe.font = fontPt + 'px ' + FONT;
+        var wPt = 1;
+        lines.forEach(function (ln) { wPt = Math.max(wPt, probe.measureText(ln).width); });
+        var lineH = fontPt * 1.25;
+        var hPt = lines.length * lineH;
+        var k = 3; // supersample for crisp glyphs
+        var c = document.createElement('canvas');
+        c.width = Math.max(1, Math.ceil(wPt * k));
+        c.height = Math.max(1, Math.ceil(hPt * k));
+        var cx = c.getContext('2d');
+        cx.scale(k, k);
+        cx.fillStyle = o.color || '#000';
+        cx.textBaseline = 'top';
+        cx.font = fontPt + 'px ' + FONT;
+        lines.forEach(function (ln, i) { cx.fillText(ln, 0, i * lineH); });
+        return { canvas: c, wPt: wPt, hPt: hPt };
+    }
+
+    function hexToRgb(hex) {
+        hex = (hex || '#000000').replace('#', '');
+        if (hex.length === 3) { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; }
+        return {
+            r: parseInt(hex.substr(0, 2), 16) / 255,
+            g: parseInt(hex.substr(2, 2), 16) / 255,
+            b: parseInt(hex.substr(4, 2), 16) / 255
+        };
+    }
+
+    /*
+        Apply every overlay to a copy of the original document and return the bytes.
+        Each image placement is embedded as its OWN PDF image object inside its own
+        graphics state, so overlapping images stay independent objects in the output
+        instead of being flattened together.
+    */
+    function buildEditedBytes(status) {
+        var byteCache = {}; // src -> Promise<{bytes,isPng}>, avoids re-decoding one source
+
+        function embedFresh(pdfDoc, src) {
+            if (!byteCache[src]) { byteCache[src] = T.imageBytesFor(src); }
+            return byteCache[src].then(function (r) {
+                return r.isPng ? pdfDoc.embedPng(r.bytes) : pdfDoc.embedJpg(r.bytes);
+            });
+        }
+
+        return T.loadPdfLib().then(function () {
+            return PDFLib.PDFDocument.load(doc.srcBytes.slice(0), { ignoreEncryption: true });
+        }).then(function (pdfDoc) {
+            var degrees = PDFLib.degrees, rgb = PDFLib.rgb;
+            return pdfDoc.embedFont(PDFLib.StandardFonts.Helvetica).then(function (helv) {
+                var libPages = pdfDoc.getPages();
+                var pageKeys = Object.keys(doc.overlays).filter(function (k) {
+                    return (doc.overlays[k] || []).length;
+                });
+
+                // Walk only the pages that actually carry overlays, in order.
+                return pageKeys.reduce(function (chain, k) {
+                    return chain.then(function () {
+                        var pn = parseInt(k, 10);
+                        var libPage = libPages[pn - 1];
+                        if (!libPage) { return; }
+                        if (status) { status('Writing page ' + pn + '…'); }
+
+                        return doc.pdf.getPage(pn).then(function (jsPage) {
+                            var vp = jsPage.getViewport({ scale: 1 });
+                            var rot = vp.rotation || 0;
+
+                            return (doc.overlays[pn] || []).reduce(function (c2, o) {
+                                return c2.then(function () {
+                                    var dx = o.fx * vp.width, dy = o.fy * vp.height;
+
+                                    if (o.type === 'image') {
+                                        var ew = o.fwFrac * vp.width, eh = o.fhFrac * vp.height;
+                                        var bl = vp.convertToPdfPoint(dx, dy + eh);
+                                        // Fresh embed per placement keeps each one a separate layer
+                                        return embedFresh(pdfDoc, o.src).then(function (img) {
+                                            libPage.pushOperators(PDFLib.pushGraphicsState());
+                                            libPage.drawImage(img, {
+                                                x: bl[0], y: bl[1], width: ew, height: eh, rotate: degrees(rot)
+                                            });
+                                            libPage.pushOperators(PDFLib.popGraphicsState());
+                                        });
+                                    }
+
+                                    var fontPt = o.fontFrac * vp.height;
+                                    var col = hexToRgb(o.color);
+                                    var canEncode = true;
+                                    try { helv.encodeText(o.t); } catch (e) { canEncode = false; }
+
+                                    if (canEncode) {
+                                        libPage.pushOperators(PDFLib.pushGraphicsState());
+                                        String(o.t).split('\n').forEach(function (ln, i) {
+                                            var topY = dy + TEXT_BASELINE_RATIO * fontPt + i * fontPt * 1.25;
+                                            var pt = vp.convertToPdfPoint(dx, topY);
+                                            libPage.drawText(ln, {
+                                                x: pt[0], y: pt[1], size: fontPt, font: helv,
+                                                color: rgb(col.r, col.g, col.b), rotate: degrees(rot)
+                                            });
+                                        });
+                                        libPage.pushOperators(PDFLib.popGraphicsState());
+                                        return;
+                                    }
+
+                                    // Glyphs the standard font cannot encode (e.g. CJK):
+                                    // rasterise just this text box and place it as an image.
+                                    var rt = rasteriseText(o, fontPt);
+                                    var blt = vp.convertToPdfPoint(dx, dy + rt.hPt);
+                                    return T.canvasToBytes(rt.canvas, 'image/png')
+                                        .then(function (png) { return pdfDoc.embedPng(png); })
+                                        .then(function (img) {
+                                            libPage.pushOperators(PDFLib.pushGraphicsState());
+                                            libPage.drawImage(img, {
+                                                x: blt[0], y: blt[1], width: rt.wPt, height: rt.hPt, rotate: degrees(rot)
+                                            });
+                                            libPage.pushOperators(PDFLib.popGraphicsState());
+                                        });
+                                });
+                            }, Promise.resolve());
+                        });
+                    });
+                }, Promise.resolve()).then(function () { return pdfDoc.save(); });
+            });
+        });
+    }
+
+    /* ── Saving ─────────────────────────────────────────────────────── */
+
+    function writeTo(dir, name, onFinished) {
+        var btn = $('peSave');
+        var label = btn.innerHTML;
+        btn.disabled = true;
+
+        function status(t) { btn.textContent = t; }
+        function done() { btn.innerHTML = label; btn.disabled = false; }
+
+        status('Saving…');
+        buildEditedBytes(status).then(function (outBytes) {
+            status('Uploading…');
+            return T.uploadBlob(new Blob([outBytes], { type: 'application/pdf' }), name, dir);
+        }).then(function () {
+            var full = dir.replace(/\/$/, '') + '/' + name;
+            // The in-memory original stays pristine, so overlays remain editable and
+            // are never applied twice — even when we just overwrote the source file.
+            doc.savePath = full;
+            doc.filename = name;
+            fileNameEl.textContent = name;
+            fileNameEl.title = full;
+            ao_module_setWindowTitle(name + ' - PDF Tool');
+            T.toast('<strong>' + T.escHtml(name) + '</strong> saved to ' + T.escHtml(dir) + '.', false);
+            done();
+            if (onFinished) { onFinished(); }
+        }).catch(function (err) {
+            T.toast('<strong>Error:</strong> ' + T.escHtml(String(err && err.message ? err.message : err)), true);
+            done();
+        });
+    }
+
+    /*
+        Build the edited document and hand it to the browser's downloader, so it
+        lands on the user's own device without going through the server at all.
+    */
+    function downloadPdf() {
+        if (!doc.pdf) { return; }
+        clearSelection();
+
+        var btn = $('peSave');
+        var label = btn.innerHTML;
+        btn.disabled = true;
+
+        function status(t) { btn.textContent = t; }
+        function done() { btn.innerHTML = label; btn.disabled = false; }
+
+        status('Preparing…');
+        buildEditedBytes(status).then(function (outBytes) {
+            var name = doc.filename || 'document.pdf';
+            if (!/\.pdf$/i.test(name)) { name += '.pdf'; }
+
+            var url = URL.createObjectURL(new Blob([outBytes], { type: 'application/pdf' }));
+            var a = document.createElement('a');
+            a.href = url;
+            a.download = name;
+            a.style.display = 'none';
+            document.body.appendChild(a);
+            a.click();
+            document.body.removeChild(a);
+            // Let the transfer start before the blob is released
+            setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
+
+            T.toast('<strong>' + T.escHtml(name) + '</strong> downloaded.', false);
+            done();
+        }).catch(function (err) {
+            T.toast('<strong>Error:</strong> ' + T.escHtml(String(err && err.message ? err.message : err)), true);
+            done();
+        });
+    }
+
+    function save() {
+        if (!doc.pdf) { return; }
+        clearSelection();
+
+        var canOverwrite = !!doc.savePath;
+        var actions = [{ label: 'Cancel', onClick: function (c) { c.close(); } }];
+
+        actions.push({
+            label: 'Save As…',
+            primary: !canOverwrite,
+            onClick: function (c) {
+                c.close();
+                var startDir = doc.savePath ? T.dirOf(doc.savePath) : 'user:/Desktop';
+                var defName = canOverwrite
+                    ? T.basenameNoExt(doc.filename) + '_edited.pdf'
+                    : doc.filename;
+                ao_module_openFileSelector('_ptSaveAsCb', startDir, 'new', false, { defaultName: defName });
+            }
+        });
+
+        if (canOverwrite) {
+            actions.push({
+                label: 'Overwrite',
+                primary: true,
+                onClick: function (c) {
+                    c.close();
+                    writeTo(T.dirOf(doc.savePath), doc.savePath.split('/').pop());
+                }
+            });
+        }
+
+        T.modal({
+            title: 'Save PDF',
+            bodyHtml: canOverwrite
+                ? '<p class="pt-hint">Overwrite the original file, or write the result to a new one.</p>' +
+                  '<span class="pt-label">Current file</span>' +
+                  '<div class="pt-pathbox">' + T.escHtml(doc.savePath) + '</div>'
+                : '<p class="pt-hint">This document is read only, use Save As to save a copy.</p>' +
+                  '<div class="pt-empty-box">Not saved yet</div>',
+            actions: actions
+        });
+    }
+
+    window._ptSaveAsCb = function (files) {
+        if (!files || !files.length) { return; }
+        var fp = files[0].filepath;
+        var name = files[0].filename || fp.split('/').pop();
+        if (!/\.pdf$/i.test(name)) {
+            name += '.pdf';
+            fp += '.pdf';
+        }
+        writeTo(T.dirOf(fp), name);
+    };
+
+    /* ── Boot ───────────────────────────────────────────────────────── */
+
+    function boot() {
+        rootEl = $('peRoot');
+        stageEl = $('peStage');
+        propsEl = $('peProps');
+        stampMenu = $('peStampMenu');
+        zoomEl = $('peZoom');
+        fileNameEl = $('peFileName');
+
+        editButtons = ['peAddText', 'peAddImg', 'peUpload', 'peAddStamp', 'peSave', 'peSaveMenu', 'peZoom']
+            .map(function (id) { return $(id); });
+
+        var ICON = {
+            server: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2" width="11" height="4" rx="1"/><rect x="1.5" y="8" width="11" height="4" rx="1"/><circle cx="4" cy="4" r="0.6" fill="currentColor"/><circle cx="4" cy="10" r="0.6" fill="currentColor"/></svg>',
+            device: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 9V2.5M4.3 5.2 7 2.5l2.7 2.7"/><path d="M2 9.5v1.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V9.5"/></svg>',
+            images: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="11" height="9" rx="1.3"/><circle cx="4.7" cy="5.5" r="1"/><path d="M1.5 10 L5 6.5 L7.5 8.5 L10 5.5 L12.5 9"/></svg>',
+            save: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 2h7.5L12 4.5V12H2z"/><path d="M4.5 2v3h4V2"/><rect x="4.5" y="7.5" width="5" height="3.5"/></svg>',
+            expt: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="11" height="9" rx="1.3"/><path d="M1.5 10 L5 6.5 L7.5 8.5 L10 5.5 L12.5 9"/><circle cx="4.7" cy="5.5" r="1"/></svg>',
+            download: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 2v6.5M4.3 5.8 7 8.5l2.7-2.7"/><path d="M2 10v1.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V10"/></svg>'
+        };
+
+        var openDeviceInput = $('peOpenDeviceInput');
+
+        $('peOpen').addEventListener('click', function () {
+            T.menu(this, [
+                {
+                    label: 'Open from server…', icon: ICON.server, onClick: function () {
+                        ao_module_openFileSelector('_ptPickPdf', 'user:/', 'file', false, { filter: ['pdf'] });
+                    }
+                },
+                {
+                    label: 'Open from this device…', icon: ICON.device, onClick: function () {
+                        openDeviceInput.value = '';
+                        openDeviceInput.click();
+                    }
+                },
+                { separator: true },
+                { label: 'Create from images…', icon: ICON.images, onClick: function () { T.openImagesToPdf(); } }
+            ]);
+        });
+
+        openDeviceInput.addEventListener('change', function () {
+            if (!this.files || !this.files.length) { return; }
+            var f = this.files[0];
+            f.arrayBuffer().then(function (buf) {
+                openBytes(new Uint8Array(buf), f.name, null);
+            });
+        });
+
+        $('peAddText').addEventListener('click', function () {
+            if (!canvas) { return; }
+            addOverlay({
+                type: 'text', t: 'Double-click to edit', fx: 0.12, fy: 0.12,
+                fontFrac: 24 / canvas.height, color: $('peColor').value || '#d0021b'
+            });
+        });
+
+        $('peAddImg').addEventListener('click', function () {
+            ao_module_openFileSelector('_ptPickImg', 'user:/', 'file', false,
+                { filter: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'] });
+        });
+
+        var uploadInput = $('peUploadInput');
+        $('peUpload').addEventListener('click', function () {
+            uploadInput.value = '';
+            uploadInput.click();
+        });
+        uploadInput.addEventListener('change', function () {
+            if (!this.files || !this.files.length) { return; }
+            var reader = new FileReader();
+            reader.onload = function (ev) { placeImageFromSrc(ev.target.result); };
+            reader.readAsDataURL(this.files[0]);
+        });
+
+        // Keep the button's own events off the document closer below, so clicking
+        // the icon (the event target is the inner <svg>) toggles rather than closes.
+        $('peAddStamp').addEventListener('mousedown', function (e) { e.stopPropagation(); });
+        $('peAddStamp').addEventListener('click', function (e) {
+            e.stopPropagation();
+            if (stampMenu.classList.contains('show')) {
+                stampMenu.classList.remove('show');
+                return;
+            }
+            openStampMenu(this);
+        });
+
+        document.addEventListener('mousedown', function (e) {
+            if (!stampMenu.contains(e.target)) { stampMenu.classList.remove('show'); }
+        });
+
+        $('pePrev').addEventListener('click', function () {
+            if (doc.page > 1) { doc.page--; renderPage(doc.page); }
+        });
+        $('peNext').addEventListener('click', function () {
+            if (doc.page < doc.num) { doc.page++; renderPage(doc.page); }
+        });
+
+        zoomEl.addEventListener('change', function () {
+            zoom = this.value === 'fit' ? 'fit' : parseFloat(this.value);
+            if (doc.pdf) { renderPage(doc.page); }
+        });
+
+        $('peFontSize').addEventListener('input', function () {
+            $('peFontVal').textContent = this.value + 'px';
+            if (sel && sel.o.type === 'text') {
+                sel.o.fontFrac = parseInt(this.value, 10) / canvas.height;
+                sel.el.style.fontSize = this.value + 'px';
+            }
+        });
+        $('peColor').addEventListener('input', function () {
+            if (sel && sel.o.type === 'text') {
+                sel.o.color = this.value;
+                sel.el.style.color = this.value;
+            }
+        });
+
+        $('peSave').addEventListener('click', save);
+        $('peSaveMenu').addEventListener('click', function () {
+            T.menu(this, [
+                { label: 'Save…', icon: ICON.save, onClick: save },
+                { label: 'Download to this device', icon: ICON.download, onClick: downloadPdf },
+                { separator: true },
+                {
+                    label: 'Export pages as images…', icon: ICON.expt,
+                    onClick: function () { T.openExportImages(doc); }
+                }
+            ]);
+        });
+
+        /* ── Drag and drop: File Manager items and OS files ── */
+        ['dragenter', 'dragover'].forEach(function (ev) {
+            stageEl.addEventListener(ev, function (e) {
+                e.preventDefault();
+                stageEl.classList.add('dragover');
+            });
+        });
+        stageEl.addEventListener('dragleave', function (e) {
+            if (e.relatedTarget && stageEl.contains(e.relatedTarget)) { return; }
+            stageEl.classList.remove('dragover');
+        });
+        stageEl.addEventListener('drop', function (e) {
+            e.preventDefault();
+            stageEl.classList.remove('dragover');
+
+            // Dropped from the OS
+            if (e.dataTransfer.files && e.dataTransfer.files.length) {
+                var f = e.dataTransfer.files[0];
+                if (!/\.pdf$/i.test(f.name)) { T.toast('Only PDF files can be opened here.', true); return; }
+                f.arrayBuffer().then(function (buf) {
+                    openBytes(new Uint8Array(buf), f.name, null);
+                });
+                return;
+            }
+
+            // Dropped from the ArozOS File Manager
+            var info = null;
+            try { info = ao_module_utils.getDropFileInfo(e); } catch (err) { info = null; }
+            if (info && info.length) {
+                if (!/\.pdf$/i.test(info[0].filename)) {
+                    T.toast('Only PDF files can be opened here.', true);
+                    return;
+                }
+                openPdf(info[0].filepath, info[0].filename);
+            }
+        });
+
+        // Re-fit on resize (Fit mode only)
+        new ResizeObserver(function () {
+            if (!doc.pdf || zoom !== 'fit') { return; }
+            clearTimeout(resizeTimer);
+            resizeTimer = setTimeout(function () { if (doc.pdf) { renderPage(doc.page); } }, 120);
+        }).observe(stageEl);
+
+        window._ptPickPdf = function (files) {
+            if (!files || !files.length) { return; }
+            openPdf(files[0].filepath, files[0].filename);
+        };
+        window._ptPickImg = function (files) {
+            if (!files || !files.length) { return; }
+            placeImageFromSrc(T.mediaUrl(files[0].filepath));
+        };
+
+        // Opened with a file (embedded mode or "open with")
+        var input = ao_module_loadInputFiles();
+        if (input && input.length) {
+            openPdf(input[0].filepath, input[0].filename);
+        }
+    }
+
+    if (document.readyState === 'loading') {
+        document.addEventListener('DOMContentLoaded', boot);
+    } else {
+        boot();
+    }
+
+})(window.PDFTool);

+ 0 - 0
src/web/Productivity/tools/lib/pdf-lib.min.js → src/web/PDF Tool/lib/pdf-lib.min.js


+ 0 - 1025
src/web/Productivity/embedded.html

@@ -1,1025 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title>Productivity</title>
-    <script src="../script/jquery.min.js"></script>
-    <script src="../script/ao_module.js"></script>
-    <style>
-        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-
-        html, body {
-            width: 100%; height: 100%;
-            overflow: hidden;
-            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
-            background: #F2F2F7;
-            color: #1C1C1E;
-        }
-
-        /* ── TOOLBAR ─────────────────────────────────────────── */
-        #toolbar {
-            position: fixed;
-            top: 0; left: 0; right: 0;
-            height: 44px;
-            display: flex;
-            align-items: center;
-            padding: 0 8px;
-            gap: 4px;
-            background: rgba(242,242,247,0.94);
-            backdrop-filter: blur(20px) saturate(1.8);
-            -webkit-backdrop-filter: blur(20px) saturate(1.8);
-            border-bottom: 1px solid rgba(0,0,0,0.1);
-            z-index: 500;
-            user-select: none;
-            -webkit-user-select: none;
-        }
-
-        .tb-group { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
-
-        .tb-btn {
-            width: 30px; height: 30px;
-            border: none; background: none;
-            border-radius: 7px;
-            cursor: pointer;
-            display: flex; align-items: center; justify-content: center;
-            color: #3C3C43;
-            transition: background 0.1s;
-            flex-shrink: 0;
-        }
-        .tb-btn:hover  { background: rgba(0,0,0,0.07); }
-        .tb-btn:active { background: rgba(0,0,0,0.13); }
-        .tb-btn.active { background: rgba(0,122,255,0.12); color: #007AFF; }
-        .tb-btn:disabled { opacity: 0.3; cursor: default; }
-        .tb-btn:disabled:hover { background: none; }
-
-        .tb-sep {
-            width: 1px; height: 18px;
-            background: rgba(0,0,0,0.14);
-            flex-shrink: 0; margin: 0 5px;
-        }
-
-        #tbCenter {
-            flex: 1;
-            display: flex; align-items: center; justify-content: center;
-            gap: 7px; min-width: 0;
-        }
-
-        #tbFileIcon { flex-shrink: 0; }
-
-        #tbFileName {
-            font-size: 13px; font-weight: 590;
-            color: #1C1C1E;
-            overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-        }
-
-        #tbZoomLabel {
-            font-size: 11.5px; font-weight: 500;
-            color: #6C6C70;
-            min-width: 38px; text-align: center;
-            font-variant-numeric: tabular-nums;
-        }
-
-        /* ── CONTENT ─────────────────────────────────────────── */
-        #contentWrap {
-            position: fixed;
-            top: 44px; left: 0; right: 0; bottom: 0;
-        }
-
-        /* Empty state */
-        #emptyState {
-            width: 100%; height: 100%;
-            display: flex; flex-direction: column;
-            align-items: center; justify-content: center;
-            gap: 12px; color: #8E8E93;
-        }
-        #emptyState svg { opacity: 0.45; }
-        #emptyState p  { font-size: 14px; font-weight: 500; }
-
-        /* ── iframe viewer (HTML + PDF) ──────────────────────── */
-        #iframeViewer {
-            display: none;
-            width: 100%; height: 100%;
-            border: none;
-            transform-origin: top left;
-        }
-
-        /* ── Image viewer ────────────────────────────────────── */
-        #imageViewer {
-            display: none;
-            width: 100%; height: 100%;
-            overflow: hidden;
-            background: #1C1C1E;
-            position: relative;
-        }
-
-        #imgStage {
-            position: absolute;
-            top: 0; left: 0;
-            width: 100%; height: 100%;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            cursor: grab;
-        }
-        #imgStage.grabbing { cursor: grabbing; }
-
-        #imgEl {
-            display: block;
-            max-width: 100%; max-height: 100%;
-            object-fit: contain;
-            transform-origin: center center;
-            pointer-events: none;
-            user-select: none;
-            -webkit-user-drag: none;
-            box-shadow: 0 12px 40px rgba(0,0,0,0.55);
-        }
-
-        #imgHint {
-            position: absolute;
-            bottom: 14px; left: 50%;
-            transform: translateX(-50%);
-            background: rgba(0,0,0,0.6);
-            color: white; font-size: 12px;
-            padding: 5px 14px; border-radius: 20px;
-            backdrop-filter: blur(8px);
-            pointer-events: none;
-            opacity: 0;
-            transition: opacity 0.35s;
-            white-space: nowrap;
-        }
-
-        /* ── Code/text viewer ────────────────────────────────── */
-        #codeViewer {
-            display: none;
-            width: 100%; height: 100%;
-            overflow: auto;
-            background: #FAFAFA;
-        }
-
-        #codeTable {
-            border-collapse: collapse;
-            min-width: 100%;
-            font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, 'Courier New', monospace;
-            font-size: 12.5px;
-            line-height: 1.65;
-        }
-
-        .cl-num {
-            padding: 0 12px 0 16px;
-            text-align: right;
-            color: #B0B0B0;
-            border-right: 1px solid #E5E5EA;
-            user-select: none;
-            white-space: nowrap;
-            vertical-align: top;
-            background: #F5F5F7;
-            min-width: 52px;
-        }
-
-        .cl-code {
-            padding: 0 20px 0 14px;
-            white-space: pre;
-            vertical-align: top;
-            color: #1C1C1E;
-        }
-
-        #codeTable tr:first-child .cl-num,
-        #codeTable tr:first-child .cl-code { padding-top: 16px; }
-
-        #codeTable tr:last-child .cl-num,
-        #codeTable tr:last-child .cl-code  { padding-bottom: 16px; }
-
-        /* ── Markdown viewer ─────────────────────────────────── */
-        #mdViewer {
-            display: none;
-            width: 100%; height: 100%;
-            overflow: auto;
-            background: white;
-        }
-
-        #mdBody {
-            max-width: 820px;
-            margin: 0 auto;
-            padding: 44px 36px 72px;
-            font-size: 15px;
-            line-height: 1.78;
-            color: #24292E;
-        }
-
-        #mdBody h1,#mdBody h2,#mdBody h3,#mdBody h4,#mdBody h5,#mdBody h6 {
-            margin-top: 1.6em; margin-bottom: 0.55em;
-            line-height: 1.3; font-weight: 600;
-        }
-        #mdBody h1 { font-size: 2em;   padding-bottom: 0.3em;  border-bottom: 1.5px solid #E5E7EB; }
-        #mdBody h2 { font-size: 1.5em; padding-bottom: 0.2em;  border-bottom: 1px solid #E5E7EB; }
-        #mdBody h3 { font-size: 1.25em; }
-        #mdBody h4 { font-size: 1em; }
-        #mdBody p  { margin: 0.75em 0; }
-        #mdBody a  { color: #0366D6; text-decoration: none; }
-        #mdBody a:hover { text-decoration: underline; }
-        #mdBody strong { font-weight: 600; }
-        #mdBody em     { font-style: italic; }
-        #mdBody del    { color: #6A737D; }
-        #mdBody code {
-            font-family: 'SF Mono','Cascadia Code','Fira Code',Consolas,monospace;
-            font-size: 0.875em;
-            background: #F6F8FA; border: 1px solid #E1E4E8;
-            border-radius: 4px; padding: 0.1em 0.4em;
-        }
-        #mdBody pre {
-            background: #F6F8FA; border: 1px solid #E1E4E8;
-            border-radius: 8px; padding: 16px;
-            overflow-x: auto; margin: 1.1em 0;
-        }
-        #mdBody pre code { background: none; border: none; padding: 0; font-size: 0.875em; }
-        #mdBody blockquote {
-            border-left: 4px solid #DFE2E5;
-            padding: 0.35em 0 0.35em 1em;
-            color: #6A737D; margin: 1em 0;
-        }
-        #mdBody ul, #mdBody ol { padding-left: 2em; margin: 0.55em 0; }
-        #mdBody li { margin: 0.25em 0; }
-        #mdBody li > ul, #mdBody li > ol { margin: 0.15em 0; }
-        #mdBody hr { border: none; border-top: 1.5px solid #E5E7EB; margin: 1.6em 0; }
-        #mdBody img { max-width: 100%; border-radius: 6px; margin: 0.5em 0; }
-        #mdBody table { border-collapse: collapse; width: 100%; margin: 1.1em 0; }
-        #mdBody th, #mdBody td { border: 1px solid #DFE2E5; padding: 7px 14px; text-align: left; }
-        #mdBody th { background: #F6F8FA; font-weight: 600; }
-        #mdBody tr:nth-child(even) td { background: #FAFBFC; }
-
-        /* ── Info sidebar ────────────────────────────────────── */
-        #infoSidebar {
-            position: fixed;
-            top: 44px; right: 0;
-            width: 260px; height: calc(100% - 44px);
-            background: rgba(242,242,247,0.97);
-            backdrop-filter: blur(20px);
-            -webkit-backdrop-filter: blur(20px);
-            border-left: 1px solid rgba(0,0,0,0.1);
-            transform: translateX(100%);
-            transition: transform 0.22s cubic-bezier(0.25,0.1,0.25,1);
-            overflow-y: auto; padding: 16px; z-index: 400;
-        }
-        #infoSidebar.open { transform: translateX(0); }
-
-        .info-hdr {
-            display: flex; align-items: center;
-            justify-content: space-between;
-            margin-bottom: 18px;
-        }
-        .info-hdr strong { font-size: 14px; font-weight: 600; }
-
-        .info-group { margin-bottom: 18px; }
-        .info-group-title {
-            font-size: 10.5px; font-weight: 700;
-            letter-spacing: 0.7px; text-transform: uppercase;
-            color: #8E8E93; margin-bottom: 8px;
-        }
-        .info-row { margin-bottom: 9px; }
-        .info-key { font-size: 11px; color: #8E8E93; margin-bottom: 2px; }
-        .info-val { font-size: 12.5px; color: #1C1C1E; word-break: break-all; line-height: 1.45; }
-
-        /* ── Dark mode ───────────────────────────────────────── */
-        @media (prefers-color-scheme: dark) {
-            html, body { background: #1C1C1E; color: #F2F2F7; }
-
-            #toolbar { background: rgba(28,28,30,0.94); border-bottom-color: rgba(255,255,255,0.1); }
-            #tbFileName { color: #F2F2F7; }
-            .tb-btn { color: #EBEBF5; }
-            .tb-btn:hover  { background: rgba(255,255,255,0.1); }
-            .tb-btn:active { background: rgba(255,255,255,0.17); }
-            .tb-btn.active { background: rgba(10,132,255,0.2); color: #0A84FF; }
-            .tb-sep { background: rgba(255,255,255,0.14); }
-            #tbZoomLabel { color: #8E8E93; }
-
-            #emptyState { color: #48484A; }
-
-            #codeViewer { background: #1C1C1E; }
-            .cl-num  { background: #2C2C2E; border-right-color: #3A3A3C; color: #48484A; }
-            .cl-code { color: #E5E5EA; }
-
-            #mdViewer { background: #1C1C1E; }
-            #mdBody { color: #E5E5EA; }
-            #mdBody h1,#mdBody h2 { border-bottom-color: #38383A; }
-            #mdBody a { color: #4EA1F3; }
-            #mdBody code { background: #2C2C2E; border-color: #38383A; }
-            #mdBody pre { background: #2C2C2E; border-color: #38383A; }
-            #mdBody blockquote { border-left-color: #38383A; color: #8E8E93; }
-            #mdBody th, #mdBody td { border-color: #38383A; }
-            #mdBody th { background: #2C2C2E; }
-            #mdBody tr:nth-child(even) td { background: #252527; }
-            #mdBody hr { border-top-color: #38383A; }
-
-            #infoSidebar { background: rgba(28,28,30,0.97); border-left-color: rgba(255,255,255,0.1); }
-            .info-val { color: #E5E5EA; }
-        }
-
-        /* ArozOS theme override — applied by ao_module_onThemeChanged via data-theme attribute.
-           These rules win over the media query because the attribute selector adds specificity. */
-        html[data-theme="dark"] body { background: #1C1C1E; color: #F2F2F7; }
-        html[data-theme="dark"] #toolbar { background: rgba(28,28,30,0.94); border-bottom-color: rgba(255,255,255,0.1); }
-        html[data-theme="dark"] #tbFileName { color: #F2F2F7; }
-        html[data-theme="dark"] .tb-btn { color: #EBEBF5; }
-        html[data-theme="dark"] .tb-btn:hover  { background: rgba(255,255,255,0.1); }
-        html[data-theme="dark"] .tb-btn:active { background: rgba(255,255,255,0.17); }
-        html[data-theme="dark"] .tb-btn.active { background: rgba(10,132,255,0.2); color: #0A84FF; }
-        html[data-theme="dark"] .tb-sep { background: rgba(255,255,255,0.14); }
-        html[data-theme="dark"] #tbZoomLabel { color: #8E8E93; }
-        html[data-theme="dark"] #emptyState { color: #48484A; }
-        html[data-theme="dark"] #codeViewer { background: #1C1C1E; }
-        html[data-theme="dark"] .cl-num  { background: #2C2C2E; border-right-color: #3A3A3C; color: #48484A; }
-        html[data-theme="dark"] .cl-code { color: #E5E5EA; }
-        html[data-theme="dark"] #mdViewer { background: #1C1C1E; }
-        html[data-theme="dark"] #mdBody { color: #E5E5EA; }
-        html[data-theme="dark"] #mdBody h1, html[data-theme="dark"] #mdBody h2 { border-bottom-color: #38383A; }
-        html[data-theme="dark"] #mdBody a { color: #4EA1F3; }
-        html[data-theme="dark"] #mdBody code { background: #2C2C2E; border-color: #38383A; }
-        html[data-theme="dark"] #mdBody pre  { background: #2C2C2E; border-color: #38383A; }
-        html[data-theme="dark"] #mdBody blockquote { border-left-color: #38383A; color: #8E8E93; }
-        html[data-theme="dark"] #mdBody th, html[data-theme="dark"] #mdBody td { border-color: #38383A; }
-        html[data-theme="dark"] #mdBody th { background: #2C2C2E; }
-        html[data-theme="dark"] #mdBody tr:nth-child(even) td { background: #252527; }
-        html[data-theme="dark"] #mdBody hr { border-top-color: #38383A; }
-        html[data-theme="dark"] #infoSidebar { background: rgba(28,28,30,0.97); border-left-color: rgba(255,255,255,0.1); }
-        html[data-theme="dark"] .info-val { color: #E5E5EA; }
-
-        /* Force light mode even when the OS prefers dark */
-        html[data-theme="light"] body { background: #F2F2F7; color: #1C1C1E; }
-        html[data-theme="light"] #toolbar { background: rgba(242,242,247,0.94); border-bottom-color: rgba(0,0,0,0.1); }
-        html[data-theme="light"] #tbFileName { color: #1C1C1E; }
-        html[data-theme="light"] .tb-btn { color: #3C3C43; }
-        html[data-theme="light"] .tb-btn:hover  { background: rgba(0,0,0,0.07); }
-        html[data-theme="light"] .tb-btn:active { background: rgba(0,0,0,0.13); }
-        html[data-theme="light"] .tb-btn.active { background: rgba(0,122,255,0.12); color: #007AFF; }
-        html[data-theme="light"] .tb-sep { background: rgba(0,0,0,0.14); }
-        html[data-theme="light"] #tbZoomLabel { color: #6C6C70; }
-        html[data-theme="light"] #emptyState { color: #8E8E93; }
-        html[data-theme="light"] #codeViewer { background: #FFFFFF; }
-        html[data-theme="light"] .cl-num  { background: #F2F2F7; border-right-color: #D1D1D6; color: #8E8E93; }
-        html[data-theme="light"] .cl-code { color: #1C1C1E; }
-        html[data-theme="light"] #mdViewer { background: #FFFFFF; }
-        html[data-theme="light"] #mdBody { color: #1C1C1E; }
-        html[data-theme="light"] #infoSidebar { background: rgba(242,242,247,0.97); border-left-color: rgba(0,0,0,0.1); }
-        html[data-theme="light"] .info-val { color: #1C1C1E; }
-    </style>
-</head>
-<body>
-
-<!-- ═══════════════════════════════════════════════════════════
-     TOOLBAR
-══════════════════════════════════════════════════════════════ -->
-<div id="toolbar">
-
-    <!-- Left: file type badge -->
-    <div class="tb-group">
-        <div id="tbFileIcon" style="width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;letter-spacing:0.3px;color:white;background:#8E8E93;flex-shrink:0;">
-            FILE
-        </div>
-    </div>
-
-    <!-- Center: file name -->
-    <div id="tbCenter">
-        <span id="tbFileName">Preview</span>
-    </div>
-
-    <!-- Right: zoom + info -->
-    <div class="tb-group">
-        <div class="tb-sep"></div>
-
-        <!-- Zoom out -->
-        <button class="tb-btn" id="btnZoomOut" onclick="adjustZoom(-0.15)" title="Zoom Out (Ctrl –)" disabled>
-            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
-                <circle cx="6.5" cy="6.5" r="4.5"/>
-                <line x1="4" y1="6.5" x2="9" y2="6.5"/>
-                <line x1="10.2" y1="10.2" x2="14" y2="14"/>
-            </svg>
-        </button>
-
-        <span id="tbZoomLabel">—</span>
-
-        <!-- Zoom in -->
-        <button class="tb-btn" id="btnZoomIn" onclick="adjustZoom(0.15)" title="Zoom In (Ctrl +)" disabled>
-            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
-                <circle cx="6.5" cy="6.5" r="4.5"/>
-                <line x1="4" y1="6.5" x2="9" y2="6.5"/>
-                <line x1="6.5" y1="4" x2="6.5" y2="9"/>
-                <line x1="10.2" y1="10.2" x2="14" y2="14"/>
-            </svg>
-        </button>
-
-        <!-- Reset zoom -->
-        <button class="tb-btn" id="btnZoomReset" onclick="resetZoom()" title="Reset Zoom (Ctrl 0)" disabled>
-            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
-                <path d="M2.5 8 A5.5 5.5 0 1 1 8 13.5"/>
-                <polyline points="2.5,5.5 2.5,8 5,8"/>
-            </svg>
-        </button>
-
-        <!-- Open in Web Builder (HTML files only) -->
-        <button class="tb-btn" id="btnWebBuilder" onclick="openInWebBuilder()" title="Open in Web Builder" style="display:none">
-            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
-                <rect x="1" y="2" width="14" height="10" rx="1.3"/>
-                <line x1="1" y1="5.5" x2="15" y2="5.5"/>
-                <circle cx="3" cy="3.8" r="0.6" fill="currentColor" stroke="none"/>
-                <circle cx="5" cy="3.8" r="0.6" fill="currentColor" stroke="none"/>
-                <path d="M5.5 8.5 L4 10 L5.5 11.5"/>
-                <path d="M9.5 8.5 L11 10 L9.5 11.5"/>
-            </svg>
-        </button>
-        <div class="tb-sep" id="sepWebBuilder" style="display:none"></div>
-
-        <div class="tb-sep"></div>
-
-        <!-- Info toggle -->
-        <button class="tb-btn" id="btnInfo" onclick="toggleInfo()" title="File Info (Ctrl I)">
-            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
-                <circle cx="8" cy="8" r="6.5"/>
-                <line x1="8" y1="7.5" x2="8" y2="11.5"/>
-                <circle cx="8" cy="5" r="0.8" fill="currentColor" stroke="none"/>
-            </svg>
-        </button>
-    </div>
-</div>
-
-<!-- ═══════════════════════════════════════════════════════════
-     CONTENT
-══════════════════════════════════════════════════════════════ -->
-<div id="contentWrap">
-
-    <!-- Empty / error state -->
-    <div id="emptyState">
-        <svg width="64" height="64" viewBox="0 0 64 64" fill="none">
-            <rect x="10" y="5" width="36" height="46" rx="5" fill="currentColor" opacity="0.2"/>
-            <path d="M36 5 L46 15 L36 15 Z" fill="currentColor" opacity="0.25"/>
-            <rect x="15" y="24" width="26" height="3" rx="1.5" fill="currentColor" opacity="0.4"/>
-            <rect x="15" y="30" width="20" height="3" rx="1.5" fill="currentColor" opacity="0.3"/>
-            <rect x="15" y="36" width="23" height="3" rx="1.5" fill="currentColor" opacity="0.3"/>
-            <circle cx="44" cy="44" r="14" stroke="currentColor" stroke-width="3" opacity="0.35"/>
-            <line x1="53.5" y1="53.5" x2="61" y2="61" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
-        </svg>
-        <p id="emptyMsg">No file to preview</p>
-    </div>
-
-    <!-- iframe viewer: HTML and PDF -->
-    <iframe id="iframeViewer" title="File Preview" allowfullscreen></iframe>
-
-    <!-- Image viewer -->
-    <div id="imageViewer">
-        <div id="imgStage">
-            <img id="imgEl" alt="Preview image">
-        </div>
-        <div id="imgHint"></div>
-    </div>
-
-    <!-- Code / text viewer -->
-    <div id="codeViewer">
-        <table id="codeTable"><tbody id="codeTbody"></tbody></table>
-    </div>
-
-    <!-- Markdown viewer -->
-    <div id="mdViewer">
-        <div id="mdBody"></div>
-    </div>
-
-</div>
-
-<!-- ═══════════════════════════════════════════════════════════
-     INFO SIDEBAR
-══════════════════════════════════════════════════════════════ -->
-<div id="infoSidebar">
-    <div class="info-hdr">
-        <strong>File Info</strong>
-        <button class="tb-btn" onclick="toggleInfo()" style="color:#8E8E93">
-            <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
-                <line x1="1" y1="1" x2="13" y2="13"/>
-                <line x1="13" y1="1" x2="1" y2="13"/>
-            </svg>
-        </button>
-    </div>
-    <div class="info-group">
-        <div class="info-group-title">General</div>
-        <div class="info-row"><div class="info-key">Name</div><div class="info-val" id="infoName">—</div></div>
-        <div class="info-row"><div class="info-key">Type</div><div class="info-val" id="infoType">—</div></div>
-        <div class="info-row"><div class="info-key">Extension</div><div class="info-val" id="infoExt">—</div></div>
-    </div>
-    <div class="info-group">
-        <div class="info-group-title">Location</div>
-        <div class="info-row"><div class="info-key">Path</div><div class="info-val" id="infoPath">—</div></div>
-    </div>
-</div>
-
-<script>
-/* ═══════════════════════════════════════════════════════════════
-   FILE TYPE REGISTRY
-═══════════════════════════════════════════════════════════════ */
-var TYPE_MAP = {
-    html: ['html','htm'],
-    pdf:  ['pdf'],
-    image:['jpg','jpeg','png','gif','webp','svg','bmp','ico'],
-    markdown: ['md','markdown'],
-    code: [
-        'js','mjs','cjs','jsx','ts','tsx',
-        'py','rb','pl','r','go','rs','swift','kt','scala',
-        'java','c','h','cpp','hpp','cs','php',
-        'sh','bash','zsh','bat','cmd','ps1',
-        'json','yaml','yml','xml','css','scss','less',
-        'sql','lua','agi','dockerfile','makefile',
-        'gitignore','editorconfig','env',
-        'toml','ini','conf','cfg','log','txt'
-    ]
-};
-
-var TYPE_LABELS = {
-    html:'HTML', pdf:'PDF', image:'IMG',
-    markdown:'MD', code:'CODE'
-};
-
-var TYPE_COLORS = {
-    html: '#E44D26',
-    pdf:  '#FF3B30',
-    image:'#30B06E',
-    markdown: '#8B5CF6',
-    code: '#007AFF'
-};
-
-var TYPE_NAMES = {
-    html:'HTML Document', pdf:'PDF Document', image:'Image',
-    markdown:'Markdown Document', code:'Source File'
-};
-
-function getFileType(ext) {
-    ext = ext.toLowerCase().replace(/^\./, '');
-    for (var t in TYPE_MAP) {
-        if (TYPE_MAP[t].indexOf(ext) !== -1) return t;
-    }
-    return 'code';
-}
-
-/* ═══════════════════════════════════════════════════════════════
-   STATE
-═══════════════════════════════════════════════════════════════ */
-var currentFile = null;
-var currentType = null;
-var zoom = 1.0;
-
-// Image pan state
-var imgPan = { dragging: false, startX: 0, startY: 0, tx: 0, ty: 0, baseTx: 0, baseTy: 0 };
-
-/* ═══════════════════════════════════════════════════════════════
-   HELPERS
-═══════════════════════════════════════════════════════════════ */
-function escHtml(s) {
-    return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
-}
-
-function showHint(msg) {
-    var h = document.getElementById('imgHint');
-    h.textContent = msg;
-    h.style.opacity = '1';
-    clearTimeout(h._tid);
-    h._tid = setTimeout(function(){ h.style.opacity = '0'; }, 1600);
-}
-
-function setZoomEnabled(on) {
-    document.getElementById('btnZoomOut').disabled   = !on;
-    document.getElementById('btnZoomIn').disabled    = !on;
-    document.getElementById('btnZoomReset').disabled = !on;
-}
-
-/* ═══════════════════════════════════════════════════════════════
-   VIEWER SWITCHING
-═══════════════════════════════════════════════════════════════ */
-function showViewer(type) {
-    document.getElementById('emptyState').style.display    = 'none';
-    document.getElementById('iframeViewer').style.display  = 'none';
-    document.getElementById('imageViewer').style.display   = 'none';
-    document.getElementById('codeViewer').style.display    = 'none';
-    document.getElementById('mdViewer').style.display      = 'none';
-
-    if      (type === 'html' || type === 'pdf') document.getElementById('iframeViewer').style.display = 'block';
-    else if (type === 'image')                  document.getElementById('imageViewer').style.display  = 'block';
-    else if (type === 'code')                   document.getElementById('codeViewer').style.display   = 'block';
-    else if (type === 'markdown')               document.getElementById('mdViewer').style.display     = 'block';
-}
-
-/* ═══════════════════════════════════════════════════════════════
-   ZOOM
-═══════════════════════════════════════════════════════════════ */
-function adjustZoom(delta) {
-    if (!currentType) return;
-    zoom = Math.round(Math.max(0.1, Math.min(8.0, zoom + delta)) * 100) / 100;
-    applyZoom();
-    updateZoomLabel();
-}
-
-function resetZoom() {
-    zoom = 1.0;
-    if (currentType === 'image') {
-        imgPan.tx = 0; imgPan.ty = 0;
-        imgPan.baseTx = 0; imgPan.baseTy = 0;
-    }
-    applyZoom();
-    updateZoomLabel();
-}
-
-function applyZoom() {
-    if (currentType === 'image') {
-        document.getElementById('imgEl').style.transform =
-            'translate(' + imgPan.tx + 'px,' + imgPan.ty + 'px) scale(' + zoom + ')';
-    } else if (currentType === 'code') {
-        document.getElementById('codeTable').style.fontSize = (12.5 * zoom) + 'px';
-    } else if (currentType === 'markdown') {
-        document.getElementById('mdBody').style.fontSize = (15 * zoom) + 'px';
-    } else if (currentType === 'html') {
-        /* CSS zoom works in Chromium/WebKit; harmless elsewhere */
-        document.getElementById('iframeViewer').style.zoom = zoom;
-    }
-}
-
-function updateZoomLabel() {
-    document.getElementById('tbZoomLabel').textContent = Math.round(zoom * 100) + '%';
-}
-
-/* ═══════════════════════════════════════════════════════════════
-   IMAGE PAN
-═══════════════════════════════════════════════════════════════ */
-function initImagePan() {
-    var stage = document.getElementById('imgStage');
-
-    stage.addEventListener('mousedown', function(e) {
-        if (zoom <= 1.0) return;
-        e.preventDefault();
-        imgPan.dragging = true;
-        imgPan.startX = e.clientX;
-        imgPan.startY = e.clientY;
-        imgPan.baseTx = imgPan.tx;
-        imgPan.baseTy = imgPan.ty;
-        stage.classList.add('grabbing');
-    });
-
-    window.addEventListener('mousemove', function(e) {
-        if (!imgPan.dragging) return;
-        imgPan.tx = imgPan.baseTx + (e.clientX - imgPan.startX);
-        imgPan.ty = imgPan.baseTy + (e.clientY - imgPan.startY);
-        applyZoom();
-    });
-
-    window.addEventListener('mouseup', function() {
-        if (!imgPan.dragging) return;
-        imgPan.dragging = false;
-        stage.classList.remove('grabbing');
-    });
-
-    /* touch pan */
-    stage.addEventListener('touchstart', function(e) {
-        if (e.touches.length === 1 && zoom > 1.0) {
-            imgPan.dragging = true;
-            imgPan.startX = e.touches[0].clientX;
-            imgPan.startY = e.touches[0].clientY;
-            imgPan.baseTx = imgPan.tx;
-            imgPan.baseTy = imgPan.ty;
-        }
-    }, {passive: true});
-
-    stage.addEventListener('touchmove', function(e) {
-        if (!imgPan.dragging || e.touches.length !== 1) return;
-        imgPan.tx = imgPan.baseTx + (e.touches[0].clientX - imgPan.startX);
-        imgPan.ty = imgPan.baseTy + (e.touches[0].clientY - imgPan.startY);
-        applyZoom();
-    }, {passive: true});
-
-    stage.addEventListener('touchend', function() { imgPan.dragging = false; });
-}
-
-/* ═══════════════════════════════════════════════════════════════
-   MARKDOWN RENDERER
-═══════════════════════════════════════════════════════════════ */
-function renderMarkdown(raw) {
-    var codeBlocks = [];
-    var inlineCodes = [];
-
-    /* 1. Extract fenced code blocks */
-    raw = raw.replace(/```([^\n]*)\n([\s\S]*?)```/gm, function(_, lang, code) {
-        var i = codeBlocks.length;
-        codeBlocks.push('<pre><code>' + escHtml(code.replace(/\n$/, '')) + '</code></pre>');
-        return '\x01CB' + i + '\x01';
-    });
-
-    /* 2. Extract inline code */
-    raw = raw.replace(/`([^`]+)`/g, function(_, code) {
-        var i = inlineCodes.length;
-        inlineCodes.push('<code>' + escHtml(code) + '</code>');
-        return '\x01IC' + i + '\x01';
-    });
-
-    /* 3. Escape remaining HTML */
-    raw = raw.replace(/&(?![a-zA-Z#]\w*;)/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
-
-    /* 4. Block-level elements */
-    var lines = raw.split('\n');
-    var out = [];
-    var i = 0;
-
-    while (i < lines.length) {
-        var line = lines[i];
-
-        /* Headings */
-        var hm = line.match(/^(#{1,6})\s+(.*)/);
-        if (hm) {
-            var lvl = hm[1].length;
-            out.push('<h' + lvl + '>' + inlineMarkdown(hm[2]) + '</h' + lvl + '>');
-            i++; continue;
-        }
-
-        /* Horizontal rule */
-        if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) {
-            out.push('<hr>'); i++; continue;
-        }
-
-        /* Blockquote */
-        if (line.startsWith('&gt;')) {
-            var qlines = [];
-            while (i < lines.length && lines[i].startsWith('&gt;')) {
-                qlines.push(lines[i].replace(/^&gt;\s?/, ''));
-                i++;
-            }
-            out.push('<blockquote>' + inlineMarkdown(qlines.join('\n')) + '</blockquote>');
-            continue;
-        }
-
-        /* Unordered list */
-        if (/^[\*\-]\s/.test(line)) {
-            var items = [];
-            while (i < lines.length && /^[\*\-]\s/.test(lines[i])) {
-                items.push('<li>' + inlineMarkdown(lines[i].replace(/^[\*\-]\s/, '')) + '</li>');
-                i++;
-            }
-            out.push('<ul>' + items.join('') + '</ul>');
-            continue;
-        }
-
-        /* Ordered list */
-        if (/^\d+\.\s/.test(line)) {
-            var oitems = [];
-            while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
-                oitems.push('<li>' + inlineMarkdown(lines[i].replace(/^\d+\.\s/, '')) + '</li>');
-                i++;
-            }
-            out.push('<ol>' + oitems.join('') + '</ol>');
-            continue;
-        }
-
-        /* Table (pipe-separated) */
-        if (line.includes('|') && i + 1 < lines.length && /^\|?[\s\-:|]+\|/.test(lines[i+1])) {
-            var trows = [];
-            var headers = line.split('|').filter(function(c,idx,arr){ return idx > 0 || c.trim(); }).map(function(c){ return c.trim(); }).filter(Boolean);
-            trows.push('<thead><tr>' + headers.map(function(h){ return '<th>' + inlineMarkdown(h) + '</th>'; }).join('') + '</tr></thead>');
-            i += 2; /* skip separator */
-            var tbody = [];
-            while (i < lines.length && lines[i].includes('|')) {
-                var cells = lines[i].split('|').filter(function(c,idx,arr){ return idx > 0 || c.trim(); }).map(function(c){ return c.trim(); }).filter(Boolean);
-                tbody.push('<tr>' + cells.map(function(c){ return '<td>' + inlineMarkdown(c) + '</td>'; }).join('') + '</tr>');
-                i++;
-            }
-            out.push('<table><' + trows[0] + '<tbody>' + tbody.join('') + '</tbody></table>');
-            continue;
-        }
-
-        /* Blank line */
-        if (line.trim() === '') { out.push(''); i++; continue; }
-
-        /* Paragraph / inline text */
-        var para = [];
-        while (i < lines.length && lines[i].trim() !== '' &&
-               !/^(#{1,6}\s|[\*\-]\s|\d+\.\s|&gt;|(\*{3,}|-{3,}|_{3,})\s*$)/.test(lines[i]) &&
-               !lines[i].includes('\x01CB')) {
-            para.push(lines[i]);
-            i++;
-        }
-        if (para.length) out.push('<p>' + inlineMarkdown(para.join(' ')) + '</p>');
-    }
-
-    var html = out.join('\n');
-
-    /* Restore inline codes and code blocks */
-    inlineCodes.forEach(function(c, idx) { html = html.split('\x01IC' + idx + '\x01').join(c); });
-    codeBlocks.forEach(function(b, idx) { html = html.split('\x01CB' + idx + '\x01').join(b); });
-
-    return html;
-}
-
-function inlineMarkdown(s) {
-    /* images before links */
-    s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2">');
-    s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g,  '<a href="$2" target="_blank" rel="noopener">$1</a>');
-    s = s.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
-    s = s.replace(/\*\*(.+?)\*\*/g,     '<strong>$1</strong>');
-    s = s.replace(/__(.+?)__/g,         '<strong>$1</strong>');
-    s = s.replace(/\*(.+?)\*/g,         '<em>$1</em>');
-    s = s.replace(/_(.+?)_/g,           '<em>$1</em>');
-    s = s.replace(/~~(.+?)~~/g,         '<del>$1</del>');
-    /* restore inline code placeholders pass-through */
-    return s;
-}
-
-/* ═══════════════════════════════════════════════════════════════
-   LOAD FILE
-═══════════════════════════════════════════════════════════════ */
-function loadFile(file) {
-    currentFile = file;
-    var ext = file.filename.split('.').pop().toLowerCase();
-    currentType = getFileType(ext);
-
-    /* Toolbar badge */
-    var badge = document.getElementById('tbFileIcon');
-    badge.textContent  = TYPE_LABELS[currentType] || 'FILE';
-    badge.style.background = TYPE_COLORS[currentType] || '#8E8E93';
-    badge.style.fontSize   = currentType === 'markdown' ? '10px' : '11px';
-
-    document.getElementById('tbFileName').textContent = file.filename;
-    ao_module_setWindowTitle('Preview — ' + file.filename);
-
-    /* Info sidebar */
-    document.getElementById('infoName').textContent = file.filename;
-    document.getElementById('infoPath').textContent = file.filepath;
-    document.getElementById('infoExt').textContent  = '.' + ext.toUpperCase();
-    document.getElementById('infoType').textContent = TYPE_NAMES[currentType] || 'File';
-
-    /* Reset zoom */
-    zoom = 1.0;
-    imgPan.tx = 0; imgPan.ty = 0; imgPan.baseTx = 0; imgPan.baseTy = 0;
-    updateZoomLabel();
-
-    showViewer(currentType);
-
-    /* Web Builder button — only available for HTML files */
-    var _wbVisible = currentType === 'html';
-    document.getElementById('btnWebBuilder').style.display = _wbVisible ? '' : 'none';
-    document.getElementById('sepWebBuilder').style.display = _wbVisible ? '' : 'none';
-
-    /* Support data URLs for files dragged in from the OS desktop */
-    var fileUrl = file.dataUrl
-        ? file.dataUrl
-        : '../media?file=' + encodeURIComponent(file.filepath);
-
-    if (currentType === 'html') {
-        setZoomEnabled(true);
-        var fr = document.getElementById('iframeViewer');
-        fr.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
-        if (file.dataUrl) {
-            /* Use srcdoc so the sandboxed iframe renders the HTML directly */
-            fetch(file.dataUrl).then(function(r){ return r.text(); }).then(function(html){
-                fr.removeAttribute('src');
-                fr.srcdoc = html;
-            });
-        } else {
-            fr.removeAttribute('srcdoc');
-            fr.src = fileUrl;
-        }
-    } else if (currentType === 'pdf') {
-        setZoomEnabled(false);
-        var fr = document.getElementById('iframeViewer');
-        fr.removeAttribute('sandbox');
-        fr.removeAttribute('srcdoc');
-        fr.src = fileUrl;
-    } else if (currentType === 'image') {
-        setZoomEnabled(true);
-        document.getElementById('imgEl').src = fileUrl;
-    } else if (currentType === 'code') {
-        setZoomEnabled(true);
-        fetchAndRenderCode(fileUrl);
-    } else if (currentType === 'markdown') {
-        setZoomEnabled(true);
-        fetchAndRenderMarkdown(fileUrl);
-    }
-}
-
-function fetchAndRenderCode(url) {
-    var tbody = document.getElementById('codeTbody');
-    tbody.innerHTML = '<tr><td class="cl-num">…</td><td class="cl-code" style="color:#8E8E93">Loading…</td></tr>';
-
-    fetch(url)
-        .then(function(r) {
-            if (!r.ok) throw new Error('HTTP ' + r.status);
-            return r.text();
-        })
-        .then(function(text) {
-            var lines = text.split('\n');
-            /* Remove trailing empty line that split() adds */
-            if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
-            var rows = '';
-            for (var i = 0; i < lines.length; i++) {
-                rows += '<tr><td class="cl-num">' + (i + 1) + '</td>'
-                      + '<td class="cl-code">' + escHtml(lines[i]) + '</td></tr>';
-            }
-            tbody.innerHTML = rows;
-        })
-        .catch(function(e) {
-            tbody.innerHTML = '<tr><td class="cl-num">!</td><td class="cl-code" style="color:#FF3B30">Failed to load file: ' + escHtml(e.message) + '</td></tr>';
-        });
-}
-
-function fetchAndRenderMarkdown(url) {
-    var body = document.getElementById('mdBody');
-    body.innerHTML = '<p style="color:#8E8E93">Loading…</p>';
-
-    fetch(url)
-        .then(function(r) {
-            if (!r.ok) throw new Error('HTTP ' + r.status);
-            return r.text();
-        })
-        .then(function(text) {
-            body.innerHTML = renderMarkdown(text);
-        })
-        .catch(function(e) {
-            body.innerHTML = '<p style="color:#FF3B30">Failed to load file: ' + escHtml(e.message) + '</p>';
-        });
-}
-
-/* ═══════════════════════════════════════════════════════════════
-   INFO SIDEBAR
-═══════════════════════════════════════════════════════════════ */
-function toggleInfo() {
-    var sb = document.getElementById('infoSidebar');
-    sb.classList.toggle('open');
-    document.getElementById('btnInfo').classList.toggle('active', sb.classList.contains('open'));
-}
-
-function openInWebBuilder() {
-    if (!currentFile) return;
-    var hashData = {
-        tool: 'webbuilder',
-        file: { filename: currentFile.filename, filepath: currentFile.filepath }
-    };
-    ao_module_newfw({
-        url: 'Productivity/index.html#' + encodeURIComponent(JSON.stringify(hashData)),
-        width: 1080,
-        height: 580,
-        title: currentFile.filename + ' - Web Builder',
-        appicon: 'Productivity/img/module_icon.svg'
-    });
-}
-
-/* ═══════════════════════════════════════════════════════════════
-   EVENT WIRING
-═══════════════════════════════════════════════════════════════ */
-
-/* Ctrl/Cmd +/- zoom */
-document.addEventListener('keydown', function(e) {
-    var ctrl = e.ctrlKey || e.metaKey;
-    if (!ctrl) return;
-    if (e.key === '=' || e.key === '+') { e.preventDefault(); adjustZoom(0.15); }
-    else if (e.key === '-')             { e.preventDefault(); adjustZoom(-0.15); }
-    else if (e.key === '0')             { e.preventDefault(); resetZoom(); }
-    else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); toggleInfo(); }
-});
-
-/* Scroll-wheel zoom on image viewer */
-document.getElementById('imageViewer').addEventListener('wheel', function(e) {
-    e.preventDefault();
-    var delta = e.deltaY < 0 ? 0.12 : -0.12;
-    adjustZoom(delta);
-    showHint(Math.round(zoom * 100) + '%');
-}, { passive: false });
-
-/* Ctrl+scroll zoom on code viewer */
-document.getElementById('codeViewer').addEventListener('wheel', function(e) {
-    if (!(e.ctrlKey || e.metaKey)) return;
-    e.preventDefault();
-    adjustZoom(e.deltaY < 0 ? 0.12 : -0.12);
-}, { passive: false });
-
-/* Ctrl+scroll zoom on markdown viewer */
-document.getElementById('mdViewer').addEventListener('wheel', function(e) {
-    if (!(e.ctrlKey || e.metaKey)) return;
-    e.preventDefault();
-    adjustZoom(e.deltaY < 0 ? 0.12 : -0.12);
-}, { passive: false });
-
-/* ═══════════════════════════════════════════════════════════════
-   INIT
-═══════════════════════════════════════════════════════════════ */
-initImagePan();
-
-var inputFiles = ao_module_loadInputFiles();
-if (inputFiles && inputFiles.length > 0) {
-    loadFile(inputFiles[0]);
-} else {
-    document.getElementById('emptyMsg').textContent = 'No file was passed to Preview';
-    document.getElementById('emptyState').style.display = 'flex';
-}
-
-/* ── ArozOS system theme binding ── */
-function applyAozTheme(theme) {
-    document.documentElement.setAttribute('data-theme', theme === 'dark' ? 'dark' : 'light');
-}
-
-ao_module_onThemeChanged(applyAozTheme);
-
-$.get(ao_root + 'system/file_system/preference?key=file_explorer/theme', function(data) {
-    applyAozTheme(data === 'darkTheme' ? 'dark' : 'light');
-});
-</script>
-</body>
-</html>

+ 0 - 762
src/web/Productivity/index.html

@@ -1,762 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title>Productivity</title>
-    <script src="../script/jquery.min.js"></script>
-    <script src="../script/ao_module.js"></script>
-    <style>
-        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-
-        :root {
-            --sidebar-w: 172px;
-            --bg: #F2F2F7;
-            --sidebar-bg: #EFEFF4;
-            --text: #1C1C1E;
-            --sub: #6B6B70;
-            --accent: #007AFF;
-            --card-bg: #FFFFFF;
-            --border: #D1D1D6;
-            --inp-bg: #FFFFFF;
-            --progress-bg: #E5E5EA;
-            --danger: #FF3B30;
-        }
-
-        @media (prefers-color-scheme: dark) {
-            :root {
-                --bg: #1C1C1E;
-                --sidebar-bg: #2C2C2E;
-                --text: #F2F2F7;
-                --sub: #8E8E93;
-                --card-bg: #2C2C2E;
-                --border: #48484A;
-                --inp-bg: #3A3A3C;
-                --progress-bg: #3A3A3C;
-                --accent: #0A84FF;
-            }
-        }
-
-        /* ArozOS system theme override via data-theme attribute */
-        html[data-theme="dark"] {
-            --bg: #1C1C1E;
-            --sidebar-bg: #2C2C2E;
-            --text: #F2F2F7;
-            --sub: #8E8E93;
-            --card-bg: #2C2C2E;
-            --border: #48484A;
-            --inp-bg: #3A3A3C;
-            --progress-bg: #3A3A3C;
-            --accent: #0A84FF;
-        }
-
-        html[data-theme="light"] {
-            --bg: #F2F2F7;
-            --sidebar-bg: #EFEFF4;
-            --text: #1C1C1E;
-            --sub: #6B6B70;
-            --card-bg: #FFFFFF;
-            --border: #D1D1D6;
-            --inp-bg: #FFFFFF;
-            --progress-bg: #E5E5EA;
-            --accent: #007AFF;
-        }
-
-        html, body {
-            width: 100%; height: 100%;
-            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
-            background: var(--bg);
-            color: var(--text);
-            overflow: hidden;
-        }
-
-        #app { display: flex; width: 100%; height: 100%; }
-
-        /* ── Sidebar ── */
-        #sidebar {
-            width: var(--sidebar-w);
-            min-width: var(--sidebar-w);
-            height: 100%;
-            background: var(--sidebar-bg);
-            border-right: 1px solid var(--border);
-            display: flex;
-            flex-direction: column;
-            overflow: hidden;
-        }
-
-        #brand {
-            display: flex; align-items: center; gap: 9px;
-            padding: 14px 14px 12px;
-            border-bottom: 1px solid var(--border);
-            flex-shrink: 0;
-        }
-
-        #brand img { width: 28px; height: 28px; border-radius: 6px; }
-        #brand span { font-size: 14px; font-weight: 700; }
-
-        .cat-section { padding: 8px 0; flex: 1; overflow-y: auto; }
-
-        .cat-section-label {
-            font-size: 10px; font-weight: 700;
-            letter-spacing: 0.7px;
-            color: var(--sub);
-            text-transform: uppercase;
-            padding: 4px 14px 4px;
-        }
-
-        .cat-item {
-            display: flex; align-items: center; gap: 8px;
-            padding: 7px 14px;
-            border-radius: 7px;
-            margin: 1px 6px;
-            cursor: pointer;
-            font-size: 13px; font-weight: 500;
-            color: var(--text);
-            transition: background 0.1s;
-            -webkit-user-select: none; user-select: none;
-        }
-
-        .cat-item:hover { background: rgba(0,0,0,0.06); }
-
-        @media (prefers-color-scheme: dark) {
-            .cat-item:hover { background: rgba(255,255,255,0.07); }
-        }
-
-        .cat-item.active { background: var(--accent); color: #FFF; }
-        .cat-item svg { flex-shrink: 0; }
-
-        /* ── Main ── */
-        #main { flex: 1; height: 100%; overflow: hidden; position: relative; }
-
-        #toolGrid {
-            position: absolute; inset: 0;
-            padding: 20px;
-            overflow-y: auto;
-        }
-
-        .grid-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; }
-
-        .tools-grid {
-            display: grid;
-            grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
-            gap: 12px;
-        }
-
-        .tool-card {
-            background: var(--card-bg);
-            border: 1px solid var(--border);
-            border-radius: 12px;
-            padding: 16px;
-            cursor: pointer;
-            display: flex; flex-direction: column; gap: 9px;
-            transition: box-shadow 0.15s, transform 0.1s;
-        }
-
-        .tool-card:hover { box-shadow: 0 4px 14px rgba(0,0,0,0.1); transform: translateY(-1px); }
-        .tool-card:active { transform: scale(0.98); box-shadow: none; }
-
-        .tc-icon {
-            width: 42px; height: 42px;
-            border-radius: 10px;
-            display: flex; align-items: center; justify-content: center;
-        }
-
-        .tc-name { font-size: 13.5px; font-weight: 700; }
-        .tc-desc { font-size: 12px; color: var(--sub); line-height: 1.4; }
-
-        /* ── Tool panel ── */
-        #toolPanel {
-            position: absolute; inset: 0;
-            display: none;
-            flex-direction: column;
-        }
-
-        #toolPanel.open { display: flex; }
-
-        .tp-header {
-            display: flex; align-items: center; gap: 10px;
-            padding: 11px 16px;
-            border-bottom: 1px solid var(--border);
-            background: var(--sidebar-bg);
-            flex-shrink: 0;
-        }
-
-        .back-btn {
-            display: flex; align-items: center; gap: 4px;
-            font-size: 13px; font-weight: 500;
-            color: var(--accent);
-            cursor: pointer;
-            padding: 4px 8px;
-            border-radius: 6px;
-            border: none; background: none;
-            font-family: inherit;
-            transition: background 0.1s;
-        }
-
-        .back-btn:hover { background: rgba(0,122,255,0.1); }
-        .tp-name { font-size: 14px; font-weight: 700; }
-
-        .tp-body { flex: 1; overflow-y: auto; padding: 20px; }
-
-        /* ── Form ── */
-        .field { margin-bottom: 15px; }
-
-        .field-label {
-            display: block;
-            font-size: 11px; font-weight: 700;
-            color: var(--sub);
-            text-transform: uppercase; letter-spacing: 0.5px;
-            margin-bottom: 6px;
-        }
-
-        .row { display: flex; align-items: center; gap: 9px; }
-
-        .btn {
-            padding: 7px 14px;
-            border-radius: 8px; border: none;
-            font-size: 13px; font-weight: 600;
-            font-family: inherit;
-            cursor: pointer;
-            transition: opacity 0.12s;
-            display: inline-flex; align-items: center; gap: 5px;
-        }
-
-        .btn-primary { background: var(--accent); color: #FFF; }
-        .btn-primary:hover { opacity: 0.87; }
-
-        .btn-outline {
-            background: transparent;
-            color: var(--accent);
-            border: 1.5px solid var(--accent);
-        }
-
-        .btn-outline:hover { background: rgba(0,122,255,0.08); }
-
-        .btn-action {
-            padding: 9px 22px;
-            background: var(--accent); color: #FFF;
-            border-radius: 10px; border: none;
-            font-size: 14px; font-weight: 600;
-            font-family: inherit;
-            cursor: pointer;
-            transition: opacity 0.12s;
-        }
-
-        .btn-action:hover { opacity: 0.87; }
-        .btn-action:disabled { opacity: 0.42; cursor: not-allowed; }
-
-        .selected-file {
-            font-size: 13px; color: var(--sub);
-            overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-            flex: 1;
-        }
-
-        .selected-file.has-value { color: var(--text); }
-
-        .radio-group { display: flex; gap: 14px; }
-
-        .radio-item {
-            display: flex; align-items: center; gap: 6px;
-            font-size: 13px; cursor: pointer;
-        }
-
-        .radio-item input { cursor: pointer; accent-color: var(--accent); }
-
-        input[type=range] { flex: 1; accent-color: var(--accent); }
-        .range-row { display: flex; align-items: center; gap: 10px; }
-        .range-val { font-size: 13px; font-weight: 600; min-width: 32px; text-align: right; }
-
-        input[type=text] {
-            width: 100%;
-            padding: 8px 10px;
-            background: var(--inp-bg);
-            border: 1px solid var(--border);
-            border-radius: 8px;
-            font-size: 13px; color: var(--text);
-            font-family: inherit;
-            outline: none;
-            transition: border-color 0.15s;
-        }
-
-        input[type=text]:focus { border-color: var(--accent); }
-
-        /* Progress */
-        .progress-wrap { margin-top: 14px; }
-
-        .progress-label { font-size: 12px; color: var(--sub); margin-bottom: 5px; }
-
-        .progress-track {
-            height: 5px;
-            background: var(--progress-bg);
-            border-radius: 3px; overflow: hidden;
-        }
-
-        .progress-fill {
-            height: 100%; background: var(--accent);
-            border-radius: 3px; width: 0%;
-            transition: width 0.25s;
-        }
-
-        /* Result boxes */
-        .result-box {
-            margin-top: 14px;
-            padding: 11px 14px;
-            border-radius: 10px;
-            font-size: 13px; line-height: 1.5;
-        }
-
-        .result-ok {
-            background: rgba(48,176,110,0.12);
-            border: 1px solid rgba(48,176,110,0.28);
-            color: #1B7A43;
-        }
-
-        .result-err {
-            background: rgba(255,59,48,0.1);
-            border: 1px solid rgba(255,59,48,0.25);
-            color: #C0392B;
-        }
-
-        @media (prefers-color-scheme: dark) {
-            .result-ok { color: #4ADE80; }
-            .result-err { color: #FF6B6B; }
-        }
-
-        /* Image list */
-        .img-list {
-            border: 1px solid var(--border);
-            border-radius: 10px;
-            overflow: hidden;
-        }
-
-        .img-list-empty {
-            padding: 18px; text-align: center;
-            font-size: 13px; color: var(--sub);
-        }
-
-        .img-row {
-            display: flex; align-items: center; gap: 10px;
-            padding: 7px 10px;
-            border-bottom: 1px solid var(--border);
-            background: var(--card-bg);
-        }
-
-        .img-row:last-child { border-bottom: none; }
-
-        .img-thumb {
-            width: 34px; height: 34px;
-            border-radius: 5px;
-            object-fit: cover;
-            background: var(--progress-bg);
-            flex-shrink: 0;
-        }
-
-        .img-row-name {
-            flex: 1; font-size: 12.5px;
-            overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-        }
-
-        .img-row-btns { display: flex; gap: 2px; }
-
-        .icon-btn {
-            background: none; border: none;
-            cursor: pointer; padding: 4px;
-            border-radius: 5px;
-            color: var(--sub);
-            display: flex; align-items: center; justify-content: center;
-            transition: background 0.1s;
-        }
-
-        .icon-btn:hover { background: rgba(0,0,0,0.07); color: var(--text); }
-        .icon-btn.rm:hover { color: var(--danger); background: rgba(255,59,48,0.1); }
-
-        .divider { height: 1px; background: var(--border); margin: 14px 0; }
-    </style>
-</head>
-<body>
-
-<script>
-if (window.location.hash && window.location.hash.length > 1) {
-    try {
-        var _hashData = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
-        if (Array.isArray(_hashData)) {
-            // Standard file-input hash — open the file in the embedded viewer
-            window.location.href = 'embedded.html' + window.location.hash;
-        }
-        // Object hash (e.g. {tool, file}) — let boot handle it below
-    } catch(e) {
-        // Not valid JSON; treat as a file array for backwards compatibility
-        window.location.href = 'embedded.html' + window.location.hash;
-    }
-}
-</script>
-
-<div id="app">
-    <nav id="sidebar">
-        <div id="brand">
-            <img src="img/module_icon.svg" alt="">
-            <span>Productivity</span>
-        </div>
-        <div class="cat-section" id="catList"></div>
-    </nav>
-
-    <main id="main">
-        <div id="toolGrid"></div>
-        <div id="toolPanel"></div>
-    </main>
-</div>
-
-<script>
-/* ── Category and tool definitions ── */
-var CATEGORIES = [
-    {
-        id: 'pdf',
-        label: 'PDF Tools',
-        icon: '<svg width="15" height="15" viewBox="0 0 15 15" fill="none"' +
-              ' stroke="currentColor" stroke-width="1.6"' +
-              ' stroke-linecap="round" stroke-linejoin="round">' +
-              '<rect x="1.5" y="1" width="8" height="11" rx="1.5"/>' +
-              '<path d="M5.5 1v3.5h4.5"/>' +
-              '<rect x="8" y="8" width="5.5" height="5.5" rx="1"/>' +
-              '</svg>'
-    },
-    {
-        id: 'image',
-        label: 'Image Tools',
-        icon: '<svg width="15" height="15" viewBox="0 0 15 15" fill="none"' +
-              ' stroke="currentColor" stroke-width="1.6"' +
-              ' stroke-linecap="round" stroke-linejoin="round">' +
-              '<rect x="1" y="2.5" width="13" height="10" rx="1.5"/>' +
-              '<circle cx="4.5" cy="6.5" r="1.1"/>' +
-              '<path d="M1 12 L4 8.5 L6.5 10.5 L9.5 7 L13 12"/>' +
-              '</svg>'
-    },
-    {
-        id: 'web',
-        label: 'Web Tools',
-        icon: '<svg width="15" height="15" viewBox="0 0 15 15" fill="none"' +
-              ' stroke="currentColor" stroke-width="1.6"' +
-              ' stroke-linecap="round" stroke-linejoin="round">' +
-              '<circle cx="7.5" cy="7.5" r="6"/>' +
-              '<path d="M7.5 1.5 C5 4 5 11 7.5 13.5"/>' +
-              '<path d="M7.5 1.5 C10 4 10 11 7.5 13.5"/>' +
-              '<line x1="1.5" y1="6" x2="13.5" y2="6"/>' +
-              '<line x1="1.5" y1="9" x2="13.5" y2="9"/>' +
-              '</svg>'
-    }
-];
-
-var TOOLS = {
-    image: [
-        {
-            id: 'imgpaste',
-            label: 'Image Paste',
-            desc: 'Paste an image from clipboard and save it to your files',
-            color: '#30B06E',
-            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
-                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
-                  '<rect x="2" y="5.5" width="12" height="14" rx="1.5"/>' +
-                  '<path d="M6 5.5V3.5h5v2"/>' +
-                  '<circle cx="6.5" cy="11.5" r="1.2" fill="#FFF" stroke="none"/>' +
-                  '<path d="M2 18.5 L5 14 L7.5 16 L10.5 12 L14 18.5"/>' +
-                  '</svg>',
-            template: 'tools/imgpaste.html'
-        }
-    ],
-    web: [
-        {
-            id: 'webbuilder',
-            label: 'Web Builder',
-            desc: 'WYSIWYG editor for creating and editing HTML web pages',
-            color: '#FF9500',
-            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
-                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
-                  '<rect x="2" y="3" width="18" height="14" rx="1.5"/>' +
-                  '<line x1="2" y1="7" x2="20" y2="7"/>' +
-                  '<circle cx="5" cy="5" r="0.9" fill="#FFF" stroke="none"/>' +
-                  '<circle cx="8" cy="5" r="0.9" fill="#FFF" stroke="none"/>' +
-                  '<path d="M7.5 12 L5.5 14 L7.5 16"/>' +
-                  '<path d="M11.5 12 L13.5 14 L11.5 16"/>' +
-                  '</svg>',
-            template: 'tools/webbuilder.html'
-        }
-    ],
-    pdf: [
-        {
-            id: 'pdf2img',
-            label: 'PDF to Images',
-            desc: 'Extract each PDF page as a JPEG or PNG image',
-            color: '#FF3B30',
-            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
-                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
-                  '<rect x="2" y="2" width="9" height="12" rx="1.5"/>' +
-                  '<path d="M6 2v4h5"/>' +
-                  '<rect x="11.5" y="10.5" width="8.5" height="9" rx="1.3"/>' +
-                  '<circle cx="14" cy="13" r="1" fill="#FFF" stroke="none"/>' +
-                  '<path d="M11.5 18.5l2.5-3 1.8 1.5 2-3 2.2 3"/>' +
-                  '</svg>',
-            template: 'tools/pdf2img.html'
-        },
-        {
-            id: 'img2pdf',
-            label: 'Images to PDF',
-            desc: 'Combine multiple images into a single PDF file',
-            color: '#007AFF',
-            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
-                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
-                  '<rect x="1" y="5" width="10" height="9" rx="1.5"/>' +
-                  '<circle cx="4" cy="8" r="1.1" fill="#FFF" stroke="none"/>' +
-                  '<path d="M1 13l2.5-3 2 1.5 2.5-3L10 11"/>' +
-                  '<rect x="12.5" y="2" width="8.5" height="11" rx="1.5"/>' +
-                  '<path d="M16.5 2v4h4.5"/>' +
-                  '<line x1="14" y1="16" x2="19.5" y2="16"/>' +
-                  '<line x1="16.8" y1="13.5" x2="16.8" y2="18.5"/>' +
-                  '</svg>',
-            template: 'tools/img2pdf.html'
-        },
-        {
-            id: 'pdfedit',
-            label: 'PDF Editor',
-            desc: 'Add text, images and company stamps onto a PDF',
-            color: '#5856D6',
-            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
-                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
-                  '<rect x="2.5" y="2" width="13" height="18" rx="1.6"/>' +
-                  '<line x1="5.5" y1="6" x2="12.5" y2="6"/>' +
-                  '<line x1="5.5" y1="9" x2="10" y2="9"/>' +
-                  '<path d="M16.5 11.5l3 3-5 5H11.5v-3z"/>' +
-                  '</svg>',
-            template: 'tools/pdfedit.html'
-        },
-        {
-            id: 'choplib',
-            label: 'Chop Library',
-            desc: 'Manage company stamps and chops for use in the editor',
-            color: '#D0021B',
-            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
-                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
-                  '<path d="M11 2.5c-2.7 0-4.2 2.1-3.5 4.6.4 1.5.7 2.6-.5 3.4h8c-1.2-.8-.9-1.9-.5-3.4C15.2 4.6 13.7 2.5 11 2.5Z"/>' +
-                  '<rect x="4" y="13" width="14" height="2.8" rx="0.9"/>' +
-                  '<line x1="3" y1="19" x2="19" y2="19"/>' +
-                  '</svg>',
-            template: 'tools/choplib.html'
-        }
-    ]
-};
-
-var toolModules = window.ProductivityToolModules = window.ProductivityToolModules || {};
-var toolTemplateCache = {};
-var activeToolLoadToken = 0;
-
-/* ── Utilities ── */
-function dirOf(vpath) {
-    var p = vpath.split('/'); p.pop(); return p.join('/');
-}
-
-function basenameNoExt(vpath) {
-    var n = vpath.split('/').pop();
-    var d = n.lastIndexOf('.');
-    return d > 0 ? n.slice(0, d) : n;
-}
-
-function mediaUrl(vpath) { return '/media?file=' + encodeURIComponent(vpath); }
-
-function uploadBlob(blob, filename, targetDir) {
-    return new Promise(function(resolve, reject) {
-        ao_module_uploadFile(new File([blob], filename, {type: blob.type}),
-            targetDir, resolve, undefined, reject);
-    });
-}
-
-function escHtml(s) {
-    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
-}
-
-function allTools() {
-    var out = [];
-    Object.keys(TOOLS).forEach(function(k) { out = out.concat(TOOLS[k]); });
-    return out;
-}
-
-/* ── Navigation ── */
-var currentCat = null;
-
-function selectCat(id) {
-    currentCat = id;
-    renderSidebar();
-    showGrid(id);
-}
-
-function selectTool(id) {
-    var tool = allTools().find(function(t) { return t.id === id; });
-    if (tool) showPanel(tool);
-}
-
-function backToGrid() {
-    document.getElementById('toolPanel').className = '';
-    document.getElementById('toolGrid').style.display = 'block';
-}
-
-/* ── Sidebar render ── */
-function renderSidebar() {
-    var html = '<div class="cat-section-label">Categories</div>';
-    CATEGORIES.forEach(function(c) {
-        html += '<div class="cat-item' + (c.id === currentCat ? ' active' : '') + '"' +
-                ' onclick="selectCat(\'' + c.id + '\')">' +
-                c.icon + '<span>' + c.label + '</span></div>';
-    });
-    document.getElementById('catList').innerHTML = html;
-}
-
-/* ── Tool grid render ── */
-function showGrid(catId) {
-    document.getElementById('toolPanel').className = '';
-    var grid = document.getElementById('toolGrid');
-    grid.style.display = 'block';
-    var cat = CATEGORIES.find(function(c) { return c.id === catId; });
-    var tools = TOOLS[catId] || [];
-    var html = '<div class="grid-title">' + (cat ? escHtml(cat.label) : catId) + '</div>';
-    html += '<div class="tools-grid">';
-    tools.forEach(function(t) {
-        html += '<div class="tool-card" onclick="selectTool(\'' + t.id + '\')">' +
-                '<div class="tc-icon" style="background:' + t.color + '">' + t.icon + '</div>' +
-                '<div class="tc-name">' + escHtml(t.label) + '</div>' +
-                '<div class="tc-desc">' + escHtml(t.desc) + '</div>' +
-                '</div>';
-    });
-    html += '</div>';
-    grid.innerHTML = html;
-}
-
-/* ── Tool panel render ── */
-function showPanel(tool) {
-    document.getElementById('toolGrid').style.display = 'none';
-    var panel = document.getElementById('toolPanel');
-    panel.className = 'open';
-    panel.innerHTML =
-        '<div class="tp-header">' +
-            '<button class="back-btn" onclick="backToGrid()">' +
-                svgChevronLeft() + 'Back' +
-            '</button>' +
-            '<span class="tp-name">' + escHtml(tool.label) + '</span>' +
-        '</div>' +
-        '<div class="tp-body" id="tpBody"></div>';
-    loadToolTemplate(tool, document.getElementById('tpBody'));
-}
-
-function loadToolTemplate(tool, mountEl) {
-    activeToolLoadToken += 1;
-    var loadToken = activeToolLoadToken;
-
-    mountEl.innerHTML = '<div class="progress-label">Loading tool...</div>';
-
-    if (toolTemplateCache[tool.id]) {
-        renderToolTemplate(tool, mountEl, toolTemplateCache[tool.id], loadToken);
-        return;
-    }
-
-    $.ajax({
-        url: tool.template,
-        method: 'GET',
-        dataType: 'html',
-        cache: true
-    }).done(function(response) {
-        toolTemplateCache[tool.id] = response;
-        renderToolTemplate(tool, mountEl, response, loadToken);
-    }).fail(function() {
-        if (loadToken !== activeToolLoadToken) { return; }
-        mountEl.innerHTML =
-            '<div class="result-box result-err"><strong>Error:</strong> Failed to load tool UI.</div>';
-    });
-}
-
-function renderToolTemplate(tool, mountEl, html, loadToken) {
-    if (loadToken !== activeToolLoadToken) { return; }
-
-    var wrapper = document.createElement('div');
-    var nodes = $.parseHTML(html, document, true) || [];
-    nodes.forEach(function(node) {
-        wrapper.appendChild(node);
-    });
-
-    var scripts = wrapper.querySelectorAll('script');
-    Array.prototype.forEach.call(scripts, function(script) {
-        script.parentNode.removeChild(script);
-    });
-
-    mountEl.innerHTML = wrapper.innerHTML;
-
-    Array.prototype.forEach.call(scripts, function(script) {
-        $.globalEval(script.text || script.textContent || script.innerHTML || '');
-    });
-
-    if (!toolModules[tool.id] || typeof toolModules[tool.id].init !== 'function') {
-        mountEl.innerHTML =
-            '<div class="result-box result-err"><strong>Error:</strong> Tool module failed to initialize.</div>';
-        return;
-    }
-
-    toolModules[tool.id].init(mountEl);
-}
-
-/* ── Micro SVG icons ── */
-function svgChevronLeft() {
-    return '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor"' +
-           ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
-           '<path d="M8.5 2 L4 6.5 L8.5 11"/></svg>';
-}
-
-function svgUp() {
-    return '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor"' +
-           ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
-           '<path d="M2.5 8.5 L6.5 4 L10.5 8.5"/></svg>';
-}
-
-function svgDown() {
-    return '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor"' +
-           ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
-           '<path d="M2.5 4.5 L6.5 9 L10.5 4.5"/></svg>';
-}
-
-function svgX() {
-    return '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor"' +
-           ' stroke-width="2" stroke-linecap="round">' +
-           '<line x1="2" y1="2" x2="11" y2="11"/><line x1="11" y1="2" x2="2" y2="11"/></svg>';
-}
-
-/* ── Boot ── */
-renderSidebar();
-(function() {
-    if (window.location.hash && window.location.hash.length > 1) {
-        try {
-            var _h = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
-            if (_h && !Array.isArray(_h) && _h.tool) {
-                var _catId = null;
-                Object.keys(TOOLS).forEach(function(k) {
-                    TOOLS[k].forEach(function(t) { if (t.id === _h.tool) _catId = k; });
-                });
-                if (_catId) {
-                    if (_h.file) window._productivityLaunchFile = _h.file;
-                    selectCat(_catId);
-                    selectTool(_h.tool);
-                    return;
-                }
-            }
-        } catch(e) {}
-    }
-    if (CATEGORIES.length) selectCat(CATEGORIES[0].id);
-})();
-
-/* ── ArozOS system theme binding ── */
-function applyAozTheme(theme) {
-    document.documentElement.setAttribute('data-theme', theme === 'dark' ? 'dark' : 'light');
-}
-
-ao_module_onThemeChanged(applyAozTheme);
-
-$.get(ao_root + 'system/file_system/preference?key=file_explorer/theme', function(data) {
-    applyAozTheme(data === 'darkTheme' ? 'dark' : 'light');
-});
-</script>
-</body>
-</html>

+ 0 - 37
src/web/Productivity/init.agi

@@ -1,37 +0,0 @@
-/*
-    Productivity
-    Universal file previewer and Productivity tool for ArozOS
-*/
-
-var moduleLaunchInfo = {
-    Name: "Productivity",
-    Desc: "PDF tools, image tools, web builder, and file previewer",
-    Group: "Utilities",
-    IconPath: "Productivity/img/module_icon.svg",
-    Version: "1.0",
-    StartDir: "Productivity/index.html",
-    SupportFW: true,
-    LaunchFWDir: "Productivity/index.html",
-    InitFWSize: [780, 560],
-    SupportEmb: true,
-    LaunchEmb: "Productivity/embedded.html",
-    InitEmbSize: [1000, 700],
-    SupportedExt: [
-        ".html", ".htm",
-        ".pdf",
-        ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".ico",
-        ".md", ".markdown",
-        ".txt", ".log", ".conf", ".cfg", ".ini", ".toml",
-        ".js", ".mjs", ".cjs", ".jsx",
-        ".ts", ".tsx",
-        ".py", ".rb", ".pl", ".r",
-        ".go", ".rs", ".swift", ".kt", ".scala",
-        ".java", ".c", ".h", ".cpp", ".hpp", ".cs",
-        ".php", ".sh", ".bash", ".zsh", ".bat", ".cmd", ".ps1",
-        ".json", ".yaml", ".yml", ".xml", ".css", ".scss", ".less",
-        ".sql", ".lua", ".agi", ".dockerfile", ".makefile",
-        ".gitignore", ".editorconfig", ".env"
-    ]
-}
-
-registerModule(JSON.stringify(moduleLaunchInfo));

+ 0 - 190
src/web/Productivity/tools/choplib.html

@@ -1,190 +0,0 @@
-<style>
-    .chop-grid {
-        display: grid;
-        grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
-        gap: 12px;
-        margin-top: 4px;
-    }
-    .chop-card {
-        background: var(--card-bg);
-        border: 1px solid var(--border);
-        border-radius: 10px;
-        padding: 10px;
-        display: flex; flex-direction: column; gap: 8px;
-    }
-    .chop-thumb {
-        width: 100%; height: 96px;
-        border-radius: 7px;
-        background: repeating-conic-gradient(#0000000d 0% 25%, transparent 0% 50%) 50% / 16px 16px;
-        display: flex; align-items: center; justify-content: center;
-        overflow: hidden;
-    }
-    .chop-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
-    .chop-name {
-        font-size: 12.5px; font-weight: 600;
-        overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-        cursor: text;
-    }
-    .chop-card-btns { display: flex; gap: 4px; }
-    .chop-empty {
-        padding: 28px 18px; text-align: center;
-        font-size: 13px; color: var(--sub);
-        border: 1.5px dashed var(--border); border-radius: 10px;
-    }
-</style>
-
-<div class="field">
-    <span class="field-label">Company Chops &amp; Stamps</span>
-    <p style="font-size:12.5px;color:var(--sub);margin-bottom:10px;line-height:1.5;">
-        Load your company stamp as a transparent <strong style="color:var(--text)">PNG</strong>.
-        Saved chops can be placed on any document from the <strong style="color:var(--text)">PDF Editor</strong>.
-    </p>
-    <button class="btn btn-primary" id="chopAddBtn">
-        <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="#FFF" stroke-width="2" stroke-linecap="round">
-            <line x1="6.5" y1="2" x2="6.5" y2="11"/><line x1="2" y1="6.5" x2="11" y2="6.5"/>
-        </svg>
-        Add Chop (PNG)
-    </button>
-</div>
-<div id="chopList"></div>
-<div id="chopResult"></div>
-
-<script>
-(function(){
-    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
-
-    /* ── Shared chop store (defined once, reused by the PDF Editor) ── */
-    if (!window.ProductivityChops) {
-        window.ProductivityChops = (function(){
-            var DIR = 'user:/.appdata/Productivity/Chops/';
-            var MOD = 'Productivity', KEY = 'chops';
-
-            // In-memory cache is the source of truth once loaded. ao_module_storage.setStorage()
-            // fires an async write and returns immediately without confirming completion, so
-            // reading the value straight back from the server after a save() can race the write
-            // and return stale data. Caching avoids that round trip entirely.
-            var cache = null;
-            var pending = [];
-            var fetching = false;
-
-            function load(cb){
-                if (cache !== null){ cb(cache.slice()); return; }
-                pending.push(cb);
-                if (fetching) return;
-                fetching = true;
-                ao_module_storage.loadStorage(MOD, KEY, function(raw){
-                    var arr = [];
-                    if (raw){ try { arr = JSON.parse(raw) || []; } catch(e){ arr = []; } }
-                    cache = arr;
-                    fetching = false;
-                    var queued = pending; pending = [];
-                    queued.forEach(function(fn){ fn(cache.slice()); });
-                });
-            }
-            function save(arr, cb){
-                cache = arr.slice();
-                ao_module_storage.setStorage(MOD, KEY, JSON.stringify(cache));
-                if (cb) cb(cache.slice());
-            }
-            function add(srcPath, name, onDone, onErr){
-                fetch(mediaUrl(srcPath)).then(function(r){
-                    if (!r.ok) throw new Error('Cannot read source image');
-                    return r.blob();
-                }).then(function(blob){
-                    var fname = 'chop_' + Date.now() + '.png';
-                    ao_module_uploadFile(new File([blob], fname, {type:'image/png'}), DIR, function(){
-                        load(function(arr){
-                            arr.push({ id: 'c' + Date.now(), name: name || 'Chop', path: DIR + fname });
-                            save(arr, function(){ if (onDone) onDone(arr); });
-                        });
-                    }, undefined, function(status){
-                        if (onErr) onErr('Upload failed (' + status + ')');
-                    });
-                }).catch(function(e){ if (onErr) onErr(e.message || String(e)); });
-            }
-            function remove(id, onDone){
-                load(function(arr){
-                    save(arr.filter(function(c){ return c.id !== id; }), onDone);
-                });
-            }
-            function rename(id, name, onDone){
-                load(function(arr){
-                    arr.forEach(function(c){ if (c.id === id) c.name = name; });
-                    save(arr, onDone);
-                });
-            }
-            return { DIR: DIR, load: load, save: save, add: add, remove: remove, rename: rename };
-        })();
-    }
-
-    registry.choplib = {
-        init: function(root) {
-            var listEl   = root.querySelector('#chopList');
-            var resultEl = root.querySelector('#chopResult');
-
-            function render(){
-                window.ProductivityChops.load(function(chops){
-                    if (!chops.length){
-                        listEl.innerHTML = '<div class="chop-empty">No chops yet. Add a transparent PNG of your company stamp to get started.</div>';
-                        return;
-                    }
-                    var html = '<div class="chop-grid">';
-                    chops.forEach(function(c){
-                        html += '<div class="chop-card">' +
-                            '<div class="chop-thumb"><img src="' + mediaUrl(c.path) + '" alt=""></div>' +
-                            '<div class="chop-name" data-rename="' + c.id + '" title="Click to rename">' + escHtml(c.name) + '</div>' +
-                            '<div class="chop-card-btns">' +
-                                '<button class="btn btn-outline" style="flex:1;padding:5px 8px;font-size:12px" data-rename="' + c.id + '">Rename</button>' +
-                                '<button class="icon-btn rm" data-remove="' + c.id + '" title="Remove">' + svgX() + '</button>' +
-                            '</div>' +
-                        '</div>';
-                    });
-                    html += '</div>';
-                    listEl.innerHTML = html;
-
-                    Array.prototype.forEach.call(listEl.querySelectorAll('[data-remove]'), function(btn){
-                        btn.addEventListener('click', function(){
-                            var id = this.getAttribute('data-remove');
-                            window.ProductivityChops.remove(id, render);
-                        });
-                    });
-                    Array.prototype.forEach.call(listEl.querySelectorAll('[data-rename]'), function(el){
-                        el.addEventListener('click', function(){
-                            var id = this.getAttribute('data-rename');
-                            var card = this.closest('.chop-card');
-                            var current = card ? card.querySelector('.chop-name').textContent : '';
-                            var name = prompt('Chop name', current);
-                            if (name != null && name.trim() !== ''){
-                                window.ProductivityChops.rename(id, name.trim(), render);
-                            }
-                        });
-                    });
-                });
-            }
-
-            root.querySelector('#chopAddBtn').addEventListener('click', function(){
-                ao_module_openFileSelector('_chopAddCb', 'user:/', 'file', false, { filter: ['png'] });
-            });
-
-            window._chopAddCb = function(files){
-                if (!files || !files.length) return;
-                var f = files[0];
-                if (!/\.png$/i.test(f.filename)){
-                    resultEl.innerHTML = '<div class="result-box result-err">Please choose a <strong>PNG</strong> image (transparent background recommended).</div>';
-                    return;
-                }
-                var defaultName = f.filename.replace(/\.png$/i, '');
-                resultEl.innerHTML = '<div class="result-box" style="color:var(--sub)">Importing chop…</div>';
-                window.ProductivityChops.add(f.filepath, defaultName, function(){
-                    resultEl.innerHTML = '';
-                    render();
-                }, function(err){
-                    resultEl.innerHTML = '<div class="result-box result-err"><strong>Error:</strong> ' + escHtml(String(err)) + '</div>';
-                });
-            };
-
-            render();
-        }
-    };
-})();
-</script>

+ 0 - 332
src/web/Productivity/tools/img2pdf.html

@@ -1,332 +0,0 @@
-<div class="field">
-    <span class="field-label">Images</span>
-    <div class="img-list" id="i2pList">
-        <div class="img-list-empty">No images added yet.</div>
-    </div>
-    <button class="btn btn-outline" style="margin-top:8px" id="i2pAddBtn">+ Add Images...</button>
-</div>
-<div class="divider"></div>
-<div class="field">
-    <span class="field-label">Output Filename</span>
-    <input type="text" id="i2pName" placeholder="output.pdf">
-</div>
-<div class="field">
-    <span class="field-label">Save to Folder</span>
-    <div class="row">
-        <button class="btn btn-primary" id="i2pFolderBtn">Select Folder...</button>
-        <span class="selected-file" id="i2pFolderLbl">Auto-detect from first image</span>
-    </div>
-</div>
-<button class="btn-action" id="i2pBtn">Create PDF</button>
-<div id="i2pProg" style="display:none" class="progress-wrap">
-    <div class="progress-label" id="i2pProgLbl">Preparing...</div>
-    <div class="progress-track"><div class="progress-fill" id="i2pFill"></div></div>
-</div>
-<div id="i2pResult"></div>
-
-<script>
-(function() {
-    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
-    var state = { images: [], overrideDir: null, root: null };
-
-    function getRoot() {
-        return state.root && document.body.contains(state.root) ? state.root : null;
-    }
-
-    function renderList() {
-        var root = getRoot();
-        if (!root) { return; }
-
-        var list = root.querySelector('#i2pList');
-        if (!state.images.length) {
-            list.innerHTML = '<div class="img-list-empty">No images added yet.</div>';
-            return;
-        }
-
-        var html = '';
-        state.images.forEach(function(img, idx) {
-            html += '<div class="img-row">' +
-                '<img class="img-thumb" src="' + mediaUrl(img.filepath) + '" alt="">' +
-                '<span class="img-row-name">' + escHtml(img.filename) + '</span>' +
-                '<div class="img-row-btns">';
-            if (idx > 0) {
-                html += '<button class="icon-btn" data-move="-1" data-idx="' + idx + '" title="Move up">' + svgUp() + '</button>';
-            }
-            if (idx < state.images.length - 1) {
-                html += '<button class="icon-btn" data-move="1" data-idx="' + idx + '" title="Move down">' + svgDown() + '</button>';
-            }
-            html += '<button class="icon-btn rm" data-remove="1" data-idx="' + idx + '" title="Remove">' + svgX() + '</button>';
-            html += '</div></div>';
-        });
-
-        list.innerHTML = html;
-
-        Array.prototype.forEach.call(list.querySelectorAll('[data-move]'), function(btn) {
-            btn.addEventListener('click', function() {
-                var idx = parseInt(this.getAttribute('data-idx'), 10);
-                var dir = parseInt(this.getAttribute('data-move'), 10);
-                var nextIdx = idx + dir;
-                if (nextIdx < 0 || nextIdx >= state.images.length) { return; }
-                var tmp = state.images[idx];
-                state.images[idx] = state.images[nextIdx];
-                state.images[nextIdx] = tmp;
-                renderList();
-            });
-        });
-
-        Array.prototype.forEach.call(list.querySelectorAll('[data-remove]'), function(btn) {
-            btn.addEventListener('click', function() {
-                var idx = parseInt(this.getAttribute('data-idx'), 10);
-                state.images.splice(idx, 1);
-                renderList();
-            });
-        });
-    }
-
-    function imgToJpeg(url, onDone, onError) {
-        var img = new Image();
-        img.crossOrigin = 'anonymous';
-        img.onload = function() {
-            var w = img.naturalWidth;
-            var h = img.naturalHeight;
-            var canvas = document.createElement('canvas');
-            canvas.width = w;
-            canvas.height = h;
-
-            var ctx = canvas.getContext('2d');
-            ctx.fillStyle = '#FFFFFF';
-            ctx.fillRect(0, 0, w, h);
-            ctx.drawImage(img, 0, 0);
-            canvas.toBlob(function(blob) {
-                if (!blob) {
-                    onError(new Error('Canvas export failed'));
-                    return;
-                }
-
-                var reader = new FileReader();
-                reader.onload = function(ev) {
-                    onDone({ jpeg: new Uint8Array(ev.target.result), width: w, height: h });
-                };
-                reader.onerror = onError;
-                reader.readAsArrayBuffer(blob);
-            }, 'image/jpeg', 0.92);
-        };
-        img.onerror = function() { onError(new Error('Could not load image')); };
-        img.src = url;
-    }
-
-    function buildPdfFromJpegs(pages) {
-        var parts = [];
-        var offsets = {};
-        var pos = 0;
-        var enc = new TextEncoder();
-
-        function wr(s) {
-            var b = typeof s === 'string' ? enc.encode(s) : s;
-            parts.push(b);
-            pos += b.length;
-        }
-
-        function objStart(id) {
-            offsets[id] = pos;
-            wr(id + ' 0 obj\n');
-        }
-
-        function objEnd() {
-            wr('\nendobj\n');
-        }
-
-        wr('%PDF-1.4\n');
-        wr(new Uint8Array([0x25, 0xC2, 0xB4, 0xC3, 0xA5, 0x0A]));
-
-        var n = pages.length;
-        objStart(1);
-        wr('<< /Type /Catalog /Pages 2 0 R >>');
-        objEnd();
-
-        var kids = '';
-        objStart(2);
-        for (var k = 0; k < n; k++) {
-            kids += (3 + k * 3) + ' 0 R ';
-        }
-        wr('<< /Type /Pages /Kids [' + kids.trim() + '] /Count ' + n + ' >>');
-        objEnd();
-
-        for (var i = 0; i < n; i++) {
-            var pg = pages[i];
-            var pId = 3 + i * 3;
-            var cId = pId + 1;
-            var xId = pId + 2;
-            var name = 'Im' + (i + 1);
-
-            objStart(pId);
-            wr('<< /Type /Page /Parent 2 0 R' +
-               ' /MediaBox [0 0 ' + pg.width + ' ' + pg.height + ']' +
-               ' /Contents ' + cId + ' 0 R' +
-               ' /Resources << /XObject << /' + name + ' ' + xId + ' 0 R >> >> >>');
-            objEnd();
-
-            var content = enc.encode('q ' + pg.width + ' 0 0 ' + pg.height + ' 0 0 cm /' + name + ' Do Q');
-            objStart(cId);
-            wr('<< /Length ' + content.length + ' >>\nstream\n');
-            wr(content);
-            wr('\nendstream');
-            objEnd();
-
-            objStart(xId);
-            wr('<< /Type /XObject /Subtype /Image' +
-               ' /Width ' + pg.width + ' /Height ' + pg.height +
-               ' /ColorSpace /DeviceRGB /BitsPerComponent 8' +
-               ' /Filter /DCTDecode /Length ' + pg.jpeg.length + ' >>\nstream\n');
-            wr(pg.jpeg);
-            wr('\nendstream');
-            objEnd();
-        }
-
-        var xrefPos = pos;
-        var totalObjs = 2 + n * 3;
-        wr('xref\n0 ' + (totalObjs + 1) + '\n');
-        wr('0000000000 65535 f \n');
-        for (var id = 1; id <= totalObjs; id++) {
-            var off = (offsets[id] || 0).toString();
-            while (off.length < 10) {
-                off = '0' + off;
-            }
-            wr(off + ' 00000 n \n');
-        }
-        wr('trailer\n<< /Size ' + (totalObjs + 1) + ' /Root 1 0 R >>\n');
-        wr('startxref\n' + xrefPos + '\n%%EOF\n');
-
-        var total = 0;
-        for (var j = 0; j < parts.length; j++) {
-            total += parts[j].length;
-        }
-
-        var out = new Uint8Array(total);
-        var offset = 0;
-        for (var k2 = 0; k2 < parts.length; k2++) {
-            out.set(parts[k2], offset);
-            offset += parts[k2].length;
-        }
-        return out;
-    }
-
-    registry.img2pdf = {
-        init: function(root) {
-            state = { images: [], overrideDir: null, root: root };
-
-            root.querySelector('#i2pAddBtn').addEventListener('click', function() {
-                ao_module_openFileSelector('_i2pAddCb', 'user:/', 'file', true);
-            });
-
-            root.querySelector('#i2pFolderBtn').addEventListener('click', function() {
-                ao_module_openFileSelector('_i2pFolderCb', 'user:/', 'folder', false);
-            });
-
-            root.querySelector('#i2pBtn').addEventListener('click', function() {
-                if (!state.images.length) {
-                    alert('Please add at least one image.');
-                    return;
-                }
-
-                var nameValue = root.querySelector('#i2pName').value.trim() || 'output';
-                if (!nameValue.toLowerCase().endsWith('.pdf')) {
-                    nameValue += '.pdf';
-                }
-
-                var outDir = state.overrideDir || dirOf(state.images[0].filepath);
-                var btn = root.querySelector('#i2pBtn');
-                var prog = root.querySelector('#i2pProg');
-                var lbl = root.querySelector('#i2pProgLbl');
-                var fill = root.querySelector('#i2pFill');
-                var result = root.querySelector('#i2pResult');
-                var pages = [];
-                var total = state.images.length;
-                var idx = 0;
-                var outName = nameValue;
-
-                btn.disabled = true;
-                prog.style.display = 'block';
-                result.innerHTML = '';
-
-                function loadNext() {
-                    if (idx >= total) {
-                        lbl.textContent = 'Building PDF...';
-                        fill.style.width = '88%';
-                        setTimeout(function() {
-                            try {
-                                var pdfBytes = buildPdfFromJpegs(pages);
-                                var blob = new Blob([pdfBytes], { type: 'application/pdf' });
-                                lbl.textContent = 'Uploading...';
-                                fill.style.width = '94%';
-                                uploadBlob(blob, outName, outDir).then(function() {
-                                    fill.style.width = '100%';
-                                    lbl.textContent = 'Done!';
-                                    result.innerHTML =
-                                        '<div class="result-box result-ok"><strong>' + escHtml(outName) +
-                                        '</strong> saved to<br>' + escHtml(outDir) + '</div>';
-                                    btn.disabled = false;
-                                }, function(err) {
-                                    result.innerHTML =
-                                        '<div class="result-box result-err"><strong>Upload failed:</strong> ' +
-                                        escHtml(String(err)) + '</div>';
-                                    btn.disabled = false;
-                                });
-                            } catch (err) {
-                                result.innerHTML =
-                                    '<div class="result-box result-err"><strong>Error:</strong> ' +
-                                    escHtml(err && err.message ? err.message : String(err)) + '</div>';
-                                btn.disabled = false;
-                            }
-                        }, 0);
-                        return;
-                    }
-
-                    lbl.textContent = 'Loading image ' + (idx + 1) + ' of ' + total + '...';
-                    fill.style.width = ((idx / total) * 80) + '%';
-
-                    imgToJpeg(mediaUrl(state.images[idx].filepath), function(pageData) {
-                        pages.push(pageData);
-                        idx += 1;
-                        loadNext();
-                    }, function(err) {
-                        result.innerHTML =
-                            '<div class="result-box result-err"><strong>Failed on image ' +
-                            (idx + 1) + ':</strong> ' + escHtml(String(err)) + '</div>';
-                        btn.disabled = false;
-                    });
-                }
-
-                loadNext();
-            });
-
-            renderList();
-        }
-    };
-
-    window._i2pAddCb = function(files) {
-        var root = getRoot();
-        if (!root || !files || !files.length) { return; }
-
-        files.forEach(function(file) {
-            var exists = state.images.find(function(img) {
-                return img.filepath === file.filepath;
-            });
-            if (!exists) {
-                state.images.push({ filepath: file.filepath, filename: file.filename });
-            }
-        });
-        renderList();
-    };
-
-    window._i2pFolderCb = function(files) {
-        var root = getRoot();
-        if (!root || !files || !files.length) { return; }
-
-        state.overrideDir = files[0].filepath;
-        var label = root.querySelector('#i2pFolderLbl');
-        label.textContent = files[0].filepath;
-        label.className = 'selected-file has-value';
-    };
-})();
-</script>

+ 0 - 125
src/web/Productivity/tools/imgpaste.html

@@ -1,125 +0,0 @@
-<div class="field">
-    <p style="font-size:13px;color:var(--sub);margin-bottom:10px;">Copy an image and press <strong style="color:var(--text);">Ctrl + V</strong> anywhere on this page to paste it here.</p>
-    <div id="ip-dropzone" style="min-height:200px;border:1.5px dashed var(--border);border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--inp-bg);position:relative;">
-        <span id="ip-hint" style="font-size:26px;font-weight:200;color:var(--border);pointer-events:none;letter-spacing:3px;">Ctrl + V</span>
-        <img id="ip-preview" src="" alt="" style="max-width:100%;max-height:300px;display:none;border-radius:6px;object-fit:contain;">
-    </div>
-</div>
-<div class="field">
-    <span class="field-label">Save Location</span>
-    <div class="row">
-        <input type="text" id="ip-savepath" placeholder="user:/Desktop" readonly style="flex:1;cursor:default;">
-        <button class="btn btn-outline" id="ip-pickFolder">
-            <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
-                <path d="M1 3h3.5l1.5 2H12v6.5H1z"/>
-            </svg>
-            Browse
-        </button>
-    </div>
-</div>
-<div class="field">
-    <label style="display:flex;align-items:center;gap:7px;font-size:13px;cursor:pointer;user-select:none;">
-        <input type="checkbox" id="ip-autoUpload" style="accent-color:var(--accent);width:15px;height:15px;">
-        Upload automatically when an image is pasted
-    </label>
-</div>
-<div class="row">
-    <button class="btn-action" id="ip-uploadBtn" disabled>Upload Image</button>
-</div>
-<div id="ip-result"></div>
-
-<script>
-(function(){
-    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
-
-    registry.imgpaste = {
-        init: function(root) {
-            var blob = null;
-            var savePath = 'user:/Desktop';
-            var autoUpload = false;
-
-            var preview   = root.querySelector('#ip-preview');
-            var hint      = root.querySelector('#ip-hint');
-            var saveInp   = root.querySelector('#ip-savepath');
-            var autoCb    = root.querySelector('#ip-autoUpload');
-            var uploadBtn = root.querySelector('#ip-uploadBtn');
-            var resultEl  = root.querySelector('#ip-result');
-            var pickBtn   = root.querySelector('#ip-pickFolder');
-
-            ao_module_storage.loadStorage('imgPaste', 'savepath', function(val){
-                if (val){ savePath = val; saveInp.value = val; }
-                else { saveInp.value = savePath; }
-            });
-            ao_module_storage.loadStorage('imgPaste', 'uploadOnPaste', function(val){
-                if (val === 'true'){ autoCb.checked = true; autoUpload = true; }
-            });
-
-            autoCb.addEventListener('change', function(){
-                autoUpload = this.checked;
-                ao_module_storage.setStorage('imgPaste', 'uploadOnPaste', autoUpload ? 'true' : 'false');
-            });
-
-            pickBtn.addEventListener('click', function(){
-                ao_module_openFileSelector('_ipFolderCb', savePath, 'folder', false);
-            });
-
-            uploadBtn.addEventListener('click', function(){ doUpload(); });
-
-            function doUpload(){
-                if (!blob) return;
-                var now = new Date();
-                var ts = Date.now() + '';
-                var filename = 'ImagePaste ' + now.getUTCFullYear() + '-' + (now.getUTCMonth()+1) + '-' + now.getUTCDate() + ts.substr(ts.length - 6) + '.png';
-                uploadBtn.disabled = true;
-                ao_module_uploadFile(new File([blob], filename), savePath, function(data){
-                    uploadBtn.disabled = false;
-                    if (data && data.error){
-                        resultEl.innerHTML = '<div class="result-box result-err"><strong>Error:</strong> ' + escHtml(String(data.error)) + '</div>';
-                    } else {
-                        resultEl.innerHTML = '<div class="result-box result-ok"><strong>' + escHtml(filename) + '</strong> saved to ' + escHtml(savePath) + '</div>';
-                    }
-                });
-            }
-
-            if (window._imgPasteDocHandler){
-                document.removeEventListener('paste', window._imgPasteDocHandler);
-            }
-            window._imgPasteDocHandler = function(e){
-                if (!document.body.contains(root)){
-                    document.removeEventListener('paste', window._imgPasteDocHandler);
-                    window._imgPasteDocHandler = null;
-                    return;
-                }
-                var items = (e.clipboardData || e.originalEvent.clipboardData).items;
-                for (var i = 0; i < items.length; i++){
-                    if (items[i].kind === 'file'){
-                        var file = items[i].getAsFile();
-                        var reader = new FileReader();
-                        reader.onload = (function(b){
-                            return function(ev){
-                                blob = b;
-                                preview.src = ev.target.result;
-                                preview.style.display = 'block';
-                                hint.style.display = 'none';
-                                uploadBtn.disabled = false;
-                                resultEl.innerHTML = '';
-                                if (autoUpload){ doUpload(); }
-                            };
-                        })(file);
-                        reader.readAsDataURL(file);
-                        break;
-                    }
-                }
-            };
-            document.addEventListener('paste', window._imgPasteDocHandler);
-
-            window._ipFolderCb = function(files){
-                if (!files || !files.length) return;
-                savePath = files[0].filepath;
-                saveInp.value = savePath;
-                ao_module_storage.setStorage('imgPaste', 'savepath', savePath);
-            };
-        }
-    };
-})();
-</script>

+ 0 - 177
src/web/Productivity/tools/pdf2img.html

@@ -1,177 +0,0 @@
-<div class="field">
-    <span class="field-label">Input PDF</span>
-    <div class="row">
-        <button class="btn btn-primary" id="p2iPickBtn">Select PDF...</button>
-        <span class="selected-file" id="p2iFile">No file selected</span>
-    </div>
-</div>
-<div class="field">
-    <span class="field-label">Output Format</span>
-    <div class="radio-group">
-        <label class="radio-item"><input type="radio" name="p2iFmt" value="jpg" checked> JPEG</label>
-        <label class="radio-item"><input type="radio" name="p2iFmt" value="png"> PNG</label>
-    </div>
-</div>
-<div class="field" id="p2iQfield">
-    <span class="field-label">JPEG Quality: <span id="p2iQval">90</span>%</span>
-    <div class="range-row">
-        <span style="font-size:11px;color:var(--sub)">50</span>
-        <input type="range" id="p2iQrange" min="50" max="100" value="90">
-        <span style="font-size:11px;color:var(--sub)">100</span>
-    </div>
-</div>
-<button class="btn-action" id="p2iBtn" disabled>Convert</button>
-<div id="p2iProg" style="display:none" class="progress-wrap">
-    <div class="progress-label" id="p2iProgLbl">Preparing...</div>
-    <div class="progress-track"><div class="progress-fill" id="p2iFill"></div></div>
-</div>
-<div id="p2iResult"></div>
-
-<script>
-(function() {
-    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
-    var state = { filepath: null, root: null };
-    var pdfJsReady = false;
-
-    function getRoot() {
-        return state.root && document.body.contains(state.root) ? state.root : null;
-    }
-
-    function loadPdfJs(onReady, onError) {
-        if (pdfJsReady || typeof pdfjsLib !== 'undefined') {
-            if (!pdfJsReady) {
-                pdfjsLib.GlobalWorkerOptions.workerSrc = '../PDF Viewer/js/pdf.worker.js';
-                pdfJsReady = true;
-            }
-            onReady();
-            return;
-        }
-
-        var s = document.createElement('script');
-        s.src = '../PDF Viewer/js/pdf.js';
-        s.onload = function() {
-            pdfjsLib.GlobalWorkerOptions.workerSrc = '../PDF Viewer/js/pdf.worker.js';
-            pdfJsReady = true;
-            onReady();
-        };
-        s.onerror = function() { onError(new Error('Failed to load PDF library')); };
-        document.head.appendChild(s);
-    }
-
-    function runConversion(filepath, fmt, quality, onProgress, onDone, onError) {
-        loadPdfJs(function() {
-            pdfjsLib.getDocument(mediaUrl(filepath)).promise.then(function(pdf) {
-                var numPages = pdf.numPages;
-                var basename = basenameNoExt(filepath);
-                var outDir = dirOf(filepath);
-                var mime = fmt === 'png' ? 'image/png' : 'image/jpeg';
-                var ext = fmt === 'png' ? '.png' : '.jpg';
-                var i = 1;
-
-                function nextPage() {
-                    if (i > numPages) {
-                        onDone(numPages, outDir);
-                        return;
-                    }
-
-                    onProgress(i, numPages);
-                    pdf.getPage(i).then(function(page) {
-                        var vp = page.getViewport({ scale: 2.0 });
-                        var canvas = document.createElement('canvas');
-                        canvas.width = vp.width;
-                        canvas.height = vp.height;
-
-                        var ctx = canvas.getContext('2d');
-                        if (fmt !== 'png') {
-                            ctx.fillStyle = '#FFF';
-                            ctx.fillRect(0, 0, vp.width, vp.height);
-                        }
-
-                        page.render({ canvasContext: ctx, viewport: vp }).promise.then(function() {
-                            canvas.toBlob(function(blob) {
-                                uploadBlob(blob, basename + '_page' + i + ext, outDir).then(function() {
-                                    i += 1;
-                                    nextPage();
-                                }, onError);
-                            }, mime, quality / 100);
-                        }, onError);
-                    }, onError);
-                }
-
-                nextPage();
-            }, onError);
-        }, onError);
-    }
-
-    registry.pdf2img = {
-        init: function(root) {
-            state = { filepath: null, root: root };
-
-            var qRange = root.querySelector('#p2iQrange');
-            var qValue = root.querySelector('#p2iQval');
-            var qField = root.querySelector('#p2iQfield');
-            var pickBtn = root.querySelector('#p2iPickBtn');
-            var convertBtn = root.querySelector('#p2iBtn');
-
-            qRange.addEventListener('input', function() {
-                qValue.textContent = this.value;
-            });
-
-            Array.prototype.forEach.call(root.querySelectorAll('input[name=p2iFmt]'), function(input) {
-                input.addEventListener('change', function() {
-                    qField.style.display = this.value === 'jpg' ? '' : 'none';
-                });
-            });
-
-            pickBtn.addEventListener('click', function() {
-                ao_module_openFileSelector('_p2iPickCb', 'user:/', 'file', false);
-            });
-
-            convertBtn.addEventListener('click', function() {
-                if (!state.filepath) { return; }
-
-                var btn = root.querySelector('#p2iBtn');
-                var prog = root.querySelector('#p2iProg');
-                var lbl = root.querySelector('#p2iProgLbl');
-                var fill = root.querySelector('#p2iFill');
-                var result = root.querySelector('#p2iResult');
-                var fmt = root.querySelector('input[name=p2iFmt]:checked').value;
-                var qual = parseInt(root.querySelector('#p2iQrange').value, 10);
-
-                btn.disabled = true;
-                prog.style.display = 'block';
-                result.innerHTML = '';
-
-                runConversion(state.filepath, fmt, qual, function(cur, total) {
-                    lbl.textContent = 'Page ' + cur + ' of ' + total + '...';
-                    fill.style.width = ((cur / total) * 100) + '%';
-                }, function(numPages, outDir) {
-                    lbl.textContent = 'Done!';
-                    fill.style.width = '100%';
-                    result.innerHTML =
-                        '<div class="result-box result-ok"><strong>' + numPages +
-                        ' image' + (numPages !== 1 ? 's' : '') + ' saved</strong> to<br>' +
-                        escHtml(outDir) + '</div>';
-                    btn.disabled = false;
-                }, function(err) {
-                    result.innerHTML =
-                        '<div class="result-box result-err"><strong>Error:</strong> ' +
-                        escHtml(err && err.message ? err.message : String(err)) + '</div>';
-                    btn.disabled = false;
-                });
-            });
-        }
-    };
-
-    window._p2iPickCb = function(files) {
-        var root = getRoot();
-        if (!root || !files || !files.length) { return; }
-
-        state.filepath = files[0].filepath;
-        var label = root.querySelector('#p2iFile');
-        label.textContent = files[0].filename;
-        label.className = 'selected-file has-value';
-        root.querySelector('#p2iBtn').disabled = false;
-    };
-})();
-</script>

+ 0 - 771
src/web/Productivity/tools/pdfedit.html

@@ -1,771 +0,0 @@
-<style>
-    /* The editor takes over the whole tool panel body (padding removed in init) */
-    .pe-root {
-        position: relative;
-        display: flex; flex-direction: column;
-        width: 100%; height: 100%;
-        overflow: hidden;
-    }
-
-    .pe-toolbar {
-        display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
-        padding: 8px 12px;
-        border-bottom: 1px solid var(--border);
-        background: var(--card-bg);
-        flex-shrink: 0;
-    }
-    .pe-sep { width: 1px; height: 20px; background: var(--border); margin: 0 3px; }
-    .pe-spacer { flex: 1; }
-    .pe-fname {
-        font-size: 12px; color: var(--sub);
-        max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-    }
-
-    .pe-tbtn {
-        display: inline-flex; align-items: center; gap: 5px;
-        padding: 6px 10px; border-radius: 7px;
-        border: 1px solid var(--border); background: var(--card-bg);
-        color: var(--text); font-size: 12.5px; font-weight: 600;
-        font-family: inherit; cursor: pointer;
-    }
-    .pe-tbtn:hover { background: rgba(0,0,0,0.05); }
-    .pe-tbtn:disabled { opacity: 0.4; cursor: default; }
-    .pe-tbtn:disabled:hover { background: var(--card-bg); }
-    .pe-tbtn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
-    .pe-tbtn.primary:hover { opacity: 0.9; background: var(--accent); }
-    .pe-tbtn.primary:disabled:hover { background: var(--accent); }
-
-    .pe-iconbtn {
-        width: 28px; height: 28px; border-radius: 6px;
-        border: 1px solid var(--border); background: var(--card-bg);
-        color: var(--text); cursor: pointer;
-        display: inline-flex; align-items: center; justify-content: center;
-    }
-    .pe-iconbtn:hover { background: rgba(0,0,0,0.05); }
-    .pe-iconbtn:disabled { opacity: 0.4; cursor: default; }
-
-    .pe-page-lbl { font-size: 12px; color: var(--sub); min-width: 70px; text-align: center; }
-    .pe-zoom {
-        font-size: 12.5px; padding: 5px 6px; border-radius: 7px;
-        border: 1px solid var(--border); background: var(--card-bg);
-        color: var(--text); font-family: inherit; cursor: pointer;
-    }
-    .pe-zoom:disabled { opacity: 0.4; cursor: default; }
-
-    /* Stage fills the remaining height; "Fit" sizes the page so it never needs scrolling */
-    .pe-stage {
-        position: relative; flex: 1; min-height: 0;
-        display: flex; overflow: hidden;
-        background: var(--progress-bg);
-    }
-    .pe-stage.zoomed { overflow: auto; }
-    .pe-empty { margin: auto; text-align: center; color: var(--sub); font-size: 13.5px; line-height: 1.6; padding: 20px; }
-
-    .pe-wrap { margin: auto; position: relative; box-shadow: 0 4px 18px rgba(0,0,0,0.22); flex-shrink: 0; }
-    .pe-wrap canvas { display: block; }
-    .pe-overlay { position: absolute; inset: 0; }
-
-    .pe-ov { position: absolute; box-sizing: border-box; cursor: move; border: 1px solid transparent; }
-    .pe-ov.selected { border-color: var(--accent); }
-    .pe-ov.pe-text {
-        padding: 1px 2px; white-space: pre; line-height: 1.25;
-        font-family: 'Helvetica Neue', Arial, sans-serif; outline: none; min-width: 12px;
-    }
-    .pe-ov.pe-text[contenteditable="true"] { cursor: text; background: rgba(0,122,255,0.06); white-space: pre-wrap; }
-    .pe-ov.pe-img img { display: block; width: 100%; height: 100%; pointer-events: none; -webkit-user-drag: none; }
-
-    .pe-handle {
-        position: absolute; right: -6px; bottom: -6px;
-        width: 13px; height: 13px; border-radius: 50%;
-        background: var(--accent); border: 2px solid #fff;
-        cursor: nwse-resize; display: none;
-    }
-    .pe-ov.selected .pe-handle { display: block; }
-    .pe-del {
-        position: absolute; right: -9px; top: -9px;
-        width: 18px; height: 18px; border-radius: 50%;
-        background: var(--danger); color: #fff; border: 2px solid #fff;
-        cursor: pointer; display: none; align-items: center; justify-content: center; padding: 0;
-    }
-    .pe-ov.selected .pe-del { display: flex; }
-
-    /* Floating contextual bar for the selected text element */
-    .pe-props {
-        position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
-        z-index: 30; display: none; align-items: center; gap: 16px;
-        padding: 8px 14px; background: var(--card-bg);
-        border: 1px solid var(--border); border-radius: 10px;
-        box-shadow: 0 6px 22px rgba(0,0,0,0.18);
-    }
-    .pe-props.show { display: flex; }
-    .pe-props label { font-size: 12px; color: var(--sub); display: flex; align-items: center; gap: 7px; }
-    .pe-props input[type=color] { width: 30px; height: 26px; border: 1px solid var(--border); border-radius: 6px; background: none; cursor: pointer; padding: 0; }
-
-    .pe-toast {
-        position: absolute; top: 14px; left: 50%; transform: translateX(-50%);
-        z-index: 40; max-width: 84%; display: none;
-    }
-    .pe-toast.show { display: block; }
-    .pe-toast .result-box { margin: 0; box-shadow: 0 6px 22px rgba(0,0,0,0.18); }
-
-    .pe-stamp-menu {
-        position: absolute; z-index: 50;
-        background: var(--card-bg); border: 1px solid var(--border);
-        border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,0.18);
-        padding: 10px; width: 230px; max-height: 280px; overflow-y: auto; display: none;
-    }
-    .pe-stamp-menu.show { display: block; }
-    .pe-stamp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
-    .pe-stamp-item {
-        border: 1px solid var(--border); border-radius: 7px; padding: 6px; cursor: pointer;
-        display: flex; flex-direction: column; gap: 4px; align-items: center;
-        background: repeating-conic-gradient(#0000000d 0% 25%, transparent 0% 50%) 50% / 14px 14px;
-    }
-    .pe-stamp-item:hover { border-color: var(--accent); }
-    .pe-stamp-item img { max-width: 100%; height: 54px; object-fit: contain; }
-    .pe-stamp-item span { font-size: 11px; color: var(--text); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-    .pe-stamp-empty { font-size: 12px; color: var(--sub); text-align: center; padding: 12px 6px; line-height: 1.5; }
-</style>
-
-<div class="pe-root" id="peRoot">
-    <div class="pe-toolbar">
-        <button class="pe-tbtn primary" id="peOpen" title="Open a PDF from the server">
-            <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
-                <path d="M1.2 3.2h3.4l1.3 1.6h6.9v6.4H1.2z"/><path d="M1.2 6h11.6"/>
-            </svg> Open File
-        </button>
-        <span class="pe-fname" id="peFileName"></span>
-
-        <div class="pe-sep"></div>
-
-        <button class="pe-tbtn" id="peAddText" title="Insert a text box" disabled>
-            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
-                <path d="M2 3.2V2h10v1.2M7 2v10M5 12h4"/>
-            </svg> Text
-        </button>
-        <button class="pe-tbtn" id="peAddImg" title="Insert an image from the server" disabled>
-            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
-                <rect x="1.5" y="2.5" width="11" height="9" rx="1.3"/><circle cx="4.7" cy="5.5" r="1"/>
-                <path d="M1.5 10 L5 6.5 L7.5 8.5 L10 5.5 L12.5 9"/>
-            </svg> Image
-        </button>
-        <button class="pe-tbtn" id="peUpload" title="Upload an image from this device" disabled>
-            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
-                <path d="M7 9V2.5M4.3 5.2 7 2.5l2.7 2.7"/><path d="M2 9.5v1.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V9.5"/>
-            </svg> Upload
-        </button>
-        <button class="pe-tbtn" id="peAddStamp" title="Place a saved company chop/stamp" disabled>
-            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
-                <path d="M7 1.5c-1.7 0-2.6 1.3-2.2 2.9.2.9.4 1.6-.3 2.1H9.5c-.7-.5-.5-1.2-.3-2.1C9.6 2.8 8.7 1.5 7 1.5Z"/>
-                <rect x="2.5" y="8.5" width="9" height="1.8" rx="0.6"/><line x1="1.8" y1="12.2" x2="12.2" y2="12.2"/>
-            </svg> Stamp
-        </button>
-
-        <div class="pe-sep"></div>
-
-        <button class="pe-iconbtn" id="pePrev" title="Previous page" disabled>
-            <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 2 L4 6.5 L8.5 11"/></svg>
-        </button>
-        <span class="pe-page-lbl" id="pePageLbl">—</span>
-        <button class="pe-iconbtn" id="peNext" title="Next page" disabled>
-            <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 2 L9 6.5 L4.5 11"/></svg>
-        </button>
-
-        <div class="pe-sep"></div>
-
-        <select class="pe-zoom" id="peZoom" title="Zoom" disabled>
-            <option value="fit" selected>Fit</option>
-            <option value="0.5">50%</option>
-            <option value="0.75">75%</option>
-            <option value="1">100%</option>
-            <option value="1.25">125%</option>
-            <option value="1.5">150%</option>
-            <option value="2">200%</option>
-        </select>
-
-        <div class="pe-spacer"></div>
-
-        <button class="pe-tbtn primary" id="peSave" title="Save the edited PDF" disabled>
-            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
-                <path d="M2 2h7.5L12 4.5V12H2z"/><path d="M4.5 2v3h4V2"/><rect x="4.5" y="7.5" width="5" height="3.5"/>
-            </svg> Save
-        </button>
-    </div>
-
-    <div class="pe-stage" id="peStage">
-        <div class="pe-empty" id="peEmpty">No PDF open.<br>Click <strong>Open File</strong> to start editing.</div>
-    </div>
-
-    <div class="pe-props" id="peProps">
-        <label>Font size
-            <input type="range" id="peFontSize" min="8" max="120" value="24" style="width:120px">
-            <span id="peFontVal" style="color:var(--text);font-weight:600;min-width:34px">24px</span>
-        </label>
-        <label>Colour <input type="color" id="peColor" value="#d0021b"></label>
-    </div>
-
-    <div class="pe-toast" id="peToast"></div>
-
-    <input type="file" id="peUploadInput" accept="image/*" style="display:none">
-    <div class="pe-stamp-menu" id="peStampMenu"></div>
-</div>
-
-<script>
-(function(){
-    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
-    var FONT = "'Helvetica Neue', Arial, sans-serif";
-    var TEXT_BASELINE_RATIO = 0.8; // distance from a line's top to its baseline, as a fraction of font size
-    var pdfJsReady = false;
-
-    function loadPdfJs(onReady, onError){
-        if (pdfJsReady || typeof pdfjsLib !== 'undefined'){
-            if (!pdfJsReady){
-                pdfjsLib.GlobalWorkerOptions.workerSrc = '../PDF Viewer/js/pdf.worker.js';
-                pdfJsReady = true;
-            }
-            onReady(); return;
-        }
-        var s = document.createElement('script');
-        s.src = '../PDF Viewer/js/pdf.js';
-        s.onload = function(){
-            pdfjsLib.GlobalWorkerOptions.workerSrc = '../PDF Viewer/js/pdf.worker.js';
-            pdfJsReady = true; onReady();
-        };
-        s.onerror = function(){ onError(new Error('Failed to load PDF library')); };
-        document.head.appendChild(s);
-    }
-
-    /* pdf-lib (vendored, MIT) — used to add overlays as real PDF content while keeping the original intact */
-    function loadPdfLib(onReady, onError){
-        if (typeof PDFLib !== 'undefined'){ onReady(); return; }
-        var s = document.createElement('script');
-        s.src = 'tools/lib/pdf-lib.min.js';
-        s.onload = function(){ onReady(); };
-        s.onerror = function(){ onError(new Error('Failed to load pdf-lib')); };
-        document.head.appendChild(s);
-    }
-
-    registry.pdfedit = {
-        init: function(root){
-            // Take over the padded, scrolling tool-panel body so the editor fills it edge to edge
-            root.style.padding = '0';
-            root.style.overflow = 'hidden';
-            root.style.display = 'flex';
-            root.style.flexDirection = 'column';
-
-            var S = { pdf:null, filepath:null, page:1, num:0, overlays:{}, sel:null, root:root };
-            var zoom = 'fit'; // 'fit' or a numeric scale (1 = 100%)
-            var resizeTimer = null;
-
-            var elStage  = root.querySelector('#peStage');
-            var elEmpty  = root.querySelector('#peEmpty');
-            var elProps  = root.querySelector('#peProps');
-            var elToast  = root.querySelector('#peToast');
-            var elFontSize = root.querySelector('#peFontSize');
-            var elFontVal  = root.querySelector('#peFontVal');
-            var elColor    = root.querySelector('#peColor');
-            var elZoom     = root.querySelector('#peZoom');
-            var elFileName = root.querySelector('#peFileName');
-            var stampMenu  = root.querySelector('#peStampMenu');
-            var uploadInput = root.querySelector('#peUploadInput');
-            var editButtons = ['#peAddText','#peAddImg','#peUpload','#peAddStamp','#peSave','#peZoom']
-                .map(function(s){ return root.querySelector(s); });
-            var canvas, ctx, overlayEl, wrapEl;
-
-            function alive(){ return document.body.contains(root); }
-
-            function enableEditing(on){
-                editButtons.forEach(function(b){ b.disabled = !on; });
-            }
-
-            function showToast(html, isError){
-                elToast.innerHTML = '<div class="result-box ' + (isError ? 'result-err' : 'result-ok') + '">' + html + '</div>';
-                elToast.classList.add('show');
-                clearTimeout(elToast._t);
-                if (!isError){ elToast._t = setTimeout(function(){ elToast.classList.remove('show'); }, 5000); }
-            }
-            function hideToast(){ elToast.classList.remove('show'); }
-
-            /* ── Selection / property bar ── */
-            function selectOverlay(o, el){
-                clearSelection();
-                S.sel = { o:o, el:el };
-                el.classList.add('selected');
-                if (o.type === 'text'){
-                    elProps.classList.add('show');
-                    var px = Math.round(o.fontFrac * canvas.height);
-                    elFontSize.value = px; elFontVal.textContent = px + 'px';
-                    elColor.value = o.color || '#000000';
-                }
-            }
-            function clearSelection(){
-                if (S.sel){ S.sel.el.classList.remove('selected'); }
-                S.sel = null;
-                elProps.classList.remove('show');
-            }
-
-            elFontSize.addEventListener('input', function(){
-                elFontVal.textContent = this.value + 'px';
-                if (S.sel && S.sel.o.type === 'text'){
-                    S.sel.o.fontFrac = parseInt(this.value, 10) / canvas.height;
-                    S.sel.el.style.fontSize = this.value + 'px';
-                }
-            });
-            elColor.addEventListener('input', function(){
-                if (S.sel && S.sel.o.type === 'text'){
-                    S.sel.o.color = this.value;
-                    S.sel.el.style.color = this.value;
-                }
-            });
-
-            /* ── Overlay element construction ── */
-            function makeOverlayEl(o){
-                var el = document.createElement('div');
-                el.className = 'pe-ov ' + (o.type === 'text' ? 'pe-text' : 'pe-img');
-                el.style.left = (o.fx * canvas.width) + 'px';
-                el.style.top  = (o.fy * canvas.height) + 'px';
-
-                if (o.type === 'text'){
-                    el.textContent = o.t;
-                    el.style.fontSize = (o.fontFrac * canvas.height) + 'px';
-                    el.style.color = o.color || '#000';
-                    el.addEventListener('dblclick', function(){
-                        el.setAttribute('contenteditable', 'true');
-                        el.focus();
-                    });
-                    el.addEventListener('blur', function(){
-                        el.removeAttribute('contenteditable');
-                        o.t = el.textContent;
-                        o.fx = el.offsetLeft / canvas.width;
-                        o.fy = el.offsetTop / canvas.height;
-                    });
-                } else {
-                    el.style.width  = (o.fwFrac * canvas.width) + 'px';
-                    el.style.height = (o.fhFrac * canvas.height) + 'px';
-                    var img = document.createElement('img');
-                    img.src = o.src; el.appendChild(img);
-                    var handle = document.createElement('div');
-                    handle.className = 'pe-handle';
-                    el.appendChild(handle);
-                    wireResize(handle, el, o);
-                }
-
-                var del = document.createElement('button');
-                del.className = 'pe-del';
-                del.innerHTML = '<svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round"><line x1="1" y1="1" x2="8" y2="8"/><line x1="8" y1="1" x2="1" y2="8"/></svg>';
-                del.addEventListener('mousedown', function(e){ e.stopPropagation(); });
-                del.addEventListener('click', function(e){
-                    e.stopPropagation();
-                    var arr = S.overlays[S.page] || [];
-                    var i = arr.indexOf(o);
-                    if (i !== -1) arr.splice(i, 1);
-                    clearSelection();
-                    el.parentNode.removeChild(el);
-                });
-                el.appendChild(del);
-
-                wireDrag(el, o);
-                el.addEventListener('mousedown', function(){ selectOverlay(o, el); });
-                return el;
-            }
-
-            function wireDrag(el, o){
-                el.addEventListener('mousedown', function(e){
-                    if (el.getAttribute('contenteditable') === 'true') return;
-                    if (e.target.classList.contains('pe-handle')) return;
-                    e.preventDefault();
-                    var startX = e.clientX, startY = e.clientY;
-                    var baseL = el.offsetLeft, baseT = el.offsetTop;
-                    function mv(ev){
-                        var nl = Math.max(0, Math.min(canvas.width  - 6, baseL + (ev.clientX - startX)));
-                        var nt = Math.max(0, Math.min(canvas.height - 6, baseT + (ev.clientY - startY)));
-                        el.style.left = nl + 'px'; el.style.top = nt + 'px';
-                        o.fx = nl / canvas.width; o.fy = nt / canvas.height;
-                    }
-                    function up(){ document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); }
-                    document.addEventListener('mousemove', mv);
-                    document.addEventListener('mouseup', up);
-                });
-            }
-
-            function wireResize(handle, el, o){
-                handle.addEventListener('mousedown', function(e){
-                    e.preventDefault(); e.stopPropagation();
-                    var startX = e.clientX;
-                    var startW = el.offsetWidth;
-                    var aspect = el.offsetHeight / el.offsetWidth;
-                    function mv(ev){
-                        var nw = Math.max(18, Math.min(canvas.width, startW + (ev.clientX - startX)));
-                        var nh = nw * aspect;
-                        el.style.width = nw + 'px'; el.style.height = nh + 'px';
-                        o.fwFrac = nw / canvas.width; o.fhFrac = nh / canvas.height;
-                    }
-                    function up(){ document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); }
-                    document.addEventListener('mousemove', mv);
-                    document.addEventListener('mouseup', up);
-                });
-            }
-
-            /* ── Page rendering (Fit fills the stage; numeric zoom scrolls) ── */
-            function pageScale(v1){
-                if (zoom === 'fit'){
-                    var availW = Math.max(80, elStage.clientWidth - 28);
-                    var availH = Math.max(80, elStage.clientHeight - 28);
-                    return Math.min(availW / v1.width, availH / v1.height, 4);
-                }
-                return zoom;
-            }
-
-            function renderPage(n){
-                if (!S.pdf) return;
-                clearSelection();
-                S.pdf.getPage(n).then(function(page){
-                    if (!alive()) return;
-                    var v1 = page.getViewport({ scale: 1 });
-                    var vp = page.getViewport({ scale: pageScale(v1) });
-
-                    elStage.classList.toggle('zoomed', zoom !== 'fit');
-                    elStage.innerHTML = '';
-                    wrapEl = document.createElement('div');
-                    wrapEl.className = 'pe-wrap';
-                    wrapEl.style.width = vp.width + 'px';
-                    wrapEl.style.height = vp.height + 'px';
-                    canvas = document.createElement('canvas');
-                    canvas.width = vp.width; canvas.height = vp.height;
-                    ctx = canvas.getContext('2d');
-                    overlayEl = document.createElement('div');
-                    overlayEl.className = 'pe-overlay';
-                    wrapEl.appendChild(canvas);
-                    wrapEl.appendChild(overlayEl);
-                    elStage.appendChild(wrapEl);
-
-                    overlayEl.addEventListener('mousedown', function(e){
-                        if (e.target === overlayEl) clearSelection();
-                    });
-
-                    page.render({ canvasContext: ctx, viewport: vp }).promise.then(function(){
-                        (S.overlays[n] || []).forEach(function(o){ overlayEl.appendChild(makeOverlayEl(o)); });
-                    });
-
-                    root.querySelector('#pePageLbl').textContent = n + ' / ' + S.num;
-                    root.querySelector('#pePrev').disabled = (n <= 1);
-                    root.querySelector('#peNext').disabled = (n >= S.num);
-                });
-            }
-
-            function addOverlay(o){
-                if (!S.overlays[S.page]) S.overlays[S.page] = [];
-                S.overlays[S.page].push(o);
-                if (overlayEl){
-                    var el = makeOverlayEl(o);
-                    overlayEl.appendChild(el);
-                    selectOverlay(o, el);
-                }
-            }
-
-            // Re-fit the page when the panel/window is resized (only in Fit mode)
-            var ro = new ResizeObserver(function(){
-                if (!alive()){ ro.disconnect(); return; }
-                if (!S.pdf || zoom !== 'fit') return;
-                clearTimeout(resizeTimer);
-                resizeTimer = setTimeout(function(){ if (alive() && S.pdf) renderPage(S.page); }, 120);
-            });
-            ro.observe(elStage);
-
-            /* ── Toolbar actions ── */
-            root.querySelector('#peOpen').addEventListener('click', function(){
-                ao_module_openFileSelector('_peoPickCb', 'user:/', 'file', false, { filter: ['pdf'] });
-            });
-
-            root.querySelector('#peAddText').addEventListener('click', function(){
-                if (!canvas) return;
-                addOverlay({ type:'text', t:'Double-click to edit', fx:0.12, fy:0.12,
-                    fontFrac: 24 / canvas.height, color: elColor.value || '#d0021b' });
-            });
-
-            root.querySelector('#peAddImg').addEventListener('click', function(){
-                ao_module_openFileSelector('_peoImgCb', 'user:/', 'file', false, { filter: ['jpg','jpeg','png','gif','webp','bmp'] });
-            });
-
-            root.querySelector('#peUpload').addEventListener('click', function(){
-                uploadInput.value = '';
-                uploadInput.click();
-            });
-            uploadInput.addEventListener('change', function(){
-                if (!this.files || !this.files.length) return;
-                var reader = new FileReader();
-                reader.onload = function(ev){ placeImageFromSrc(ev.target.result); };
-                reader.readAsDataURL(this.files[0]);
-            });
-
-            root.querySelector('#peAddStamp').addEventListener('click', function(e){
-                e.stopPropagation();
-                openStampMenu(this);
-            });
-
-            root.querySelector('#pePrev').addEventListener('click', function(){
-                if (S.page > 1){ S.page--; renderPage(S.page); }
-            });
-            root.querySelector('#peNext').addEventListener('click', function(){
-                if (S.page < S.num){ S.page++; renderPage(S.page); }
-            });
-
-            elZoom.addEventListener('change', function(){
-                zoom = this.value === 'fit' ? 'fit' : parseFloat(this.value);
-                if (S.pdf) renderPage(S.page);
-            });
-
-            /* ── Stamp picker ── */
-            function placeImageFromSrc(src){
-                if (!canvas) return;
-                var probe = new Image();
-                probe.onload = function(){
-                    var dispW = Math.min(150, canvas.width * 0.32);
-                    var dispH = dispW * (probe.naturalHeight / probe.naturalWidth);
-                    addOverlay({ type:'image', src:src,
-                        fx:0.4, fy:0.4, fwFrac: dispW / canvas.width, fhFrac: dispH / canvas.height });
-                };
-                probe.onerror = function(){ showToast('Could not load that image.', true); };
-                probe.src = src;
-            }
-
-            function openStampMenu(anchor){
-                if (!window.ProductivityChops){
-                    stampMenu.innerHTML = '<div class="pe-stamp-empty">Chop library unavailable.</div>';
-                }
-                window.ProductivityChops && window.ProductivityChops.load(function(chops){
-                    if (!chops.length){
-                        stampMenu.innerHTML = '<div class="pe-stamp-empty">No chops saved yet.<br>Add your company stamp in the <strong>Chop Library</strong> tool first.</div>';
-                    } else {
-                        var html = '<div class="pe-stamp-grid">';
-                        chops.forEach(function(c){
-                            html += '<div class="pe-stamp-item" data-src="' + mediaUrl(c.path) + '">' +
-                                    '<img src="' + mediaUrl(c.path) + '" alt=""><span>' + escHtml(c.name) + '</span></div>';
-                        });
-                        html += '</div>';
-                        stampMenu.innerHTML = html;
-                        Array.prototype.forEach.call(stampMenu.querySelectorAll('.pe-stamp-item'), function(it){
-                            it.addEventListener('click', function(){
-                                placeImageFromSrc(this.getAttribute('data-src'));
-                                stampMenu.classList.remove('show');
-                            });
-                        });
-                    }
-                    stampMenu.style.left = (anchor.offsetLeft) + 'px';
-                    stampMenu.style.top  = (anchor.offsetTop + anchor.offsetHeight + 6) + 'px';
-                    stampMenu.classList.add('show');
-                });
-            }
-            document.addEventListener('mousedown', function(e){
-                if (!stampMenu.contains(e.target) && e.target.id !== 'peAddStamp'){
-                    stampMenu.classList.remove('show');
-                }
-            });
-
-            /* ── File-selector callbacks (global, guarded by alive()) ── */
-            window._peoPickCb = function(files){
-                if (!alive() || !files || !files.length) return;
-                var f = files[0];
-                S.filepath = f.filepath;
-                elFileName.textContent = f.filename;
-                elFileName.title = f.filename;
-                hideToast();
-                S.overlays = {}; S.page = 1; zoom = 'fit'; elZoom.value = 'fit';
-                elStage.classList.remove('zoomed');
-                elStage.innerHTML = '<div class="pe-empty">Opening…</div>';
-                ao_module_setWindowTitle('PDF Editor — ' + f.filename);
-                loadPdfJs(function(){
-                    pdfjsLib.getDocument(mediaUrl(f.filepath)).promise.then(function(pdf){
-                        if (!alive()) return;
-                        S.pdf = pdf; S.num = pdf.numPages; S.page = 1;
-                        enableEditing(true);
-                        renderPage(1);
-                    }, function(err){
-                        elStage.innerHTML = '<div class="pe-empty">Failed to open PDF:<br>' + escHtml(String(err && err.message || err)) + '</div>';
-                    });
-                }, function(err){
-                    elStage.innerHTML = '<div class="pe-empty">' + escHtml(err.message) + '</div>';
-                });
-            };
-
-            window._peoImgCb = function(files){
-                if (!alive() || !files || !files.length) return;
-                placeImageFromSrc(mediaUrl(files[0].filepath));
-            };
-
-            /* ══════════════════════════════════════════════════════════════
-               EXPORT — overlays are written as real PDF content with pdf-lib.
-               Pages without overlays are left byte-for-byte untouched, so the
-               original vector text stays selectable and searchable.
-            ══════════════════════════════════════════════════════════════ */
-            function fetchBytes(url){
-                return fetch(url).then(function(r){
-                    if (!r.ok) throw new Error('HTTP ' + r.status);
-                    return r.arrayBuffer();
-                }).then(function(buf){ return new Uint8Array(buf); });
-            }
-
-            function canvasToPngBytes(c){
-                return new Promise(function(resolve, reject){
-                    c.toBlob(function(blob){
-                        if (!blob){ reject(new Error('Canvas export failed')); return; }
-                        var fr = new FileReader();
-                        fr.onload = function(e){ resolve(new Uint8Array(e.target.result)); };
-                        fr.onerror = reject;
-                        fr.readAsArrayBuffer(blob);
-                    }, 'image/png');
-                });
-            }
-
-            // Embed an overlay image: pass PNG/JPEG through directly, rasterise anything else to PNG.
-            function embedImageSrc(pdfDoc, src, cache){
-                if (cache[src]) return cache[src];
-                var p = fetchBytes(src).then(function(bytes){
-                    if (bytes[0] === 0x89 && bytes[1] === 0x50){ return pdfDoc.embedPng(bytes); }
-                    if (bytes[0] === 0xFF && bytes[1] === 0xD8){ return pdfDoc.embedJpg(bytes); }
-                    return new Promise(function(resolve, reject){
-                        var img = new Image();
-                        img.onload = function(){
-                            var c = document.createElement('canvas');
-                            c.width = img.naturalWidth; c.height = img.naturalHeight;
-                            c.getContext('2d').drawImage(img, 0, 0);
-                            canvasToPngBytes(c).then(function(png){ resolve(pdfDoc.embedPng(png)); }, reject);
-                        };
-                        img.onerror = function(){ reject(new Error('Could not load image')); };
-                        img.src = src;
-                    });
-                });
-                cache[src] = p;
-                return p;
-            }
-
-            // Rasterise one text overlay to a PNG — fallback for glyphs the standard font can't encode (e.g. CJK).
-            function rasteriseText(o, fontPt){
-                var lines = String(o.t).split('\n');
-                var probe = document.createElement('canvas').getContext('2d');
-                probe.font = fontPt + 'px ' + FONT;
-                var wPt = 1;
-                lines.forEach(function(ln){ wPt = Math.max(wPt, probe.measureText(ln).width); });
-                var lineH = fontPt * 1.25;
-                var hPt = lines.length * lineH;
-                var k = 3; // supersample for crisp glyphs
-                var c = document.createElement('canvas');
-                c.width = Math.max(1, Math.ceil(wPt * k));
-                c.height = Math.max(1, Math.ceil(hPt * k));
-                var cx = c.getContext('2d');
-                cx.scale(k, k);
-                cx.fillStyle = o.color || '#000';
-                cx.textBaseline = 'top';
-                cx.font = fontPt + 'px ' + FONT;
-                lines.forEach(function(ln, i){ cx.fillText(ln, 0, i * lineH); });
-                return { canvas: c, wPt: wPt, hPt: hPt };
-            }
-
-            function hexToRgb(hex){
-                hex = (hex || '#000000').replace('#', '');
-                if (hex.length === 3){ hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; }
-                return {
-                    r: parseInt(hex.substr(0,2),16)/255,
-                    g: parseInt(hex.substr(2,2),16)/255,
-                    b: parseInt(hex.substr(4,2),16)/255
-                };
-            }
-
-            var elSave = root.querySelector('#peSave');
-            var SAVE_LABEL = elSave.innerHTML;
-            elSave.addEventListener('click', function(){
-                if (!S.pdf || !S.filepath) return;
-                clearSelection();
-                var btn = elSave;
-                btn.disabled = true;
-                function status(t){ btn.textContent = t; }
-                function done(){ btn.innerHTML = SAVE_LABEL; btn.disabled = false; }
-                function fail(msg){ showToast('<strong>Error:</strong> ' + escHtml(String(msg)), true); done(); }
-
-                status('Loading…');
-                loadPdfLib(function(){
-                    var L = window.PDFLib;
-                    var degrees = L.degrees, rgb = L.rgb;
-
-                    status('Opening…');
-                    fetchBytes(mediaUrl(S.filepath)).then(function(srcBytes){
-                        return L.PDFDocument.load(srcBytes, { ignoreEncryption: true });
-                    }).then(function(pdfDoc){
-                        return pdfDoc.embedFont(L.StandardFonts.Helvetica).then(function(helv){
-                            var imgCache = {};
-                            var libPages = pdfDoc.getPages();
-                            var pageKeys = Object.keys(S.overlays).filter(function(k){ return (S.overlays[k] || []).length; });
-
-                            // Walk only the pages that actually have overlays, in order.
-                            return pageKeys.reduce(function(chain, k){
-                                return chain.then(function(){
-                                    var pn = parseInt(k, 10);
-                                    var libPage = libPages[pn - 1];
-                                    if (!libPage) return;
-                                    status('Writing page ' + pn + '…');
-
-                                    return S.pdf.getPage(pn).then(function(jsPage){
-                                        var vp = jsPage.getViewport({ scale: 1 });
-                                        var rot = vp.rotation || 0;
-
-                                        return (S.overlays[pn] || []).reduce(function(c2, o){
-                                            return c2.then(function(){
-                                                var dx = o.fx * vp.width, dy = o.fy * vp.height;
-
-                                                if (o.type === 'image'){
-                                                    var ew = o.fwFrac * vp.width, eh = o.fhFrac * vp.height;
-                                                    var bl = vp.convertToPdfPoint(dx, dy + eh);
-                                                    return embedImageSrc(pdfDoc, o.src, imgCache).then(function(img){
-                                                        libPage.drawImage(img, { x: bl[0], y: bl[1], width: ew, height: eh, rotate: degrees(rot) });
-                                                    });
-                                                }
-
-                                                var fontPt = o.fontFrac * vp.height;
-                                                var col = hexToRgb(o.color);
-                                                var canEncode = true;
-                                                try { helv.encodeText(o.t); } catch(e){ canEncode = false; }
-
-                                                if (canEncode){
-                                                    String(o.t).split('\n').forEach(function(ln, i){
-                                                        var topY = dy + TEXT_BASELINE_RATIO * fontPt + i * fontPt * 1.25;
-                                                        var pt = vp.convertToPdfPoint(dx, topY);
-                                                        libPage.drawText(ln, {
-                                                            x: pt[0], y: pt[1], size: fontPt, font: helv,
-                                                            color: rgb(col.r, col.g, col.b), rotate: degrees(rot)
-                                                        });
-                                                    });
-                                                    return;
-                                                }
-
-                                                // Unsupported glyphs: rasterise just this text box and place it as an image.
-                                                var rt = rasteriseText(o, fontPt);
-                                                var blt = vp.convertToPdfPoint(dx, dy + rt.hPt);
-                                                return canvasToPngBytes(rt.canvas)
-                                                    .then(function(png){ return pdfDoc.embedPng(png); })
-                                                    .then(function(img){
-                                                        libPage.drawImage(img, { x: blt[0], y: blt[1], width: rt.wPt, height: rt.hPt, rotate: degrees(rot) });
-                                                    });
-                                            });
-                                        }, Promise.resolve());
-                                    });
-                                });
-                            }, Promise.resolve()).then(function(){ return pdfDoc.save(); });
-                        });
-                    }).then(function(outBytes){
-                        var outName = basenameNoExt(S.filepath) + '_edited.pdf';
-                        var outDir = dirOf(S.filepath);
-                        status('Uploading…');
-                        return uploadBlob(new Blob([outBytes], { type: 'application/pdf' }), outName, outDir).then(function(){
-                            showToast('<strong>' + escHtml(outName) + '</strong> saved to ' + escHtml(outDir) +
-                                '.', false);
-                            done();
-                        });
-                    }).catch(function(err){
-                        fail(err && err.message ? err.message : err);
-                    });
-                }, function(err){ fail(err.message); });
-            });
-        }
-    };
-})();
-</script>

+ 0 - 34
src/web/Productivity/tools/webbuilder.html

@@ -1,34 +0,0 @@
-<script>
-(function(){
-    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
-
-    registry.webbuilder = {
-        init: function(root) {
-            root.style.padding = '0';
-            root.style.overflow = 'hidden';
-            root.style.position = 'relative';
-
-            var src = '../Web Builder/index.html';
-            if (window._productivityLaunchFile) {
-                src += '#' + encodeURIComponent(JSON.stringify([window._productivityLaunchFile]));
-                delete window._productivityLaunchFile;
-            }
-
-            var iframe = document.createElement('iframe');
-            iframe.title = 'Web Builder';
-            iframe.src = src;
-            iframe.style.position = 'absolute';
-            iframe.style.top = '0';
-            iframe.style.left = '0';
-            iframe.style.right = '0';
-            iframe.style.bottom = '0';
-            iframe.style.width = '100%';
-            iframe.style.height = '100%';
-            iframe.style.border = 'none';
-
-            root.innerHTML = '';
-            root.appendChild(iframe);
-        }
-    };
-})();
-</script>

+ 1 - 1
src/web/Text/index.html

@@ -2466,7 +2466,7 @@ function exportDocument(){
 
 // ════════════════════════════════════════════════════════════════════════
 // Text-based PDF export (pdf-lib): real selectable text + embedded images.
-// Mirrors the Productivity PDF Editor — standard fonts for encodable text,
+// Mirrors the PDF Tool editor — standard fonts for encodable text,
 // rasterise fallback for glyphs the standard fonts can't encode (e.g. CJK).
 // ════════════════════════════════════════════════════════════════════════
 function exportPDF(){