mevebu 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # mevebu
4
4
 
5
- **188 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
5
+ **198 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
6
6
 
7
7
  A private toolbox for the small jobs that do not deserve a whole app: format JSON, decode a JWT, make a password, build a cron line, pick a color, scan a QR code, and a lot more.
8
8
 
@@ -10,7 +10,7 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
10
10
  [![npm downloads](https://img.shields.io/npm/dm/mevebu?color=2ea44f)](https://www.npmjs.com/package/mevebu)
11
11
  [![License: MIT](https://img.shields.io/github/license/mevebu/mevebu?color=blue)](./LICENSE)
12
12
  [![GitHub stars](https://img.shields.io/github/stars/mevebu/mevebu?style=flat&color=yellow)](https://github.com/mevebu/mevebu/stargazers)
13
- ![Tools](https://img.shields.io/badge/tools-188-brightgreen)
13
+ ![Tools](https://img.shields.io/badge/tools-198-brightgreen)
14
14
  ![Privacy](https://img.shields.io/badge/privacy-100%25%20in--browser-blue)
15
15
 
16
16
  [Quick start](#quick-start) · [What's inside](#whats-inside) · [Privacy](#privacy-by-design) · [Host it yourself](#host-it-yourself) · [Development](#development) · [FAQ](#faq)
@@ -23,7 +23,7 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
23
23
 
24
24
  ## What is mevebu?
25
25
 
26
- mevebu is a collection of **188 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
26
+ mevebu is a collection of **198 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
27
27
 
28
28
  Think of it as a clean, private alternative to the usual "free online tools" websites. There are no accounts, no ads in your way, no uploads, and no tracking. Your text, passwords, tokens, and files stay on your device.
29
29
 
@@ -65,14 +65,14 @@ mevebu tries port `5555` first, then `5678`, then `5432`, and finally any free p
65
65
 
66
66
  ## What's inside
67
67
 
68
- mevebu has **188 tools in 20 categories**. Here is the full breakdown:
68
+ mevebu has **198 tools in 20 categories**. Here is the full breakdown:
69
69
 
70
70
  | Category | Tools |
71
71
  | ------------------ | ----: |
72
72
  | Random | 3 |
73
73
  | Passwords and keys | 10 |
74
74
  | Developer | 21 |
75
- | Convert | 5 |
75
+ | Convert | 15 |
76
76
  | Text and data | 14 |
77
77
  | Daily use | 8 |
78
78
  | Math | 17 |
@@ -89,7 +89,7 @@ mevebu has **188 tools in 20 categories**. Here is the full breakdown:
89
89
  | GitHub | 8 |
90
90
  | Units | 8 |
91
91
  | QR codes | 2 |
92
- | **Total** | **188** |
92
+ | **Total** | **198** |
93
93
 
94
94
  The counts come straight from the tool registry, so this table and the app never drift apart.
95
95
 
@@ -180,7 +180,7 @@ That is all. The catalog, search, breadcrumbs, and category pages pick it up aut
180
180
  ## FAQ
181
181
 
182
182
  **What is mevebu?**
183
- mevebu is a free, open source collection of 188 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
183
+ mevebu is a free, open source collection of 198 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
184
184
 
185
185
  **Is mevebu really free?**
186
186
  Yes. It is MIT licensed, with no paid tier, no account, and no usage limits.
@@ -218,6 +218,9 @@
218
218
  .inset-0 {
219
219
  inset: 0px;
220
220
  }
221
+ .start-1 {
222
+ inset-inline-start: calc(var(--spacing) * 1);
223
+ }
221
224
  .end-1 {
222
225
  inset-inline-end: calc(var(--spacing) * 1);
223
226
  }
@@ -421,6 +424,10 @@
421
424
  width: calc(var(--spacing) * 6);
422
425
  height: calc(var(--spacing) * 6);
423
426
  }
427
+ .size-7 {
428
+ width: calc(var(--spacing) * 7);
429
+ height: calc(var(--spacing) * 7);
430
+ }
424
431
  .size-8 {
425
432
  width: calc(var(--spacing) * 8);
426
433
  height: calc(var(--spacing) * 8);
@@ -433,6 +440,10 @@
433
440
  width: calc(var(--spacing) * 11);
434
441
  height: calc(var(--spacing) * 11);
435
442
  }
443
+ .size-12 {
444
+ width: calc(var(--spacing) * 12);
445
+ height: calc(var(--spacing) * 12);
446
+ }
436
447
  .size-14 {
437
448
  width: calc(var(--spacing) * 14);
438
449
  height: calc(var(--spacing) * 14);
@@ -495,6 +506,9 @@
495
506
  .h-14 {
496
507
  height: calc(var(--spacing) * 14);
497
508
  }
509
+ .h-24 {
510
+ height: calc(var(--spacing) * 24);
511
+ }
498
512
  .h-\[22px\] {
499
513
  height: 22px;
500
514
  }
@@ -525,6 +539,9 @@
525
539
  .max-h-80 {
526
540
  max-height: calc(var(--spacing) * 80);
527
541
  }
542
+ .max-h-\[320px\] {
543
+ max-height: 320px;
544
+ }
528
545
  .max-h-\[340px\] {
529
546
  max-height: 340px;
530
547
  }
@@ -603,6 +620,9 @@
603
620
  .w-\[min\(640px\,100\%\)\] {
604
621
  width: min(640px, 100%);
605
622
  }
623
+ .w-auto {
624
+ width: auto;
625
+ }
606
626
  .w-full {
607
627
  width: 100%;
608
628
  }
@@ -642,6 +662,9 @@
642
662
  .max-w-\[var\(--bench\)\] {
643
663
  max-width: var(--bench);
644
664
  }
665
+ .max-w-full {
666
+ max-width: 100%;
667
+ }
645
668
  .min-w-0 {
646
669
  min-width: 0px;
647
670
  }
@@ -657,6 +680,9 @@
657
680
  .shrink {
658
681
  flex-shrink: 1;
659
682
  }
683
+ .grow {
684
+ flex-grow: 1;
685
+ }
660
686
  .border-collapse {
661
687
  border-collapse: collapse;
662
688
  }
@@ -675,6 +701,15 @@
675
701
  .scale-\[0\.97\] {
676
702
  scale: 0.97;
677
703
  }
704
+ .-rotate-90 {
705
+ rotate: calc(90deg * -1);
706
+ }
707
+ .rotate-90 {
708
+ rotate: 90deg;
709
+ }
710
+ .rotate-180 {
711
+ rotate: 180deg;
712
+ }
678
713
  .transform {
679
714
  transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
680
715
  }
@@ -705,12 +740,21 @@
705
740
  .\[grid-template-columns\:minmax\(90px\,34\%\)_minmax\(0\,1fr\)\] {
706
741
  grid-template-columns: minmax(90px,34%) minmax(0,1fr);
707
742
  }
743
+ .\[grid-template-columns\:repeat\(96\,minmax\(0\,1fr\)\)\] {
744
+ grid-template-columns: repeat(96,minmax(0,1fr));
745
+ }
708
746
  .\[grid-template-columns\:repeat\(auto-fill\,minmax\(92px\,1fr\)\)\] {
709
747
  grid-template-columns: repeat(auto-fill,minmax(92px,1fr));
710
748
  }
749
+ .\[grid-template-columns\:repeat\(auto-fill\,minmax\(150px\,1fr\)\)\] {
750
+ grid-template-columns: repeat(auto-fill,minmax(150px,1fr));
751
+ }
711
752
  .\[grid-template-columns\:repeat\(auto-fill\,minmax\(160px\,1fr\)\)\] {
712
753
  grid-template-columns: repeat(auto-fill,minmax(160px,1fr));
713
754
  }
755
+ .\[grid-template-columns\:repeat\(auto-fill\,minmax\(170px\,1fr\)\)\] {
756
+ grid-template-columns: repeat(auto-fill,minmax(170px,1fr));
757
+ }
714
758
  .\[grid-template-columns\:repeat\(auto-fit\,minmax\(96px\,1fr\)\)\] {
715
759
  grid-template-columns: repeat(auto-fit,minmax(96px,1fr));
716
760
  }
@@ -750,6 +794,9 @@
750
794
  .items-center {
751
795
  align-items: center;
752
796
  }
797
+ .items-end {
798
+ align-items: flex-end;
799
+ }
753
800
  .items-start {
754
801
  align-items: flex-start;
755
802
  }
@@ -851,6 +898,9 @@
851
898
  .rounded {
852
899
  border-radius: 0.25rem;
853
900
  }
901
+ .rounded-\[1px\] {
902
+ border-radius: 1px;
903
+ }
854
904
  .rounded-\[3px\] {
855
905
  border-radius: 3px;
856
906
  }
@@ -973,6 +1023,12 @@
973
1023
  .bg-jade-soft {
974
1024
  background-color: var(--jade-soft);
975
1025
  }
1026
+ .bg-jade\/70 {
1027
+ background-color: var(--jade);
1028
+ @supports (color: color-mix(in lab, red, red)) {
1029
+ background-color: color-mix(in oklab, var(--jade) 70%, transparent);
1030
+ }
1031
+ }
976
1032
  .bg-orbit {
977
1033
  background-color: var(--orbit);
978
1034
  }
@@ -997,9 +1053,15 @@
997
1053
  .bg-\[image\:var\(--caret\)\] {
998
1054
  background-image: var(--caret);
999
1055
  }
1056
+ .bg-\[repeating-conic-gradient\(\#0000_0\%_25\%\,var\(--deck-2\)_0\%_50\%\)\] {
1057
+ background-image: repeating-conic-gradient(#0000 0% 25%,var(--deck-2) 0% 50%);
1058
+ }
1000
1059
  .bg-\[length\:14px\] {
1001
1060
  background-size: 14px;
1002
1061
  }
1062
+ .bg-\[length\:16px_16px\] {
1063
+ background-size: 16px 16px;
1064
+ }
1003
1065
  .bg-\[position\:right_12px_center\] {
1004
1066
  background-position: right 12px center;
1005
1067
  }
@@ -1009,6 +1071,9 @@
1009
1071
  .object-contain {
1010
1072
  object-fit: contain;
1011
1073
  }
1074
+ .object-cover {
1075
+ object-fit: cover;
1076
+ }
1012
1077
  .p-0 {
1013
1078
  padding: 0px;
1014
1079
  }
@@ -1027,6 +1092,9 @@
1027
1092
  .p-2\.5 {
1028
1093
  padding: calc(var(--spacing) * 2.5);
1029
1094
  }
1095
+ .p-3 {
1096
+ padding: calc(var(--spacing) * 3);
1097
+ }
1030
1098
  .p-3\.5 {
1031
1099
  padding: calc(var(--spacing) * 3.5);
1032
1100
  }
@@ -1060,6 +1128,9 @@
1060
1128
  .px-1 {
1061
1129
  padding-inline: var(--spacing);
1062
1130
  }
1131
+ .px-1\.5 {
1132
+ padding-inline: calc(var(--spacing) * 1.5);
1133
+ }
1063
1134
  .px-2 {
1064
1135
  padding-inline: calc(var(--spacing) * 2);
1065
1136
  }
@@ -1096,6 +1167,9 @@
1096
1167
  .px-\[var\(--margin\)\] {
1097
1168
  padding-inline: var(--margin);
1098
1169
  }
1170
+ .py-0\.5 {
1171
+ padding-block: calc(var(--spacing) * 0.5);
1172
+ }
1099
1173
  .py-1 {
1100
1174
  padding-block: var(--spacing);
1101
1175
  }
@@ -1516,6 +1590,11 @@
1516
1590
  transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
1517
1591
  transition-duration: var(--tw-duration, var(--default-transition-duration));
1518
1592
  }
1593
+ .transition-\[width\] {
1594
+ transition-property: width;
1595
+ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
1596
+ transition-duration: var(--tw-duration, var(--default-transition-duration));
1597
+ }
1519
1598
  .transition-colors {
1520
1599
  transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
1521
1600
  transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -1704,9 +1783,15 @@
1704
1783
  .disabled\:cursor-not-allowed:disabled {
1705
1784
  cursor: not-allowed;
1706
1785
  }
1786
+ .disabled\:opacity-40:disabled {
1787
+ opacity: 40%;
1788
+ }
1707
1789
  .disabled\:opacity-45:disabled {
1708
1790
  opacity: 45%;
1709
1791
  }
1792
+ .disabled\:opacity-50:disabled {
1793
+ opacity: 50%;
1794
+ }
1710
1795
  .has-checked\:border-jade:has(:checked) {
1711
1796
  border-color: var(--jade);
1712
1797
  }
@@ -750,7 +750,7 @@ export function blank(iconName, text) {
750
750
  // ---------- version ----------
751
751
 
752
752
  // Kept in one place. The CLI reads the same value from package.json.
753
- export const VERSION = "1.10.0";
753
+ export const VERSION = "1.11.0";
754
754
  export const PACKAGE_NAME = "mevebu";
755
755
  export const REPO_SLUG = "mevebu/mevebu";
756
756
 
@@ -0,0 +1,125 @@
1
+ // File plumbing shared by the converter tools: a drop target, a size label, a
2
+ // name without its extension, and an image decoder. Kept here so ten tools do
3
+ // not each grow their own copy.
4
+ import { el, icon } from "./core.js";
5
+
6
+ export function formatBytes(bytes) {
7
+ if (!bytes) return "0 B";
8
+ if (bytes < 1024) return `${bytes} B`;
9
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`;
10
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
11
+ }
12
+
13
+ export function baseName(name) {
14
+ return (name || "file").replace(/\.[^.]+$/, "") || "file";
15
+ }
16
+
17
+ export function extension(name) {
18
+ const match = /\.([^.]+)$/.exec(name || "");
19
+ return match ? match[1].toLowerCase() : "";
20
+ }
21
+
22
+ /** A dashed drop zone. Calls `onFiles` with a FileList or an array of files. */
23
+ export function dropzone({ label, hint = "or choose a file", accept = "", multiple = false, iconName = "image-up" }) {
24
+ const picker = el("input", { type: "file", accept, multiple, class: "hidden" });
25
+ const node = el(
26
+ "div",
27
+ {
28
+ class:
29
+ "grid cursor-pointer justify-items-center gap-1.5 rounded-r2 border border-dashed border-orbit-hard bg-deck-2 px-4 py-7 text-center transition-[border-color,background-color] duration-150 hover:border-jade focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-jade data-[drag]:border-jade data-[drag]:bg-jade-soft",
30
+ role: "button",
31
+ tabindex: "0",
32
+ "aria-label": label,
33
+ },
34
+ icon(iconName, "size-6 text-ink-3"),
35
+ el("p", { class: "text-sm font-semibold" }, label),
36
+ el("p", { class: "text-2xs text-ink-3" }, hint),
37
+ picker
38
+ );
39
+
40
+ let handler = () => {};
41
+ let depth = 0;
42
+
43
+ node.addEventListener("click", () => picker.click());
44
+ node.addEventListener("keydown", (event) => {
45
+ if (event.key === "Enter" || event.key === " ") {
46
+ event.preventDefault();
47
+ picker.click();
48
+ }
49
+ });
50
+ picker.addEventListener("change", () => {
51
+ if (picker.files?.length) handler(picker.files);
52
+ picker.value = "";
53
+ });
54
+ node.addEventListener("dragenter", (event) => {
55
+ event.preventDefault();
56
+ depth++;
57
+ node.dataset.drag = "";
58
+ });
59
+ node.addEventListener("dragover", (event) => event.preventDefault());
60
+ node.addEventListener("dragleave", () => {
61
+ depth = Math.max(0, depth - 1);
62
+ if (!depth) delete node.dataset.drag;
63
+ });
64
+ node.addEventListener("drop", (event) => {
65
+ event.preventDefault();
66
+ depth = 0;
67
+ delete node.dataset.drag;
68
+ const files = Array.from(event.dataTransfer?.files ?? []);
69
+ if (files.length) handler(files);
70
+ });
71
+
72
+ node.onFiles = (fn) => {
73
+ handler = fn;
74
+ };
75
+ node.picker = picker;
76
+ return node;
77
+ }
78
+
79
+ /** Decodes an image file, preferring createImageBitmap and falling back to <img>. */
80
+ export async function decodeImage(file) {
81
+ if (typeof createImageBitmap === "function") {
82
+ try {
83
+ return await createImageBitmap(file, { imageOrientation: "from-image" });
84
+ } catch {
85
+ // SVG and a few odd files only decode through an <img>.
86
+ }
87
+ }
88
+ const url = URL.createObjectURL(file);
89
+ try {
90
+ const image = new Image();
91
+ image.decoding = "sync";
92
+ await new Promise((resolve, reject) => {
93
+ image.onload = resolve;
94
+ image.onerror = () => reject(new Error("This file is not an image the browser can read."));
95
+ image.src = url;
96
+ });
97
+ return image;
98
+ } finally {
99
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
100
+ }
101
+ }
102
+
103
+ export function imageSize(source) {
104
+ return {
105
+ width: source.width || source.naturalWidth || 0,
106
+ height: source.height || source.naturalHeight || 0,
107
+ };
108
+ }
109
+
110
+ export function canvasToBlob(canvas, type = "image/png", quality) {
111
+ return new Promise((resolve, reject) => {
112
+ canvas.toBlob(
113
+ (blob) => (blob ? resolve(blob) : reject(new Error("The browser could not write this file."))),
114
+ type,
115
+ quality
116
+ );
117
+ });
118
+ }
119
+
120
+ /** Sorts files by name with numbers read as numbers, so page-2 comes before page-10. */
121
+ export function naturalSort(files) {
122
+ return [...files].sort((a, b) =>
123
+ a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
124
+ );
125
+ }
@@ -1,5 +1,5 @@
1
1
  // GENERATED FILE, DO NOT EDIT.
2
- // Source: lucide v1.57.0 (ISC), icons/174 names from scripts/vendor-icons.mjs
2
+ // Source: lucide v1.57.0 (ISC), icons/175 names from scripts/vendor-icons.mjs
3
3
  // Regenerate with: npm run icons
4
4
 
5
5
  const D = {
@@ -45,6 +45,7 @@ const D = {
45
45
  "download": [ ["path", { d: "M12 15V3" }], ["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }], ["path", { d: "m7 10 5 5 5-5" }] ],
46
46
  "upload": [ ["path", { d: "M12 3v12" }], ["path", { d: "m17 8-5-5-5 5" }], ["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }] ],
47
47
  "delete": [ [ "path", { d: "M10 5a2 2 0 0 0-1.344.519l-6.328 5.74a1 1 0 0 0 0 1.481l6.328 5.741A2 2 0 0 0 10 19h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z" } ], ["path", { d: "m12 9 6 6" }], ["path", { d: "m18 9-6 6" }] ],
48
+ "scissors": [ ["circle", { cx: "6", cy: "6", r: "3" }], ["path", { d: "M8.12 8.12 12 12" }], ["path", { d: "M20 4 8.12 15.88" }], ["circle", { cx: "6", cy: "18", r: "3" }], ["path", { d: "M14.8 14.8 20 20" }] ],
48
49
  "arrow-left-right": [ ["path", { d: "M8 3 4 7l4 4" }], ["path", { d: "M4 7h16" }], ["path", { d: "m16 21 4-4-4-4" }], ["path", { d: "M20 17H4" }] ],
49
50
  "trash": [ ["path", { d: "M10 11v6" }], ["path", { d: "M14 11v6" }], ["path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }], ["path", { d: "M3 6h18" }], ["path", { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }] ],
50
51
  "plus": [ ["path", { d: "M5 12h14" }], ["path", { d: "M12 5v14" }] ],
@@ -0,0 +1,67 @@
1
+ // Loaders for the two vendored PDF libraries. Each one is fetched the first time
2
+ // a tool needs it, so opening the index never downloads a PDF engine.
3
+ const PDFJS_URL = new URL("./vendor/pdf.min.mjs", import.meta.url).href;
4
+ const WORKER_URL = new URL("./vendor/pdf.worker.min.mjs", import.meta.url).href;
5
+ const PDFLIB_URL = new URL("./vendor/pdf-lib.min.js", import.meta.url).href;
6
+
7
+ let pdfjsPromise = null;
8
+ let pdfLibPromise = null;
9
+
10
+ /** pdf.js, configured to run its worker from this folder, never from a CDN. */
11
+ export function loadPdfjs() {
12
+ if (!pdfjsPromise) {
13
+ pdfjsPromise = import(PDFJS_URL).then((module) => {
14
+ module.GlobalWorkerOptions.workerSrc = WORKER_URL;
15
+ return module;
16
+ });
17
+ }
18
+ return pdfjsPromise;
19
+ }
20
+
21
+ /** pdf-lib, which ships as a UMD bundle and lands on `globalThis.PDFLib`. */
22
+ export function loadPdfLib() {
23
+ if (globalThis.PDFLib) return Promise.resolve(globalThis.PDFLib);
24
+ if (!pdfLibPromise) {
25
+ pdfLibPromise = new Promise((resolve, reject) => {
26
+ const script = document.createElement("script");
27
+ script.src = PDFLIB_URL;
28
+ script.onload = () =>
29
+ globalThis.PDFLib ? resolve(globalThis.PDFLib) : reject(new Error("The PDF writer did not start."));
30
+ script.onerror = () => reject(new Error("The PDF writer could not be loaded."));
31
+ document.head.append(script);
32
+ });
33
+ }
34
+ return pdfLibPromise;
35
+ }
36
+
37
+ export async function readPdfjs(file) {
38
+ const pdfjs = await loadPdfjs();
39
+ const bytes = new Uint8Array(await file.arrayBuffer());
40
+ return pdfjs.getDocument({ data: bytes, isEvalSupported: false }).promise;
41
+ }
42
+
43
+ export async function readPdfLib(file) {
44
+ const { PDFDocument } = await loadPdfLib();
45
+ return PDFDocument.load(await file.arrayBuffer(), { ignoreEncryption: false });
46
+ }
47
+
48
+ /** "1-3, 7, 10-" against a page count, returning zero based indexes. */
49
+ export function parsePageRange(text, count) {
50
+ const clean = String(text || "").trim();
51
+ if (!clean) return Array.from({ length: count }, (_, i) => i);
52
+ const pages = new Set();
53
+ for (const part of clean.split(",")) {
54
+ const piece = part.trim();
55
+ if (!piece) continue;
56
+ const match = /^(\d+)?\s*(?:-\s*(\d+)?)?$/.exec(piece);
57
+ if (!match) throw new Error(`"${piece}" is not a page or a range.`);
58
+ const from = match[1] ? Number(match[1]) : 1;
59
+ const to = match[2] ? Number(match[2]) : match[1] ? from : count;
60
+ if (from < 1 || to < 1) throw new Error("Pages start at 1.");
61
+ if (from > count || to > count) throw new Error(`This file has ${count} page${count === 1 ? "" : "s"}.`);
62
+ if (from > to) throw new Error(`"${piece}" runs backwards.`);
63
+ for (let page = from; page <= to; page++) pages.add(page - 1);
64
+ }
65
+ if (!pages.size) throw new Error("That range selects no pages.");
66
+ return [...pages].sort((a, b) => a - b);
67
+ }
@@ -395,6 +395,186 @@ export const TOOLS = [
395
395
  keywords: "favicon ico png apple touch icon android manifest icon generator",
396
396
  load: () => import("./tools/favicon-converter.js"),
397
397
  },
398
+ {
399
+ id: "image-to-pdf",
400
+ category: "convert",
401
+ name: "Image to PDF",
402
+ icon: "image-up",
403
+ note: "Turn images into one PDF.",
404
+ about: "Pick a page size, drag the images into order, and get one PDF.",
405
+ tutorial: {
406
+ intro: "Turn one image or a set into a single PDF.",
407
+ steps: [
408
+ "Drop the images and drag them into the order you want.",
409
+ "Pick A4 or Letter, a margin, and whether each image fits inside the page or fills it.",
410
+ "Press Build PDF. The file downloads from this tab.",
411
+ ],
412
+ },
413
+ keywords: "image to pdf jpg to pdf png to pdf photo to pdf combine images document",
414
+ load: () => import("./tools/image-to-pdf.js"),
415
+ },
416
+ {
417
+ id: "pdf-to-image",
418
+ category: "convert",
419
+ name: "PDF to Image",
420
+ icon: "file-text",
421
+ note: "Render every page as a PNG or JPEG.",
422
+ about: "Choose a page range and a scale, then take the pages as a ZIP of images.",
423
+ tutorial: {
424
+ intro: "Every page of a PDF as its own image.",
425
+ steps: [
426
+ "Drop a PDF. A preview of the first pages appears.",
427
+ "Set the range, the format, and the scale. 2x is a sharp screen size.",
428
+ "Press Download ZIP for one image per page.",
429
+ ],
430
+ },
431
+ keywords: "pdf to image pdf to png pdf to jpg render page screenshot",
432
+ load: () => import("./tools/pdf-to-image.js"),
433
+ },
434
+ {
435
+ id: "pdf-merge",
436
+ category: "convert",
437
+ name: "PDF Merge",
438
+ icon: "combine",
439
+ note: "Join several PDFs into one.",
440
+ about: "Files merge from top to bottom, and the pages stay as they were.",
441
+ tutorial: {
442
+ intro: "Combine PDFs without re-rendering them.",
443
+ steps: [
444
+ "Drop two or more PDFs.",
445
+ "Drag or use the arrows to set the order.",
446
+ "Press Merge PDFs. Text stays selectable because the pages are copied, not redrawn.",
447
+ ],
448
+ },
449
+ keywords: "merge pdf combine pdf join pdf append document",
450
+ load: () => import("./tools/pdf-merge.js"),
451
+ },
452
+ {
453
+ id: "pdf-split",
454
+ category: "convert",
455
+ name: "PDF Split",
456
+ icon: "scissors",
457
+ note: "Cut one PDF into parts.",
458
+ about: "Split by range, by a fixed page count, or one file per page.",
459
+ tutorial: {
460
+ intro: "Turn one PDF into several files.",
461
+ steps: [
462
+ "Drop a PDF.",
463
+ "Pick By range, Every N pages, or One per page.",
464
+ "Press Split and download for a ZIP of the parts.",
465
+ ],
466
+ },
467
+ keywords: "split pdf cut pdf separate pages extract range",
468
+ load: () => import("./tools/pdf-split.js"),
469
+ },
470
+ {
471
+ id: "pdf-to-text",
472
+ category: "convert",
473
+ name: "PDF to Text",
474
+ icon: "text-search",
475
+ note: "Copy the text out of a PDF.",
476
+ about: "Each page is labelled, so the order survives the copy.",
477
+ tutorial: {
478
+ intro: "Read the text layer of a PDF.",
479
+ steps: [
480
+ "Drop a PDF.",
481
+ "Set a page range, or leave it empty for every page.",
482
+ "Press Extract text, then copy it or download a .txt. A scan has no text layer, and the tool says so.",
483
+ ],
484
+ },
485
+ keywords: "pdf to text extract text copy pdf txt document",
486
+ load: () => import("./tools/pdf-to-text.js"),
487
+ },
488
+ {
489
+ id: "pdf-organizer",
490
+ category: "convert",
491
+ name: "PDF Page Organizer",
492
+ icon: "layout-grid",
493
+ note: "Reorder, rotate and delete pages.",
494
+ about: "Every page is a thumbnail. Drag it, turn it, or drop it.",
495
+ tutorial: {
496
+ intro: "Rearrange a PDF page by page.",
497
+ steps: [
498
+ "Drop a PDF and wait for the thumbnails.",
499
+ "Drag a page to move it, use the arrows, rotate it, or delete it.",
500
+ "Press Save PDF. Your original file is never touched.",
501
+ ],
502
+ },
503
+ keywords: "reorder pdf rotate pdf delete page organize pages arrange",
504
+ load: () => import("./tools/pdf-organizer.js"),
505
+ },
506
+ {
507
+ id: "text-to-pdf",
508
+ category: "convert",
509
+ name: "Text to PDF",
510
+ icon: "file-text",
511
+ note: "Turn plain text into a clean PDF.",
512
+ about: "Wrapped lines, real page breaks, selectable text.",
513
+ tutorial: {
514
+ intro: "Paste text and get a PDF.",
515
+ steps: [
516
+ "Paste the text and add an optional title.",
517
+ "Pick a page size, margin, font size and line height.",
518
+ "Press Build PDF. Long lines wrap and long text breaks onto new pages.",
519
+ ],
520
+ },
521
+ keywords: "text to pdf txt to pdf write document page break",
522
+ load: () => import("./tools/text-to-pdf.js"),
523
+ },
524
+ {
525
+ id: "svg-to-png",
526
+ category: "convert",
527
+ name: "SVG to PNG",
528
+ icon: "image-up",
529
+ note: "Render an SVG at any pixel size.",
530
+ about: "Drawn at the size you ask for, so the PNG stays sharp.",
531
+ tutorial: {
532
+ intro: "Turn a vector into a PNG.",
533
+ steps: [
534
+ "Drop an SVG.",
535
+ "Set the width and height, or pick a preset.",
536
+ "Choose a transparent or solid background, then download the PNG.",
537
+ ],
538
+ },
539
+ keywords: "svg to png vector rasterize icon export",
540
+ load: () => import("./tools/svg-to-png.js"),
541
+ },
542
+ {
543
+ id: "audio-converter",
544
+ category: "convert",
545
+ name: "Audio Converter",
546
+ icon: "activity",
547
+ note: "Convert audio to WAV.",
548
+ about: "Resample, trim and normalize, then write 16 bit PCM.",
549
+ tutorial: {
550
+ intro: "Decode an audio file and write a clean WAV.",
551
+ steps: [
552
+ "Drop an audio file the browser can decode.",
553
+ "Pick a sample rate and mono or stereo, then trim if you need to.",
554
+ "Press Convert to WAV.",
555
+ ],
556
+ },
557
+ keywords: "audio converter wav mp3 ogg flac resample normalize trim",
558
+ load: () => import("./tools/audio-converter.js"),
559
+ },
560
+ {
561
+ id: "video-converter",
562
+ category: "convert",
563
+ name: "Video Converter",
564
+ icon: "video",
565
+ note: "Convert a video to WebM.",
566
+ about: "Or pull the audio track out as Opus.",
567
+ tutorial: {
568
+ intro: "Record a video into WebM, or take its audio.",
569
+ steps: [
570
+ "Drop a video the browser can play.",
571
+ "Pick WebM video or audio only, a resolution and a bitrate.",
572
+ "Press Convert and leave the tab in front. Recording runs at real time.",
573
+ ],
574
+ },
575
+ keywords: "video converter webm mp4 opus audio track extract transcode",
576
+ load: () => import("./tools/video-converter.js"),
577
+ },
398
578
 
399
579
  {
400
580
  id: "lorem-ipsum",