@sofereditor/export-pdf 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sofereditor/export-pdf",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "@sofereditor/export-pdf — Serialize the paginated Sofer DOM to self-contained HTML, ready for Puppeteer PDF rendering.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Rafael Marreca",
@@ -21,27 +21,33 @@
21
21
  "sofer"
22
22
  ],
23
23
  "type": "module",
24
- "main": "./src/index.ts",
25
- "types": "./src/index.ts",
24
+ "main": "./dist/index.cjs",
25
+ "module": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
26
27
  "exports": {
27
28
  ".": {
28
- "types": "./src/index.ts",
29
- "import": "./src/index.ts",
30
- "require": "./src/index.ts"
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "require": "./dist/index.cjs"
31
32
  }
32
33
  },
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsup",
41
+ "dev": "tsup --watch",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "vitest run"
44
+ },
33
45
  "dependencies": {
34
- "@sofereditor/core": "0.1.0"
46
+ "@sofereditor/core": "^0.2.0"
35
47
  },
36
48
  "devDependencies": {
37
49
  "typescript": "^5.7.2",
38
50
  "tsup": "^8.3.5",
39
51
  "vitest": "^2.1.8"
40
- },
41
- "scripts": {
42
- "build": "tsup",
43
- "dev": "tsup --watch",
44
- "typecheck": "tsc --noEmit",
45
- "test": "vitest run"
46
52
  }
