mevebu 1.10.0 → 2.0.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 +17 -21
- package/mevebu/app/mevebu.js +1 -1
- package/mevebu/app/tailwind.css +110 -17
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/csv.js +87 -0
- package/mevebu/libs/js/files.js +125 -0
- package/mevebu/libs/js/icons.js +2 -1
- package/mevebu/libs/js/pdf-engine.js +67 -0
- package/mevebu/libs/js/registry.js +253 -37
- package/mevebu/libs/js/tools/audio-converter.js +228 -0
- package/mevebu/libs/js/tools/csv-viewer.js +1 -51
- package/mevebu/libs/js/tools/data-dictionary.js +204 -0
- package/mevebu/libs/js/tools/image-to-pdf.js +251 -0
- package/mevebu/libs/js/tools/pdf-merge.js +158 -0
- package/mevebu/libs/js/tools/pdf-organizer.js +215 -0
- package/mevebu/libs/js/tools/pdf-split.js +198 -0
- package/mevebu/libs/js/tools/pdf-to-image.js +190 -0
- package/mevebu/libs/js/tools/pdf-to-text.js +172 -0
- package/mevebu/libs/js/tools/roster-generator.js +195 -0
- package/mevebu/libs/js/tools/sample-csv.js +252 -0
- package/mevebu/libs/js/tools/sql-insert.js +184 -0
- package/mevebu/libs/js/tools/svg-to-png.js +207 -0
- package/mevebu/libs/js/tools/team-generator.js +203 -0
- package/mevebu/libs/js/tools/text-to-pdf.js +182 -0
- package/mevebu/libs/js/tools/video-converter.js +316 -0
- package/package.json +1 -1
- package/mevebu/libs/js/tools/coin-flip.js +0 -78
- package/mevebu/libs/js/tools/name-picker.js +0 -68
- package/mevebu/libs/js/tools/spin-wheel.js +0 -168
|
@@ -1,56 +1,6 @@
|
|
|
1
1
|
// Reads CSV or delimited text as a table, with row and column counts.
|
|
2
2
|
import { el, bench, stack, panel, textArea, selectInput, fileInput, notice, copyButton, button, actions, toolcard } from "../core.js";
|
|
3
|
-
|
|
4
|
-
function parseDelimited(text, delimiter) {
|
|
5
|
-
const rows = [];
|
|
6
|
-
let row = [];
|
|
7
|
-
let field = "";
|
|
8
|
-
let quoted = false;
|
|
9
|
-
for (let i = 0; i < text.length; i++) {
|
|
10
|
-
const char = text[i];
|
|
11
|
-
if (quoted) {
|
|
12
|
-
if (char === '"') {
|
|
13
|
-
if (text[i + 1] === '"') { field += '"'; i++; }
|
|
14
|
-
else quoted = false;
|
|
15
|
-
} else field += char;
|
|
16
|
-
} else if (char === '"') {
|
|
17
|
-
quoted = true;
|
|
18
|
-
} else if (char === delimiter) {
|
|
19
|
-
row.push(field);
|
|
20
|
-
field = "";
|
|
21
|
-
} else if (char === "\n") {
|
|
22
|
-
row.push(field);
|
|
23
|
-
rows.push(row);
|
|
24
|
-
row = [];
|
|
25
|
-
field = "";
|
|
26
|
-
} else if (char !== "\r") {
|
|
27
|
-
field += char;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
if (field !== "" || row.length) {
|
|
31
|
-
row.push(field);
|
|
32
|
-
rows.push(row);
|
|
33
|
-
}
|
|
34
|
-
return rows;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function detectDelimiter(text) {
|
|
38
|
-
const candidates = [",", ";", "\t", "|"];
|
|
39
|
-
const first = text.split(/\r?\n/)[0] ?? "";
|
|
40
|
-
const counts = { ",": 0, ";": 0, "\t": 0, "|": 0 };
|
|
41
|
-
let quoted = false;
|
|
42
|
-
for (let i = 0; i < first.length; i++) {
|
|
43
|
-
const char = first[i];
|
|
44
|
-
if (char === '"') quoted = !quoted;
|
|
45
|
-
else if (!quoted && char in counts) counts[char]++;
|
|
46
|
-
}
|
|
47
|
-
let best = ",";
|
|
48
|
-
let bestCount = 0;
|
|
49
|
-
for (const candidate of candidates) {
|
|
50
|
-
if (counts[candidate] > bestCount) { bestCount = counts[candidate]; best = candidate; }
|
|
51
|
-
}
|
|
52
|
-
return best;
|
|
53
|
-
}
|
|
3
|
+
import { parseDelimited, detectDelimiter } from "../csv.js";
|
|
54
4
|
|
|
55
5
|
function tile(label) {
|
|
56
6
|
const value = el("span", { class: "wrap-anywhere text-xl font-bold tracking-[-0.02em] tabular-nums" }, "0");
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The document a dataset needs before anyone uses it: one row per column, with
|
|
2
|
+
// the type, the null count, the distinct count, the range and a sample.
|
|
3
|
+
import {
|
|
4
|
+
el, toast, download, bench, stack, panel, button, actions, toolcard, blank,
|
|
5
|
+
notice, output,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import { dropzone, baseName, formatBytes } from "../files.js";
|
|
8
|
+
import { parseDelimited, detectDelimiter, headerKeys, toCsv } from "../csv.js";
|
|
9
|
+
|
|
10
|
+
const TYPE_LABELS = {
|
|
11
|
+
number: "number",
|
|
12
|
+
date: "date",
|
|
13
|
+
boolean: "boolean",
|
|
14
|
+
text: "text",
|
|
15
|
+
empty: "empty",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function guessType(values) {
|
|
19
|
+
const sample = values.filter((value) => value !== "");
|
|
20
|
+
if (!sample.length) return "empty";
|
|
21
|
+
if (sample.every((value) => /^-?\d+(\.\d+)?$/.test(value))) return "number";
|
|
22
|
+
if (sample.every((value) => /^\d{4}-\d{2}-\d{2}$/.test(value))) return "date";
|
|
23
|
+
if (sample.every((value) => /^(true|false)$/i.test(value))) return "boolean";
|
|
24
|
+
return "text";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function summarise(name, values) {
|
|
28
|
+
const filled = values.filter((value) => value !== "");
|
|
29
|
+
const nulls = values.length - filled.length;
|
|
30
|
+
const distinct = new Set(filled).size;
|
|
31
|
+
const type = guessType(filled);
|
|
32
|
+
|
|
33
|
+
let min = "";
|
|
34
|
+
let max = "";
|
|
35
|
+
if (type === "number") {
|
|
36
|
+
const numbers = filled.map(Number);
|
|
37
|
+
min = String(Math.min(...numbers));
|
|
38
|
+
max = String(Math.max(...numbers));
|
|
39
|
+
} else if (type === "date" || type === "text") {
|
|
40
|
+
const sorted = [...filled].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
41
|
+
min = sorted[0] ?? "";
|
|
42
|
+
max = sorted[sorted.length - 1] ?? "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const counts = new Map();
|
|
46
|
+
for (const value of filled) counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
47
|
+
const top = [...counts].sort((a, b) => b[1] - a[1])[0];
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
type,
|
|
52
|
+
nulls,
|
|
53
|
+
distinct,
|
|
54
|
+
min,
|
|
55
|
+
max,
|
|
56
|
+
sample: filled[0] ?? "",
|
|
57
|
+
top: top ? `${top[0]} (${top[1]})` : "",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function mount(slot) {
|
|
62
|
+
let file = null;
|
|
63
|
+
let header = [];
|
|
64
|
+
let rows = [];
|
|
65
|
+
let summaryRows = [];
|
|
66
|
+
|
|
67
|
+
const error = notice("warn");
|
|
68
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
69
|
+
const table = el("div", { class: "overflow-x-auto rounded-r2 border border-orbit" });
|
|
70
|
+
const markdown = output({ rows: 12 });
|
|
71
|
+
const empty = blank("table", "Drop a CSV to describe it.");
|
|
72
|
+
|
|
73
|
+
const drop = dropzone({ label: "Drop a CSV here", accept: ".csv,text/csv,text/plain", iconName: "table" });
|
|
74
|
+
const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
|
|
75
|
+
const copyButton = button("Copy Markdown", {
|
|
76
|
+
icon: "copy",
|
|
77
|
+
onclick: async () => {
|
|
78
|
+
if (!markdown.textContent) return;
|
|
79
|
+
try {
|
|
80
|
+
await navigator.clipboard.writeText(markdown.textContent);
|
|
81
|
+
toast("Dictionary copied");
|
|
82
|
+
} catch {
|
|
83
|
+
error.write("The clipboard is blocked here. Download the file instead.");
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const downloadMarkdown = button("Download .md", {
|
|
88
|
+
icon: "download",
|
|
89
|
+
onclick: () => {
|
|
90
|
+
if (!markdown.textContent) return;
|
|
91
|
+
download(`${baseName(file?.name || "data")}-dictionary.md`, markdown.textContent, "text/markdown");
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const downloadCsv = button("Download CSV", { icon: "download", kind: "fill" });
|
|
95
|
+
|
|
96
|
+
function renderTable() {
|
|
97
|
+
empty.hidden = summaryRows.length > 0;
|
|
98
|
+
table.hidden = summaryRows.length === 0;
|
|
99
|
+
markdown.hidden = summaryRows.length === 0;
|
|
100
|
+
if (!summaryRows.length) {
|
|
101
|
+
table.replaceChildren();
|
|
102
|
+
markdown.write("");
|
|
103
|
+
summary.textContent = "";
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const columns = ["Column", "Type", "Nulls", "Distinct", "Min", "Max", "Sample"];
|
|
108
|
+
const head = el("div", {
|
|
109
|
+
class: "grid gap-3 border-b border-orbit bg-deck-2 px-3 py-2 text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase [grid-template-columns:minmax(90px,1fr)_70px_52px_64px_minmax(70px,.9fr)_minmax(70px,.9fr)_minmax(90px,1.1fr)]",
|
|
110
|
+
}, ...columns.map((cell) => el("span", {}, cell)));
|
|
111
|
+
|
|
112
|
+
const body = summaryRows.map((row) =>
|
|
113
|
+
el("div", {
|
|
114
|
+
class: "grid gap-3 border-b border-orbit px-3 py-2 text-sm last:border-b-0 [grid-template-columns:minmax(90px,1fr)_70px_52px_64px_minmax(70px,.9fr)_minmax(70px,.9fr)_minmax(90px,1.1fr)]",
|
|
115
|
+
},
|
|
116
|
+
el("span", { class: "truncate font-semibold" }, row.name),
|
|
117
|
+
el("span", { class: "text-2xs text-ink-3" }, TYPE_LABELS[row.type] ?? row.type),
|
|
118
|
+
el("span", { class: "font-mono text-2xs tabular-nums" }, String(row.nulls)),
|
|
119
|
+
el("span", { class: "font-mono text-2xs tabular-nums" }, String(row.distinct)),
|
|
120
|
+
el("span", { class: "truncate font-mono text-2xs" }, row.min),
|
|
121
|
+
el("span", { class: "truncate font-mono text-2xs" }, row.max),
|
|
122
|
+
el("span", { class: "truncate font-mono text-2xs text-ink-3" }, row.sample))
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
table.replaceChildren(head, ...body);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderMarkdown() {
|
|
129
|
+
const lines = [
|
|
130
|
+
`| Column | Type | Nulls | Distinct | Min | Max | Sample |`,
|
|
131
|
+
`| --- | --- | ---: | ---: | --- | --- | --- |`,
|
|
132
|
+
...summaryRows.map((row) =>
|
|
133
|
+
`| ${row.name} | ${row.type} | ${row.nulls} | ${row.distinct} | ${row.min} | ${row.max} | ${row.sample} |`),
|
|
134
|
+
];
|
|
135
|
+
markdown.write(lines.join("\n"));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function run() {
|
|
139
|
+
summaryRows = header.map((name, index) => summarise(name, rows.map((row) => row[index] ?? "")));
|
|
140
|
+
const nulls = summaryRows.reduce((total, row) => total + row.nulls, 0);
|
|
141
|
+
summary.textContent = `${rows.length} row${rows.length === 1 ? "" : "s"} · ${header.length} column${header.length === 1 ? "" : "s"} · ${nulls} empty cell${nulls === 1 ? "" : "s"}`;
|
|
142
|
+
renderTable();
|
|
143
|
+
renderMarkdown();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function open(files) {
|
|
147
|
+
const picked = Array.from(files).find((entry) => /\.(csv|tsv|txt)$/i.test(entry.name) || entry.type.startsWith("text/"));
|
|
148
|
+
if (!picked) {
|
|
149
|
+
error.write("That file is not a CSV.");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const text = await picked.text();
|
|
154
|
+
const parsed = parseDelimited(text, detectDelimiter(text));
|
|
155
|
+
if (parsed.length < 2) throw new Error("A CSV needs a header row and at least one data row.");
|
|
156
|
+
file = picked;
|
|
157
|
+
header = headerKeys(parsed[0]);
|
|
158
|
+
rows = parsed.slice(1).filter((row) => row.some((cell) => cell !== ""));
|
|
159
|
+
error.write("");
|
|
160
|
+
run();
|
|
161
|
+
} catch (caught) {
|
|
162
|
+
error.write(caught?.message || "This file could not be read.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
drop.onFiles(open);
|
|
167
|
+
clearButton.addEventListener("click", () => {
|
|
168
|
+
file = null;
|
|
169
|
+
header = [];
|
|
170
|
+
rows = [];
|
|
171
|
+
summaryRows = [];
|
|
172
|
+
error.write("");
|
|
173
|
+
renderTable();
|
|
174
|
+
});
|
|
175
|
+
downloadCsv.addEventListener("click", () => {
|
|
176
|
+
if (!summaryRows.length) return;
|
|
177
|
+
const csv = toCsv([
|
|
178
|
+
["column", "type", "nulls", "distinct", "min", "max", "sample", "top"],
|
|
179
|
+
...summaryRows.map((row) => [row.name, row.type, row.nulls, row.distinct, row.min, row.max, row.sample, row.top]),
|
|
180
|
+
]);
|
|
181
|
+
download(`${baseName(file?.name || "data")}-dictionary.csv`, csv, "text/csv");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
renderTable();
|
|
185
|
+
|
|
186
|
+
const shell = toolcard({
|
|
187
|
+
body: bench(
|
|
188
|
+
stack(
|
|
189
|
+
drop,
|
|
190
|
+
panel("What it reports", el("p", { class: "m-0 text-sm leading-[1.6] text-ink-3" },
|
|
191
|
+
"The type it detects, empty cells, distinct values, the smallest and largest value, and one sample. A type can be wrong on a mixed column, so check it before you publish.")),
|
|
192
|
+
error
|
|
193
|
+
),
|
|
194
|
+
stack(summary, empty, table, markdown)
|
|
195
|
+
),
|
|
196
|
+
foot: actions(clearButton, copyButton, downloadMarkdown, downloadCsv),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
slot.append(shell.node);
|
|
200
|
+
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export default { mount };
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Images into one PDF. Every picture is redrawn at the page's pixel size and
|
|
2
|
+
// embedded as JPEG, so a 40 MB phone photo becomes a normal page instead of a
|
|
3
|
+
// 40 MB document.
|
|
4
|
+
import {
|
|
5
|
+
el, icon, toast, download, bench, stack, panel, selectInput, numberInput,
|
|
6
|
+
button, actions, toolcard, blank, notice, chipRow,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
import { dropzone, decodeImage, imageSize, canvasToBlob, baseName, formatBytes } from "../files.js";
|
|
9
|
+
import { loadPdfLib } from "../pdf-engine.js";
|
|
10
|
+
|
|
11
|
+
const PAGES = {
|
|
12
|
+
a4: { label: "A4", width: 595.28, height: 841.89 },
|
|
13
|
+
letter: { label: "Letter", width: 612, height: 792 },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DPI = 150;
|
|
17
|
+
|
|
18
|
+
function pageSize(key) {
|
|
19
|
+
const preset = PAGES[key] ?? PAGES.a4;
|
|
20
|
+
return { width: preset.width, height: preset.height };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function drawImage(source, targetWidth, targetHeight, fit) {
|
|
24
|
+
const canvas = document.createElement("canvas");
|
|
25
|
+
canvas.width = Math.max(1, Math.round(targetWidth));
|
|
26
|
+
canvas.height = Math.max(1, Math.round(targetHeight));
|
|
27
|
+
const context = canvas.getContext("2d", { alpha: false });
|
|
28
|
+
context.fillStyle = "#ffffff";
|
|
29
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
30
|
+
context.imageSmoothingEnabled = true;
|
|
31
|
+
context.imageSmoothingQuality = "high";
|
|
32
|
+
|
|
33
|
+
const { width, height } = imageSize(source);
|
|
34
|
+
if (!width || !height) throw new Error("This image has no pixel size to read.");
|
|
35
|
+
|
|
36
|
+
const scale = fit === "cover"
|
|
37
|
+
? Math.max(canvas.width / width, canvas.height / height)
|
|
38
|
+
: Math.min(canvas.width / width, canvas.height / height);
|
|
39
|
+
const drawWidth = width * scale;
|
|
40
|
+
const drawHeight = height * scale;
|
|
41
|
+
context.drawImage(
|
|
42
|
+
source,
|
|
43
|
+
(canvas.width - drawWidth) / 2,
|
|
44
|
+
(canvas.height - drawHeight) / 2,
|
|
45
|
+
drawWidth,
|
|
46
|
+
drawHeight
|
|
47
|
+
);
|
|
48
|
+
return canvas;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function mount(slot) {
|
|
52
|
+
const entries = [];
|
|
53
|
+
let nextId = 1;
|
|
54
|
+
let token = 0;
|
|
55
|
+
let timer = null;
|
|
56
|
+
let busy = false;
|
|
57
|
+
|
|
58
|
+
const size = selectInput(
|
|
59
|
+
Object.entries(PAGES).map(([value, page]) => ({ value, label: `${page.label} (${Math.round(page.width)} x ${Math.round(page.height)} pt)` })),
|
|
60
|
+
{ value: "a4", "aria-label": "Page size" }
|
|
61
|
+
);
|
|
62
|
+
const margin = numberInput({ min: "0", max: "96", step: "2", value: "24", "aria-label": "Margin in points" });
|
|
63
|
+
const fit = chipRow(
|
|
64
|
+
[
|
|
65
|
+
{ value: "contain", label: "Fit inside" },
|
|
66
|
+
{ value: "cover", label: "Fill page" },
|
|
67
|
+
],
|
|
68
|
+
{ value: "contain", label: "Image fit" }
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const error = notice("warn");
|
|
72
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
73
|
+
const list = el("div", { class: "grid gap-2" });
|
|
74
|
+
const empty = blank("image-up", "Add images to build a PDF.");
|
|
75
|
+
|
|
76
|
+
const drop = dropzone({
|
|
77
|
+
label: "Drop images here",
|
|
78
|
+
hint: "PNG, JPEG, WebP, GIF, BMP, SVG",
|
|
79
|
+
accept: "image/*",
|
|
80
|
+
multiple: true,
|
|
81
|
+
iconName: "image-up",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const clearAll = button("Clear", { icon: "trash", kind: "quiet" });
|
|
85
|
+
const buildButton = button("Build PDF", { icon: "file-text", kind: "fill" });
|
|
86
|
+
|
|
87
|
+
function render() {
|
|
88
|
+
empty.hidden = entries.length > 0;
|
|
89
|
+
list.hidden = entries.length === 0;
|
|
90
|
+
buildButton.disabled = entries.length === 0 || busy;
|
|
91
|
+
clearAll.disabled = entries.length === 0 || busy;
|
|
92
|
+
const bytes = entries.reduce((sum, entry) => sum + entry.file.size, 0);
|
|
93
|
+
summary.textContent = entries.length
|
|
94
|
+
? `${entries.length} image${entries.length === 1 ? "" : "s"} · ${formatBytes(bytes)} in`
|
|
95
|
+
: "";
|
|
96
|
+
list.replaceChildren(
|
|
97
|
+
...entries.map((entry, index) =>
|
|
98
|
+
el(
|
|
99
|
+
"div",
|
|
100
|
+
{ class: "flex min-w-0 items-center gap-3 rounded-r1 border border-orbit bg-deck-2 p-2" },
|
|
101
|
+
el("img", { src: entry.url, alt: "", class: "size-12 flex-none rounded-[5px] border border-orbit bg-deck object-cover" }),
|
|
102
|
+
el(
|
|
103
|
+
"div",
|
|
104
|
+
{ class: "min-w-0 flex-1" },
|
|
105
|
+
el("p", { class: "truncate text-sm font-semibold" }, entry.file.name),
|
|
106
|
+
el("p", { class: "text-2xs text-ink-3" }, `${entry.width} x ${entry.height} · ${formatBytes(entry.file.size)}`)
|
|
107
|
+
),
|
|
108
|
+
el("div", { class: "flex flex-none items-center gap-0.5" },
|
|
109
|
+
el("button", {
|
|
110
|
+
class: "inline-flex size-8 cursor-pointer items-center justify-center rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-ink disabled:opacity-40",
|
|
111
|
+
type: "button",
|
|
112
|
+
"aria-label": `Move ${entry.file.name} up`,
|
|
113
|
+
disabled: index === 0,
|
|
114
|
+
onclick: () => move(index, -1),
|
|
115
|
+
}, icon("chevron-right", "size-4 -rotate-90")),
|
|
116
|
+
el("button", {
|
|
117
|
+
class: "inline-flex size-8 cursor-pointer items-center justify-center rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-ink disabled:opacity-40",
|
|
118
|
+
type: "button",
|
|
119
|
+
"aria-label": `Move ${entry.file.name} down`,
|
|
120
|
+
disabled: index === entries.length - 1,
|
|
121
|
+
onclick: () => move(index, 1),
|
|
122
|
+
}, icon("chevron-right", "size-4 rotate-90")),
|
|
123
|
+
el("button", {
|
|
124
|
+
class: "inline-flex size-8 cursor-pointer items-center justify-center rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-danger",
|
|
125
|
+
type: "button",
|
|
126
|
+
"aria-label": `Remove ${entry.file.name}`,
|
|
127
|
+
onclick: () => remove(entry.id),
|
|
128
|
+
}, icon("x", "size-4"))
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function move(index, delta) {
|
|
136
|
+
const next = index + delta;
|
|
137
|
+
if (next < 0 || next >= entries.length) return;
|
|
138
|
+
[entries[index], entries[next]] = [entries[next], entries[index]];
|
|
139
|
+
render();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function remove(id) {
|
|
143
|
+
const at = entries.findIndex((entry) => entry.id === id);
|
|
144
|
+
if (at < 0) return;
|
|
145
|
+
URL.revokeObjectURL(entries[at].url);
|
|
146
|
+
entries.splice(at, 1);
|
|
147
|
+
render();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function add(files) {
|
|
151
|
+
const images = Array.from(files).filter((file) => file.type.startsWith("image/") || /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name));
|
|
152
|
+
if (!images.length) {
|
|
153
|
+
error.write("Those files are not images.");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
error.write("");
|
|
157
|
+
for (const file of images) {
|
|
158
|
+
try {
|
|
159
|
+
const bitmap = await decodeImage(file);
|
|
160
|
+
const { width, height } = imageSize(bitmap);
|
|
161
|
+
if (bitmap.close) bitmap.close();
|
|
162
|
+
entries.push({ id: nextId++, file, url: URL.createObjectURL(file), width, height });
|
|
163
|
+
} catch (caught) {
|
|
164
|
+
error.write(`${file.name}: ${caught.message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
render();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function build() {
|
|
171
|
+
if (!entries.length || busy) return;
|
|
172
|
+
busy = true;
|
|
173
|
+
const current = ++token;
|
|
174
|
+
buildButton.disabled = true;
|
|
175
|
+
summary.textContent = "Building the PDF";
|
|
176
|
+
error.write("");
|
|
177
|
+
try {
|
|
178
|
+
const { PDFDocument } = await loadPdfLib();
|
|
179
|
+
const doc = await PDFDocument.create();
|
|
180
|
+
const { width, height } = pageSize(size.value);
|
|
181
|
+
const inset = Math.max(0, Number(margin.value) || 0);
|
|
182
|
+
const areaWidth = Math.max(24, width - inset * 2);
|
|
183
|
+
const areaHeight = Math.max(24, height - inset * 2);
|
|
184
|
+
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (current !== token) return;
|
|
187
|
+
const source = await decodeImage(entry.file);
|
|
188
|
+
const canvas = drawImage(source, (areaWidth / 72) * DPI, (areaHeight / 72) * DPI, fit.value);
|
|
189
|
+
if (source.close) source.close();
|
|
190
|
+
const jpeg = await canvasToBlob(canvas, "image/jpeg", 0.9);
|
|
191
|
+
const image = await doc.embedJpg(await jpeg.arrayBuffer());
|
|
192
|
+
const page = doc.addPage([width, height]);
|
|
193
|
+
page.drawImage(image, { x: inset, y: inset, width: areaWidth, height: areaHeight });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const bytes = await doc.save();
|
|
197
|
+
const name = entries.length === 1 ? `${baseName(entries[0].file.name)}.pdf` : "images.pdf";
|
|
198
|
+
download(name, new Blob([bytes], { type: "application/pdf" }));
|
|
199
|
+
summary.textContent = `${entries.length} page${entries.length === 1 ? "" : "s"} · ${formatBytes(bytes.length)}`;
|
|
200
|
+
toast("PDF ready");
|
|
201
|
+
} catch (caught) {
|
|
202
|
+
summary.textContent = "";
|
|
203
|
+
error.write(caught?.message || "Could not build the PDF.");
|
|
204
|
+
} finally {
|
|
205
|
+
if (current === token) {
|
|
206
|
+
busy = false;
|
|
207
|
+
render();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
drop.onFiles(add);
|
|
213
|
+
buildButton.addEventListener("click", build);
|
|
214
|
+
clearAll.addEventListener("click", () => {
|
|
215
|
+
for (const entry of entries) URL.revokeObjectURL(entry.url);
|
|
216
|
+
entries.length = 0;
|
|
217
|
+
error.write("");
|
|
218
|
+
render();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
render();
|
|
222
|
+
|
|
223
|
+
const shell = toolcard({
|
|
224
|
+
body: bench(
|
|
225
|
+
stack(
|
|
226
|
+
drop,
|
|
227
|
+
panel("Page size", size),
|
|
228
|
+
panel("Margin", margin, "In points. 72 points make an inch."),
|
|
229
|
+
el("div", { class: "grid gap-1.5" },
|
|
230
|
+
el("span", { class: "text-sm font-semibold text-ink-2" }, "Image fit"),
|
|
231
|
+
fit,
|
|
232
|
+
el("span", { class: "text-2xs text-ink-3" }, "Fit inside keeps the whole image. Fill page crops the edges.")),
|
|
233
|
+
error
|
|
234
|
+
),
|
|
235
|
+
stack(summary, empty, list)
|
|
236
|
+
),
|
|
237
|
+
foot: actions(clearAll, buildButton),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
slot.append(shell.node);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
unmount() {
|
|
244
|
+
token++;
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
for (const entry of entries) URL.revokeObjectURL(entry.url);
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export default { mount };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Several PDFs into one. Pages are copied as they are, so the text stays
|
|
2
|
+
// selectable and nothing is re-rendered.
|
|
3
|
+
import {
|
|
4
|
+
el, icon, toast, download, bench, stack, panel, button, actions, toolcard, blank, notice,
|
|
5
|
+
} from "../core.js";
|
|
6
|
+
import { dropzone, baseName, formatBytes } from "../files.js";
|
|
7
|
+
import { loadPdfLib, readPdfLib } from "../pdf-engine.js";
|
|
8
|
+
|
|
9
|
+
export function mount(slot) {
|
|
10
|
+
const entries = [];
|
|
11
|
+
let nextId = 1;
|
|
12
|
+
let busy = false;
|
|
13
|
+
let token = 0;
|
|
14
|
+
|
|
15
|
+
const error = notice("warn");
|
|
16
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
17
|
+
const list = el("div", { class: "grid gap-2" });
|
|
18
|
+
const empty = blank("file-text", "Add two or more PDFs to merge them.");
|
|
19
|
+
|
|
20
|
+
const drop = dropzone({ label: "Drop PDFs here", accept: "application/pdf,.pdf", multiple: true, iconName: "file-text" });
|
|
21
|
+
const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
|
|
22
|
+
const mergeButton = button("Merge PDFs", { icon: "combine", kind: "fill" });
|
|
23
|
+
|
|
24
|
+
function render() {
|
|
25
|
+
empty.hidden = entries.length > 0;
|
|
26
|
+
list.hidden = entries.length === 0;
|
|
27
|
+
mergeButton.disabled = entries.length < 2 || busy;
|
|
28
|
+
clearButton.disabled = entries.length === 0 || busy;
|
|
29
|
+
const pages = entries.reduce((sum, entry) => sum + entry.pages, 0);
|
|
30
|
+
const bytes = entries.reduce((sum, entry) => sum + entry.file.size, 0);
|
|
31
|
+
summary.textContent = entries.length
|
|
32
|
+
? `${entries.length} files · ${pages} pages · ${formatBytes(bytes)}`
|
|
33
|
+
: "";
|
|
34
|
+
list.replaceChildren(
|
|
35
|
+
...entries.map((entry, index) =>
|
|
36
|
+
el(
|
|
37
|
+
"div",
|
|
38
|
+
{ class: "flex min-w-0 items-center gap-3 rounded-r1 border border-orbit bg-deck-2 p-2.5" },
|
|
39
|
+
el("span", { class: "grid size-9 flex-none place-items-center rounded-[5px] bg-jade-soft text-jade" }, icon("file-text", "size-4")),
|
|
40
|
+
el("div", { class: "min-w-0 flex-1" },
|
|
41
|
+
el("p", { class: "truncate text-sm font-semibold" }, entry.file.name),
|
|
42
|
+
el("p", { class: "text-2xs text-ink-3" }, `${entry.pages} page${entry.pages === 1 ? "" : "s"} · ${formatBytes(entry.file.size)}`)),
|
|
43
|
+
el("div", { class: "flex flex-none items-center gap-0.5" },
|
|
44
|
+
el("button", {
|
|
45
|
+
class: "inline-flex size-8 cursor-pointer items-center justify-center rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-ink disabled:opacity-40",
|
|
46
|
+
type: "button",
|
|
47
|
+
"aria-label": `Move ${entry.file.name} up`,
|
|
48
|
+
disabled: index === 0,
|
|
49
|
+
onclick: () => move(index, -1),
|
|
50
|
+
}, icon("chevron-right", "size-4 -rotate-90")),
|
|
51
|
+
el("button", {
|
|
52
|
+
class: "inline-flex size-8 cursor-pointer items-center justify-center rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-ink disabled:opacity-40",
|
|
53
|
+
type: "button",
|
|
54
|
+
"aria-label": `Move ${entry.file.name} down`,
|
|
55
|
+
disabled: index === entries.length - 1,
|
|
56
|
+
onclick: () => move(index, 1),
|
|
57
|
+
}, icon("chevron-right", "size-4 rotate-90")),
|
|
58
|
+
el("button", {
|
|
59
|
+
class: "inline-flex size-8 cursor-pointer items-center justify-center rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-danger",
|
|
60
|
+
type: "button",
|
|
61
|
+
"aria-label": `Remove ${entry.file.name}`,
|
|
62
|
+
onclick: () => remove(entry.id),
|
|
63
|
+
}, icon("x", "size-4"))))
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function move(index, delta) {
|
|
69
|
+
const next = index + delta;
|
|
70
|
+
if (next < 0 || next >= entries.length) return;
|
|
71
|
+
[entries[index], entries[next]] = [entries[next], entries[index]];
|
|
72
|
+
render();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function remove(id) {
|
|
76
|
+
const at = entries.findIndex((entry) => entry.id === id);
|
|
77
|
+
if (at >= 0) entries.splice(at, 1);
|
|
78
|
+
render();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function add(files) {
|
|
82
|
+
const pdfs = Array.from(files).filter((file) => file.type === "application/pdf" || /\.pdf$/i.test(file.name));
|
|
83
|
+
if (!pdfs.length) {
|
|
84
|
+
error.write("Those files are not PDFs.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
error.write("");
|
|
88
|
+
for (const file of pdfs) {
|
|
89
|
+
try {
|
|
90
|
+
const doc = await readPdfLib(file);
|
|
91
|
+
entries.push({ id: nextId++, file, pages: doc.getPageCount() });
|
|
92
|
+
} catch (caught) {
|
|
93
|
+
error.write(`${file.name}: ${caught.message || "could not be read"}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
render();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function merge() {
|
|
100
|
+
if (entries.length < 2 || busy) return;
|
|
101
|
+
busy = true;
|
|
102
|
+
const current = ++token;
|
|
103
|
+
mergeButton.disabled = true;
|
|
104
|
+
summary.textContent = "Merging";
|
|
105
|
+
error.write("");
|
|
106
|
+
try {
|
|
107
|
+
const { PDFDocument } = await loadPdfLib();
|
|
108
|
+
const merged = await PDFDocument.create();
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (current !== token) return;
|
|
111
|
+
const source = await PDFDocument.load(await entry.file.arrayBuffer());
|
|
112
|
+
const pages = await merged.copyPages(source, source.getPageIndices());
|
|
113
|
+
for (const page of pages) merged.addPage(page);
|
|
114
|
+
}
|
|
115
|
+
const bytes = await merged.save();
|
|
116
|
+
const name = entries.length === 1 ? entries[0].file.name : "merged.pdf";
|
|
117
|
+
download(name, new Blob([bytes], { type: "application/pdf" }));
|
|
118
|
+
summary.textContent = `${merged.getPageCount()} pages · ${formatBytes(bytes.length)}`;
|
|
119
|
+
toast("Merged");
|
|
120
|
+
} catch (caught) {
|
|
121
|
+
summary.textContent = "";
|
|
122
|
+
error.write(caught?.message || "Could not merge these files.");
|
|
123
|
+
} finally {
|
|
124
|
+
if (current === token) {
|
|
125
|
+
busy = false;
|
|
126
|
+
render();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
drop.onFiles(add);
|
|
132
|
+
mergeButton.addEventListener("click", merge);
|
|
133
|
+
clearButton.addEventListener("click", () => {
|
|
134
|
+
entries.length = 0;
|
|
135
|
+
error.write("");
|
|
136
|
+
render();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
render();
|
|
140
|
+
|
|
141
|
+
const shell = toolcard({
|
|
142
|
+
body: bench(
|
|
143
|
+
stack(drop, panel("Order", el("p", { class: "m-0 text-sm text-ink-3" }, "Files merge from top to bottom.")), error),
|
|
144
|
+
stack(summary, empty, list)
|
|
145
|
+
),
|
|
146
|
+
foot: actions(clearButton, mergeButton),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
slot.append(shell.node);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
unmount() {
|
|
153
|
+
token++;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default { mount };
|