Upload Pocket_Image_Converter_v1_6.html
Browse files- Pocket_Image_Converter_v1_6.html +1293 -0
Pocket_Image_Converter_v1_6.html
ADDED
|
@@ -0,0 +1,1293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Pocket Image Converter</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root{
|
| 9 |
+
--bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d;
|
| 10 |
+
--txt:#e6edf3; --dim:#8b98a5; --accent:#4cc2ff; --accent2:#2a9fd6;
|
| 11 |
+
--ok:#3fb950; --warn:#d29922; --danger:#f85149; --red:#7f1018;
|
| 12 |
+
--r:10px;
|
| 13 |
+
}
|
| 14 |
+
*{box-sizing:border-box}
|
| 15 |
+
html,body{margin:0;min-height:100%}
|
| 16 |
+
body{
|
| 17 |
+
background:var(--bg); color:var(--txt);
|
| 18 |
+
font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
| 19 |
+
-webkit-font-smoothing:antialiased;
|
| 20 |
+
}
|
| 21 |
+
.wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px}
|
| 22 |
+
header{display:flex;align-items:center;gap:12px;margin-bottom:14px}
|
| 23 |
+
header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px}
|
| 24 |
+
header .sub{color:var(--dim);font-size:12.5px}
|
| 25 |
+
header .verwrap{display:flex;flex-direction:column;align-items:center;gap:3px}
|
| 26 |
+
header .ver{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums;
|
| 27 |
+
border:1px solid var(--line);border-radius:99px;padding:3px 10px;background:var(--panel)}
|
| 28 |
+
header .by{color:var(--dim);font-size:10px;letter-spacing:.3px;opacity:.8}
|
| 29 |
+
.spacer{flex:1}
|
| 30 |
+
button{
|
| 31 |
+
font:inherit;color:var(--txt);background:var(--panel2);border:1px solid var(--line);
|
| 32 |
+
padding:7px 13px;border-radius:8px;cursor:pointer;transition:.12s;
|
| 33 |
+
}
|
| 34 |
+
button:hover:not(:disabled){background:#242d39;border-color:#3a4553}
|
| 35 |
+
button:disabled{opacity:.4;cursor:not-allowed}
|
| 36 |
+
button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
| 37 |
+
button.primary{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
|
| 38 |
+
button.primary:hover:not(:disabled){background:#6bcfff}
|
| 39 |
+
button.ghost{background:transparent}
|
| 40 |
+
button.danger{color:#ffb4ae;border-color:#5a2b2b}
|
| 41 |
+
button.danger:hover:not(:disabled){background:#341c1c;border-color:#7a3a3a}
|
| 42 |
+
.small{padding:5px 10px;font-size:12.5px}
|
| 43 |
+
/* ---------- drop zone ---------- */
|
| 44 |
+
#drop{
|
| 45 |
+
border:2px dashed var(--line);border-radius:14px;padding:40px 24px;text-align:center;background:var(--panel);
|
| 46 |
+
cursor:pointer;transition:.15s;margin-bottom:16px;
|
| 47 |
+
}
|
| 48 |
+
#drop:hover,#drop.over{border-color:var(--accent);background:#12212b}
|
| 49 |
+
#drop .big{font-size:38px;line-height:1;margin-bottom:12px;opacity:.65}
|
| 50 |
+
#drop h2{margin:0 0 6px;font-size:17px;font-weight:600}
|
| 51 |
+
#drop p{margin:0;color:var(--dim);font-size:12.5px}
|
| 52 |
+
#drop .row2{display:flex;gap:8px;justify-content:center;margin-top:14px;flex-wrap:wrap}
|
| 53 |
+
#drop{position:relative}
|
| 54 |
+
#dropToggle{position:absolute;top:8px;right:8px;padding:3px 10px;font-size:12px;border-radius:7px;z-index:2}
|
| 55 |
+
#dropToggle .chev{display:inline-block;transition:transform .15s;font-size:10px}
|
| 56 |
+
/* collapsed state */
|
| 57 |
+
#drop.mini{padding:10px 14px;text-align:left;border-style:solid;display:flex;align-items:center;gap:10px}
|
| 58 |
+
#drop.mini .big,#drop.mini h2,#drop.mini p{display:none}
|
| 59 |
+
#drop.mini .row2{margin:0;justify-content:flex-start}
|
| 60 |
+
#drop.mini #dropToggle{position:static;margin-left:auto}
|
| 61 |
+
#drop .miniLabel{display:none;font-size:12.5px;color:var(--dim)}
|
| 62 |
+
#drop.mini .miniLabel{display:block}
|
| 63 |
+
#drop.mini .miniLabel b{color:var(--txt);font-weight:600}
|
| 64 |
+
/* ---------- page-wide drop overlay ---------- */
|
| 65 |
+
#pageDrop{position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;
|
| 66 |
+
background:rgba(8,14,20,.82);backdrop-filter:blur(3px);pointer-events:none}
|
| 67 |
+
#pageDrop.show{display:flex}
|
| 68 |
+
#pageDrop .box{border:3px dashed var(--accent);border-radius:20px;padding:44px 60px;text-align:center;
|
| 69 |
+
background:rgba(18,33,43,.92);box-shadow:0 20px 60px rgba(0,0,0,.5)}
|
| 70 |
+
#pageDrop .box .big{font-size:46px;opacity:.8;margin-bottom:10px}
|
| 71 |
+
#pageDrop .box h2{margin:0 0 6px;font-size:20px}
|
| 72 |
+
#pageDrop .box p{margin:0;color:var(--dim);font-size:13px}
|
| 73 |
+
/* ---------- layout ---------- */
|
| 74 |
+
.grid{display:grid;grid-template-columns:1fr 330px;gap:18px;align-items:start}
|
| 75 |
+
@media(max-width:940px){.grid{grid-template-columns:1fr}}
|
| 76 |
+
.card{background:#11161d;border:1px solid var(--line);border-radius:var(--r);padding:14px}
|
| 77 |
+
.card h3,.panelbox h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
|
| 78 |
+
.panelbox{background:#0d1219;border:1px solid var(--line);border-radius:10px;padding:10px 12px 12px;margin-bottom:14px}
|
| 79 |
+
.panelbox:last-child{margin-bottom:0}
|
| 80 |
+
h3.collap{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;
|
| 81 |
+
margin:-10px -12px 10px;padding:9px 12px;border-radius:9px 9px 0 0;background:#171d26;
|
| 82 |
+
border-bottom:1px solid var(--line);color:var(--txt)}
|
| 83 |
+
h3.collap:hover{background:#1c232d}
|
| 84 |
+
h3.collap.shut{margin-bottom:-12px;border-bottom:none;border-radius:9px}
|
| 85 |
+
h3.collap .chev{font-size:11px;color:var(--accent);transition:transform .15s}
|
| 86 |
+
h3.collap.shut .chev{transform:rotate(-90deg)}
|
| 87 |
+
.collapbody.shut{display:none}
|
| 88 |
+
/* ---------- view tabs ---------- */
|
| 89 |
+
.viewtabs{display:flex;gap:5px;margin-bottom:12px}
|
| 90 |
+
.viewtabs button{padding:6px 14px;font-size:12.5px;border-radius:8px}
|
| 91 |
+
.viewtabs button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
|
| 92 |
+
/* ---------- queue ---------- */
|
| 93 |
+
.q-head{display:flex;align-items:center;gap:8px;margin:0 0 8px;flex-wrap:wrap}
|
| 94 |
+
.q-head h3{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
|
| 95 |
+
.qlist{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);max-height:420px;overflow-y:auto}
|
| 96 |
+
.qrow{display:flex;align-items:center;gap:10px;padding:7px 10px;border-top:1px solid var(--line);font-size:12.5px}
|
| 97 |
+
.qrow:first-child{border-top:none}
|
| 98 |
+
.qrow:hover{background:var(--panel2)}
|
| 99 |
+
.qrow.on{background:#12212b;box-shadow:inset 2px 0 0 var(--accent)}
|
| 100 |
+
.qrow input[type=checkbox]{width:16px;height:16px;flex:none;accent-color:var(--accent);cursor:pointer}
|
| 101 |
+
.qthumb{width:52px;height:40px;border-radius:4px;border:1px solid var(--line);background:#07090c;flex:none;object-fit:cover;display:block}
|
| 102 |
+
.qthumb.ph{display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:16px}
|
| 103 |
+
.qmeta{flex:1 1 90px;min-width:60px;overflow:hidden}
|
| 104 |
+
.qname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
| 105 |
+
.qsub{color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums;margin-top:1px}
|
| 106 |
+
.qstate{font-size:11px;font-variant-numeric:tabular-nums;flex:none;width:74px;text-align:right}
|
| 107 |
+
.qstate.done{color:var(--ok)} .qstate.err{color:#ffb4ae} .qstate.work{color:var(--accent)}
|
| 108 |
+
.qx{padding:2px 8px;font-size:13px;border-radius:6px;flex:none}
|
| 109 |
+
.empty{color:var(--dim);font-size:12.5px;text-align:center;padding:24px 10px}
|
| 110 |
+
/* ---------- grid view ---------- */
|
| 111 |
+
.imgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(122px,1fr));gap:10px}
|
| 112 |
+
.gcard{position:relative;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);cursor:pointer;transition:.12s}
|
| 113 |
+
.gcard:hover{border-color:#3a4553}
|
| 114 |
+
.gcard.on{box-shadow:0 0 0 2px var(--accent);border-color:var(--accent)}
|
| 115 |
+
.gcard img{width:100%;height:104px;object-fit:cover;display:block;background:#07090c}
|
| 116 |
+
.gcard .gph{width:100%;height:104px;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:20px;background:#07090c}
|
| 117 |
+
.gcard .gname{font-size:11px;padding:5px 7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--txt)}
|
| 118 |
+
.gcard .gchk{position:absolute;top:6px;left:6px;width:18px;height:18px;accent-color:var(--accent);cursor:pointer;z-index:2}
|
| 119 |
+
.gcard .gx{position:absolute;top:4px;right:4px;padding:1px 7px;font-size:12px;line-height:1.5;border-radius:6px;
|
| 120 |
+
background:rgba(0,0,0,.55);color:var(--dim);z-index:2}
|
| 121 |
+
.gcard .gx:hover{color:#ffb4ae;border-color:#5a2b2b;background:#341c1c}
|
| 122 |
+
.gcard .gstate{position:absolute;bottom:24px;right:5px;font-size:10px;padding:1px 6px;border-radius:5px;background:rgba(0,0,0,.6);font-variant-numeric:tabular-nums}
|
| 123 |
+
.gcard .gstate.done{color:#9ae6a8} .gcard .gstate.err{color:#ffb4ae} .gcard .gstate.work{color:var(--accent)}
|
| 124 |
+
/* ---------- preview stage ---------- */
|
| 125 |
+
.stage{background:#000;border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;
|
| 126 |
+
position:relative;border:1px solid var(--line);min-height:240px;margin-bottom:14px}
|
| 127 |
+
.stage canvas{max-width:100%;max-height:52vh;width:auto;height:auto;display:block;background:#000}
|
| 128 |
+
.badge{position:absolute;top:10px;left:10px;z-index:3;background:rgba(0,0,0,.66);backdrop-filter:blur(4px);
|
| 129 |
+
padding:4px 9px;border-radius:6px;font-size:11.5px;color:#cfe;letter-spacing:.3px}
|
| 130 |
+
.ba-right{left:auto;right:10px;color:#bfe9ff}
|
| 131 |
+
/* ---------- settings ---------- */
|
| 132 |
+
.row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
| 133 |
+
.row label{flex:0 0 92px;color:var(--dim);font-size:12.5px}
|
| 134 |
+
.row:last-child{margin-bottom:0}
|
| 135 |
+
input[type=text],input[type=number],select{
|
| 136 |
+
font:inherit;background:var(--panel2);color:var(--txt);border:1px solid var(--line);
|
| 137 |
+
border-radius:7px;padding:6px 8px;width:100%;min-width:0;
|
| 138 |
+
}
|
| 139 |
+
input[type=color]{padding:2px;height:31px;width:44px;flex:none;background:var(--panel2);
|
| 140 |
+
border:1px solid var(--line);border-radius:7px;cursor:pointer}
|
| 141 |
+
input:focus,select:focus{outline:none;border-color:var(--accent)}
|
| 142 |
+
input[type=range]{width:100%;accent-color:var(--accent)}
|
| 143 |
+
.seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px}
|
| 144 |
+
.seg button{padding:5px 9px;font-size:12.5px;border-radius:7px;flex:1 1 auto}
|
| 145 |
+
.seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
|
| 146 |
+
.hint{color:var(--dim);font-size:11.5px;margin:-2px 0 10px;line-height:1.45}
|
| 147 |
+
.chk{display:flex;align-items:center;gap:7px;color:var(--txt);font-size:12.5px;margin-bottom:10px;cursor:pointer}
|
| 148 |
+
.chk input{width:auto;flex:none;accent-color:var(--accent)}
|
| 149 |
+
.chk .sub{color:var(--dim);font-size:11px}
|
| 150 |
+
.est{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:12.5px;margin-bottom:12px}
|
| 151 |
+
.est div{display:flex;justify-content:space-between;margin:2px 0;gap:10px}
|
| 152 |
+
.est span{color:var(--dim)}
|
| 153 |
+
.est b{font-variant-numeric:tabular-nums;font-weight:600;text-align:right}
|
| 154 |
+
.expBtn{width:100%;padding:11px}
|
| 155 |
+
.stopBtn{width:100%;padding:9px;margin-top:8px;display:none}
|
| 156 |
+
.bar{height:6px;background:var(--panel2);border-radius:99px;overflow:hidden;margin-top:10px;display:none}
|
| 157 |
+
.bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
|
| 158 |
+
.status{font-size:12px;color:var(--dim);margin-top:8px;text-align:center;min-height:16px;line-height:1.4}
|
| 159 |
+
.status.err{color:#ffb4ae}
|
| 160 |
+
.status.ok{color:#9ae6a8}
|
| 161 |
+
.note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4}
|
| 162 |
+
.toolbtns{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px}
|
| 163 |
+
.toolbtns button{flex:1 1 auto;padding:6px 8px;font-size:12.5px}
|
| 164 |
+
.keys{color:var(--dim);font-size:11.5px;margin-top:12px;line-height:1.7}
|
| 165 |
+
.kbd{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-bottom-width:2px;
|
| 166 |
+
border-radius:5px;padding:0 5px;font-size:11px;font-family:ui-monospace,Menlo,Consolas,monospace}
|
| 167 |
+
code{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 4px;font-size:11.5px}
|
| 168 |
+
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
| 169 |
+
</style>
|
| 170 |
+
</head>
|
| 171 |
+
<body>
|
| 172 |
+
<div class="wrap">
|
| 173 |
+
<header>
|
| 174 |
+
<h1>Pocket Image Converter</h1>
|
| 175 |
+
<span class="sub">HEIC & more · convert · resize · batch</span>
|
| 176 |
+
<span class="spacer"></span>
|
| 177 |
+
<span class="verwrap"><span class="ver">v1.6</span><span class="by">by C_Nugget</span></span>
|
| 178 |
+
</header>
|
| 179 |
+
<div id="drop" role="button" tabindex="0" aria-label="Add images">
|
| 180 |
+
<button id="dropToggle" class="ghost" type="button" title="Collapse / expand" aria-expanded="true">Hide <span class="chev">▲</span></button>
|
| 181 |
+
<span class="miniLabel"><b>Add more images</b> · or drop them anywhere on the page</span>
|
| 182 |
+
<div class="big">▢</div>
|
| 183 |
+
<h2>Drop images here to convert</h2>
|
| 184 |
+
<p>HEIC / HEIF · JPEG / JFIF · PNG · WebP · AVIF · GIF · BMP · TIFF* · ICO · add as many as you like · nothing is uploaded, it all stays on this computer</p>
|
| 185 |
+
<div class="row2">
|
| 186 |
+
<button id="pickFiles" class="small">Choose files</button>
|
| 187 |
+
<button id="pickFolder" class="small ghost">Choose a folder</button>
|
| 188 |
+
</div>
|
| 189 |
+
<input type="file" id="fileInput" accept="image/*,.heic,.heif,.heics,.jfif,.pjpeg,.pjp,.avif,.tif,.tiff,.bmp,.ico" multiple hidden>
|
| 190 |
+
<input type="file" id="folderInput" webkitdirectory directory multiple hidden>
|
| 191 |
+
</div>
|
| 192 |
+
<div class="grid">
|
| 193 |
+
<!-- LEFT: preview + queue -->
|
| 194 |
+
<div>
|
| 195 |
+
<div class="viewtabs" id="viewTabs">
|
| 196 |
+
<button data-v="preview" class="on">Preview</button>
|
| 197 |
+
<button data-v="grid">Grid</button>
|
| 198 |
+
</div>
|
| 199 |
+
<div id="previewView">
|
| 200 |
+
<div class="stage">
|
| 201 |
+
<canvas id="preview"></canvas>
|
| 202 |
+
<div class="badge" id="srcBadge">No image selected</div>
|
| 203 |
+
<div class="badge ba-right" id="outBadge"></div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
<div id="gridView" style="display:none">
|
| 207 |
+
<div class="imgrid" id="imgrid"></div>
|
| 208 |
+
</div>
|
| 209 |
+
<div class="q-head">
|
| 210 |
+
<h3>Queue</h3>
|
| 211 |
+
<span class="hint" id="qCount" style="margin:0">0 images</span>
|
| 212 |
+
<span class="spacer"></span>
|
| 213 |
+
<button class="small ghost" id="selAll">Select all</button>
|
| 214 |
+
<button class="small ghost" id="selNone">None</button>
|
| 215 |
+
<button class="small ghost" id="clearDone">Clear finished</button>
|
| 216 |
+
<button class="small ghost" id="clearAll">Remove all</button>
|
| 217 |
+
</div>
|
| 218 |
+
<div id="listView">
|
| 219 |
+
<div class="qlist" id="qList"><div class="empty">No images yet. Drop files above and they will appear here in order.</div></div>
|
| 220 |
+
</div>
|
| 221 |
+
<div class="keys">
|
| 222 |
+
<span class="kbd">←</span> <span class="kbd">→</span> move between images ·
|
| 223 |
+
<span class="kbd">Space</span> toggle selected ·
|
| 224 |
+
<span class="kbd">R</span> rotate · <span class="kbd">Del</span> remove ·
|
| 225 |
+
only <b style="color:var(--txt)">ticked</b> images are converted; edits apply to every image.
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
<!-- RIGHT: settings -->
|
| 229 |
+
<div class="card">
|
| 230 |
+
<!-- Output format -->
|
| 231 |
+
<div class="panelbox">
|
| 232 |
+
<h3>Output format</h3>
|
| 233 |
+
<div class="seg" id="fmtSeg">
|
| 234 |
+
<button data-fmt="image/png" class="on">PNG</button>
|
| 235 |
+
<button data-fmt="image/jpeg">JPEG</button>
|
| 236 |
+
<button data-fmt="image/webp">WebP</button>
|
| 237 |
+
<button data-fmt="image/avif">AVIF</button>
|
| 238 |
+
<button data-fmt="application/pdf">PDF</button>
|
| 239 |
+
</div>
|
| 240 |
+
<div class="row" id="qualityRow">
|
| 241 |
+
<label>Quality</label>
|
| 242 |
+
<input type="range" id="quality" min="1" max="100" value="82">
|
| 243 |
+
<b class="hint" id="qualityL" style="margin:0;flex:0 0 36px;text-align:right;color:var(--txt)">82</b>
|
| 244 |
+
</div>
|
| 245 |
+
<label class="chk" id="targetChk"><input type="checkbox" id="targetOn"> Target file size <span class="sub">(auto-tunes quality)</span></label>
|
| 246 |
+
<div class="row" id="targetRow" style="display:none">
|
| 247 |
+
<label>Max size</label>
|
| 248 |
+
<input type="number" id="targetSize" min="0.05" step="0.1" value="2" style="flex:1">
|
| 249 |
+
<select id="targetUnit" style="flex:0 0 78px"><option value="1048576" selected>MB</option><option value="1024">KB</option></select>
|
| 250 |
+
</div>
|
| 251 |
+
<!-- PNG mode -->
|
| 252 |
+
<div id="pngOpts" style="display:none">
|
| 253 |
+
<div class="row">
|
| 254 |
+
<label>PNG mode</label>
|
| 255 |
+
<select id="pngColors">
|
| 256 |
+
<option value="0" selected>Lossless · best quality (24-bit)</option>
|
| 257 |
+
<option value="256">Indexed · 256 colours</option>
|
| 258 |
+
<option value="128">Indexed · 128 colours</option>
|
| 259 |
+
<option value="64">Indexed · 64 colours</option>
|
| 260 |
+
<option value="32">Indexed · 32 colours</option>
|
| 261 |
+
</select>
|
| 262 |
+
</div>
|
| 263 |
+
<label class="chk" id="ditherChk" style="display:none"><input type="checkbox" id="pngDither" checked> Dither <span class="sub">(smoother gradients)</span></label>
|
| 264 |
+
</div>
|
| 265 |
+
<div class="hint" id="fmtHint">PNG is lossless — great for screenshots and graphics. The quality slider only affects JPEG, WebP and AVIF.</div>
|
| 266 |
+
</div>
|
| 267 |
+
<!-- Resize -->
|
| 268 |
+
<div class="panelbox">
|
| 269 |
+
<h3 class="collap shut" data-target="resizeBody">Resize <span class="chev">▼</span></h3>
|
| 270 |
+
<div class="collapbody shut" id="resizeBody">
|
| 271 |
+
<div class="seg" id="resizeMode">
|
| 272 |
+
<button data-m="none" class="on">Original</button>
|
| 273 |
+
<button data-m="pct">Percent</button>
|
| 274 |
+
<button data-m="px">Pixels</button>
|
| 275 |
+
<button data-m="max">Max side</button>
|
| 276 |
+
</div>
|
| 277 |
+
<div class="row" id="pctRow" style="display:none"><label>Scale</label><input type="number" id="pct" min="1" max="400" step="1" value="100"><span class="hint" style="margin:0">%</span></div>
|
| 278 |
+
<div id="pxRows" style="display:none">
|
| 279 |
+
<div class="row"><label>Width</label><input type="number" id="pxW" min="1" step="1"></div>
|
| 280 |
+
<div class="row"><label>Height</label><input type="number" id="pxH" min="1" step="1"></div>
|
| 281 |
+
<label class="chk"><input type="checkbox" id="lockAr" checked> Lock aspect ratio</label>
|
| 282 |
+
</div>
|
| 283 |
+
<div class="row" id="maxRow" style="display:none">
|
| 284 |
+
<label>Longest side</label>
|
| 285 |
+
<select id="maxPreset">
|
| 286 |
+
<option value="640">640 px</option>
|
| 287 |
+
<option value="1024">1024 px</option>
|
| 288 |
+
<option value="1600" selected>1600 px</option>
|
| 289 |
+
<option value="1920">1920 px</option>
|
| 290 |
+
<option value="2560">2560 px</option>
|
| 291 |
+
<option value="4096">4096 px</option>
|
| 292 |
+
</select>
|
| 293 |
+
</div>
|
| 294 |
+
<div class="hint">Images are only ever scaled down to fit — never blown up past their real pixels unless you use Percent above 100.</div>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
<!-- Adjust -->
|
| 298 |
+
<div class="panelbox">
|
| 299 |
+
<h3 class="collap shut" data-target="adjBody">Rotate & flip <span class="chev">▼</span></h3>
|
| 300 |
+
<div class="collapbody shut" id="adjBody">
|
| 301 |
+
<div class="toolbtns">
|
| 302 |
+
<button id="rotL" title="Rotate left">↶ Left</button>
|
| 303 |
+
<button id="rotR" title="Rotate right (R)">Right ↷</button>
|
| 304 |
+
</div>
|
| 305 |
+
<div class="toolbtns">
|
| 306 |
+
<button id="flipH">⇆ Flip H</button>
|
| 307 |
+
<button id="flipV">⇅ Flip V</button>
|
| 308 |
+
<button id="resetAdj" class="ghost">Reset</button>
|
| 309 |
+
</div>
|
| 310 |
+
<label class="chk"><input type="checkbox" id="autoExif" checked> Auto-rotate from EXIF <span class="sub">(fixes sideways iPhone photos)</span></label>
|
| 311 |
+
<div class="hint">Rotate / flip applies to every image in the queue.</div>
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
<!-- Watermark -->
|
| 315 |
+
<div class="panelbox">
|
| 316 |
+
<h3 class="collap shut" data-target="wmBody">Watermark <span class="chev">▼</span></h3>
|
| 317 |
+
<div class="collapbody shut" id="wmBody">
|
| 318 |
+
<div class="row"><label>Text</label><input type="text" id="wmText" placeholder="Leave blank for none"></div>
|
| 319 |
+
<div class="row">
|
| 320 |
+
<label>Position</label>
|
| 321 |
+
<select id="wmPos">
|
| 322 |
+
<option value="br" selected>Bottom right</option>
|
| 323 |
+
<option value="bl">Bottom left</option>
|
| 324 |
+
<option value="tr">Top right</option>
|
| 325 |
+
<option value="tl">Top left</option>
|
| 326 |
+
<option value="c">Centre</option>
|
| 327 |
+
</select>
|
| 328 |
+
</div>
|
| 329 |
+
<div class="row">
|
| 330 |
+
<label>Size</label>
|
| 331 |
+
<select id="wmSize"><option value="0.03">Small</option><option value="0.05" selected>Medium</option><option value="0.08">Large</option></select>
|
| 332 |
+
<input type="color" id="wmColor" value="#ffffff" title="Text colour">
|
| 333 |
+
</div>
|
| 334 |
+
<div class="row"><label>Opacity</label><input type="range" id="wmOpacity" min="10" max="100" value="70"><b class="hint" id="wmOpacityL" style="margin:0;flex:0 0 36px;text-align:right;color:var(--txt)">70%</b></div>
|
| 335 |
+
</div>
|
| 336 |
+
</div>
|
| 337 |
+
<!-- Naming -->
|
| 338 |
+
<div class="panelbox">
|
| 339 |
+
<h3 class="collap shut" data-target="nameBody">File names <span class="chev">▼</span></h3>
|
| 340 |
+
<div class="collapbody shut" id="nameBody">
|
| 341 |
+
<div class="seg" id="nameMode">
|
| 342 |
+
<button data-m="keep" class="on">Keep original</button>
|
| 343 |
+
<button data-m="tpl">Template</button>
|
| 344 |
+
</div>
|
| 345 |
+
<div id="tplRows" style="display:none">
|
| 346 |
+
<div class="row"><label>Prefix</label><input type="text" id="prefix" placeholder="e.g. web_"></div>
|
| 347 |
+
<div class="row"><label>Base</label><input type="text" id="tpl" value="IMG_{n}"></div>
|
| 348 |
+
<div class="row"><label>Suffix</label><input type="text" id="suffix" placeholder="e.g. _v2"></div>
|
| 349 |
+
<div class="hint">Tokens: <code>{n}</code> number · <code>{name}</code> original name · <code>{date}</code> today · <code>{w}</code>/<code>{h}</code> output size.</div>
|
| 350 |
+
</div>
|
| 351 |
+
<div class="row"><label>Start #</label><input type="number" id="startN" min="0" step="1" value="1"></div>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
<!-- Export -->
|
| 355 |
+
<div class="panelbox">
|
| 356 |
+
<h3>Export</h3>
|
| 357 |
+
<div class="est">
|
| 358 |
+
<div><span>Selected</span><b id="eCount">—</b></div>
|
| 359 |
+
<div><span>Output format</span><b id="eFmt">PNG</b></div>
|
| 360 |
+
<div><span>Selected output</span><b id="eDims">—</b></div>
|
| 361 |
+
<div><span>Est. size (selected)</span><b id="eBytes">—</b></div>
|
| 362 |
+
</div>
|
| 363 |
+
<button id="exportBtn" class="primary expBtn">Convert & save</button>
|
| 364 |
+
<button id="stopBtn" class="danger stopBtn">Stop</button>
|
| 365 |
+
<div class="bar" id="bar"><i id="barI"></i></div>
|
| 366 |
+
<div class="status" id="status"></div>
|
| 367 |
+
<div class="note" id="note">Batches of 2+ save into a folder you pick (Chrome/Edge), otherwise they download one by one.</div>
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
<div class="panelbox" id="offlineNote" style="margin-top:14px;background:#11161d">
|
| 372 |
+
<h3 class="collap shut" data-target="offlineBody">Offline & HEIC support <span class="chev">▼</span></h3>
|
| 373 |
+
<div class="collapbody shut" id="offlineBody" style="color:var(--dim);font-size:12px;line-height:1.6">
|
| 374 |
+
<p style="margin:0 0 8px">This file is <b style="color:var(--txt)">fully self-contained</b> — no internet needed, nothing is uploaded. All the common formats (<b style="color:var(--txt)">JPEG / JFIF, PNG, WebP, AVIF, GIF, BMP</b>) are decoded and converted entirely by your browser.</p>
|
| 375 |
+
<p style="margin:0 0 8px"><b style="color:var(--txt)">HEIC</b> is the one format browsers can't decode on their own. This tool tries, in order & all offline: your browser's native decoder → the OS codec via WebCodecs → a local <code>heic2any.min.js</code>. Only if all three fail <i>and</i> you happen to be online does it try a CDN as a last resort — your images still never leave the machine either way. It works with no setup on:</p>
|
| 376 |
+
<ul style="margin:0 0 8px;padding-left:18px">
|
| 377 |
+
<li><b style="color:var(--txt)">Safari</b> (Mac/iPhone/iPad) — decodes HEIC natively, works instantly.</li>
|
| 378 |
+
<li><b style="color:var(--txt)">Chrome / Edge on Windows</b> — install the free <i>HEVC Video Extensions</i> from the Microsoft Store once, then HEIC decodes offline via the OS.</li>
|
| 379 |
+
<li><b style="color:var(--txt)">Chrome / Edge on macOS</b> — uses the system codec, usually works out of the box.</li>
|
| 380 |
+
</ul>
|
| 381 |
+
<p style="margin:0"><b style="color:var(--txt)">Guaranteed-offline HEIC on any browser:</b> download <code>heic2any.min.js</code> once (from <code>github.com/alexcorvi/heic2any</code>, the <code>/dist</code> folder) and save it <b style="color:var(--txt)">in the same folder as this HTML file</b>. The app auto-detects it and then decodes HEIC locally forever, with zero network.</p>
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
<div class="keys" style="margin-top:8px">
|
| 385 |
+
AVIF <i>output</i> depends on your browser (Chrome/Edge support it; Safari/Firefox may not). A few spec items (original file dates, Live Photo extraction, subfolder mirroring) need a desktop app.
|
| 386 |
+
</div>
|
| 387 |
+
</div>
|
| 388 |
+
<div id="pageDrop" aria-hidden="true">
|
| 389 |
+
<div class="box">
|
| 390 |
+
<div class="big">⇩</div>
|
| 391 |
+
<h2>Drop to add images</h2>
|
| 392 |
+
<p>Anywhere on the page · folders work too · nothing leaves this computer</p>
|
| 393 |
+
</div>
|
| 394 |
+
</div>
|
| 395 |
+
<canvas id="work" style="display:none"></canvas>
|
| 396 |
+
<script>
|
| 397 |
+
"use strict";
|
| 398 |
+
(function(){
|
| 399 |
+
/* ======================= helpers ======================= */
|
| 400 |
+
const $ = id => document.getElementById(id);
|
| 401 |
+
const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
|
| 402 |
+
function human(bytes){
|
| 403 |
+
if(!isFinite(bytes) || bytes <= 0) return "\u2014";
|
| 404 |
+
if(bytes < 1024) return bytes + " B";
|
| 405 |
+
if(bytes < 1024*1024) return Math.round(bytes/1024) + " KB";
|
| 406 |
+
if(bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(bytes < 10*1024*1024 ? 2 : 1) + " MB";
|
| 407 |
+
return (bytes/1024/1024/1024).toFixed(2) + " GB";
|
| 408 |
+
}
|
| 409 |
+
function baseName(n){ return String(n||"image").replace(/\.[^.]+$/, "") || "image"; }
|
| 410 |
+
function safeName(s, fallback){
|
| 411 |
+
return String(s || fallback).replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,90) || fallback;
|
| 412 |
+
}
|
| 413 |
+
function extFor(mime){
|
| 414 |
+
return ({"image/png":"png","image/jpeg":"jpg","image/webp":"webp","image/avif":"avif","application/pdf":"pdf"})[mime] || "img";
|
| 415 |
+
}
|
| 416 |
+
function isLossy(mime){ return mime==="image/jpeg" || mime==="image/webp" || mime==="image/avif"; }
|
| 417 |
+
function say(el, msg, kind){ el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); }
|
| 418 |
+
function todayStr(){ const d = new Date(); const p = n => String(n).padStart(2,"0"); return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate()); }
|
| 419 |
+
function wait(ms){ return new Promise(r => setTimeout(r, ms)); }
|
| 420 |
+
/* format support probe */
|
| 421 |
+
async function supportsMime(mime){
|
| 422 |
+
try{
|
| 423 |
+
const c = document.createElement("canvas"); c.width = c.height = 4;
|
| 424 |
+
const blob = await new Promise(r => c.toBlob(r, mime, 0.8));
|
| 425 |
+
return !!(blob && blob.type === mime);
|
| 426 |
+
}catch(e){ return false; }
|
| 427 |
+
}
|
| 428 |
+
/* ======================= state ======================= */
|
| 429 |
+
const items = []; // {id,file,name,selected,bitmap,w,h,status,outName,outSize,thumbUrl,err}
|
| 430 |
+
let sel = -1, uid = 0, exporting = false, cancel = false, view = "preview";
|
| 431 |
+
let outFmt = "image/png";
|
| 432 |
+
const support = {};
|
| 433 |
+
/* ======================= collapsibles ======================= */
|
| 434 |
+
document.querySelectorAll("h3.collap").forEach(h => {
|
| 435 |
+
h.addEventListener("click", () => {
|
| 436 |
+
h.classList.toggle("shut");
|
| 437 |
+
const b = $(h.dataset.target);
|
| 438 |
+
if(b) b.classList.toggle("shut");
|
| 439 |
+
});
|
| 440 |
+
});
|
| 441 |
+
/* ======================= loading files ======================= */
|
| 442 |
+
const drop = $("drop");
|
| 443 |
+
$("pickFiles").addEventListener("click", e => { e.stopPropagation(); $("fileInput").click(); });
|
| 444 |
+
$("pickFolder").addEventListener("click", e => { e.stopPropagation(); $("folderInput").click(); });
|
| 445 |
+
drop.addEventListener("click", e => { if(e.target.closest("button")) return; $("fileInput").click(); });
|
| 446 |
+
drop.addEventListener("keydown", e => {
|
| 447 |
+
if(e.target !== drop) return;
|
| 448 |
+
if(e.key === "Enter" || e.key === " "){ e.preventDefault(); $("fileInput").click(); }
|
| 449 |
+
});
|
| 450 |
+
$("fileInput").addEventListener("change", e => { addFiles(e.target.files); e.target.value = ""; });
|
| 451 |
+
$("folderInput").addEventListener("change", e => { addFiles(e.target.files); e.target.value = ""; });
|
| 452 |
+
|
| 453 |
+
/* ---- collapse / expand the drop zone ---- */
|
| 454 |
+
let dropUserOpen = true; // user's explicit preference; auto-collapse only happens once
|
| 455 |
+
let dropAutoDone = false;
|
| 456 |
+
function setDropMini(mini){
|
| 457 |
+
drop.classList.toggle("mini", !!mini);
|
| 458 |
+
const t = $("dropToggle");
|
| 459 |
+
t.setAttribute("aria-expanded", String(!mini));
|
| 460 |
+
t.innerHTML = mini ? 'Add <span class="chev">▼</span>' : 'Hide <span class="chev">▲</span>';
|
| 461 |
+
}
|
| 462 |
+
$("dropToggle").addEventListener("click", e => {
|
| 463 |
+
e.stopPropagation();
|
| 464 |
+
dropUserOpen = drop.classList.contains("mini");
|
| 465 |
+
setDropMini(!dropUserOpen);
|
| 466 |
+
});
|
| 467 |
+
function autoCollapseDrop(){
|
| 468 |
+
if(dropAutoDone || !items.length) return;
|
| 469 |
+
dropAutoDone = true; dropUserOpen = false;
|
| 470 |
+
setDropMini(true);
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
/* ---- page-wide drag & drop ---- */
|
| 474 |
+
const pageDrop = $("pageDrop");
|
| 475 |
+
let dragDepth = 0;
|
| 476 |
+
const hasFiles = e => {
|
| 477 |
+
const t = e.dataTransfer && e.dataTransfer.types;
|
| 478 |
+
return !!t && (Array.prototype.indexOf.call(t, "Files") !== -1);
|
| 479 |
+
};
|
| 480 |
+
function showOverlay(on){
|
| 481 |
+
pageDrop.classList.toggle("show", !!on);
|
| 482 |
+
drop.classList.toggle("over", !!on);
|
| 483 |
+
if(!on) dragDepth = 0;
|
| 484 |
+
}
|
| 485 |
+
window.addEventListener("dragenter", e => {
|
| 486 |
+
if(!hasFiles(e)) return;
|
| 487 |
+
e.preventDefault(); dragDepth++; showOverlay(true);
|
| 488 |
+
});
|
| 489 |
+
window.addEventListener("dragover", e => {
|
| 490 |
+
if(!hasFiles(e)) return;
|
| 491 |
+
e.preventDefault();
|
| 492 |
+
try{ e.dataTransfer.dropEffect = "copy"; }catch(_){}
|
| 493 |
+
});
|
| 494 |
+
window.addEventListener("dragleave", e => {
|
| 495 |
+
if(!hasFiles(e)) return;
|
| 496 |
+
dragDepth = Math.max(0, dragDepth - 1);
|
| 497 |
+
if(dragDepth === 0) showOverlay(false);
|
| 498 |
+
});
|
| 499 |
+
window.addEventListener("drop", async e => {
|
| 500 |
+
if(!hasFiles(e)) return;
|
| 501 |
+
e.preventDefault(); showOverlay(false);
|
| 502 |
+
const dt = e.dataTransfer;
|
| 503 |
+
let files = null;
|
| 504 |
+
try{ files = await filesFromDataTransfer(dt); }catch(_){}
|
| 505 |
+
if(!files || !files.length) files = dt.files;
|
| 506 |
+
if(files && files.length) addFiles(files);
|
| 507 |
+
});
|
| 508 |
+
/* walk dropped folders (Chrome/Edge/Safari/Firefox all support webkitGetAsEntry) */
|
| 509 |
+
async function filesFromDataTransfer(dt){
|
| 510 |
+
const its = dt.items;
|
| 511 |
+
if(!its || !its.length || typeof DataTransferItem === "undefined" ||
|
| 512 |
+
!DataTransferItem.prototype.webkitGetAsEntry) return null;
|
| 513 |
+
const entries = [];
|
| 514 |
+
for(const it of its){ if(it.kind === "file"){ const en = it.webkitGetAsEntry && it.webkitGetAsEntry(); if(en) entries.push(en); } }
|
| 515 |
+
if(!entries.length) return null;
|
| 516 |
+
if(!entries.some(en => en.isDirectory)) return null; // plain files: use dt.files, it's faster
|
| 517 |
+
const out = [];
|
| 518 |
+
const readDir = dr => new Promise(res => {
|
| 519 |
+
const acc = [], rd = dr.createReader();
|
| 520 |
+
const step = () => rd.readEntries(batch => {
|
| 521 |
+
if(!batch.length) return res(acc);
|
| 522 |
+
acc.push(...batch); step();
|
| 523 |
+
}, () => res(acc));
|
| 524 |
+
step();
|
| 525 |
+
});
|
| 526 |
+
const walk = async en => {
|
| 527 |
+
if(en.isFile){ await new Promise(r => en.file(f => { out.push(f); r(); }, r)); }
|
| 528 |
+
else if(en.isDirectory){ for(const c of await readDir(en)) await walk(c); }
|
| 529 |
+
};
|
| 530 |
+
for(const en of entries) await walk(en);
|
| 531 |
+
return out;
|
| 532 |
+
}
|
| 533 |
+
function looksImage(f){
|
| 534 |
+
return (f.type && f.type.startsWith("image/")) ||
|
| 535 |
+
/\.(heic|heif|heics|jpe?g|jfif|pjpeg|pjp|png|webp|gif|bmp|tiff?|avif|ico)$/i.test(f.name);
|
| 536 |
+
}
|
| 537 |
+
async function addFiles(fileList){
|
| 538 |
+
const files = Array.from(fileList).filter(looksImage);
|
| 539 |
+
if(!files.length){ say($("status"), "Those files are not images. Add HEIC, JPEG, PNG, WebP, GIF or BMP.", "err"); return; }
|
| 540 |
+
say($("status"), "Reading " + files.length + (files.length === 1 ? " image\u2026" : " images\u2026"));
|
| 541 |
+
const firstNew = items.length;
|
| 542 |
+
for(const f of files){
|
| 543 |
+
items.push({ id: ++uid, file: f, name: f.name, selected: true, status: "queued",
|
| 544 |
+
bitmap: null, w: 0, h: 0, outName: "", outSize: 0, thumbUrl: "", err: "" });
|
| 545 |
+
}
|
| 546 |
+
render();
|
| 547 |
+
autoCollapseDrop();
|
| 548 |
+
for(let i = firstNew; i < items.length; i++){
|
| 549 |
+
if(!items[i].bitmap && items[i].status !== "error") await decode(items[i]);
|
| 550 |
+
render();
|
| 551 |
+
}
|
| 552 |
+
if(sel < 0 && items.length){ sel = 0; drawPreview(); }
|
| 553 |
+
say($("status"), items.length + " image" + (items.length===1?"":"s") + " ready.", "ok");
|
| 554 |
+
updateEstimate();
|
| 555 |
+
}
|
| 556 |
+
const isHeic = f => /\.(heic|heif)$/i.test(f.name) || f.type === "image/heic" || f.type === "image/heif" || f.type === "image/heic-sequence";
|
| 557 |
+
/* ---- HEIC engine (optional, offline-first) ---- */
|
| 558 |
+
let heicPromise = null, heicSource = "";
|
| 559 |
+
function loadScript(src){
|
| 560 |
+
return new Promise((res, rej) => {
|
| 561 |
+
const s = document.createElement("script");
|
| 562 |
+
s.src = src; s.async = true;
|
| 563 |
+
s.onload = () => res(true);
|
| 564 |
+
s.onerror = () => rej(new Error("failed " + src));
|
| 565 |
+
document.head.appendChild(s);
|
| 566 |
+
});
|
| 567 |
+
}
|
| 568 |
+
function ensureHeic(){
|
| 569 |
+
if(typeof heic2any !== "undefined") return Promise.resolve(true);
|
| 570 |
+
if(heicPromise) return heicPromise;
|
| 571 |
+
const sources = [
|
| 572 |
+
{ url: "heic2any.min.js", local: true },
|
| 573 |
+
{ url: "libheif/heic2any.min.js", local: true },
|
| 574 |
+
{ url: "https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js", local: false },
|
| 575 |
+
{ url: "https://unpkg.com/heic2any@0.0.4/dist/heic2any.min.js", local: false }
|
| 576 |
+
];
|
| 577 |
+
heicPromise = (async () => {
|
| 578 |
+
for(const s of sources){
|
| 579 |
+
try{ await loadScript(s.url); if(typeof heic2any !== "undefined"){ heicSource = s.local ? "local file" : "CDN"; return true; } }
|
| 580 |
+
catch(e){ /* try next */ }
|
| 581 |
+
}
|
| 582 |
+
heicPromise = null;
|
| 583 |
+
throw new Error("no HEIC engine available offline");
|
| 584 |
+
})();
|
| 585 |
+
return heicPromise;
|
| 586 |
+
}
|
| 587 |
+
async function decodeViaWebCodecs(blob){
|
| 588 |
+
if(typeof ImageDecoder === "undefined") return null;
|
| 589 |
+
try{
|
| 590 |
+
const type = blob.type && blob.type !== "" ? blob.type : "image/heic";
|
| 591 |
+
if(ImageDecoder.isTypeSupported){
|
| 592 |
+
const ok = await ImageDecoder.isTypeSupported(type).catch(() => false);
|
| 593 |
+
if(!ok) return null;
|
| 594 |
+
}
|
| 595 |
+
const buf = await blob.arrayBuffer();
|
| 596 |
+
const dec = new ImageDecoder({ data: buf, type });
|
| 597 |
+
const { image } = await dec.decode();
|
| 598 |
+
const bmp = await createImageBitmap(image);
|
| 599 |
+
try{ image.close && image.close(); }catch(_){}
|
| 600 |
+
return bmp;
|
| 601 |
+
}catch(e){ return null; }
|
| 602 |
+
}
|
| 603 |
+
async function decodeNative(blob){
|
| 604 |
+
const useExif = $("autoExif").checked;
|
| 605 |
+
try{
|
| 606 |
+
return await createImageBitmap(blob, useExif ? { imageOrientation: "from-image" } : {});
|
| 607 |
+
}catch(e){
|
| 608 |
+
try{ return await loadViaImg(blob); }
|
| 609 |
+
catch(e2){ return null; }
|
| 610 |
+
}
|
| 611 |
+
}
|
| 612 |
+
async function decode(it){
|
| 613 |
+
it.status = "decoding"; render();
|
| 614 |
+
try{
|
| 615 |
+
let bmp = await decodeNative(it.file);
|
| 616 |
+
if(!bmp && isHeic(it.file)) bmp = await decodeViaWebCodecs(it.file);
|
| 617 |
+
if(!bmp && isHeic(it.file)){
|
| 618 |
+
try{
|
| 619 |
+
await ensureHeic();
|
| 620 |
+
let out;
|
| 621 |
+
try{ out = await heic2any({ blob: it.file, toType: "image/jpeg", quality: 0.92 }); }
|
| 622 |
+
catch(e){
|
| 623 |
+
if(/already browser readable/i.test(e && e.message || "")){ bmp = await decodeNative(it.file); }
|
| 624 |
+
else { out = await heic2any({ blob: it.file, toType: "image/png" }); }
|
| 625 |
+
}
|
| 626 |
+
if(!bmp && out){ bmp = await decodeNative(Array.isArray(out) ? out[0] : out); }
|
| 627 |
+
}catch(engineErr){}
|
| 628 |
+
}
|
| 629 |
+
if(!bmp) throw new Error(isHeic(it.file)
|
| 630 |
+
? "HEIC needs a codec this browser lacks — see the offline HEIC note, or open in Safari"
|
| 631 |
+
: "this browser can't read that format");
|
| 632 |
+
it.bitmap = bmp; it.w = bmp.width; it.h = bmp.height; it.status = "ready";
|
| 633 |
+
it.thumbUrl = makeThumb(bmp);
|
| 634 |
+
}catch(e){
|
| 635 |
+
it.status = "error"; it.err = e.message || "could not decode";
|
| 636 |
+
}
|
| 637 |
+
}
|
| 638 |
+
function loadViaImg(blob){
|
| 639 |
+
return new Promise((res, rej) => {
|
| 640 |
+
const url = URL.createObjectURL(blob), img = new Image();
|
| 641 |
+
img.onload = () => { res(img); setTimeout(()=>URL.revokeObjectURL(url), 5000); };
|
| 642 |
+
img.onerror = () => { URL.revokeObjectURL(url); rej(new Error("decode failed")); };
|
| 643 |
+
img.src = url;
|
| 644 |
+
});
|
| 645 |
+
}
|
| 646 |
+
function makeThumb(bmp){
|
| 647 |
+
const c = document.createElement("canvas");
|
| 648 |
+
const w = 160, h = 120;
|
| 649 |
+
c.width = w; c.height = h;
|
| 650 |
+
const x = c.getContext("2d");
|
| 651 |
+
x.fillStyle = "#07090c"; x.fillRect(0,0,w,h);
|
| 652 |
+
const sc = Math.max(w/bmp.width, h/bmp.height);
|
| 653 |
+
const dw = bmp.width*sc, dh = bmp.height*sc;
|
| 654 |
+
x.drawImage(bmp, (w-dw)/2, (h-dh)/2, dw, dh);
|
| 655 |
+
return c.toDataURL("image/jpeg", 0.72);
|
| 656 |
+
}
|
| 657 |
+
/* ======================= transforms ======================= */
|
| 658 |
+
let rot = 0, flipH = false, flipV = false;
|
| 659 |
+
$("rotL").addEventListener("click", () => { rot = (rot + 270) % 360; drawPreview(); updateEstimate(); });
|
| 660 |
+
$("rotR").addEventListener("click", () => { rot = (rot + 90) % 360; drawPreview(); updateEstimate(); });
|
| 661 |
+
$("flipH").addEventListener("click", () => { flipH = !flipH; drawPreview(); });
|
| 662 |
+
$("flipV").addEventListener("click", () => { flipV = !flipV; drawPreview(); });
|
| 663 |
+
$("resetAdj").addEventListener("click", () => { rot = 0; flipH = flipV = false; drawPreview(); updateEstimate(); });
|
| 664 |
+
$("autoExif").addEventListener("change", async () => {
|
| 665 |
+
for(const it of items){ if(it.bitmap){ try{ it.bitmap.close && it.bitmap.close(); }catch(_){} it.bitmap = null; it.status = "queued"; } }
|
| 666 |
+
render();
|
| 667 |
+
for(const it of items) if(it.status !== "error") { await decode(it); render(); }
|
| 668 |
+
drawPreview();
|
| 669 |
+
});
|
| 670 |
+
function targetSize(sw, sh){
|
| 671 |
+
const mode = document.querySelector("#resizeMode button.on").dataset.m;
|
| 672 |
+
let w = sw, h = sh;
|
| 673 |
+
if(mode === "pct"){ const p = (+$("pct").value||100)/100; w = sw*p; h = sh*p; }
|
| 674 |
+
else if(mode === "px"){
|
| 675 |
+
const pw = +$("pxW").value||0, ph = +$("pxH").value||0;
|
| 676 |
+
if(pw && ph){ w = pw; h = ph; }
|
| 677 |
+
else if(pw){ w = pw; h = sh*pw/sw; }
|
| 678 |
+
else if(ph){ h = ph; w = sw*ph/sh; }
|
| 679 |
+
}
|
| 680 |
+
else if(mode === "max"){
|
| 681 |
+
const m = +$("maxPreset").value||1600;
|
| 682 |
+
const s = Math.min(1, m/Math.max(sw, sh));
|
| 683 |
+
w = sw*s; h = sh*s;
|
| 684 |
+
}
|
| 685 |
+
return { w: Math.max(1, Math.round(w)), h: Math.max(1, Math.round(h)) };
|
| 686 |
+
}
|
| 687 |
+
function renderToCanvas(bmp){
|
| 688 |
+
const swapped = (rot === 90 || rot === 270);
|
| 689 |
+
const bw = swapped ? bmp.height : bmp.width;
|
| 690 |
+
const bh = swapped ? bmp.width : bmp.height;
|
| 691 |
+
const t = targetSize(bw, bh);
|
| 692 |
+
const cv = $("work");
|
| 693 |
+
cv.width = t.w; cv.height = t.h;
|
| 694 |
+
const g = cv.getContext("2d");
|
| 695 |
+
g.imageSmoothingEnabled = true; g.imageSmoothingQuality = "high";
|
| 696 |
+
const indexedPng = (outFmt === "image/png" && (+$("pngColors").value||0) > 0);
|
| 697 |
+
// JPEG/PDF have no alpha; indexed PNG flattens alpha. Lossless PNG keeps alpha.
|
| 698 |
+
if(outFmt === "image/jpeg" || outFmt === "application/pdf" || indexedPng){ g.fillStyle = "#fff"; g.fillRect(0,0,t.w,t.h); }
|
| 699 |
+
g.save();
|
| 700 |
+
g.translate(t.w/2, t.h/2);
|
| 701 |
+
g.rotate(rot * Math.PI/180);
|
| 702 |
+
g.scale(flipH ? -1 : 1, flipV ? -1 : 1);
|
| 703 |
+
const drawW = swapped ? t.h : t.w;
|
| 704 |
+
const drawH = swapped ? t.w : t.h;
|
| 705 |
+
g.drawImage(bmp, -drawW/2, -drawH/2, drawW, drawH);
|
| 706 |
+
g.restore();
|
| 707 |
+
drawWatermark(g, t.w, t.h);
|
| 708 |
+
return cv;
|
| 709 |
+
}
|
| 710 |
+
function drawWatermark(g, W, H){
|
| 711 |
+
const text = ($("wmText").value || "").trim();
|
| 712 |
+
if(!text) return;
|
| 713 |
+
const size = Math.max(11, Math.round(Math.min(W,H) * (parseFloat($("wmSize").value)||0.05)));
|
| 714 |
+
g.font = "600 " + size + "px -apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif";
|
| 715 |
+
g.textBaseline = "alphabetic";
|
| 716 |
+
const pad = Math.round(size*0.6);
|
| 717 |
+
const w = g.measureText(text).width;
|
| 718 |
+
const pos = $("wmPos").value;
|
| 719 |
+
let x = W - w - pad, y = H - pad;
|
| 720 |
+
if(pos === "bl"){ x = pad; }
|
| 721 |
+
else if(pos === "tr"){ y = size + pad; }
|
| 722 |
+
else if(pos === "tl"){ x = pad; y = size + pad; }
|
| 723 |
+
else if(pos === "c"){ x = (W - w)/2; y = (H + size)/2; }
|
| 724 |
+
g.globalAlpha = (+$("wmOpacity").value||70)/100;
|
| 725 |
+
g.fillStyle = "rgba(0,0,0,.45)";
|
| 726 |
+
g.fillText(text, x+Math.max(1,size*0.04), y+Math.max(1,size*0.04));
|
| 727 |
+
g.fillStyle = $("wmColor").value || "#ffffff";
|
| 728 |
+
g.fillText(text, x, y);
|
| 729 |
+
g.globalAlpha = 1;
|
| 730 |
+
}
|
| 731 |
+
/* ======================= preview ======================= */
|
| 732 |
+
function drawPreview(){
|
| 733 |
+
const cv = $("preview"), g = cv.getContext("2d");
|
| 734 |
+
const it = items[sel];
|
| 735 |
+
if(!it || !it.bitmap){
|
| 736 |
+
cv.width = 640; cv.height = 360;
|
| 737 |
+
g.fillStyle = "#000"; g.fillRect(0,0,cv.width,cv.height);
|
| 738 |
+
$("srcBadge").textContent = it ? (it.status === "error" ? "Could not decode this file" : "Decoding\u2026") : "No image selected";
|
| 739 |
+
$("outBadge").textContent = "";
|
| 740 |
+
return;
|
| 741 |
+
}
|
| 742 |
+
const out = renderToCanvas(it.bitmap);
|
| 743 |
+
cv.width = out.width; cv.height = out.height;
|
| 744 |
+
g.drawImage(out, 0, 0);
|
| 745 |
+
$("srcBadge").textContent = it.name + " \u00b7 " + it.w + "\u00d7" + it.h + (isHeic(it.file) ? " \u00b7 HEIC" : "");
|
| 746 |
+
$("outBadge").textContent = out.width + "\u00d7" + out.height + " \u00b7 " + fmtLabel(outFmt);
|
| 747 |
+
}
|
| 748 |
+
function fmtLabel(m){ return ({"image/png":"PNG","image/jpeg":"JPEG","image/webp":"WebP","image/avif":"AVIF","application/pdf":"PDF"})[m] || m; }
|
| 749 |
+
/* ======================= view toggle ======================= */
|
| 750 |
+
$("viewTabs").addEventListener("click", e => {
|
| 751 |
+
const b = e.target.closest("button"); if(!b) return;
|
| 752 |
+
view = b.dataset.v;
|
| 753 |
+
document.querySelectorAll("#viewTabs button").forEach(x => x.classList.toggle("on", x === b));
|
| 754 |
+
$("previewView").style.display = view === "preview" ? "block" : "none";
|
| 755 |
+
$("gridView").style.display = view === "grid" ? "block" : "none";
|
| 756 |
+
$("listView").style.display = view === "grid" ? "none" : "block";
|
| 757 |
+
if(view === "grid") renderGrid(); else drawPreview();
|
| 758 |
+
});
|
| 759 |
+
/* ======================= selection helpers ======================= */
|
| 760 |
+
function selectableCount(){ return items.filter(it => it.status !== "error").length; }
|
| 761 |
+
function selectedReady(){ return items.filter(it => it.selected && it.status !== "error" && it.bitmap); }
|
| 762 |
+
$("selAll").addEventListener("click", () => { items.forEach(it => { if(it.status !== "error") it.selected = true; }); render(); });
|
| 763 |
+
$("selNone").addEventListener("click", () => { items.forEach(it => it.selected = false); render(); });
|
| 764 |
+
/* ======================= queue rendering ======================= */
|
| 765 |
+
function render(){
|
| 766 |
+
$("qCount").textContent = items.length + (items.length===1?" image":" images") +
|
| 767 |
+
(items.length ? " \u00b7 " + selectedReady().length + " ticked" : "");
|
| 768 |
+
renderList();
|
| 769 |
+
if(view === "grid") renderGrid();
|
| 770 |
+
updateEstimate();
|
| 771 |
+
}
|
| 772 |
+
function renderList(){
|
| 773 |
+
const list = $("qList");
|
| 774 |
+
if(!items.length){ list.innerHTML = '<div class="empty">No images yet. Drop files above and they will appear here in order.</div>'; drawPreview(); return; }
|
| 775 |
+
list.textContent = "";
|
| 776 |
+
items.forEach((it, i) => {
|
| 777 |
+
const row = document.createElement("div");
|
| 778 |
+
row.className = "qrow" + (i === sel ? " on" : "");
|
| 779 |
+
row.addEventListener("click", () => { sel = i; drawPreview(); render(); });
|
| 780 |
+
const chk = document.createElement("input");
|
| 781 |
+
chk.type = "checkbox"; chk.checked = !!it.selected; chk.title = "Include in conversion";
|
| 782 |
+
chk.disabled = it.status === "error";
|
| 783 |
+
chk.addEventListener("click", ev => { ev.stopPropagation(); it.selected = chk.checked; render(); });
|
| 784 |
+
row.appendChild(chk);
|
| 785 |
+
if(it.thumbUrl){ const img = document.createElement("img"); img.className = "qthumb"; img.src = it.thumbUrl; row.appendChild(img); }
|
| 786 |
+
else { const ph = document.createElement("div"); ph.className = "qthumb ph"; ph.textContent = it.status === "error" ? "!" : "\u2026"; row.appendChild(ph); }
|
| 787 |
+
const meta = document.createElement("div"); meta.className = "qmeta";
|
| 788 |
+
const nm = document.createElement("div"); nm.className = "qname"; nm.textContent = it.name; nm.title = it.name;
|
| 789 |
+
const sub = document.createElement("div"); sub.className = "qsub";
|
| 790 |
+
sub.textContent = it.status === "error" ? ("error: " + it.err)
|
| 791 |
+
: it.w ? (it.w + "\u00d7" + it.h + " \u00b7 " + human(it.file.size)) : "\u2026";
|
| 792 |
+
meta.appendChild(nm); meta.appendChild(sub);
|
| 793 |
+
row.appendChild(meta);
|
| 794 |
+
const st = document.createElement("div");
|
| 795 |
+
st.className = "qstate " + (it.status === "done" ? "done" : it.status === "error" ? "err" : (it.status==="decoding"||it.status==="working") ? "work" : "");
|
| 796 |
+
st.textContent = it.status === "done" ? human(it.outSize)
|
| 797 |
+
: it.status === "error" ? "failed"
|
| 798 |
+
: it.status === "working" ? "\u2026"
|
| 799 |
+
: it.status === "decoding" ? "reading" : "ready";
|
| 800 |
+
row.appendChild(st);
|
| 801 |
+
const x = document.createElement("button");
|
| 802 |
+
x.className = "qx danger ghost"; x.innerHTML = "×"; x.title = "Remove";
|
| 803 |
+
x.addEventListener("click", ev => { ev.stopPropagation(); removeAt(i); });
|
| 804 |
+
row.appendChild(x);
|
| 805 |
+
list.appendChild(row);
|
| 806 |
+
});
|
| 807 |
+
}
|
| 808 |
+
function renderGrid(){
|
| 809 |
+
const g = $("imgrid");
|
| 810 |
+
if(!items.length){ g.innerHTML = '<div class="empty">No images yet.</div>'; return; }
|
| 811 |
+
g.textContent = "";
|
| 812 |
+
items.forEach((it, i) => {
|
| 813 |
+
const card = document.createElement("div");
|
| 814 |
+
card.className = "gcard" + (i === sel ? " on" : "");
|
| 815 |
+
card.addEventListener("click", () => { sel = i; if(view==="preview") drawPreview(); render(); });
|
| 816 |
+
const chk = document.createElement("input");
|
| 817 |
+
chk.type = "checkbox"; chk.className = "gchk"; chk.checked = !!it.selected;
|
| 818 |
+
chk.disabled = it.status === "error"; chk.title = "Include in conversion";
|
| 819 |
+
chk.addEventListener("click", ev => { ev.stopPropagation(); it.selected = chk.checked; render(); });
|
| 820 |
+
card.appendChild(chk);
|
| 821 |
+
const x = document.createElement("button");
|
| 822 |
+
x.className = "gx"; x.innerHTML = "×"; x.title = "Remove";
|
| 823 |
+
x.addEventListener("click", ev => { ev.stopPropagation(); removeAt(i); });
|
| 824 |
+
card.appendChild(x);
|
| 825 |
+
if(it.thumbUrl){ const img = document.createElement("img"); img.src = it.thumbUrl; card.appendChild(img); }
|
| 826 |
+
else { const ph = document.createElement("div"); ph.className = "gph"; ph.textContent = it.status === "error" ? "!" : "\u2026"; card.appendChild(ph); }
|
| 827 |
+
if(it.status !== "queued" && it.status !== "ready"){
|
| 828 |
+
const st = document.createElement("div");
|
| 829 |
+
st.className = "gstate " + (it.status === "done" ? "done" : it.status === "error" ? "err" : "work");
|
| 830 |
+
st.textContent = it.status === "done" ? human(it.outSize) : it.status === "error" ? "failed" : it.status === "decoding" ? "reading" : "\u2026";
|
| 831 |
+
card.appendChild(st);
|
| 832 |
+
}
|
| 833 |
+
const nm = document.createElement("div"); nm.className = "gname"; nm.textContent = it.name; nm.title = it.name;
|
| 834 |
+
card.appendChild(nm);
|
| 835 |
+
g.appendChild(card);
|
| 836 |
+
});
|
| 837 |
+
}
|
| 838 |
+
function removeAt(i){
|
| 839 |
+
if(exporting) return;
|
| 840 |
+
const it = items[i]; if(it && it.bitmap){ try{ it.bitmap.close && it.bitmap.close(); }catch(_){} }
|
| 841 |
+
items.splice(i, 1);
|
| 842 |
+
if(sel >= items.length) sel = items.length - 1;
|
| 843 |
+
if(!items.length){ dropAutoDone = false; setDropMini(false); }
|
| 844 |
+
render(); if(view==="preview") drawPreview();
|
| 845 |
+
}
|
| 846 |
+
$("clearAll").addEventListener("click", () => { if(exporting) return; items.forEach(it => it.bitmap && it.bitmap.close && it.bitmap.close()); items.length = 0; sel = -1; dropAutoDone = false; setDropMini(false); render(); drawPreview(); say($("status"),""); });
|
| 847 |
+
$("clearDone").addEventListener("click", () => {
|
| 848 |
+
if(exporting) return;
|
| 849 |
+
for(let i = items.length-1; i>=0; i--) if(items[i].status === "done") removeAt(i);
|
| 850 |
+
});
|
| 851 |
+
/* ======================= settings wiring ======================= */
|
| 852 |
+
function syncFmtUI(){
|
| 853 |
+
const lossy = isLossy(outFmt);
|
| 854 |
+
$("qualityRow").style.display = lossy ? "flex" : "none";
|
| 855 |
+
$("targetChk").style.display = lossy ? "flex" : "none";
|
| 856 |
+
$("targetRow").style.display = (lossy && $("targetOn").checked) ? "flex" : "none";
|
| 857 |
+
$("pngOpts").style.display = outFmt === "image/png" ? "block" : "none";
|
| 858 |
+
$("ditherChk").style.display = (outFmt === "image/png" && (+$("pngColors").value||0) > 0) ? "flex" : "none";
|
| 859 |
+
let hint = "";
|
| 860 |
+
if(outFmt === "image/png"){
|
| 861 |
+
hint = (+$("pngColors").value||0) > 0
|
| 862 |
+
? "Indexed PNG (like Photoshop PNG-8) — much smaller, but limited to the chosen palette; photos may posterise. Keeps sharp graphics crisp."
|
| 863 |
+
: "Lossless 24-bit PNG (like Photoshop PNG-24) — full quality, no colour loss. Uses adaptive filtering so files are far smaller than a plain PNG. Transparency is preserved.";
|
| 864 |
+
}
|
| 865 |
+
else if(outFmt === "image/jpeg") hint = "JPEG is best for photos. No transparency — transparent areas become white.";
|
| 866 |
+
else if(outFmt === "image/webp") hint = "WebP gives smaller files than JPEG at similar quality, with transparency support.";
|
| 867 |
+
else if(outFmt === "image/avif") hint = support["image/avif"] === false ? "\u26a0 Your browser can\u2019t encode AVIF — try Chrome or Edge, or pick WebP." : "AVIF is the smallest for photos, but slower to encode. Chrome/Edge only.";
|
| 868 |
+
else if(outFmt === "application/pdf") hint = "Each ticked image becomes one PDF page (embedded as JPEG) in a single document.";
|
| 869 |
+
$("fmtHint").textContent = hint;
|
| 870 |
+
}
|
| 871 |
+
$("fmtSeg").addEventListener("click", async e => {
|
| 872 |
+
const b = e.target.closest("button"); if(!b || b.disabled) return;
|
| 873 |
+
document.querySelectorAll("#fmtSeg button").forEach(x => x.classList.toggle("on", x === b));
|
| 874 |
+
outFmt = b.dataset.fmt;
|
| 875 |
+
syncFmtUI();
|
| 876 |
+
drawPreview(); updateEstimate();
|
| 877 |
+
});
|
| 878 |
+
$("quality").addEventListener("input", () => { $("qualityL").textContent = $("quality").value; updateEstimate(); });
|
| 879 |
+
$("targetOn").addEventListener("change", () => { $("targetRow").style.display = ($("targetOn").checked && isLossy(outFmt)) ? "flex" : "none"; updateEstimate(); });
|
| 880 |
+
["targetSize","targetUnit"].forEach(id => $(id).addEventListener("input", updateEstimate));
|
| 881 |
+
$("pngColors").addEventListener("change", () => { syncFmtUI(); drawPreview(); updateEstimate(); });
|
| 882 |
+
$("pngDither").addEventListener("change", updateEstimate);
|
| 883 |
+
$("resizeMode").addEventListener("click", e => {
|
| 884 |
+
const b = e.target.closest("button"); if(!b) return;
|
| 885 |
+
document.querySelectorAll("#resizeMode button").forEach(x => x.classList.toggle("on", x === b));
|
| 886 |
+
const m = b.dataset.m;
|
| 887 |
+
$("pctRow").style.display = m === "pct" ? "flex" : "none";
|
| 888 |
+
$("pxRows").style.display = m === "px" ? "block" : "none";
|
| 889 |
+
$("maxRow").style.display = m === "max" ? "flex" : "none";
|
| 890 |
+
if(m === "px" && items[sel]){ const it = items[sel]; $("pxW").value = it.w; $("pxH").value = it.h; }
|
| 891 |
+
drawPreview(); updateEstimate();
|
| 892 |
+
});
|
| 893 |
+
["pct","maxPreset"].forEach(id => $(id).addEventListener("input", () => { drawPreview(); updateEstimate(); }));
|
| 894 |
+
$("pxW").addEventListener("input", () => { if($("lockAr").checked && items[sel]){ const it = items[sel]; $("pxH").value = Math.round((+$("pxW").value||1) * it.h / it.w); } drawPreview(); updateEstimate(); });
|
| 895 |
+
$("pxH").addEventListener("input", () => { if($("lockAr").checked && items[sel]){ const it = items[sel]; $("pxW").value = Math.round((+$("pxH").value||1) * it.w / it.h); } drawPreview(); updateEstimate(); });
|
| 896 |
+
["wmText","wmPos","wmSize","wmColor"].forEach(id => $(id).addEventListener("input", () => { drawPreview(); updateEstimate(); }));
|
| 897 |
+
$("wmOpacity").addEventListener("input", () => { $("wmOpacityL").textContent = $("wmOpacity").value + "%"; drawPreview(); });
|
| 898 |
+
$("nameMode").addEventListener("click", e => {
|
| 899 |
+
const b = e.target.closest("button"); if(!b) return;
|
| 900 |
+
document.querySelectorAll("#nameMode button").forEach(x => x.classList.toggle("on", x === b));
|
| 901 |
+
$("tplRows").style.display = b.dataset.m === "tpl" ? "block" : "none";
|
| 902 |
+
});
|
| 903 |
+
/* ======================= estimate ======================= */
|
| 904 |
+
let estTimer = 0, estToken = 0;
|
| 905 |
+
function updateEstimate(){
|
| 906 |
+
const selN = selectedReady().length;
|
| 907 |
+
$("eCount").textContent = items.length ? (selN + " of " + items.length) : "\u2014";
|
| 908 |
+
const pc = +$("pngColors").value||0;
|
| 909 |
+
$("eFmt").textContent = fmtLabel(outFmt) + (outFmt==="image/png" ? (pc>0 ? " \u00b7 " + pc + "c" : " \u00b7 24-bit") : "");
|
| 910 |
+
$("exportBtn").disabled = exporting || selN === 0;
|
| 911 |
+
const it = items[sel];
|
| 912 |
+
if(!it || !it.bitmap){ $("eDims").textContent = "\u2014"; $("eBytes").textContent = "\u2014"; return; }
|
| 913 |
+
const out = renderToCanvas(it.bitmap);
|
| 914 |
+
$("eDims").textContent = out.width + "\u00d7" + out.height;
|
| 915 |
+
clearTimeout(estTimer);
|
| 916 |
+
const token = ++estToken;
|
| 917 |
+
estTimer = setTimeout(async () => {
|
| 918 |
+
try{
|
| 919 |
+
const cur = items[sel];
|
| 920 |
+
if(!cur || !cur.bitmap){ $("eBytes").textContent = "\u2014"; return; }
|
| 921 |
+
// re-render now: #work is shared, so the canvas captured above may be stale by this point
|
| 922 |
+
const blob = await encodeCanvas(renderToCanvas(cur.bitmap), false);
|
| 923 |
+
if(token !== estToken) return; // a newer estimate started, drop this one
|
| 924 |
+
$("eBytes").textContent = blob ? human(blob.size) : "\u2014";
|
| 925 |
+
}catch(e){ if(token === estToken) $("eBytes").textContent = "\u2014"; }
|
| 926 |
+
}, 300);
|
| 927 |
+
}
|
| 928 |
+
/* ======================= encoding ======================= */
|
| 929 |
+
function canvasToBlob(cv, mime, q){ return new Promise(res => cv.toBlob(res, mime, q)); }
|
| 930 |
+
async function encodeCanvas(cv, doTarget){
|
| 931 |
+
if(outFmt === "application/pdf") return makePdf([cv]);
|
| 932 |
+
if(outFmt === "image/png"){
|
| 933 |
+
const colors = +$("pngColors").value || 0;
|
| 934 |
+
if(colors > 0) return encodeIndexedPNG(cv, colors, $("pngDither").checked);
|
| 935 |
+
return encodeLosslessPNG(cv); // adaptive-filtered 24/32-bit (Photoshop PNG-24 style)
|
| 936 |
+
}
|
| 937 |
+
const q = (+$("quality").value||82)/100;
|
| 938 |
+
if(doTarget && $("targetOn").checked){
|
| 939 |
+
const unit = +$("targetUnit").value, limit = (+$("targetSize").value||2) * unit;
|
| 940 |
+
return encodeToTarget(cv, limit);
|
| 941 |
+
}
|
| 942 |
+
return canvasToBlob(cv, outFmt, q);
|
| 943 |
+
}
|
| 944 |
+
async function encodeToTarget(cv, limit){
|
| 945 |
+
let lo = 0.05, hi = 0.98, best = null;
|
| 946 |
+
let blob = await canvasToBlob(cv, outFmt, hi);
|
| 947 |
+
if(blob && blob.size <= limit) return blob;
|
| 948 |
+
for(let i = 0; i < 8; i++){
|
| 949 |
+
const mid = (lo + hi) / 2;
|
| 950 |
+
blob = await canvasToBlob(cv, outFmt, mid);
|
| 951 |
+
if(!blob){ break; }
|
| 952 |
+
if(blob.size <= limit){ best = blob; lo = mid; } else { hi = mid; }
|
| 953 |
+
}
|
| 954 |
+
return best || await canvasToBlob(cv, outFmt, lo);
|
| 955 |
+
}
|
| 956 |
+
/* ---------- PNG plumbing (CRC, chunks, zlib, adaptive filtering) ---------- */
|
| 957 |
+
async function deflateZlib(u8){
|
| 958 |
+
if(typeof CompressionStream === "undefined") return null;
|
| 959 |
+
const cs = new CompressionStream("deflate"); // zlib wrapper (RFC1950) — what PNG needs
|
| 960 |
+
const w = cs.writable.getWriter(); w.write(u8); w.close();
|
| 961 |
+
const buf = await new Response(cs.readable).arrayBuffer();
|
| 962 |
+
return new Uint8Array(buf);
|
| 963 |
+
}
|
| 964 |
+
const _crcTable = (() => { const t = new Uint32Array(256);
|
| 965 |
+
for(let n=0;n<256;n++){ let c=n; for(let k=0;k<8;k++) c = c&1 ? 0xEDB88320^(c>>>1) : c>>>1; t[n]=c>>>0; } return t; })();
|
| 966 |
+
function crc32(buf){ let c=0xFFFFFFFF; for(let i=0;i<buf.length;i++) c=_crcTable[(c^buf[i])&255]^(c>>>8); return (c^0xFFFFFFFF)>>>0; }
|
| 967 |
+
function u32be(n){ return new Uint8Array([(n>>>24)&255,(n>>>16)&255,(n>>>8)&255,n&255]); }
|
| 968 |
+
function pngChunk(type, data){
|
| 969 |
+
const t = new Uint8Array(4); for(let i=0;i<4;i++) t[i]=type.charCodeAt(i);
|
| 970 |
+
const body = new Uint8Array(t.length + data.length); body.set(t,0); body.set(data,t.length);
|
| 971 |
+
const out = new Uint8Array(4 + body.length + 4);
|
| 972 |
+
out.set(u32be(data.length),0); out.set(body,4); out.set(u32be(crc32(body)), 4+body.length);
|
| 973 |
+
return out;
|
| 974 |
+
}
|
| 975 |
+
function paeth(a,b,c){ const p=a+b-c, pa=Math.abs(p-a), pb=Math.abs(p-b), pc=Math.abs(p-c);
|
| 976 |
+
return pa<=pb && pa<=pc ? a : pb<=pc ? b : c; }
|
| 977 |
+
/* adaptive per-scanline filtering (None/Sub/Up/Average/Paeth), min-sum-of-abs heuristic.
|
| 978 |
+
Works for any bytes-per-pixel: 1 (indexed), 3 (RGB), 4 (RGBA). This is the core of
|
| 979 |
+
Photoshop-grade lossless PNG compression — no colour is changed, files just get much smaller. */
|
| 980 |
+
function filterScanlines(data, w, h, bpp){
|
| 981 |
+
const stride = w*bpp;
|
| 982 |
+
const out = new Uint8Array(h*(stride+1));
|
| 983 |
+
const c0=new Uint8Array(stride), c1=new Uint8Array(stride), c2=new Uint8Array(stride), c3=new Uint8Array(stride), c4=new Uint8Array(stride);
|
| 984 |
+
let prev = new Uint8Array(stride);
|
| 985 |
+
for(let y=0;y<h;y++){
|
| 986 |
+
const row = data.subarray(y*stride, y*stride+stride);
|
| 987 |
+
c0.set(row);
|
| 988 |
+
for(let i=0;i<stride;i++){
|
| 989 |
+
const a = i>=bpp ? row[i-bpp] : 0, b = prev[i], c = i>=bpp ? prev[i-bpp] : 0;
|
| 990 |
+
c1[i] = (row[i]-a) & 255;
|
| 991 |
+
c2[i] = (row[i]-b) & 255;
|
| 992 |
+
c3[i] = (row[i]-((a+b)>>1)) & 255;
|
| 993 |
+
c4[i] = (row[i]-paeth(a,b,c)) & 255;
|
| 994 |
+
}
|
| 995 |
+
let bestF=0, bestScore=Infinity;
|
| 996 |
+
const cands=[c0,c1,c2,c3,c4];
|
| 997 |
+
for(let f=0;f<5;f++){ let s=0; const cc=cands[f]; for(let i=0;i<stride;i++){ const v=cc[i]; s += v<128 ? v : 256-v; } if(s<bestScore){ bestScore=s; bestF=f; } }
|
| 998 |
+
out[y*(stride+1)] = bestF;
|
| 999 |
+
out.set(cands[bestF], y*(stride+1)+1);
|
| 1000 |
+
prev = row.slice ? row.slice() : new Uint8Array(row); // keep a stable copy for the next row
|
| 1001 |
+
}
|
| 1002 |
+
return out;
|
| 1003 |
+
}
|
| 1004 |
+
function assemblePNG(w, h, colorType, chunksBetween, idat){
|
| 1005 |
+
const sig = new Uint8Array([137,80,78,71,13,10,26,10]);
|
| 1006 |
+
const ihdr = new Uint8Array(13);
|
| 1007 |
+
ihdr.set(u32be(w),0); ihdr.set(u32be(h),4); ihdr[8]=8; ihdr[9]=colorType; ihdr[10]=0; ihdr[11]=0; ihdr[12]=0;
|
| 1008 |
+
const chunks = [ sig, pngChunk("IHDR",ihdr) ];
|
| 1009 |
+
for(const c of (chunksBetween||[])) chunks.push(c);
|
| 1010 |
+
chunks.push(pngChunk("IDAT", idat));
|
| 1011 |
+
chunks.push(pngChunk("IEND", new Uint8Array(0)));
|
| 1012 |
+
let total=0; chunks.forEach(c=>total+=c.length);
|
| 1013 |
+
const out=new Uint8Array(total); let off=0; chunks.forEach(c=>{ out.set(c,off); off+=c.length; });
|
| 1014 |
+
return new Blob([out], { type:"image/png" });
|
| 1015 |
+
}
|
| 1016 |
+
/* Lossless 24/32-bit PNG. Detects alpha; RGB (type 2) if fully opaque, else RGBA (type 6). */
|
| 1017 |
+
async function encodeLosslessPNG(cv){
|
| 1018 |
+
const ctx = cv.getContext("2d");
|
| 1019 |
+
const { data, width:w, height:h } = ctx.getImageData(0,0,cv.width,cv.height);
|
| 1020 |
+
let hasAlpha = false;
|
| 1021 |
+
for(let i=3;i<data.length;i+=4){ if(data[i] !== 255){ hasAlpha = true; break; } }
|
| 1022 |
+
let pixels, bpp, colorType;
|
| 1023 |
+
if(hasAlpha){ pixels = new Uint8Array(data.buffer.slice(0)); bpp = 4; colorType = 6; }
|
| 1024 |
+
else {
|
| 1025 |
+
bpp = 3; colorType = 2;
|
| 1026 |
+
pixels = new Uint8Array(w*h*3);
|
| 1027 |
+
for(let p=0,q=0;q<w*h;q++,p+=4){ pixels[q*3]=data[p]; pixels[q*3+1]=data[p+1]; pixels[q*3+2]=data[p+2]; }
|
| 1028 |
+
}
|
| 1029 |
+
const filtered = filterScanlines(pixels, w, h, bpp);
|
| 1030 |
+
const idat = await deflateZlib(filtered);
|
| 1031 |
+
if(!idat) return canvasToBlob(cv, "image/png"); // no CompressionStream → browser PNG
|
| 1032 |
+
return assemblePNG(w, h, colorType, null, idat);
|
| 1033 |
+
}
|
| 1034 |
+
/* ---------- indexed PNG (median cut + adaptive filtering) ---------- */
|
| 1035 |
+
const _hist = new Int32Array(32768), _rs = new Int32Array(32768), _gs = new Int32Array(32768), _bs = new Int32Array(32768);
|
| 1036 |
+
const _binArr = new Int32Array(32768);
|
| 1037 |
+
function buildPalette(data, maxColors){
|
| 1038 |
+
_hist.fill(0); _rs.fill(0); _gs.fill(0); _bs.fill(0);
|
| 1039 |
+
for(let i=0;i<data.length;i+=4){
|
| 1040 |
+
const r=data[i],g=data[i+1],b=data[i+2];
|
| 1041 |
+
const k=((r>>3)<<10)|((g>>3)<<5)|(b>>3);
|
| 1042 |
+
_hist[k]++; _rs[k]+=r; _gs[k]+=g; _bs[k]+=b;
|
| 1043 |
+
}
|
| 1044 |
+
let m=0; for(let k=0;k<32768;k++) if(_hist[k]) _binArr[m++]=k;
|
| 1045 |
+
const stat=(s,e)=>{
|
| 1046 |
+
let rmin=31,rmax=0,gmin=31,gmax=0,bmin=31,bmax=0;
|
| 1047 |
+
for(let i=s;i<e;i++){ const k=_binArr[i], r=(k>>10)&31,g=(k>>5)&31,b=k&31;
|
| 1048 |
+
if(r<rmin)rmin=r; if(r>rmax)rmax=r; if(g<gmin)gmin=g; if(g>gmax)gmax=g; if(b<bmin)bmin=b; if(b>bmax)bmax=b; }
|
| 1049 |
+
const dr=rmax-rmin,dg=gmax-gmin,db=bmax-bmin; let chan=0,ext=dr;
|
| 1050 |
+
if(dg>ext){chan=1;ext=dg;} if(db>ext){chan=2;ext=db;}
|
| 1051 |
+
return {s,e,chan,ext};
|
| 1052 |
+
};
|
| 1053 |
+
let boxes=[stat(0,m)];
|
| 1054 |
+
while(boxes.length<maxColors){
|
| 1055 |
+
let bi=-1,best=0;
|
| 1056 |
+
for(let i=0;i<boxes.length;i++){ const B=boxes[i]; if(B.e-B.s<2) continue; if(B.ext>best){best=B.ext;bi=i;} }
|
| 1057 |
+
if(bi<0) break;
|
| 1058 |
+
const B=boxes[bi], shift=B.chan===0?10:B.chan===1?5:0;
|
| 1059 |
+
const sub=Array.from(_binArr.subarray(B.s,B.e));
|
| 1060 |
+
sub.sort((x,y)=>(((x>>shift)&31)-((y>>shift)&31)));
|
| 1061 |
+
_binArr.set(sub,B.s);
|
| 1062 |
+
let total=0; for(let i=B.s;i<B.e;i++) total+=_hist[_binArr[i]];
|
| 1063 |
+
let acc=0,mid=B.s+1;
|
| 1064 |
+
for(let i=B.s;i<B.e;i++){ acc+=_hist[_binArr[i]]; if(acc*2>=total){ mid=i+1; break; } }
|
| 1065 |
+
if(mid<=B.s) mid=B.s+1; if(mid>=B.e) mid=B.e-1;
|
| 1066 |
+
boxes.splice(bi,1,stat(B.s,mid),stat(mid,B.e));
|
| 1067 |
+
}
|
| 1068 |
+
const pal=new Uint8Array(768); let count=0;
|
| 1069 |
+
for(let i=0;i<boxes.length;i++){
|
| 1070 |
+
const B=boxes[i]; let c=0,R=0,G=0,Bl=0;
|
| 1071 |
+
for(let x=B.s;x<B.e;x++){ const k=_binArr[x]; c+=_hist[k]; R+=_rs[k]; G+=_gs[k]; Bl+=_bs[k]; }
|
| 1072 |
+
if(c){ pal[i*3]=Math.round(R/c); pal[i*3+1]=Math.round(G/c); pal[i*3+2]=Math.round(Bl/c); count++; }
|
| 1073 |
+
}
|
| 1074 |
+
return { pal, count: Math.max(1,count) };
|
| 1075 |
+
}
|
| 1076 |
+
const _near = new Int16Array(32768);
|
| 1077 |
+
function mapToPalette(data, w, h, pal, palCount, dither){
|
| 1078 |
+
const idx=new Uint8Array(w*h); _near.fill(-1);
|
| 1079 |
+
const nearest=(r,g,b)=>{
|
| 1080 |
+
const key=((r>>3)<<10)|((g>>3)<<5)|(b>>3); const hit=_near[key]; if(hit>=0) return hit;
|
| 1081 |
+
let bd=Infinity,bi=0;
|
| 1082 |
+
for(let i=0;i<palCount;i++){ const dr=r-pal[i*3],dg=g-pal[i*3+1],db=b-pal[i*3+2]; const d=dr*dr*3+dg*dg*6+db*db; if(d<bd){bd=d;bi=i;} }
|
| 1083 |
+
_near[key]=bi; return bi;
|
| 1084 |
+
};
|
| 1085 |
+
if(!dither){ for(let p=0,q=0;q<idx.length;q++,p+=4) idx[q]=nearest(data[p],data[p+1],data[p+2]); return idx; }
|
| 1086 |
+
const buf=new Float32Array(w*h*3);
|
| 1087 |
+
for(let p=0,q=0;q<w*h;q++,p+=4){ buf[q*3]=data[p]; buf[q*3+1]=data[p+1]; buf[q*3+2]=data[p+2]; }
|
| 1088 |
+
const cl=v=>v<0?0:(v>255?255:v);
|
| 1089 |
+
for(let y=0;y<h;y++) for(let x=0;x<w;x++){
|
| 1090 |
+
const q=y*w+x,o=q*3; const r=cl(buf[o]),g=cl(buf[o+1]),b=cl(buf[o+2]);
|
| 1091 |
+
const i=nearest(r|0,g|0,b|0); idx[q]=i;
|
| 1092 |
+
const er=(r-pal[i*3])*0.75,eg=(g-pal[i*3+1])*0.75,eb=(b-pal[i*3+2])*0.75;
|
| 1093 |
+
if(x+1<w){ const oo=(q+1)*3; buf[oo]+=er*0.4375; buf[oo+1]+=eg*0.4375; buf[oo+2]+=eb*0.4375; }
|
| 1094 |
+
if(y+1<h){
|
| 1095 |
+
if(x>0){ const oo=(q+w-1)*3; buf[oo]+=er*0.1875; buf[oo+1]+=eg*0.1875; buf[oo+2]+=eb*0.1875; }
|
| 1096 |
+
{ const oo=(q+w)*3; buf[oo]+=er*0.3125; buf[oo+1]+=eg*0.3125; buf[oo+2]+=eb*0.3125; }
|
| 1097 |
+
if(x+1<w){ const oo=(q+w+1)*3; buf[oo]+=er*0.0625; buf[oo+1]+=eg*0.0625; buf[oo+2]+=eb*0.0625; }
|
| 1098 |
+
}
|
| 1099 |
+
}
|
| 1100 |
+
return idx;
|
| 1101 |
+
}
|
| 1102 |
+
async function encodeIndexedPNG(cv, colors, dither){
|
| 1103 |
+
const ctx = cv.getContext("2d");
|
| 1104 |
+
const { data, width:w, height:h } = ctx.getImageData(0,0,cv.width,cv.height);
|
| 1105 |
+
const { pal, count } = buildPalette(data, colors);
|
| 1106 |
+
const idx = mapToPalette(data, w, h, pal, count, dither);
|
| 1107 |
+
const filtered = filterScanlines(idx, w, h, 1); // adaptive filtering on indices too
|
| 1108 |
+
const idat = await deflateZlib(filtered);
|
| 1109 |
+
if(!idat) return canvasToBlob(cv, "image/png");
|
| 1110 |
+
const plte = pngChunk("PLTE", pal.subarray(0, count*3));
|
| 1111 |
+
return assemblePNG(w, h, 3, [plte], idat);
|
| 1112 |
+
}
|
| 1113 |
+
/* ---------- PDF: one page per image, JPEG embedded (DCTDecode) ---------- */
|
| 1114 |
+
async function makePdf(canvases){
|
| 1115 |
+
const q = clamp((+$("quality").value||82)/100, 0.4, 0.95);
|
| 1116 |
+
const bytes = [];
|
| 1117 |
+
const push = s => { for(let i=0;i<s.length;i++) bytes.push(s.charCodeAt(i) & 255); };
|
| 1118 |
+
const pushBytes = arr => { for(let i=0;i<arr.length;i++) bytes.push(arr[i]); };
|
| 1119 |
+
const imgDatas = [];
|
| 1120 |
+
for(const cv of canvases){ const b = await canvasToBlob(cv, "image/jpeg", q); imgDatas.push(new Uint8Array(await b.arrayBuffer())); }
|
| 1121 |
+
const N = canvases.length;
|
| 1122 |
+
const offsets = [];
|
| 1123 |
+
const start = () => offsets.push(bytes.length);
|
| 1124 |
+
push("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
|
| 1125 |
+
start(); push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
|
| 1126 |
+
const kids = []; for(let i=0;i<N;i++) kids.push((3 + i*3) + " 0 R");
|
| 1127 |
+
start(); push("2 0 obj\n<< /Type /Pages /Count " + N + " /Kids [ " + kids.join(" ") + " ] >>\nendobj\n");
|
| 1128 |
+
for(let i=0;i<N;i++){
|
| 1129 |
+
const cv = canvases[i], w = cv.width, h = cv.height;
|
| 1130 |
+
const pageObj = 3 + i*3, contentObj = pageObj + 1, imgObj = pageObj + 2;
|
| 1131 |
+
start();
|
| 1132 |
+
push(pageObj + " 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + w + " " + h + "] " +
|
| 1133 |
+
"/Resources << /XObject << /Im0 " + imgObj + " 0 R >> >> /Contents " + contentObj + " 0 R >>\nendobj\n");
|
| 1134 |
+
const content = "q\n" + w + " 0 0 " + h + " 0 0 cm\n/Im0 Do\nQ\n";
|
| 1135 |
+
start();
|
| 1136 |
+
push(contentObj + " 0 obj\n<< /Length " + content.length + " >>\nstream\n" + content + "endstream\nendobj\n");
|
| 1137 |
+
const data = imgDatas[i];
|
| 1138 |
+
start();
|
| 1139 |
+
push(imgObj + " 0 obj\n<< /Type /XObject /Subtype /Image /Width " + w + " /Height " + h +
|
| 1140 |
+
" /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " + data.length + " >>\nstream\n");
|
| 1141 |
+
pushBytes(data);
|
| 1142 |
+
push("\nendstream\nendobj\n");
|
| 1143 |
+
}
|
| 1144 |
+
const xrefPos = bytes.length, total = 2 + N*3;
|
| 1145 |
+
push("xref\n0 " + (total+1) + "\n");
|
| 1146 |
+
push("0000000000 65535 f \n");
|
| 1147 |
+
for(const off of offsets){ push(String(off).padStart(10,"0") + " 00000 n \n"); }
|
| 1148 |
+
push("trailer\n<< /Size " + (total+1) + " /Root 1 0 R >>\nstartxref\n" + xrefPos + "\n%%EOF");
|
| 1149 |
+
return new Blob([new Uint8Array(bytes)], { type: "application/pdf" });
|
| 1150 |
+
}
|
| 1151 |
+
/* ======================= naming ======================= */
|
| 1152 |
+
function outNameFor(it, i, w, h){
|
| 1153 |
+
const mode = document.querySelector("#nameMode button.on").dataset.m;
|
| 1154 |
+
const ext = extFor(outFmt);
|
| 1155 |
+
const num = (+$("startN").value||1) + i;
|
| 1156 |
+
if(mode === "keep") return safeName(baseName(it.name), "image") + "." + ext;
|
| 1157 |
+
const tpl = ($("tpl").value || "IMG_{n}")
|
| 1158 |
+
.replace(/\{n\}/g, String(num).padStart(3,"0"))
|
| 1159 |
+
.replace(/\{name\}/g, baseName(it.name))
|
| 1160 |
+
.replace(/\{date\}/g, todayStr())
|
| 1161 |
+
.replace(/\{w\}/g, w).replace(/\{h\}/g, h);
|
| 1162 |
+
return safeName(($("prefix").value||"") + tpl + ($("suffix").value||""), "image") + "." + ext;
|
| 1163 |
+
}
|
| 1164 |
+
/* Two source files can easily produce the same output name (e.g. "Keep original"
|
| 1165 |
+
with IMG_001.heic from two different folders). Without this, the second silently
|
| 1166 |
+
overwrites the first when saving to a picked folder. */
|
| 1167 |
+
function uniqueName(name, used){
|
| 1168 |
+
if(!used.has(name)){ used.add(name); return name; }
|
| 1169 |
+
const dot = name.lastIndexOf(".");
|
| 1170 |
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
| 1171 |
+
const ext = dot > 0 ? name.slice(dot) : "";
|
| 1172 |
+
let k = 2, next;
|
| 1173 |
+
do { next = stem + "-" + k + ext; k++; } while(used.has(next));
|
| 1174 |
+
used.add(next); return next;
|
| 1175 |
+
}
|
| 1176 |
+
/* ======================= save helpers ======================= */
|
| 1177 |
+
function download(blob, name){
|
| 1178 |
+
const a = document.createElement("a");
|
| 1179 |
+
a.href = URL.createObjectURL(blob); a.download = name; a.style.display = "none";
|
| 1180 |
+
document.body.appendChild(a); a.click();
|
| 1181 |
+
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 12000);
|
| 1182 |
+
}
|
| 1183 |
+
/* ======================= export ======================= */
|
| 1184 |
+
$("stopBtn").addEventListener("click", () => { cancel = true; say($("status"), "Stopping\u2026"); });
|
| 1185 |
+
$("exportBtn").addEventListener("click", async () => {
|
| 1186 |
+
if(exporting) return;
|
| 1187 |
+
const ready = selectedReady();
|
| 1188 |
+
if(!ready.length){ say($("status"), "Tick at least one readable image first.", "err"); return; }
|
| 1189 |
+
if(outFmt === "image/avif" && support["image/avif"] === false){
|
| 1190 |
+
say($("status"), "This browser can\u2019t encode AVIF. Pick WebP or JPEG, or use Chrome/Edge.", "err"); return;
|
| 1191 |
+
}
|
| 1192 |
+
if(outFmt === "application/pdf") return exportPdf(ready);
|
| 1193 |
+
exporting = true; cancel = false; lock(true);
|
| 1194 |
+
$("bar").style.display = "block"; $("barI").style.width = "0%";
|
| 1195 |
+
say($("status"), "Converting\u2026");
|
| 1196 |
+
let dirHandle = null;
|
| 1197 |
+
if(ready.length > 1 && window.showDirectoryPicker){
|
| 1198 |
+
try{ dirHandle = await window.showDirectoryPicker({ id:"pocket-img", mode:"readwrite", startIn:"pictures" }); }
|
| 1199 |
+
catch(e){ if(e && e.name === "AbortError"){ exporting=false; lock(false); $("bar").style.display="none"; say($("status"),"Export cancelled \u2014 no folder chosen."); return; } dirHandle = null; }
|
| 1200 |
+
}
|
| 1201 |
+
let done = 0, failed = 0, i = 0;
|
| 1202 |
+
const usedNames = new Set();
|
| 1203 |
+
for(const it of ready){
|
| 1204 |
+
if(cancel) break;
|
| 1205 |
+
it.status = "working"; render();
|
| 1206 |
+
try{
|
| 1207 |
+
const cv = renderToCanvas(it.bitmap);
|
| 1208 |
+
const blob = await encodeCanvas(cv, true);
|
| 1209 |
+
if(!blob || !blob.size) throw new Error("empty output");
|
| 1210 |
+
const name = uniqueName(outNameFor(it, i, cv.width, cv.height), usedNames);
|
| 1211 |
+
it.outName = name; it.outSize = blob.size;
|
| 1212 |
+
if(dirHandle){
|
| 1213 |
+
const fh = await dirHandle.getFileHandle(name, { create: true });
|
| 1214 |
+
const ws = await fh.createWritable(); await ws.write(blob); await ws.close();
|
| 1215 |
+
}else{
|
| 1216 |
+
download(blob, name);
|
| 1217 |
+
if(ready.length > 1) await wait(300);
|
| 1218 |
+
}
|
| 1219 |
+
it.status = "done"; done++;
|
| 1220 |
+
}catch(e){ it.status = "error"; it.err = e.message || "convert failed"; failed++; }
|
| 1221 |
+
i++;
|
| 1222 |
+
$("barI").style.width = ((done+failed)/ready.length*100).toFixed(1) + "%";
|
| 1223 |
+
render();
|
| 1224 |
+
await wait(0);
|
| 1225 |
+
}
|
| 1226 |
+
exporting = false; lock(false); render();
|
| 1227 |
+
$("stopBtn").style.display = "none"; $("bar").style.display = "none";
|
| 1228 |
+
const where = dirHandle ? " to \u201c" + dirHandle.name + "\u201d" : (ready.length>1 ? " to your Downloads folder" : "");
|
| 1229 |
+
say($("status"), cancel ? ("Stopped \u2014 " + done + " saved.") :
|
| 1230 |
+
("Done \u2014 " + done + " saved" + where + (failed ? " \u00b7 " + failed + " failed" : "") + "."), failed ? "err" : "ok");
|
| 1231 |
+
});
|
| 1232 |
+
async function exportPdf(ready){
|
| 1233 |
+
exporting = true; cancel = false; lock(true);
|
| 1234 |
+
$("bar").style.display = "block"; $("barI").style.width = "0%";
|
| 1235 |
+
say($("status"), "Building PDF\u2026");
|
| 1236 |
+
try{
|
| 1237 |
+
const canvases = [];
|
| 1238 |
+
for(let k=0;k<ready.length;k++){
|
| 1239 |
+
if(cancel) break;
|
| 1240 |
+
const cv = renderToCanvas(ready[k].bitmap);
|
| 1241 |
+
const c2 = document.createElement("canvas"); c2.width = cv.width; c2.height = cv.height;
|
| 1242 |
+
c2.getContext("2d").drawImage(cv, 0, 0);
|
| 1243 |
+
canvases.push(c2);
|
| 1244 |
+
$("barI").style.width = ((k+1)/ready.length*80).toFixed(1) + "%";
|
| 1245 |
+
say($("status"), "Rendering page " + (k+1) + " of " + ready.length + "\u2026");
|
| 1246 |
+
await wait(0);
|
| 1247 |
+
}
|
| 1248 |
+
if(cancel){ say($("status"), "Stopped."); }
|
| 1249 |
+
else{
|
| 1250 |
+
$("barI").style.width = "92%";
|
| 1251 |
+
const blob = await makePdf(canvases);
|
| 1252 |
+
const name = safeName(($("prefix").value||"") + "images_" + todayStr(), "images") + ".pdf";
|
| 1253 |
+
download(blob, name);
|
| 1254 |
+
ready.forEach(it => { it.status = "done"; it.outSize = Math.round(blob.size/ready.length); });
|
| 1255 |
+
say($("status"), "Saved " + name + " (" + human(blob.size) + ", " + ready.length + " page" + (ready.length===1?"":"s") + ").", "ok");
|
| 1256 |
+
}
|
| 1257 |
+
}catch(e){ say($("status"), e.message || "PDF failed.", "err"); }
|
| 1258 |
+
finally{ exporting = false; lock(false); $("stopBtn").style.display = "none"; $("bar").style.display = "none"; render(); }
|
| 1259 |
+
}
|
| 1260 |
+
function lock(on){
|
| 1261 |
+
$("exportBtn").disabled = on;
|
| 1262 |
+
$("stopBtn").style.display = on ? "block" : "none";
|
| 1263 |
+
["clearAll","clearDone","pickFiles","pickFolder","selAll","selNone"].forEach(id => $(id).disabled = on);
|
| 1264 |
+
}
|
| 1265 |
+
/* don't lose a batch to an accidental tab close */
|
| 1266 |
+
window.addEventListener("beforeunload", e => { if(exporting){ e.preventDefault(); e.returnValue = ""; } });
|
| 1267 |
+
/* ======================= keyboard ======================= */
|
| 1268 |
+
document.addEventListener("keydown", e => {
|
| 1269 |
+
const tag = (e.target.tagName||"").toLowerCase();
|
| 1270 |
+
if(tag === "input" || tag === "select" || tag === "textarea") return;
|
| 1271 |
+
if(e.metaKey || e.ctrlKey || e.altKey) return;
|
| 1272 |
+
if(exporting) return;
|
| 1273 |
+
if(e.key === "ArrowLeft"){ e.preventDefault(); if(sel > 0){ sel--; drawPreview(); render(); } }
|
| 1274 |
+
else if(e.key === "ArrowRight"){ e.preventDefault(); if(sel < items.length-1){ sel++; drawPreview(); render(); } }
|
| 1275 |
+
else if(e.key === " "){ e.preventDefault(); const it=items[sel]; if(it && it.status!=="error"){ it.selected=!it.selected; render(); } }
|
| 1276 |
+
else if(e.key === "r" || e.key === "R"){ e.preventDefault(); rot = (rot+90)%360; drawPreview(); updateEstimate(); }
|
| 1277 |
+
else if(e.key === "Delete" || e.key === "Backspace"){ if(sel>=0){ e.preventDefault(); removeAt(sel); } }
|
| 1278 |
+
});
|
| 1279 |
+
/* ======================= startup ======================= */
|
| 1280 |
+
(async function init(){
|
| 1281 |
+
support["image/webp"] = await supportsMime("image/webp");
|
| 1282 |
+
support["image/avif"] = await supportsMime("image/avif");
|
| 1283 |
+
if(!support["image/avif"]){
|
| 1284 |
+
const b = document.querySelector('#fmtSeg button[data-fmt="image/avif"]');
|
| 1285 |
+
if(b){ b.disabled = true; b.title = "Not supported by this browser"; b.style.opacity = ".4"; b.style.cursor = "not-allowed"; }
|
| 1286 |
+
}
|
| 1287 |
+
syncFmtUI();
|
| 1288 |
+
drawPreview(); render();
|
| 1289 |
+
})();
|
| 1290 |
+
})();
|
| 1291 |
+
</script>
|
| 1292 |
+
</body>
|
| 1293 |
+
</html>
|