@openpresentation/opf-editor 0.10.6 → 0.11.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 +333 -8
- package/dist/annotations.d.ts +71 -0
- package/dist/annotations.js +281 -0
- package/dist/assets.d.ts +67 -0
- package/dist/assets.js +176 -0
- package/dist/background-options.d.ts +48 -0
- package/dist/background-options.js +134 -0
- package/dist/block-convert.d.ts +64 -0
- package/dist/block-convert.js +142 -0
- package/dist/canvas.d.ts +16 -0
- package/dist/canvas.js +82 -21
- package/dist/chart-data.d.ts +32 -0
- package/dist/chart-data.js +101 -0
- package/dist/chart-options-panel.d.ts +16 -0
- package/dist/chart-options-panel.js +127 -0
- package/dist/chart-options.d.ts +49 -0
- package/dist/chart-options.js +157 -0
- package/dist/content-actions.d.ts +91 -0
- package/dist/content-actions.js +207 -0
- package/dist/content-controls.js +326 -0
- package/dist/data-grid.d.ts +37 -0
- package/dist/data-grid.js +1035 -0
- package/dist/design-controls.d.ts +43 -0
- package/dist/design-controls.js +1077 -0
- package/dist/design-options.d.ts +108 -0
- package/dist/design-options.js +412 -0
- package/dist/edit-helpers.js +52 -0
- package/dist/export.d.ts +77 -0
- package/dist/export.js +216 -0
- package/dist/find-panel.d.ts +44 -0
- package/dist/find-panel.js +431 -0
- package/dist/find-replace.d.ts +100 -0
- package/dist/find-replace.js +374 -0
- package/dist/grid-model.d.ts +135 -0
- package/dist/grid-model.js +836 -0
- package/dist/grid-text.d.ts +33 -0
- package/dist/grid-text.js +251 -0
- package/dist/image-crop.d.ts +59 -0
- package/dist/image-crop.js +336 -0
- package/dist/image-cropper.d.ts +29 -0
- package/dist/image-cropper.js +519 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +104 -171
- package/dist/numbering-panel.d.ts +21 -0
- package/dist/numbering-panel.js +200 -0
- package/dist/numbering.d.ts +62 -0
- package/dist/numbering.js +223 -0
- package/dist/outline-view.d.ts +17 -0
- package/dist/outline-view.js +278 -0
- package/dist/outline.d.ts +56 -0
- package/dist/outline.js +271 -0
- package/dist/persistence-ui.d.ts +24 -0
- package/dist/persistence-ui.js +81 -0
- package/dist/persistence.d.ts +105 -0
- package/dist/persistence.js +429 -0
- package/dist/review-panel.d.ts +44 -0
- package/dist/review-panel.js +359 -0
- package/dist/review.d.ts +75 -0
- package/dist/review.js +170 -0
- package/dist/slide-manager.d.ts +44 -0
- package/dist/slide-manager.js +695 -0
- package/dist/slides.d.ts +96 -0
- package/dist/slides.js +433 -0
- package/dist/switches.d.ts +26 -0
- package/dist/switches.js +127 -43
- package/dist/table-options.d.ts +80 -0
- package/dist/table-options.js +419 -0
- package/dist/table-structure.d.ts +30 -0
- package/dist/table-structure.js +92 -0
- package/dist/template-panel.d.ts +31 -0
- package/dist/template-panel.js +377 -0
- package/dist/templates.d.ts +126 -0
- package/dist/templates.js +331 -0
- package/dist/zip.d.ts +4 -0
- package/dist/zip.js +71 -0
- package/package.json +150 -10
package/dist/export.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// RR-23: download a deck as PDF, PNG or SVG from the editor, next to the PowerPoint export. Everything runs in the page:
|
|
2
|
+
// the slides are drawn by the same renderer the preview uses (`renderSvgDeck`, with the editor's own text measurement), so
|
|
3
|
+
// a download is the preview, not a second layout. Fonts are the registry's: the faces the document needs load through the
|
|
4
|
+
// font gate first, and only faces the registry holds (bundled or hash-pinned, permissively licensed) are embedded, as
|
|
5
|
+
// @font-face data in each SVG and as subsets in the PDF. No system font is read and nothing is fetched at export time.
|
|
6
|
+
//
|
|
7
|
+
// const result = await exportDeck(editor.document, { format: "pdf", renderOptions, fonts: gate, registry, signal, onProgress });
|
|
8
|
+
// // result.download is { name, type, bytes }: one PDF, one PNG or SVG, or a ZIP of the slides.
|
|
9
|
+
|
|
10
|
+
import { renderSvgDeck } from "@openpresentation/opf-render/svg";
|
|
11
|
+
import { createZip } from "./zip.js";
|
|
12
|
+
|
|
13
|
+
export const EXPORT_FORMATS = Object.freeze({
|
|
14
|
+
pdf: Object.freeze({ extension: "pdf", type: "application/pdf", label: "PDF" }),
|
|
15
|
+
png: Object.freeze({ extension: "png", type: "image/png", label: "PNG" }),
|
|
16
|
+
svg: Object.freeze({ extension: "svg", type: "image/svg+xml", label: "SVG" }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Largest PNG scale offered; a 1280 x 720 slide at 4x is 5120 x 2880 pixels. */
|
|
20
|
+
export const MAX_PNG_SCALE = 4;
|
|
21
|
+
|
|
22
|
+
// Licenses a face may be embedded under (the repository's font policy: OFL-1.1, Apache-2.0, MIT, UFL-1.0). The registry's faces carry the
|
|
23
|
+
// face's own license text; a text that names none of these keeps the face out of the download.
|
|
24
|
+
const PERMISSIVE_LICENSE = /SIL OPEN FONT LICENSE|OPEN FONT LICENSE|\bOFL\b|APACHE LICENSE|\bMIT LICENSE\b|PERMISSION IS HEREBY GRANTED|UBUNTU FONT LICEN[SC]E|\bUFL\b/i;
|
|
25
|
+
const FONT_WARNINGS = new Set([
|
|
26
|
+
"pdf-font-substituted", "pdf-font-fallback", "pdf-glyph-missing", "pdf-raster-fallback", "pdf-image-skipped", "pdf-font-unreadable",
|
|
27
|
+
"pdf-font-embedding-restricted", "pdf-font-unsupported-format", "pdf-expansion-limit", "pdf-font-unavailable",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const yieldToHost = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The download file name: the deck's `filename` (a trailing .pptx, .pdf, .png or .svg is dropped), else the slugified `name`, else
|
|
34
|
+
* "presentation", plus `suffix` and `.extension`. Characters a file system rejects are replaced, so the name is safe to save.
|
|
35
|
+
*/
|
|
36
|
+
export function exportFileName(deck, extension, suffix = "") {
|
|
37
|
+
const clean = (value) => String(value ?? "").replace(/[\\/:*?"<>|\u0000-\u001f]+/g, "-").replace(/\s+/g, "-").replace(/-{2,}/g, "-").replace(/^[-.\s]+|[-.\s]+$/g, "");
|
|
38
|
+
const strip = (value) => String(value ?? "").trim().replace(/\.(pptx|pdf|png|svg)$/i, "");
|
|
39
|
+
const slug = (value) => String(value ?? "").replace(/[^\p{L}\p{N}_-]+/gu, "-").replace(/-{2,}/g, "-").replace(/^-|-$/g, "");
|
|
40
|
+
const base = clean(strip(deck?.filename)) || slug(deck?.name) || "presentation";
|
|
41
|
+
return `${base}${suffix}.${extension}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The slide numbers (0-based) an export covers. Hidden slides are skipped in an "all" export unless `includeHidden`; one chosen slide is always exported. */
|
|
45
|
+
export function slidesToExport(deck, { slides = "all", slideIndex = 0, includeHidden = false } = {}) {
|
|
46
|
+
const count = deck?.slides?.length ?? 0;
|
|
47
|
+
if (Array.isArray(slides)) return slides.filter((index) => Number.isInteger(index) && index >= 0 && index < count);
|
|
48
|
+
if (slides === "current") return slideIndex >= 0 && slideIndex < count ? [slideIndex] : [];
|
|
49
|
+
return deck.slides.map((slide, index) => (includeHidden || slide?.hidden !== true ? index : -1)).filter((index) => index >= 0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The faces a registry can embed, marked to embed only where a slide draws them (renderer `embed: "used"`). A face whose own license text is
|
|
54
|
+
* not permissive is left out and reported through `onSkip`; a face with no license text is kept (the registry only holds reviewed faces).
|
|
55
|
+
*/
|
|
56
|
+
export function embeddableFonts(registry, onSkip) {
|
|
57
|
+
const faces = typeof registry?.selectEmbeddedFonts === "function" ? registry.selectEmbeddedFonts(() => true) : registry?.embeddedFonts ?? [];
|
|
58
|
+
const kept = [];
|
|
59
|
+
for (const face of faces) {
|
|
60
|
+
if (face.license && !PERMISSIVE_LICENSE.test(face.license)) { onSkip?.(face); continue; }
|
|
61
|
+
kept.push({ ...face, embed: "used" });
|
|
62
|
+
}
|
|
63
|
+
return kept;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function exportError(code, message, cause) {
|
|
67
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
68
|
+
error.code = code;
|
|
69
|
+
return error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fontLabel = (face) => `${face.family} ${face.weight}${face.italic ? " italic" : ""}`;
|
|
73
|
+
|
|
74
|
+
function slideOf(path) {
|
|
75
|
+
const match = /^slides\.(\d+)(?:\.|$)/.exec(String(path ?? ""));
|
|
76
|
+
return match ? Number(match[1]) : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A renderer or converter diagnostic as `{ code, severity, message, slide?, path? }` (`slide` is 0-based). */
|
|
80
|
+
export function describeDiagnostic(diagnostic, source = "render") {
|
|
81
|
+
const code = diagnostic.code ?? `${source}-note`;
|
|
82
|
+
let message = diagnostic.message ?? code;
|
|
83
|
+
let severity = "warning";
|
|
84
|
+
if (code === "pdf-font-embedded") {
|
|
85
|
+
severity = "info";
|
|
86
|
+
message = `Embedded ${diagnostic.family} ${diagnostic.weight}${diagnostic.italic ? " italic" : ""} (${diagnostic.embedding}, ${diagnostic.glyphs} glyphs).`;
|
|
87
|
+
} else if (code === "pdf-font-substituted" || code === "pdf-font-fallback") {
|
|
88
|
+
message = diagnostic.message ?? "A font was substituted in the PDF.";
|
|
89
|
+
} else if (source === "pdf" && !FONT_WARNINGS.has(code) && !/^pdf-unsupported-/.test(code)) {
|
|
90
|
+
severity = "info";
|
|
91
|
+
}
|
|
92
|
+
const slide = slideOf(diagnostic.path);
|
|
93
|
+
return { code, severity, message, ...(slide === undefined ? {} : { slide }), ...(diagnostic.path ? { path: diagnostic.path } : {}), ...(diagnostic.family ? { family: diagnostic.family } : {}) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function loadConverters(options) {
|
|
97
|
+
if (options.convert) return options.convert;
|
|
98
|
+
try {
|
|
99
|
+
return await import("@openpresentation/opf-render/export-browser");
|
|
100
|
+
} catch (cause) {
|
|
101
|
+
throw exportError("export-unavailable", "PDF and PNG export need @openpresentation/opf-render with its export-browser entry. SVG export works without it.", cause);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Convert the deck's slides to a download.
|
|
107
|
+
*
|
|
108
|
+
* `options`:
|
|
109
|
+
* - `format`: "pdf", "png" or "svg" (required).
|
|
110
|
+
* - `slides`: "current" (with `slideIndex`), "all" (default; hidden slides only with `includeHidden`) or an array of slide numbers.
|
|
111
|
+
* - `pdfMode`: "vector" (default: selectable text, vector shapes) or "raster" (each slide an image).
|
|
112
|
+
* - `scale`: PNG pixel density, 1 to 4 (default 2); also the raster PDF's density.
|
|
113
|
+
* - `renderOptions`: the render options the host draws with (`textMeasurement`, `catalogs`, ...), the same as its preview.
|
|
114
|
+
* - `fonts`: a font gate (`createFontGate`); the faces the deck needs load before anything is drawn.
|
|
115
|
+
* - `registry`: the browser font registry; its faces are what gets embedded.
|
|
116
|
+
* - `signal`, `onProgress({ stage, done, total, message })`, `onDiagnostic(diagnostic)`.
|
|
117
|
+
* Resolves `{ download, files, diagnostics, slides }`. Rejects with an error whose `code` is `export-aborted`,
|
|
118
|
+
* `export-no-slides`, `export-unavailable`, `fonts-unavailable` or a renderer code.
|
|
119
|
+
*/
|
|
120
|
+
export async function exportDeck(deck, options = {}) {
|
|
121
|
+
const format = EXPORT_FORMATS[options.format];
|
|
122
|
+
if (!format) throw exportError("export-format", `Choose a format: ${Object.keys(EXPORT_FORMATS).join(", ")}.`);
|
|
123
|
+
const { signal } = options;
|
|
124
|
+
const diagnostics = [];
|
|
125
|
+
const note = (diagnostic) => { diagnostics.push(diagnostic); options.onDiagnostic?.(diagnostic); };
|
|
126
|
+
const progress = (stage, done, total, message) => options.onProgress?.({ stage, done, total, message });
|
|
127
|
+
const check = () => { if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled."); };
|
|
128
|
+
const indexes = slidesToExport(deck, options);
|
|
129
|
+
if (!indexes.length) throw exportError("export-no-slides", "There are no slides to export.");
|
|
130
|
+
const selected = new Set(indexes);
|
|
131
|
+
const keep = (diagnostic) => diagnostic.slide === undefined || selected.has(diagnostic.slide);
|
|
132
|
+
const scale = Math.min(MAX_PNG_SCALE, Math.max(1, Number(options.scale) || 2));
|
|
133
|
+
const renderOptions = options.renderOptions ?? {};
|
|
134
|
+
|
|
135
|
+
check();
|
|
136
|
+
if (options.fonts?.pending?.(deck, renderOptions)?.length) {
|
|
137
|
+
progress("fonts", 0, 1, "Loading fonts…");
|
|
138
|
+
try { await options.fonts.ensure(deck, { signal, renderOptions }); }
|
|
139
|
+
catch (error) { if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled."); throw error; }
|
|
140
|
+
}
|
|
141
|
+
check();
|
|
142
|
+
const converters = format.extension === "svg" ? null : await loadConverters(options);
|
|
143
|
+
|
|
144
|
+
const embeddedFonts = options.embeddedFonts ?? embeddableFonts(options.registry, (face) => note({ code: "export-font-license", severity: "warning", message: `${fontLabel(face)} is not embedded: its license is not one of OFL-1.1, Apache-2.0, MIT or UFL-1.0.`, family: face.family }));
|
|
145
|
+
progress("render", 0, indexes.length, "Drawing slides…");
|
|
146
|
+
await yieldToHost();
|
|
147
|
+
const rendered = renderSvgDeck(deck, {
|
|
148
|
+
...renderOptions,
|
|
149
|
+
embeddedFonts,
|
|
150
|
+
// The PDF reports problems by element path; the SVG and PNG are the plain drawing.
|
|
151
|
+
trace: format.extension === "pdf",
|
|
152
|
+
onDiagnostic: (diagnostic) => {
|
|
153
|
+
const described = describeDiagnostic(diagnostic, "render");
|
|
154
|
+
if (keep(described)) note(described);
|
|
155
|
+
renderOptions.onDiagnostic?.(diagnostic);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
check();
|
|
159
|
+
const svgs = indexes.map((index) => rendered[index]);
|
|
160
|
+
progress("render", indexes.length, indexes.length, "Slides drawn");
|
|
161
|
+
|
|
162
|
+
const digits = Math.max(2, String(deck.slides.length).length);
|
|
163
|
+
const numbered = (index, extension) => exportFileName(deck, extension, `-${String(index + 1).padStart(digits, "0")}`);
|
|
164
|
+
let files;
|
|
165
|
+
if (format.extension === "svg") {
|
|
166
|
+
const header = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
167
|
+
files = svgs.map((svg, position) => ({ name: numbered(indexes[position], "svg"), type: format.type, bytes: new TextEncoder().encode(header + svg) }));
|
|
168
|
+
} else if (format.extension === "png") {
|
|
169
|
+
files = [];
|
|
170
|
+
for (const [position, svg] of svgs.entries()) {
|
|
171
|
+
check();
|
|
172
|
+
progress("convert", position, svgs.length, `Slide ${indexes[position] + 1} of ${deck.slides.length}`);
|
|
173
|
+
let bytes;
|
|
174
|
+
try { bytes = await converters.svgToPng(svg, { scale, signal }); }
|
|
175
|
+
catch (error) { if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled."); throw error; }
|
|
176
|
+
files.push({ name: numbered(indexes[position], "png"), type: format.type, bytes });
|
|
177
|
+
await yieldToHost();
|
|
178
|
+
}
|
|
179
|
+
progress("convert", svgs.length, svgs.length, "Images ready");
|
|
180
|
+
} else {
|
|
181
|
+
const metadata = { ...(deck.name ? { title: String(deck.name) } : {}), ...(deck.description ? { subject: String(deck.description) } : {}), ...(options.metadata ?? {}) };
|
|
182
|
+
let bytes;
|
|
183
|
+
try {
|
|
184
|
+
bytes = await converters.svgToPdf(svgs, {
|
|
185
|
+
mode: options.pdfMode === "raster" ? "raster" : "vector",
|
|
186
|
+
scale,
|
|
187
|
+
metadata,
|
|
188
|
+
signal,
|
|
189
|
+
...(options.fallbackFamily ? { defaultFontFamily: options.fallbackFamily } : {}),
|
|
190
|
+
onDiagnostic: (diagnostic) => { const described = describeDiagnostic(diagnostic, "pdf"); if (keep(described)) note(described); },
|
|
191
|
+
onProgress: ({ page, pages }) => progress("convert", page, pages, `Page ${page} of ${pages}`),
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled.");
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
files = [{ name: exportFileName(deck, "pdf"), type: format.type, bytes }];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
check();
|
|
201
|
+
let download = files[0];
|
|
202
|
+
if (files.length > 1) {
|
|
203
|
+
progress("archive", 0, files.length, "Packing files…");
|
|
204
|
+
const bytes = await createZip(files.map(({ name, bytes: data }) => ({ name, bytes: data })), { signal });
|
|
205
|
+
download = { name: exportFileName(deck, "zip", `-${format.extension}`), type: "application/zip", bytes };
|
|
206
|
+
}
|
|
207
|
+
progress("done", 1, 1, "Ready");
|
|
208
|
+
// One note per kind: a deck of 200 slides reports one substituted font once, not 200 times.
|
|
209
|
+
const unique = [];
|
|
210
|
+
const seen = new Set();
|
|
211
|
+
for (const diagnostic of diagnostics) {
|
|
212
|
+
const key = `${diagnostic.code}|${diagnostic.message}|${diagnostic.path ?? ""}`;
|
|
213
|
+
if (!seen.has(key)) { seen.add(key); unique.push(diagnostic); }
|
|
214
|
+
}
|
|
215
|
+
return { download, files, diagnostics: unique, slides: indexes };
|
|
216
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { EditorSession } from "./index.js";
|
|
2
|
+
import type { CanvasEditor } from "./canvas.js";
|
|
3
|
+
import type { SearchMatch } from "./find-replace.js";
|
|
4
|
+
|
|
5
|
+
export interface FindPanelOptions {
|
|
6
|
+
editor: EditorSession;
|
|
7
|
+
/** Matches are shown and selected on this canvas. */
|
|
8
|
+
canvas?: CanvasEditor | (() => CanvasEditor | undefined);
|
|
9
|
+
/** A host that owns slide navigation shows the slide (return a promise to wait for its fonts); defaults to `canvas.setSlide`. */
|
|
10
|
+
goToSlide?: (slideIndex: number) => void | Promise<void>;
|
|
11
|
+
/** Called after a go to, with the path the canvas selected (null for speaker notes and deck fields: the host puts them in view here; move focus only for `via: "list"`, so Enter keeps stepping). */
|
|
12
|
+
onGoTo?: (match: SearchMatch, selectedPath: string | null, details: { via: "list" | "step" }) => void;
|
|
13
|
+
/** The slide "This slide only" searches. Defaults to the canvas's slide. */
|
|
14
|
+
getSlideIndex?: () => number;
|
|
15
|
+
/** Seeds the find field when the panel opens (a single-line selection of at most 200 characters). */
|
|
16
|
+
getSelectionText?: () => string | undefined;
|
|
17
|
+
/** Finish an inline edit before the document is replaced; return false to stop. Defaults to `canvas.commit`. */
|
|
18
|
+
commit?: () => boolean;
|
|
19
|
+
onStatus?: (message: string, details: { error: boolean }) => void;
|
|
20
|
+
onClose?: () => void;
|
|
21
|
+
}
|
|
22
|
+
export interface FindPanel {
|
|
23
|
+
readonly element: HTMLElement;
|
|
24
|
+
readonly isOpen: boolean;
|
|
25
|
+
readonly matches: SearchMatch[];
|
|
26
|
+
/** Index of the current match, or -1. */
|
|
27
|
+
readonly current: number;
|
|
28
|
+
/** Show the panel (`replace: true` also shows the replace field), focus the find field and select its text. `query` seeds the search. */
|
|
29
|
+
open(options?: { replace?: boolean; query?: string }): void;
|
|
30
|
+
close(): void;
|
|
31
|
+
/** Set the search programmatically. */
|
|
32
|
+
set(values: { query?: string; replacement?: string; matchCase?: boolean; wholeWord?: boolean; regex?: boolean; thisSlide?: boolean }): void;
|
|
33
|
+
next(): Promise<void> | undefined;
|
|
34
|
+
previous(): Promise<void> | undefined;
|
|
35
|
+
goTo(index: number, options?: { focusResult?: boolean; via?: "list" | "step" }): Promise<void>;
|
|
36
|
+
replaceOne(): void;
|
|
37
|
+
replaceAll(): void;
|
|
38
|
+
refresh(): void;
|
|
39
|
+
destroy(): void;
|
|
40
|
+
}
|
|
41
|
+
/** Mount the find and replace panel in `container`; it is hidden until `open()`. */
|
|
42
|
+
export declare function createFindPanel(container: HTMLElement, options: FindPanelOptions): FindPanel;
|
|
43
|
+
/** Ctrl/Cmd+F opens find, Ctrl/Cmd+H and Ctrl/Cmd+Shift+H open find and replace. Returns a function that removes the listener. */
|
|
44
|
+
export declare function installFindShortcuts(panel: Pick<FindPanel, "open" | "element">, options?: { document?: Document; isEnabled?: () => boolean }): () => void;
|