@sofereditor/export-pdf 0.1.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/LICENSE +661 -0
- package/README.md +20 -0
- package/dist/index.cjs +540 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +106 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.js +506 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/__tests__/caption.test.ts +126 -0
- package/src/__tests__/html.test.ts +226 -0
- package/src/download.ts +26 -0
- package/src/html.ts +474 -0
- package/src/index.ts +17 -0
- package/src/pdf.ts +223 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +11 -0
package/src/pdf.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { LegacySerializedDocument, SerializedDocument } from "@sofereditor/core";
|
|
2
|
+
import { documentToHtml, type DocumentToHtmlOptions } from "./html";
|
|
3
|
+
|
|
4
|
+
export interface ExportPdfFromDocumentOptions extends DocumentToHtmlOptions {
|
|
5
|
+
/** Suggested file name (used by the OS save dialog as the default). */
|
|
6
|
+
filename?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Open the browser's print dialog for a `SerializedDocument`.
|
|
11
|
+
*
|
|
12
|
+
* Strategy: render the document to standalone HTML in a hidden iframe and call
|
|
13
|
+
* `iframe.contentWindow.print()`. The user picks "Save as PDF" (or any printer)
|
|
14
|
+
* from the native dialog. No external PDF library required.
|
|
15
|
+
*
|
|
16
|
+
* The returned promise resolves after the print dialog closes (`afterprint`).
|
|
17
|
+
*/
|
|
18
|
+
export async function exportPdfFromDocument(
|
|
19
|
+
doc: SerializedDocument | LegacySerializedDocument,
|
|
20
|
+
options: ExportPdfFromDocumentOptions = {},
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const html = documentToHtml(doc, { ...options, standalone: true });
|
|
23
|
+
await printHtmlInIframe(html, options.filename ?? options.title ?? "Documento");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SerializePaginatedHtmlOptions {
|
|
27
|
+
/** Document title (becomes `<title>` and is used as default filename). */
|
|
28
|
+
title?: string;
|
|
29
|
+
/** Extra CSS appended to the cloned styles (e.g. for headers/footers). */
|
|
30
|
+
extraCss?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Override the `@page { size }`. Default reads `--ed-page-width` /
|
|
33
|
+
* `--ed-page-height` CSS variables off the root (set by `<Editor>` from the
|
|
34
|
+
* current `PageSettings`); falls back to A4 if absent.
|
|
35
|
+
*/
|
|
36
|
+
pageSize?: { width: string; height: string };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ExportPdfFromElementOptions extends SerializePaginatedHtmlOptions {
|
|
40
|
+
/** Suggested file name. */
|
|
41
|
+
filename?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cssPageSizeFromRoot(
|
|
45
|
+
cs: CSSStyleDeclaration | undefined,
|
|
46
|
+
): { width: string; height: string } {
|
|
47
|
+
if (!cs) return { width: "210mm", height: "297mm" };
|
|
48
|
+
const w = cs.getPropertyValue("--ed-page-width").trim();
|
|
49
|
+
const h = cs.getPropertyValue("--ed-page-height").trim();
|
|
50
|
+
if (!w || !h) return { width: "210mm", height: "297mm" };
|
|
51
|
+
// CSS vars come back as e.g. "794px". `@page` accepts px directly.
|
|
52
|
+
return { width: w, height: h };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Serialize a live editor root (the `.ed-root` paginated DOM) into a
|
|
57
|
+
* self-contained HTML string. The output preserves the in-editor pagination
|
|
58
|
+
* decisions verbatim — each `.ed-page` becomes a fixed-size page in the final
|
|
59
|
+
* print medium with no re-layout possible.
|
|
60
|
+
*
|
|
61
|
+
* The returned HTML inlines every accessible stylesheet from the host document
|
|
62
|
+
* (or links it as a fallback for cross-origin sheets) so the layout survives
|
|
63
|
+
* transplant to an iframe, a saved file, or a headless renderer.
|
|
64
|
+
*
|
|
65
|
+
* This is the building block for two paths:
|
|
66
|
+
* - `exportPdfFromElement` (this file): writes the HTML into a hidden iframe
|
|
67
|
+
* and calls `window.print()` for the local "Save as PDF" flow.
|
|
68
|
+
* - Upload-to-server snapshots: POST the string to a backend that converts it
|
|
69
|
+
* to PDF via Puppeteer's `setContent + page.pdf()` without re-running the
|
|
70
|
+
* pagination engine.
|
|
71
|
+
*/
|
|
72
|
+
export function serializePaginatedHtml(
|
|
73
|
+
root: HTMLElement,
|
|
74
|
+
options: SerializePaginatedHtmlOptions = {},
|
|
75
|
+
): string {
|
|
76
|
+
const clone = root.cloneNode(true) as HTMLElement;
|
|
77
|
+
// Strip interactive scaffolding that print styles can't see ahead of time.
|
|
78
|
+
clone.querySelectorAll("[contenteditable]").forEach((el) => {
|
|
79
|
+
(el as HTMLElement).removeAttribute("contenteditable");
|
|
80
|
+
});
|
|
81
|
+
clone.querySelectorAll(".ed-image-overlay").forEach((el) => el.remove());
|
|
82
|
+
|
|
83
|
+
// Read the page size baked into the editor via `--ed-page-*` CSS variables.
|
|
84
|
+
// The Editor component sets these on its root from the current PageSettings
|
|
85
|
+
// (`packages/react/src/Editor.tsx`). Falls back to A4 if we can't read them.
|
|
86
|
+
const cs = root.ownerDocument?.defaultView?.getComputedStyle(root);
|
|
87
|
+
const pageSize = options.pageSize ?? cssPageSizeFromRoot(cs);
|
|
88
|
+
|
|
89
|
+
const styles = collectStyles();
|
|
90
|
+
const title = escapeHtml(options.title ?? "Documento");
|
|
91
|
+
const extra = options.extraCss ?? "";
|
|
92
|
+
// The `@page { size }` and page-break rules live inside `@media print` so
|
|
93
|
+
// they merge cleanly with any host-document print stylesheet that already
|
|
94
|
+
// declares its own `@page` (e.g. `apps/playground/src/styles.css`). Putting
|
|
95
|
+
// ours after the cloned `<style>`s gives our override last-write-wins.
|
|
96
|
+
return [
|
|
97
|
+
"<!doctype html>",
|
|
98
|
+
`<html><head><meta charset="utf-8"><title>${title}</title>`,
|
|
99
|
+
styles,
|
|
100
|
+
`<style>
|
|
101
|
+
html, body { margin: 0; padding: 0; background: white; }
|
|
102
|
+
@media print {
|
|
103
|
+
@page { size: ${pageSize.width} ${pageSize.height}; margin: 0; }
|
|
104
|
+
.ed-page { box-shadow: none !important; border: none !important; border-radius: 0 !important; margin: 0 !important; page-break-after: always; break-after: page; }
|
|
105
|
+
.ed-page:last-child { page-break-after: auto; break-after: auto; }
|
|
106
|
+
.ed-image-overlay { display: none !important; }
|
|
107
|
+
}
|
|
108
|
+
${extra}
|
|
109
|
+
</style>`,
|
|
110
|
+
"</head><body>",
|
|
111
|
+
clone.outerHTML,
|
|
112
|
+
"</body></html>",
|
|
113
|
+
].join("");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Snapshot a live editor root element (the `.ed-root` paginated DOM) and open
|
|
118
|
+
* the browser's print dialog. Preserves the in-editor pagination decisions
|
|
119
|
+
* verbatim — no re-layout in the print frame.
|
|
120
|
+
*
|
|
121
|
+
* For the "send the snapshot to a server" use case, use
|
|
122
|
+
* `serializePaginatedHtml` directly and POST the returned string.
|
|
123
|
+
*/
|
|
124
|
+
export async function exportPdfFromElement(
|
|
125
|
+
root: HTMLElement,
|
|
126
|
+
options: ExportPdfFromElementOptions = {},
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const html = serializePaginatedHtml(root, options);
|
|
129
|
+
await printHtmlInIframe(html, options.filename ?? options.title ?? "Documento");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- internals ----------
|
|
133
|
+
|
|
134
|
+
async function printHtmlInIframe(html: string, _filename: string): Promise<void> {
|
|
135
|
+
const iframe = document.createElement("iframe");
|
|
136
|
+
iframe.setAttribute("aria-hidden", "true");
|
|
137
|
+
iframe.style.cssText =
|
|
138
|
+
"position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden;";
|
|
139
|
+
document.body.appendChild(iframe);
|
|
140
|
+
|
|
141
|
+
const win = iframe.contentWindow;
|
|
142
|
+
const docu = iframe.contentDocument ?? win?.document;
|
|
143
|
+
if (!win || !docu) {
|
|
144
|
+
iframe.remove();
|
|
145
|
+
throw new Error("export-pdf: failed to access iframe document");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
docu.open();
|
|
149
|
+
docu.write(html);
|
|
150
|
+
docu.close();
|
|
151
|
+
|
|
152
|
+
// Wait for the iframe to finish loading (images, fonts).
|
|
153
|
+
await new Promise<void>((resolve) => {
|
|
154
|
+
if (docu.readyState === "complete") resolve();
|
|
155
|
+
else iframe.addEventListener("load", () => resolve(), { once: true });
|
|
156
|
+
});
|
|
157
|
+
// Best-effort wait for embedded images: print dialogs that snapshot too early
|
|
158
|
+
// miss late-loading data URIs (rare, but real).
|
|
159
|
+
await waitForImages(docu);
|
|
160
|
+
|
|
161
|
+
await new Promise<void>((resolve) => {
|
|
162
|
+
const cleanup = () => {
|
|
163
|
+
win.removeEventListener("afterprint", cleanup);
|
|
164
|
+
// Defer removal one tick so Safari's print queue doesn't choke.
|
|
165
|
+
setTimeout(() => iframe.remove(), 0);
|
|
166
|
+
resolve();
|
|
167
|
+
};
|
|
168
|
+
win.addEventListener("afterprint", cleanup);
|
|
169
|
+
win.focus();
|
|
170
|
+
win.print();
|
|
171
|
+
// Fallback in case afterprint never fires (older browsers).
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
if (iframe.isConnected) cleanup();
|
|
174
|
+
}, 60_000);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function collectStyles(): string {
|
|
179
|
+
const parts: string[] = [];
|
|
180
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
181
|
+
try {
|
|
182
|
+
const rules = sheet.cssRules;
|
|
183
|
+
if (!rules) continue;
|
|
184
|
+
const css = Array.from(rules)
|
|
185
|
+
.map((r) => r.cssText)
|
|
186
|
+
.join("\n");
|
|
187
|
+
if (css) parts.push(`<style>${css}</style>`);
|
|
188
|
+
} catch {
|
|
189
|
+
// Cross-origin sheet — fall back to linking it.
|
|
190
|
+
const href = sheet.href;
|
|
191
|
+
if (href) parts.push(`<link rel="stylesheet" href="${escapeAttr(href)}">`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return parts.join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function waitForImages(docu: Document): Promise<void> {
|
|
198
|
+
const imgs = Array.from(docu.images);
|
|
199
|
+
await Promise.all(
|
|
200
|
+
imgs.map(
|
|
201
|
+
(img) =>
|
|
202
|
+
new Promise<void>((resolve) => {
|
|
203
|
+
if (img.complete) return resolve();
|
|
204
|
+
img.addEventListener("load", () => resolve(), { once: true });
|
|
205
|
+
img.addEventListener("error", () => resolve(), { once: true });
|
|
206
|
+
}),
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const HTML_ESCAPES: Record<string, string> = {
|
|
212
|
+
"&": "&",
|
|
213
|
+
"<": "<",
|
|
214
|
+
">": ">",
|
|
215
|
+
'"': """,
|
|
216
|
+
"'": "'",
|
|
217
|
+
};
|
|
218
|
+
function escapeHtml(s: string): string {
|
|
219
|
+
return s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
|
|
220
|
+
}
|
|
221
|
+
function escapeAttr(s: string): string {
|
|
222
|
+
return escapeHtml(s);
|
|
223
|
+
}
|
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED