@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { LegacySerializedDocument } from "@sofereditor/core";
|
|
3
|
+
import { documentToHtmlFragment } from "../html";
|
|
4
|
+
|
|
5
|
+
const PNG_1PX =
|
|
6
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgAAIAAAUAAeImBZsAAAAASUVORK5CYII=";
|
|
7
|
+
|
|
8
|
+
describe("image caption in HTML export", () => {
|
|
9
|
+
it("emits <figure><img/><figcaption> when caption is set", () => {
|
|
10
|
+
const doc: LegacySerializedDocument = [
|
|
11
|
+
{
|
|
12
|
+
type: "paragraph",
|
|
13
|
+
text: "",
|
|
14
|
+
delta: [
|
|
15
|
+
{
|
|
16
|
+
insert: {
|
|
17
|
+
type: "image",
|
|
18
|
+
src: PNG_1PX,
|
|
19
|
+
width: 100,
|
|
20
|
+
height: 80,
|
|
21
|
+
caption: "Figura 1",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
attrs: {},
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
const html = documentToHtmlFragment(doc);
|
|
29
|
+
expect(html).toContain("<figure");
|
|
30
|
+
expect(html).toContain("<figcaption");
|
|
31
|
+
expect(html).toContain("Figura 1</figcaption>");
|
|
32
|
+
expect(html).toContain('data-embed-figure="true"');
|
|
33
|
+
// The img keeps its data-embed attribute for round-trip identification.
|
|
34
|
+
expect(html).toContain('data-embed="image"');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("emits bare <img/> when caption is absent", () => {
|
|
38
|
+
const doc: LegacySerializedDocument = [
|
|
39
|
+
{
|
|
40
|
+
type: "paragraph",
|
|
41
|
+
text: "",
|
|
42
|
+
delta: [
|
|
43
|
+
{ insert: { type: "image", src: PNG_1PX, width: 50, height: 50 } },
|
|
44
|
+
],
|
|
45
|
+
attrs: {},
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
const html = documentToHtmlFragment(doc);
|
|
49
|
+
expect(html).not.toContain("<figure");
|
|
50
|
+
expect(html).not.toContain("<figcaption");
|
|
51
|
+
expect(html).toContain('data-embed="image"');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("respects captionAlign in <figcaption> inline style", () => {
|
|
55
|
+
const doc: LegacySerializedDocument = [
|
|
56
|
+
{
|
|
57
|
+
type: "paragraph",
|
|
58
|
+
text: "",
|
|
59
|
+
delta: [
|
|
60
|
+
{
|
|
61
|
+
insert: {
|
|
62
|
+
type: "image",
|
|
63
|
+
src: PNG_1PX,
|
|
64
|
+
width: 50,
|
|
65
|
+
height: 50,
|
|
66
|
+
caption: "à direita",
|
|
67
|
+
captionAlign: "right",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
attrs: {},
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
const html = documentToHtmlFragment(doc);
|
|
75
|
+
expect(html).toContain('data-caption-align="right"');
|
|
76
|
+
expect(html).toContain("text-align:right");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("defaults captionAlign to center when omitted", () => {
|
|
80
|
+
const doc: LegacySerializedDocument = [
|
|
81
|
+
{
|
|
82
|
+
type: "paragraph",
|
|
83
|
+
text: "",
|
|
84
|
+
delta: [
|
|
85
|
+
{
|
|
86
|
+
insert: {
|
|
87
|
+
type: "image",
|
|
88
|
+
src: PNG_1PX,
|
|
89
|
+
width: 50,
|
|
90
|
+
height: 50,
|
|
91
|
+
caption: "sem alinhamento",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
attrs: {},
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
const html = documentToHtmlFragment(doc);
|
|
99
|
+
expect(html).toContain('data-caption-align="center"');
|
|
100
|
+
expect(html).toContain("text-align:center");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("escapes HTML special chars in caption", () => {
|
|
104
|
+
const doc: LegacySerializedDocument = [
|
|
105
|
+
{
|
|
106
|
+
type: "paragraph",
|
|
107
|
+
text: "",
|
|
108
|
+
delta: [
|
|
109
|
+
{
|
|
110
|
+
insert: {
|
|
111
|
+
type: "image",
|
|
112
|
+
src: PNG_1PX,
|
|
113
|
+
width: 50,
|
|
114
|
+
height: 50,
|
|
115
|
+
caption: "<script>alert('x')</script>",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
attrs: {},
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
const html = documentToHtmlFragment(doc);
|
|
123
|
+
expect(html).not.toContain("<script>");
|
|
124
|
+
expect(html).toContain("<script>");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { LegacySerializedDocument } from "@sofereditor/core";
|
|
3
|
+
import { documentToHtml, documentToHtmlFragment, documentToJson } from "../html";
|
|
4
|
+
|
|
5
|
+
function frag(doc: LegacySerializedDocument): string {
|
|
6
|
+
return documentToHtmlFragment(doc);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("documentToHtmlFragment", () => {
|
|
10
|
+
it("renders paragraphs with text", () => {
|
|
11
|
+
const html = frag([
|
|
12
|
+
{ type: "paragraph", text: "olá", delta: [{ insert: "olá" }], attrs: {} },
|
|
13
|
+
]);
|
|
14
|
+
expect(html).toBe(`<p class="ed-paragraph ed-block">olá</p>`);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("escapes HTML special characters", () => {
|
|
18
|
+
const html = frag([
|
|
19
|
+
{
|
|
20
|
+
type: "paragraph",
|
|
21
|
+
text: '<b>a & "b"</b>',
|
|
22
|
+
delta: [{ insert: '<b>a & "b"</b>' }],
|
|
23
|
+
attrs: {},
|
|
24
|
+
},
|
|
25
|
+
]);
|
|
26
|
+
expect(html).toContain("<b>a & "b"</b>");
|
|
27
|
+
expect(html).not.toContain("<b>");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("wraps marks innermost-first (bold inside italic inside link)", () => {
|
|
31
|
+
const html = frag([
|
|
32
|
+
{
|
|
33
|
+
type: "paragraph",
|
|
34
|
+
text: "hi",
|
|
35
|
+
delta: [
|
|
36
|
+
{
|
|
37
|
+
insert: "hi",
|
|
38
|
+
attributes: { bold: true, italic: true, link: { href: "https://x" } },
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
attrs: {},
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
expect(html).toContain(
|
|
45
|
+
`<a href="https://x" rel="noopener noreferrer" target="_blank"><em><strong>hi</strong></em></a>`,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("emits heading levels and clamps out-of-range", () => {
|
|
50
|
+
const html = frag([
|
|
51
|
+
{ type: "heading", text: "A", delta: [{ insert: "A" }], attrs: { level: 2 as any } },
|
|
52
|
+
{ type: "heading", text: "B", delta: [{ insert: "B" }], attrs: { level: 99 as any } },
|
|
53
|
+
]);
|
|
54
|
+
expect(html).toContain("<h2");
|
|
55
|
+
expect(html).toContain("<h6"); // clamped
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("reconstructs nested lists from flat listItem blocks", () => {
|
|
59
|
+
const html = frag([
|
|
60
|
+
{
|
|
61
|
+
type: "listItem",
|
|
62
|
+
text: "a",
|
|
63
|
+
delta: [{ insert: "a" }],
|
|
64
|
+
attrs: { listKind: "bullet", listLevel: 0 },
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
type: "listItem",
|
|
68
|
+
text: "a1",
|
|
69
|
+
delta: [{ insert: "a1" }],
|
|
70
|
+
attrs: { listKind: "bullet", listLevel: 1 },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
type: "listItem",
|
|
74
|
+
text: "b",
|
|
75
|
+
delta: [{ insert: "b" }],
|
|
76
|
+
attrs: { listKind: "bullet", listLevel: 0 },
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
// outer ul > li (a) + nested ul > li (a1); then sibling li (b)
|
|
80
|
+
expect(html).toMatch(/<ul[^>]*>.*<li[^>]*>a<ul[^>]*>.*<li[^>]*>a1<\/li><\/ul><\/li><li[^>]*>b<\/li><\/ul>/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("renders tables and skips covered cells", () => {
|
|
84
|
+
const html = frag([
|
|
85
|
+
{
|
|
86
|
+
type: "table",
|
|
87
|
+
text: "",
|
|
88
|
+
delta: [],
|
|
89
|
+
attrs: { rows: 1, cols: 2 },
|
|
90
|
+
cells: [
|
|
91
|
+
{ text: "x", delta: [{ insert: "x" }], attrs: { colspan: 2 } },
|
|
92
|
+
{ text: "", delta: [], attrs: { covered: true } },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
expect(html).toContain('colspan="2"');
|
|
97
|
+
// exactly one td emitted
|
|
98
|
+
const tdCount = (html.match(/<td/g) ?? []).length;
|
|
99
|
+
expect(tdCount).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("emits image embeds with data URL and dimensions", () => {
|
|
103
|
+
const html = frag([
|
|
104
|
+
{
|
|
105
|
+
type: "paragraph",
|
|
106
|
+
text: "",
|
|
107
|
+
delta: [
|
|
108
|
+
{
|
|
109
|
+
insert: {
|
|
110
|
+
type: "image",
|
|
111
|
+
src: "data:image/png;base64,abc",
|
|
112
|
+
width: 100,
|
|
113
|
+
height: 50,
|
|
114
|
+
} as any,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
attrs: {},
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
expect(html).toContain(`<img src="data:image/png;base64,abc"`);
|
|
121
|
+
expect(html).toContain(`width="100"`);
|
|
122
|
+
expect(html).toContain(`height="50"`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("blocks CSS injection in mark style values", () => {
|
|
126
|
+
const html = frag([
|
|
127
|
+
{
|
|
128
|
+
type: "paragraph",
|
|
129
|
+
text: "x",
|
|
130
|
+
delta: [
|
|
131
|
+
{ insert: "x", attributes: { color: 'red";</style><script>alert(1)</script><style>"' } },
|
|
132
|
+
],
|
|
133
|
+
attrs: {},
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
// The dangerous chars should be stripped from the inline style.
|
|
137
|
+
expect(html).not.toContain("</style>");
|
|
138
|
+
expect(html).not.toContain("<script>");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("puts align class on the block", () => {
|
|
142
|
+
const html = frag([
|
|
143
|
+
{
|
|
144
|
+
type: "paragraph",
|
|
145
|
+
text: "x",
|
|
146
|
+
delta: [{ insert: "x" }],
|
|
147
|
+
attrs: { align: "center" },
|
|
148
|
+
},
|
|
149
|
+
]);
|
|
150
|
+
expect(html).toContain("ed-align-center");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("documentToHtml (standalone)", () => {
|
|
155
|
+
it("wraps the fragment with a full html document and a stylesheet", () => {
|
|
156
|
+
const html = documentToHtml([
|
|
157
|
+
{ type: "paragraph", text: "a", delta: [{ insert: "a" }], attrs: {} },
|
|
158
|
+
]);
|
|
159
|
+
expect(html.startsWith("<!doctype html>")).toBe(true);
|
|
160
|
+
expect(html).toContain("<title>Documento</title>");
|
|
161
|
+
expect(html).toContain("@page");
|
|
162
|
+
expect(html).toContain("</body></html>");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("escapes the title", () => {
|
|
166
|
+
const html = documentToHtml([], { title: "<x>" });
|
|
167
|
+
expect(html).toContain("<title><x></title>");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("standalone HTML margins", () => {
|
|
172
|
+
// Regression: previously, both `@page { margin: ... }` AND `body { padding: ... }`
|
|
173
|
+
// declared the same offsets, doubling the printable margin when Chromium
|
|
174
|
+
// honored `@page` (via `preferCSSPageSize` in puppeteer or `window.print`).
|
|
175
|
+
it("declares margins via @page only — body padding never carries page offsets", () => {
|
|
176
|
+
const html = documentToHtml(
|
|
177
|
+
{
|
|
178
|
+
blocks: [{ type: "paragraph", text: "x", delta: [{ insert: "x" }], attrs: {} }],
|
|
179
|
+
pageSettings: {
|
|
180
|
+
width: 794,
|
|
181
|
+
height: 1123,
|
|
182
|
+
marginTop: 96,
|
|
183
|
+
marginBottom: 96,
|
|
184
|
+
marginLeft: 96,
|
|
185
|
+
marginRight: 96,
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{ standalone: true },
|
|
189
|
+
);
|
|
190
|
+
expect(html).toMatch(/@page\s*\{[^}]*margin:\s*25\.40mm/);
|
|
191
|
+
// No `body { ... padding: <Xmm> ... }` carrying the page margin (Xmm > 0).
|
|
192
|
+
// `padding: 0` (CSS reset) is fine; anything in mm/cm/in would double the
|
|
193
|
+
// page margin on top of `@page`.
|
|
194
|
+
expect(html).not.toMatch(/body[^{]*\{[^}]*padding:\s*\d+(?:\.\d+)?(?:mm|cm|in|px)\b/i);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("@page margin reflects custom pageSettings (Ofício + estreita)", () => {
|
|
198
|
+
const html = documentToHtml(
|
|
199
|
+
{
|
|
200
|
+
blocks: [{ type: "paragraph", text: "x", delta: [{ insert: "x" }], attrs: {} }],
|
|
201
|
+
pageSettings: {
|
|
202
|
+
width: 816,
|
|
203
|
+
height: 1248,
|
|
204
|
+
marginTop: 48,
|
|
205
|
+
marginBottom: 48,
|
|
206
|
+
marginLeft: 48,
|
|
207
|
+
marginRight: 48,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{ standalone: true },
|
|
211
|
+
);
|
|
212
|
+
// 48 px → 12.70mm; 816 px ≈ 215.90mm (Ofício preset); 1248 px ≈ 330.20mm
|
|
213
|
+
expect(html).toMatch(/@page[^}]*size:\s*215\.90mm\s+330\.20mm/);
|
|
214
|
+
expect(html).toMatch(/@page[^}]*margin:\s*12\.70mm\s+12\.70mm\s+12\.70mm\s+12\.70mm/);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("documentToJson", () => {
|
|
219
|
+
it("round-trips through JSON.parse", () => {
|
|
220
|
+
const doc: LegacySerializedDocument = [
|
|
221
|
+
{ type: "paragraph", text: "ç", delta: [{ insert: "ç" }], attrs: {} },
|
|
222
|
+
];
|
|
223
|
+
const round = JSON.parse(documentToJson(doc));
|
|
224
|
+
expect(round).toEqual({ blocks: doc });
|
|
225
|
+
});
|
|
226
|
+
});
|
package/src/download.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigger a browser download for an in-memory blob/string. Tiny utility — kept
|
|
3
|
+
* in this package because PDF/HTML/JSON exporters all want it.
|
|
4
|
+
*/
|
|
5
|
+
export function downloadBlob(blob: Blob, filename: string): void {
|
|
6
|
+
const url = URL.createObjectURL(blob);
|
|
7
|
+
const a = document.createElement("a");
|
|
8
|
+
a.href = url;
|
|
9
|
+
a.download = filename;
|
|
10
|
+
a.rel = "noopener";
|
|
11
|
+
a.style.display = "none";
|
|
12
|
+
document.body.appendChild(a);
|
|
13
|
+
a.click();
|
|
14
|
+
setTimeout(() => {
|
|
15
|
+
a.remove();
|
|
16
|
+
URL.revokeObjectURL(url);
|
|
17
|
+
}, 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function downloadString(
|
|
21
|
+
text: string,
|
|
22
|
+
filename: string,
|
|
23
|
+
mimeType = "text/plain;charset=utf-8",
|
|
24
|
+
): void {
|
|
25
|
+
downloadBlob(new Blob([text], { type: mimeType }), filename);
|
|
26
|
+
}
|