47
- }
53
+ }
@@ -1,126 +0,0 @@
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("&lt;script&gt;");
125
- });
126
- });
@@ -1,226 +0,0 @@
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("&lt;b&gt;a &amp; &quot;b&quot;&lt;/b&gt;");
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>&lt;x&gt;</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 DELETED
@@ -1,26 +0,0 @@
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
- }
package/src/html.ts DELETED
@@ -1,474 +0,0 @@
1
- import type {
2
- AlignValue,
3
- BlockAttrs,
4
- DeltaOp,
5
- DirValue,
6
- ImageEmbed,
7
- LegacySerializedDocument,
8
- ListKind,
9
- MarkAttrs,
10
- PageSettings,
11
- SerializedBlock,
12
- SerializedCell,
13
- SerializedDocument,
14
- } from "@sofereditor/core";
15
- import { isImageEmbed, isLegacySerializedDocument, pxToMm } from "@sofereditor/core";
16
-
17
- function normalize(doc: SerializedDocument | LegacySerializedDocument): SerializedDocument {
18
- return isLegacySerializedDocument(doc) ? { blocks: doc } : doc;
19
- }
20
-
21
- export interface DocumentToHtmlOptions {
22
- /** Document title (set on `<title>` and used as the file name hint). Default "Documento". */
23
- title?: string;
24
- /** When true (default) emits a full `<html>` document. When false emits only the body fragment. */
25
- standalone?: boolean;
26
- /** Extra CSS appended after the default print stylesheet. Only honored when `standalone`. */
27
- extraCss?: string;
28
- /** Page geometry hints injected into `@page`. Defaults to A4. */
29
- page?: {
30
- width: string;
31
- height: string;
32
- marginTop: string;
33
- marginBottom: string;
34
- marginLeft: string;
35
- marginRight: string;
36
- };
37
- }
38
-
39
- const DEFAULT_PAGE = {
40
- width: "210mm",
41
- height: "297mm",
42
- marginTop: "25mm",
43
- marginBottom: "25mm",
44
- marginLeft: "25mm",
45
- marginRight: "25mm",
46
- };
47
-
48
- /**
49
- * Serialize a `SerializedDocument` to standalone HTML.
50
- *
51
- * Output structure mirrors what `@sofereditor/react` paints at runtime: each
52
- * top-level block becomes one element (`<p>`, `<h1..6>`, `<blockquote>`, `<pre>`,
53
- * `<table>`), consecutive `listItem` blocks are reconstructed into nested
54
- * `<ul>`/`<ol>` trees, and image embeds are emitted as `<img>` tags carrying the
55
- * embed's data URL (so the export is self-contained).
56
- *
57
- * NOT a fragment of the live editor DOM — this walks the model so the output is
58
- * editor-state agnostic (no caret artifacts, no zero-width phantom anchors).
59
- */
60
- export function documentToHtml(
61
- doc: SerializedDocument | LegacySerializedDocument,
62
- options: DocumentToHtmlOptions = {},
63
- ): string {
64
- const normalized = normalize(doc);
65
- const body = documentToHtmlFragment(normalized);
66
- if (options.standalone === false) return body;
67
- const page = options.page ?? pageFromSettings(normalized.pageSettings);
68
- const title = escapeHtml(options.title ?? "Documento");
69
- const extra = options.extraCss ?? "";
70
- return [
71
- "<!doctype html>",
72
- `<html lang="pt-BR"><head><meta charset="utf-8"><title>${title}</title>`,
73
- `<style>${baseStylesheet(page)}${extra}</style>`,
74
- "</head><body>",
75
- body,
76
- "</body></html>",
77
- ].join("");
78
- }
79
-
80
- function pageFromSettings(s: PageSettings | undefined): NonNullable<DocumentToHtmlOptions["page"]> {
81
- if (!s) return DEFAULT_PAGE;
82
- const fmt = (px: number) => `${pxToMm(px).toFixed(2)}mm`;
83
- return {
84
- width: fmt(s.width),
85
- height: fmt(s.height),
86
- marginTop: fmt(s.marginTop),
87
- marginBottom: fmt(s.marginBottom),
88
- marginLeft: fmt(s.marginLeft),
89
- marginRight: fmt(s.marginRight),
90
- };
91
- }
92
-
93
- /** Body-only HTML for embedding in other documents. */
94
- export function documentToHtmlFragment(
95
- doc: SerializedDocument | LegacySerializedDocument,
96
- ): string {
97
- const blocks = normalize(doc).blocks;
98
- const out: string[] = [];
99
- let i = 0;
100
- while (i < blocks.length) {
101
- const block = blocks[i];
102
- if (block.type === "listItem") {
103
- const groupStart = i;
104
- const kind: ListKind = block.attrs.listKind === "ordered" ? "ordered" : "bullet";
105
- const items: SerializedBlock[] = [];
106
- while (
107
- i < blocks.length &&
108
- blocks[i].type === "listItem" &&
109
- (blocks[i].attrs.listKind ?? "bullet") === kind
110
- ) {
111
- items.push(blocks[i]);
112
- i++;
113
- }
114
- out.push(renderListGroup(items, kind, groupStart));
115
- continue;
116
- }
117
- out.push(renderBlock(block, i));
118
- i++;
119
- }
120
- return out.join("");
121
- }
122
-
123
- /** JSON export — a stable serialization for save/load round-trips. */
124
- export function documentToJson(
125
- doc: SerializedDocument | LegacySerializedDocument,
126
- pretty = true,
127
- ): string {
128
- return JSON.stringify(normalize(doc), null, pretty ? 2 : 0);
129
- }
130
-
131
- // ---------- blocks ----------
132
-
133
- function renderBlock(block: SerializedBlock, index: number): string {
134
- switch (block.type) {
135
- case "paragraph":
136
- return `<p${blockAttrs("ed-paragraph", block.attrs)}>${renderInline(block.delta)}</p>`;
137
- case "heading": {
138
- const level = clampHeadingLevel(block.attrs.level);
139
- return `<h${level}${blockAttrs(`ed-heading ed-heading-${level}`, block.attrs)}>${renderInline(
140
- block.delta,
141
- )}</h${level}>`;
142
- }
143
- case "blockquote":
144
- return `<blockquote${blockAttrs("ed-blockquote", block.attrs)}>${renderInline(
145
- block.delta,
146
- )}</blockquote>`;
147
- case "codeBlock": {
148
- const lang = block.attrs.language;
149
- const codeAttrs = lang ? ` class="language-${escapeAttr(lang)}"` : "";
150
- return `<pre${blockAttrs("ed-codeblock", block.attrs)}><code${codeAttrs}>${renderInline(
151
- block.delta,
152
- )}</code></pre>`;
153
- }
154
- case "table":
155
- return renderTable(block);
156
- default:
157
- return `<p${blockAttrs("ed-paragraph", block.attrs)}>${renderInline(block.delta)}</p>`;
158
- }
159
- }
160
-
161
- function renderListGroup(items: SerializedBlock[], kind: ListKind, key: number): string {
162
- // Tree-build using listLevel just like Editor.buildListTree.
163
- interface Node { item: SerializedBlock; children: Node[]; }
164
- const roots: Node[] = [];
165
- const stack: { children: Node[]; level: number }[] = [{ children: roots, level: -1 }];
166
- for (const it of items) {
167
- const level = clampListLevel(it.attrs.listLevel);
168
- while (stack.length > 1 && stack[stack.length - 1].level >= level) stack.pop();
169
- const node: Node = { item: it, children: [] };
170
- stack[stack.length - 1].children.push(node);
171
- stack.push({ children: node.children, level });
172
- }
173
- return renderListTree(roots, kind, `list-${key}`);
174
- }
175
-
176
- function renderListTree(nodes: { item: SerializedBlock; children: any[] }[], kind: ListKind, _key: string): string {
177
- const Tag = kind === "ordered" ? "ol" : "ul";
178
- const cls = `ed-list ed-list-${kind}`;
179
- const items = nodes
180
- .map((n) => {
181
- const inner = renderInline(n.item.delta);
182
- const nested =
183
- n.children.length > 0 ? renderListTree(n.children, kind, _key + "n") : "";
184
- return `<li${blockAttrs("ed-listitem", n.item.attrs)}>${inner}${nested}</li>`;
185
- })
186
- .join("");
187
- return `<${Tag} class="${cls}" data-list-kind="${kind}">${items}</${Tag}>`;
188
- }
189
-
190
- function renderTable(block: SerializedBlock): string {
191
- const rows = typeof block.attrs.rows === "number" ? block.attrs.rows : 0;
192
- const cols = typeof block.attrs.cols === "number" ? block.attrs.cols : 0;
193
- const cells = block.cells ?? [];
194
- if (rows <= 0 || cols <= 0) return "";
195
-
196
- const colGroup = renderColGroup(block.attrs.colWidths, cols);
197
- const trs: string[] = [];
198
- for (let r = 0; r < rows; r++) {
199
- const tds: string[] = [];
200
- for (let c = 0; c < cols; c++) {
201
- const cell = cells[r * cols + c];
202
- if (!cell) {
203
- tds.push("<td class=\"ed-cell\"></td>");
204
- continue;
205
- }
206
- if (cell.attrs?.covered) continue;
207
- tds.push(renderCell(cell));
208
- }
209
- trs.push(`<tr>${tds.join("")}</tr>`);
210
- }
211
- return `<div class="ed-table-wrap"><table class="ed-table">${colGroup}<tbody>${trs.join("")}</tbody></table></div>`;
212
- }
213
-
214
- function renderColGroup(widths: number[] | undefined, cols: number): string {
215
- if (!widths || widths.length === 0) return "";
216
- const cells: string[] = [];
217
- for (let i = 0; i < cols; i++) {
218
- const w = widths[i];
219
- cells.push(typeof w === "number" ? `<col style="width:${w}px">` : "<col>");
220
- }
221
- return `<colgroup>${cells.join("")}</colgroup>`;
222
- }
223
-
224
- function renderCell(cell: SerializedCell): string {
225
- const rs = cell.attrs?.rowspan && cell.attrs.rowspan > 1
226
- ? ` rowspan="${cell.attrs.rowspan}"`
227
- : "";
228
- const cs = cell.attrs?.colspan && cell.attrs.colspan > 1
229
- ? ` colspan="${cell.attrs.colspan}"`
230
- : "";
231
- return `<td class="ed-cell"${rs}${cs}>${renderInline(cell.delta)}</td>`;
232
- }
233
-
234
- // ---------- inline ----------
235
-
236
- function renderInline(delta: DeltaOp[]): string {
237
- if (delta.length === 0) return "<br>";
238
- const parts: string[] = [];
239
- // Floating wrap-left/wrap-right embeds are emitted FIRST so CSS float wraps
240
- // the following inline text — mirrors the in-editor renderer.
241
- const wraps: string[] = [];
242
- for (const op of delta) {
243
- if (typeof op.insert === "string") {
244
- if (op.insert.length === 0) continue;
245
- parts.push(applyMarks(escapeHtml(op.insert), op.attributes));
246
- } else if (isImageEmbed(op.insert)) {
247
- const layout = op.insert.layout ?? "inline";
248
- if (layout === "wrap-left" || layout === "wrap-right") {
249
- wraps.push(renderImg(op.insert));
250
- } else {
251
- parts.push(renderImg(op.insert));
252
- }
253
- }
254
- }
255
- if (wraps.length === 0 && parts.length === 0) return "<br>";
256
- return wraps.join("") + parts.join("");
257
- }
258
-
259
- function applyMarks(text: string, attrs: MarkAttrs | undefined): string {
260
- if (!attrs) return text;
261
- let html = text;
262
- if (attrs.bold) html = `<strong>${html}</strong>`;
263
- if (attrs.italic) html = `<em>${html}</em>`;
264
- if (attrs.underline) html = `<u>${html}</u>`;
265
- if (attrs.strike) html = `<s>${html}</s>`;
266
- const styles: string[] = [];
267
- if (attrs.color) styles.push(`color:${cssValue(attrs.color)}`);
268
- if (attrs.fontFamily) styles.push(`font-family:${cssValue(attrs.fontFamily)}`);
269
- if (attrs.fontSize) styles.push(`font-size:${cssValue(attrs.fontSize)}`);
270
- if (styles.length > 0) html = `<span style="${styles.join(";")}">${html}</span>`;
271
- if (attrs.link?.href) {
272
- const t = attrs.link.title ? ` title="${escapeAttr(attrs.link.title)}"` : "";
273
- html = `<a href="${escapeAttr(attrs.link.href)}"${t} rel="noopener noreferrer" target="_blank">${html}</a>`;
274
- }
275
- if (attrs.comment?.markId) {
276
- html = `<span data-comment-id="${escapeAttr(attrs.comment.markId)}">${html}</span>`;
277
- }
278
- return html;
279
- }
280
-
281
- function renderImg(embed: ImageEmbed): string {
282
- const layout = embed.layout ?? "inline";
283
- const align = embed.align;
284
- const ox = embed.offsetX ?? 0;
285
- const oy = embed.offsetY ?? 0;
286
- const hasCaption = !!embed.caption && embed.caption.length > 0;
287
-
288
- // The wrapper carries the positioning (float/absolute/inline-block/block).
289
- // The img keeps the intrinsic size when wrapped; when there's no caption we
290
- // keep the historical bare `<img>` output for unchanged DOM/CSS.
291
- const wrapperStyles: string[] = [];
292
- switch (layout) {
293
- case "wrap-left":
294
- wrapperStyles.push(
295
- "float:left",
296
- `width:${embed.width}px`,
297
- `margin-right:${Math.max(8, ox)}px`,
298
- `margin-top:${Math.max(0, oy)}px`,
299
- "margin-bottom:4px",
300
- );
301
- break;
302
- case "wrap-right":
303
- wrapperStyles.push(
304
- "float:right",
305
- `width:${embed.width}px`,
306
- `margin-left:${Math.max(8, ox)}px`,
307
- `margin-top:${Math.max(0, oy)}px`,
308
- "margin-bottom:4px",
309
- );
310
- break;
311
- case "behind":
312
- case "front":
313
- wrapperStyles.push(
314
- "position:absolute",
315
- `left:${ox}px`,
316
- `top:${oy}px`,
317
- `width:${embed.width}px`,
318
- `z-index:${layout === "behind" ? -1 : 2}`,
319
- );
320
- break;
321
- case "inline":
322
- default:
323
- if (align) {
324
- wrapperStyles.push(
325
- "display:block",
326
- `width:${embed.width}px`,
327
- `margin-left:${align === "left" ? "0" : "auto"}`,
328
- `margin-right:${align === "right" ? "0" : "auto"}`,
329
- );
330
- } else {
331
- wrapperStyles.push(
332
- "display:inline-block",
333
- "vertical-align:text-bottom",
334
- `width:${embed.width}px`,
335
- );
336
- }
337
- break;
338
- }
339
-
340
- const imgStyles: string[] = hasCaption
341
- ? [`width:${embed.width}px`, `height:${embed.height}px`, "display:block"]
342
- : wrapperStyles.concat(
343
- // Preserve old height when emitting bare <img>.
344
- `height:${embed.height}px`,
345
- );
346
-
347
- const imgHtml = `<img src="${escapeAttr(embed.src)}" width="${embed.width}" height="${embed.height}" alt="${escapeAttr(embed.caption ?? "")}" data-embed="image" data-embed-layout="${layout}"${
348
- align ? ` data-embed-align="${escapeAttr(align)}"` : ""
349
- } style="${imgStyles.join(";")}">`;
350
-
351
- if (!hasCaption) return imgHtml;
352
-
353
- const captionAlign = embed.captionAlign ?? "center";
354
- return `<figure class="ed-figure" data-embed-figure="true" data-embed-layout="${layout}" style="${wrapperStyles.join(";")}">${imgHtml}<figcaption class="ed-figcaption" data-caption-align="${captionAlign}" style="text-align:${captionAlign}">${escapeHtml(
355
- embed.caption!,
356
- )}</figcaption></figure>`;
357
- }
358
-
359
- // ---------- helpers ----------
360
-
361
- function blockAttrs(baseClass: string, attrs: BlockAttrs): string {
362
- const classes = [baseClass, "ed-block"];
363
- const align = clampAlign(attrs.align);
364
- if (align) classes.push(`ed-align-${align}`);
365
- const dir = clampDir(attrs.dir);
366
- const dirAttr = dir ? ` dir="${dir}"` : "";
367
- return ` class="${classes.join(" ")}"${dirAttr}`;
368
- }
369
-
370
- function clampHeadingLevel(l: unknown): 1 | 2 | 3 | 4 | 5 | 6 {
371
- if (typeof l !== "number") return 1;
372
- const n = Math.trunc(l);
373
- if (n < 1) return 1;
374
- if (n > 6) return 6;
375
- return n as 1 | 2 | 3 | 4 | 5 | 6;
376
- }
377
-
378
- function clampListLevel(l: unknown): number {
379
- if (typeof l !== "number" || !Number.isFinite(l)) return 0;
380
- return Math.max(0, Math.min(5, Math.trunc(l)));
381
- }
382
-
383
- function clampAlign(a: unknown): AlignValue | undefined {
384
- if (a === "left" || a === "center" || a === "right" || a === "justify") return a;
385
- return undefined;
386
- }
387
-
388
- function clampDir(d: unknown): DirValue | undefined {
389
- if (d === "ltr" || d === "rtl") return d;
390
- return undefined;
391
- }
392
-
393
- const HTML_ESCAPES: Record<string, string> = {
394
- "&": "&amp;",
395
- "<": "&lt;",
396
- ">": "&gt;",
397
- '"': "&quot;",
398
- "'": "&#39;",
399
- };
400
-
401
- function escapeHtml(s: string): string {
402
- return s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
403
- }
404
-
405
- function escapeAttr(s: string): string {
406
- return s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
407
- }
408
-
409
- /** Allow only safe characters in CSS values — defense in depth against injected `</style>`. */
410
- function cssValue(v: string): string {
411
- return v.replace(/[<>"';\\]/g, "");
412
- }
413
-
414
- function baseStylesheet(page: NonNullable<DocumentToHtmlOptions["page"]>): string {
415
- // Margins are owned by `@page` only. Chromium honors that block both for
416
- // `window.print()` (client-side `exportPdfFromDocument`) and for
417
- // `page.pdf({ preferCSSPageSize: true })` (server-side `renderPdf` in
418
- // `@sofereditor/export-pdf-server`). Adding the same offsets via `body { padding }`
419
- // would compound and double the margin in the output PDF.
420
- return `
421
- @page {
422
- size: ${page.width} ${page.height};
423
- margin: ${page.marginTop} ${page.marginRight} ${page.marginBottom} ${page.marginLeft};
424
- }
425
- html, body { margin: 0; padding: 0; background: white; color: #1a1a1f; }
426
- body {
427
- font-family: "Arial", "Helvetica", sans-serif;
428
- font-size: 11pt;
429
- line-height: 1.5;
430
- }
431
- .ed-paragraph { margin: 0 0 0.5em; min-height: 1.5em; }
432
- .ed-heading { margin: 0.5em 0 0.25em; font-weight: 600; line-height: 1.25; }
433
- .ed-heading-1 { font-size: 28pt; }
434
- .ed-heading-2 { font-size: 22pt; }
435
- .ed-heading-3 { font-size: 18pt; }
436
- .ed-heading-4 { font-size: 14pt; }
437
- .ed-heading-5 { font-size: 12pt; }
438
- .ed-heading-6 { font-size: 11pt; color: #6b7280; }
439
- .ed-blockquote {
440
- margin: 0 0 0.5em; padding: 0.25em 12px;
441
- border-inline-start: 3px solid #e5e7eb; color: #6b7280;
442
- }
443
- .ed-codeblock {
444
- margin: 0 0 0.5em; background: #0f172a; color: #e2e8f0;
445
- padding: 12px 14px; border-radius: 6px;
446
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
447
- font-size: 11pt; white-space: pre-wrap;
448
- }
449
- .ed-codeblock code { font-family: inherit; background: transparent; color: inherit; }
450
- .ed-block[dir="rtl"] { text-align: start; }
451
- .ed-align-left { text-align: left; }
452
- .ed-align-center { text-align: center; }
453
- .ed-align-right { text-align: right; }
454
- .ed-align-justify { text-align: justify; }
455
- .ed-list { margin: 0 0 0.5em; padding-inline-start: 28px; }
456
- .ed-list .ed-list { margin-block: 0; }
457
- .ed-list-bullet { list-style-type: disc; }
458
- .ed-list-bullet .ed-list-bullet { list-style-type: circle; }
459
- .ed-list-bullet .ed-list-bullet .ed-list-bullet { list-style-type: square; }
460
- .ed-list-ordered { list-style-type: decimal; }
461
- .ed-list-ordered .ed-list-ordered { list-style-type: lower-alpha; }
462
- .ed-list-ordered .ed-list-ordered .ed-list-ordered { list-style-type: lower-roman; }
463
- .ed-listitem { margin: 0; min-height: 1.5em; }
464
- .ed-table-wrap { margin: 0 0 0.5em; position: relative; }
465
- .ed-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
466
- .ed-cell {
467
- border: 1px solid #cbd5e1; padding: 6px 8px;
468
- vertical-align: top; word-break: break-word; white-space: pre-wrap;
469
- }
470
- img[data-embed="image"] { max-width: 100%; }
471
- .ed-block { position: relative; isolation: isolate; }
472
- a { color: #2563eb; }
473
- `;
474
- }
package/src/index.ts DELETED
@@ -1,17 +0,0 @@
1
- export {
2
- documentToHtml,
3
- documentToHtmlFragment,
4
- documentToJson,
5
- } from "./html";
6
- export type { DocumentToHtmlOptions } from "./html";
7
- export {
8
- exportPdfFromDocument,
9
- exportPdfFromElement,
10
- serializePaginatedHtml,
11
- } from "./pdf";
12
- export type {
13
- ExportPdfFromDocumentOptions,
14
- ExportPdfFromElementOptions,
15
- SerializePaginatedHtmlOptions,
16
- } from "./pdf";
17
- export { downloadBlob, downloadString } from "./download";
package/src/pdf.ts DELETED
@@ -1,223 +0,0 @@
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
- "&": "&amp;",
213
- "<": "&lt;",
214
- ">": "&gt;",
215
- '"': "&quot;",
216
- "'": "&#39;",
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 DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "noEmit": true,
5
- "rootDir": "src"
6
- },
7
- "include": ["src/**/*"]
8
- }
package/tsup.config.ts DELETED
@@ -1,11 +0,0 @@
1
- import { defineConfig } from "tsup";
2
-
3
- export default defineConfig({
4
- entry: ["src/index.ts"],
5
- format: ["esm", "cjs"],
6
- dts: true,
7
- sourcemap: true,
8
- clean: true,
9
- target: "es2022",
10
- external: ["@sofereditor/core"],
11
- });