Skip to content

Commit d536ebf

Browse files
committed
feat(new tool): URL/HTML to PDF
Using a self hosted Puppeteer webservice Fix #328
1 parent 02e86b2 commit d536ebf

2 files changed

Lines changed: 402 additions & 0 deletions

File tree

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
1+
<script setup lang="ts">
2+
import { useITStorage } from '@/composable/queryParams';
3+
import { Base64 } from 'js-base64';
4+
5+
const url = ref('');
6+
const html = ref('');
7+
const error = ref('');
8+
const isRunning = ref(false);
9+
10+
const serverHost = useITStorage('html-to-pdf:url', 'http://localhost:3000');
11+
const serverAuth = useITStorage('html-to-pdf:auth', '');
12+
13+
const options = useITStorage('html-to-pdf:opts', {
14+
format: 'A4',
15+
landscape: false,
16+
printBackground: true,
17+
onePage: false,
18+
language: 'en-US',
19+
autoHideCookies: true,
20+
margin: {
21+
top: 20,
22+
bottom: 20,
23+
left: 15,
24+
right: 15,
25+
},
26+
});
27+
28+
const pdfFormats = [
29+
{ label: 'A2', value: 'A2' },
30+
{ label: 'A3', value: 'A3' },
31+
{ label: 'A4', value: 'A4' },
32+
{ label: 'A5', value: 'A5' },
33+
{ label: 'Letter', value: 'Letter' },
34+
{ label: 'Legal', value: 'Legal' },
35+
];
36+
37+
function downloadURL(data: string, fileName: string) {
38+
const a = document.createElement('a');
39+
a.href = data;
40+
a.download = fileName;
41+
document.body.appendChild(a);
42+
a.style.display = 'none';
43+
a.click();
44+
a.remove();
45+
}
46+
47+
function downloadBlob(blob: Blob, fileName: string) {
48+
const url = window.URL.createObjectURL(blob);
49+
downloadURL(url, fileName);
50+
setTimeout(() => window.URL.revokeObjectURL(url), 1000);
51+
}
52+
53+
function urlToFilename(input: string): string {
54+
let url: URL;
55+
56+
try {
57+
url = new URL(input);
58+
}
59+
catch {
60+
throw new Error(`Invalid URL: ${input}`);
61+
}
62+
63+
// Extract pathname or fallback
64+
let pathname = url.pathname === '/' ? '' : url.pathname;
65+
66+
// Replace slashes with dashes
67+
pathname = pathname.replace(/\//g, '-');
68+
69+
// Remove unsafe filename characters
70+
let safe = pathname.replace(/[^a-zA-Z0-9\._-]/g, '_');
71+
72+
// Include hostname for uniqueness
73+
safe = `${url.hostname}${safe}`;
74+
75+
// Append query hash if present (hashed for readability)
76+
if (url.search) {
77+
const queryHash = Base64.encode(url.search);
78+
safe += `-${queryHash}`;
79+
}
80+
81+
return safe;
82+
}
83+
84+
async function generateFromUrl() {
85+
const payload = { url: url.value, options: options.value };
86+
87+
error.value = '';
88+
isRunning.value = true;
89+
try {
90+
const res = await fetch(`${serverHost.value}/pdf/url`, {
91+
...{
92+
method: 'POST',
93+
headers: { 'Content-Type': 'application/json' },
94+
body: JSON.stringify(payload),
95+
},
96+
...(serverAuth.value ? { headers: { Authorization: `Basic ${Base64.encode(serverAuth.value)}` } } : {}),
97+
});
98+
99+
const blob = await res.blob();
100+
downloadBlob(blob, `${urlToFilename(url.value)}.pdf`);
101+
}
102+
catch (e: any) {
103+
error.value = e.toString();
104+
}
105+
isRunning.value = false;
106+
}
107+
108+
async function generateFromHtml() {
109+
const payload = { html: html.value, options: options.value };
110+
111+
error.value = '';
112+
isRunning.value = true;
113+
try {
114+
const res = await fetch(`${serverHost.value}/pdf/html`, {
115+
...{
116+
method: 'POST',
117+
headers: { 'Content-Type': 'application/json' },
118+
body: JSON.stringify(payload),
119+
},
120+
...(serverAuth.value ? { headers: { Authorization: `Basic ${Base64.encode(serverAuth.value)}` } } : {}),
121+
});
122+
123+
const blob = await res.blob();
124+
downloadBlob(blob, 'printed.pdf');
125+
}
126+
catch (e: any) {
127+
error.value = e.toString();
128+
}
129+
isRunning.value = false;
130+
}
131+
132+
const batchUrls = ref('');
133+
134+
const batchResults = ref<{ url: string; status: 'success' | 'error' ; pdfBlob?: Blob; error?: string }[]>([]);
135+
const batchProgress = ref(0);
136+
const batchTotal = ref(0);
137+
const isBatchRunning = ref(false);
138+
139+
async function generateBatch() {
140+
const urls = batchUrls.value
141+
.split('\n')
142+
.map(u => u.trim())
143+
.filter(Boolean);
144+
145+
batchResults.value = [];
146+
batchTotal.value = urls.length;
147+
batchProgress.value = 0;
148+
isBatchRunning.value = true;
149+
150+
for (const u of urls) {
151+
const payload = { url: u, options: options.value };
152+
153+
try {
154+
const res = await fetch(`${serverHost.value}/pdf/url`, {
155+
...{
156+
method: 'POST',
157+
headers: { 'Content-Type': 'application/json' },
158+
body: JSON.stringify(payload),
159+
},
160+
...(serverAuth.value ? { headers: { Authorization: `Basic ${Base64.encode(serverAuth.value)}` } } : {}),
161+
});
162+
163+
const blob = await res.blob();
164+
165+
batchResults.value.push({
166+
url: u,
167+
status: 'success',
168+
pdfBlob: blob,
169+
});
170+
}
171+
catch (err: any) {
172+
batchResults.value.push({
173+
url: u,
174+
status: 'error',
175+
error: err.toString(),
176+
});
177+
}
178+
179+
batchProgress.value++;
180+
}
181+
182+
isBatchRunning.value = false;
183+
}
184+
</script>
185+
186+
<template>
187+
<div>
188+
<details mb-2>
189+
<summary>HTML to PDF Service Configuration (self hosted)</summary>
190+
<n-card>
191+
<NFormItem label="HTML to PDF Service Url:" label-placement="top">
192+
<NInput v-model:value="serverHost" placeholder="http://localhost:3000" />
193+
</NFormItem>
194+
<NFormItem label="Basic Authentication:" label-placement="left" label-width="auto">
195+
<NInput v-model:value="serverAuth" placeholder="username:password" />
196+
</NFormItem>
197+
<n-p>
198+
You must self host HTML to PDF Service. See:
199+
<c-link href="https://github.com/sharevb/puppeteer-htmltopdf?tab=readme-ov-file#running-in-docker" target="_blank">
200+
HTML to PDF Service install
201+
</c-link>
202+
</n-p>
203+
</n-card>
204+
</details>
205+
206+
<NTabs type="line">
207+
<NTabPane name="url" tab="URL → PDF">
208+
<NForm label-placement="left">
209+
<NFormItem label="URL:">
210+
<NInput v-model:value="url" placeholder="https://example.com" />
211+
</NFormItem>
212+
213+
<n-space mb-2 justify="center">
214+
<NButton
215+
type="primary"
216+
:loading="isRunning"
217+
:disabled="isRunning"
218+
@click="generateFromUrl"
219+
>
220+
Generate PDF
221+
</NButton>
222+
</n-space>
223+
224+
<c-alert v-if="error">
225+
{{ error }}
226+
</c-alert>
227+
</NForm>
228+
</NTabPane>
229+
230+
<NTabPane name="html" tab="HTML → PDF">
231+
<NForm label-placement="top">
232+
<NFormItem label="HTML Content:">
233+
<NInput
234+
v-model:value="html"
235+
type="textarea"
236+
:autosize="{ minRows: 10 }"
237+
/>
238+
</NFormItem>
239+
240+
<n-space mb-2 justify="center">
241+
<NButton
242+
type="primary"
243+
:loading="isRunning"
244+
:disabled="isRunning"
245+
@click="generateFromHtml"
246+
>
247+
Generate PDF
248+
</NButton>
249+
</n-space>
250+
251+
<c-alert v-if="error">
252+
{{ error }}
253+
</c-alert>
254+
</NForm>
255+
</NTabPane>
256+
257+
<NTabPane name="batch" tab="Batch URL → PDF">
258+
<NForm label-placement="top">
259+
<NFormItem label="URLs (one per line):">
260+
<NInput
261+
v-model:value="batchUrls"
262+
type="textarea"
263+
:autosize="{ minRows: 8 }"
264+
/>
265+
</NFormItem>
266+
267+
<n-space mb-2 justify="center">
268+
<NButton
269+
type="primary"
270+
:loading="isBatchRunning"
271+
:disabled="isBatchRunning"
272+
@click="generateBatch"
273+
>
274+
Generate PDFs
275+
</NButton>
276+
</n-space>
277+
</NForm>
278+
279+
<div v-if="isBatchRunning || batchProgress > 0">
280+
<NProgress
281+
type="line"
282+
:percentage="Math.round((batchProgress / batchTotal) * 100)"
283+
indicator-placement="inside"
284+
processing
285+
/>
286+
<n-space justify="center">
287+
{{ batchProgress }} / {{ batchTotal }} processed
288+
</n-space>
289+
</div>
290+
291+
<!-- Results -->
292+
<NCard v-if="batchResults.length > 0" title="Results">
293+
<n-table>
294+
<thead>
295+
<th>Url</th>
296+
<th>Download</th>
297+
</thead>
298+
<tbody>
299+
<tr
300+
v-for="item in batchResults"
301+
:key="item.url"
302+
>
303+
<td>
304+
<strong>{{ item.url }}</strong>
305+
</td>
306+
307+
<td>
308+
<template v-if="item.status === 'success'">
309+
<NButton
310+
size="small"
311+
type="success"
312+
@click="downloadBlob(item.pdfBlob!, `${urlToFilename(item.url)}.pdf`)"
313+
>
314+
Download PDF
315+
</NButton>
316+
</template>
317+
318+
<template v-else>
319+
<NButton size="small" type="error" disabled>
320+
Failed
321+
</NButton>
322+
<div style="color: red; font-size: 12px">
323+
{{ item.error }}
324+
</div>
325+
</template>
326+
</td>
327+
</tr>
328+
</tbody>
329+
</n-table>
330+
</NCard>
331+
</NTabPane>
332+
333+
<!-- Custom PDF Options -->
334+
<NTabPane name="custom-options" tab="Custom PDF Options">
335+
<NForm label-placement="left">
336+
<NFormItem label="Format:">
337+
<NSelect
338+
v-model:value="options.format"
339+
:options="pdfFormats"
340+
/>
341+
</NFormItem>
342+
343+
<NFormItem label="Language:">
344+
<NInput v-model:value="options.language" placeholder="en-US" />
345+
</NFormItem>
346+
347+
<n-space justify="center">
348+
<NFormItem label="Landscape:">
349+
<NSwitch v-model:value="options.landscape" />
350+
</NFormItem>
351+
352+
<NFormItem label="One Long Page:">
353+
<NSwitch v-model:value="options.onePage" />
354+
</NFormItem>
355+
356+
<NFormItem label="Auto-Hide Cookie Banners:">
357+
<NSwitch v-model:value="options.autoHideCookies" />
358+
</NFormItem>
359+
360+
<NFormItem label="Print Background:">
361+
<NSwitch v-model:value="options.printBackground" />
362+
</NFormItem>
363+
</n-space>
364+
365+
<c-card title="Margins">
366+
<NSpace justify="center">
367+
<NFormItem label="Top (mm):" style="width: 200px">
368+
<NInputNumber v-model:value="options.margin.top" />
369+
</NFormItem>
370+
371+
<NFormItem label="Bottom (mm):" style="width: 200px">
372+
<NInputNumber v-model:value="options.margin.bottom" />
373+
</NFormItem>
374+
375+
<NFormItem label="Left (mm):" style="width: 200px">
376+
<NInputNumber v-model:value="options.margin.left" />
377+
</NFormItem>
378+
379+
<NFormItem label="Right (mm):" style="width: 200px">
380+
<NInputNumber v-model:value="options.margin.right" />
381+
</NFormItem>
382+
</NSpace>
383+
</c-card>
384+
</NForm>
385+
</NTabPane>
386+
</NTabs>
387+
</div>
388+
</template>

0 commit comments

Comments
 (0)