@openpresentation/opf-editor 0.16.0 → 0.18.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 +53 -44
- package/dist/assets.js +1 -1
- package/dist/canvas.d.ts +4 -4
- package/dist/canvas.js +4 -4
- package/dist/data.d.ts +3 -3
- package/dist/data.js +3 -3
- package/dist/design-controls.js +217 -63
- package/dist/design-options.d.ts +98 -29
- package/dist/design-options.js +307 -118
- package/dist/export.d.ts +42 -41
- package/dist/export.js +151 -87
- package/dist/find-replace.js +9 -1
- package/dist/font-gate.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -5
- package/dist/review.d.ts +6 -1
- package/dist/review.js +37 -1
- package/dist/template-panel.d.ts +1 -1
- package/dist/template-panel.js +10 -5
- package/dist/templates.d.ts +3 -1
- package/dist/templates.js +1 -1
- package/package.json +6 -5
package/dist/export.d.ts
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Catalog, ConvertResult, ConvertedFile, Finding, SlideSelection } from "@openpresentation/opf";
|
|
2
|
+
import type { ToSvgOptions } from "@openpresentation/opf-render";
|
|
2
3
|
import type { EditorFonts } from "./canvas.js";
|
|
3
4
|
|
|
5
|
+
/** Core's result types: `convert` resolves exactly what core's in-memory `convert` resolves. */
|
|
6
|
+
export type { ConvertResult, ConvertedFile };
|
|
7
|
+
|
|
4
8
|
export type ExportFormat = "pdf" | "png" | "svg";
|
|
5
9
|
export declare const EXPORT_FORMATS: Readonly<Record<ExportFormat, { extension: string; type: string; label: string }>>;
|
|
6
10
|
/** Largest PNG scale offered (4: a 1280 x 720 slide is 5120 x 2880 pixels). */
|
|
7
11
|
export declare const MAX_PNG_SCALE: 4;
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
|
-
* The
|
|
14
|
+
* The file name: the deck's `filename` (a trailing .pptx, .pdf, .png or .svg dropped), else the slugified `name`, else "presentation",
|
|
11
15
|
* then `suffix` and `.extension`.
|
|
12
16
|
*/
|
|
13
17
|
export declare function exportFileName(deck: { filename?: string; name?: string } | undefined, extension: string, suffix?: string): string;
|
|
14
|
-
/**
|
|
15
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The slide numbers (one-based, ascending) a conversion covers: the `slides` selection (core's `parseSlideSelection`; hidden slides it
|
|
20
|
+
* names are included), else every slide that is not hidden (every slide with `includeHidden`). `[]` for a deck without slides. A malformed
|
|
21
|
+
* selection or a slide past the end throws core's `OPFApiError` `invalid-option`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function slidesToConvert(deck: { slides?: Array<{ hidden?: boolean }> }, options?: { slides?: SlideSelection; includeHidden?: boolean }): number[];
|
|
16
24
|
/** The registry's faces marked to embed only where a slide draws them; a face with a non-permissive license text is left out (`onSkip` is told). */
|
|
17
25
|
export declare function embeddableFonts(registry: object | undefined, onSkip?: (face: { family: string; weight: number; italic?: boolean; license?: string }) => void): Array<Record<string, unknown>>;
|
|
18
26
|
|
|
19
|
-
export interface
|
|
20
|
-
code: string;
|
|
21
|
-
severity: "info" | "warning";
|
|
22
|
-
message: string;
|
|
23
|
-
/** 0-based slide number, when the note belongs to one slide. */
|
|
24
|
-
slide?: number;
|
|
25
|
-
path?: string;
|
|
26
|
-
family?: string;
|
|
27
|
-
}
|
|
28
|
-
export declare function describeDiagnostic(diagnostic: { code?: string; message?: string; path?: string; family?: string; [key: string]: unknown }, source?: "render" | "pdf"): ExportDiagnostic;
|
|
29
|
-
|
|
30
|
-
export interface ExportProgress {
|
|
27
|
+
export interface ConvertProgress {
|
|
31
28
|
stage: "fonts" | "render" | "convert" | "archive" | "done";
|
|
32
29
|
done: number;
|
|
33
30
|
total: number;
|
|
34
31
|
message?: string;
|
|
35
32
|
}
|
|
36
|
-
export interface
|
|
37
|
-
export interface ExportOptions {
|
|
33
|
+
export interface ConvertOptions {
|
|
38
34
|
format: ExportFormat;
|
|
39
|
-
/** "
|
|
40
|
-
slides?:
|
|
41
|
-
|
|
35
|
+
/** The slides, counted from 1: `3`, `"1-3"`, `"1,3-5"`, `"2-"`, `[1, 3]` (core's `SlideSelection`). Exactly those slides, hidden or not. Omitted: every slide that is not hidden. */
|
|
36
|
+
slides?: SlideSelection;
|
|
37
|
+
/** With `slides` omitted, hidden slides too. */
|
|
42
38
|
includeHidden?: boolean;
|
|
43
|
-
/**
|
|
44
|
-
|
|
39
|
+
/** PDF only: each page an image of its slide (larger, text not selectable). Default: selectable text and vector shapes. */
|
|
40
|
+
raster?: boolean;
|
|
45
41
|
/**
|
|
46
42
|
* Raster PDF only: the pdf-lib module (`import * as pdfLib from "pdf-lib"`). The renderer's `export-browser` entry imports no PDF library (opf-render 0.16), so the host passes it;
|
|
47
|
-
* without it a `
|
|
43
|
+
* without it a `raster: true` PDF rejects with the renderer's `converter-missing`. Vector PDF, PNG and SVG need no option.
|
|
48
44
|
*/
|
|
49
45
|
pdfLib?: { PDFDocument: { create(options?: { updateMetadata?: boolean }): Promise<any> } };
|
|
46
|
+
/** PNG and SVG only: one ZIP archive of the slide files (its `entries` name them), even for one slide. Default: one file per slide. */
|
|
47
|
+
zip?: boolean;
|
|
48
|
+
/** The base name of the files (`name.pdf`, `name-001.png`, `name.zip`). Default: the deck's `filename`, else its slugified `name`, else "presentation". */
|
|
49
|
+
name?: string;
|
|
50
50
|
/** PNG pixel density (and the raster PDF's), 1 to 4; default 2. */
|
|
51
51
|
scale?: number;
|
|
52
52
|
/** The options the host draws its preview with (`catalogs`, `date`, ...); the fonts go in `fonts`. */
|
|
53
|
-
renderOptions?:
|
|
53
|
+
renderOptions?: ToSvgOptions;
|
|
54
54
|
/** Host catalogs (merged with `renderOptions.catalogs`). The deck is embedded first (core `embed`), like a saved file. */
|
|
55
|
-
catalogs?: readonly
|
|
55
|
+
catalogs?: readonly Catalog[];
|
|
56
56
|
/** The renderer's fonts handle (`loadFonts()`): it measures the text, loads the faces the deck needs before anything is drawn, and its registry's faces are what gets embedded. */
|
|
57
57
|
fonts?: EditorFonts;
|
|
58
58
|
/** Faces to embed instead of the registry's. */
|
|
@@ -61,22 +61,23 @@ export interface ExportOptions {
|
|
|
61
61
|
metadata?: Record<string, unknown>;
|
|
62
62
|
/** The family the PDF uses when a requested one is missing; default "Roboto". */
|
|
63
63
|
fallbackFamily?: string;
|
|
64
|
-
/** Replace the renderer's `export-browser` entry (tests, or a host with its own converter). */
|
|
65
|
-
|
|
64
|
+
/** Replace the renderer's `export-browser` entry (tests, or a host with its own converter). Each takes the SVG the renderer drew. */
|
|
65
|
+
converters?: { toPdf(svgs: string[], options?: object): Promise<Uint8Array>; toPng(svg: string, options?: object): Promise<Uint8Array> };
|
|
66
66
|
signal?: AbortSignal;
|
|
67
|
-
onProgress?: (progress:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
export interface ExportResult {
|
|
71
|
-
/** One PDF, PNG or SVG, or a ZIP of several. */
|
|
72
|
-
download: ExportFile;
|
|
73
|
-
files: ExportFile[];
|
|
74
|
-
diagnostics: ExportDiagnostic[];
|
|
75
|
-
/** The slide numbers (0-based) exported. */
|
|
76
|
-
slides: number[];
|
|
67
|
+
onProgress?: (progress: ConvertProgress) => void;
|
|
68
|
+
/** Each finding as it is made (also in the result's `findings`). */
|
|
69
|
+
onFinding?: (finding: Finding) => void;
|
|
77
70
|
}
|
|
78
71
|
/**
|
|
79
|
-
* Draw the deck with the renderer the preview uses and convert it
|
|
80
|
-
*
|
|
72
|
+
* Draw the deck with the renderer the preview uses and convert it, in memory: core's `convert(deck, { format })` for the editor.
|
|
73
|
+
*
|
|
74
|
+
* `files`: one PDF (`pages`, and the one-based `slides` it shows); one PNG or SVG per slide (`slide`, one-based, `id`, `width`, `height`);
|
|
75
|
+
* or with `zip` one archive (`entries`). `findings`: core's Finding shape, `ruleId` `render/<code>` (the renderer), `pdf/<code>` (the PDF
|
|
76
|
+
* converter) or `fonts/export-font-license` (a face left out under the license rule); `slide` on a finding is zero-based, as the Finding
|
|
77
|
+
* schema defines it.
|
|
78
|
+
*
|
|
79
|
+
* Rejects with an error whose `code` is `export-aborted`, `export-fonts-unlicensed` (the license rule left the PDF converter no face for some text), `export-no-slides`,
|
|
80
|
+
* `export-format`, `export-unavailable` (PDF and PNG need the renderer's `export-browser` entry), `fonts-unavailable`, `invalid-option` (a malformed slide selection or a
|
|
81
|
+
* slide past the end, from core's `parseSlideSelection`; `zip` with PDF; `raster` with PNG or SVG) or a renderer code (`converter-missing` for a raster PDF without `pdfLib`).
|
|
81
82
|
*/
|
|
82
|
-
export declare function
|
|
83
|
+
export declare function convert(deck: unknown, options: ConvertOptions): Promise<ConvertResult>;
|
package/dist/export.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
// RR-23:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// the
|
|
5
|
-
//
|
|
1
|
+
// RR-23, RR-73: `convert(deck, { format })` turns the open deck into PDF, PNG or SVG files in the page, next to the PowerPoint
|
|
2
|
+
// export. It has the name and the result of core's in-memory `convert` (`{ files, findings }`, each PNG or SVG file carrying its
|
|
3
|
+
// one-based `slide`). The slides are drawn by the same renderer the preview uses (`toSvg`, with the fonts handle's text measurement),
|
|
4
|
+
// so a file is the preview, not a second layout. Fonts are the renderer's fonts handle's: the faces the document needs load through
|
|
5
|
+
// the handle first, and only faces its registry holds (bundled or hash-pinned, permissively licensed) are embedded, as @font-face
|
|
6
|
+
// data in each SVG and as subsets in the PDF. No system font is read and nothing is fetched at export time.
|
|
6
7
|
//
|
|
7
|
-
// const
|
|
8
|
-
// //
|
|
8
|
+
// const { files, findings } = await convert(editor.presentation, { format: "pdf", fonts, renderOptions, signal, onProgress });
|
|
9
|
+
// // files: one PDF; one PNG or SVG per slide; or, with `zip: true`, one archive of them.
|
|
9
10
|
|
|
10
|
-
import {
|
|
11
|
+
import { parseSlideSelection } from "@openpresentation/opf";
|
|
12
|
+
import { pointerFromPath } from "@openpresentation/opf/patch";
|
|
13
|
+
import { toSvg } from "@openpresentation/opf-render/svg";
|
|
11
14
|
import { mergeCatalogs, prepareSave } from "./catalogs.js";
|
|
12
15
|
import { fontGate } from "./font-gate.js";
|
|
13
16
|
import { createZip } from "./zip.js";
|
|
@@ -17,14 +20,16 @@ export const EXPORT_FORMATS = Object.freeze({
|
|
|
17
20
|
png: Object.freeze({ extension: "png", type: "image/png", label: "PNG" }),
|
|
18
21
|
svg: Object.freeze({ extension: "svg", type: "image/svg+xml", label: "SVG" }),
|
|
19
22
|
});
|
|
23
|
+
const ZIP_TYPE = "application/zip";
|
|
20
24
|
|
|
21
25
|
/** Largest PNG scale offered; a 1280 x 720 slide at 4x is 5120 x 2880 pixels. */
|
|
22
26
|
export const MAX_PNG_SCALE = 4;
|
|
23
27
|
|
|
24
28
|
// 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
|
|
25
|
-
// face's own license text; a text that names none of these keeps the face out of the
|
|
29
|
+
// face's own license text; a text that names none of these keeps the face out of the file.
|
|
26
30
|
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;
|
|
27
|
-
|
|
31
|
+
// PDF converter codes that are warnings; every other `pdf-` code (embedded faces, page notes) is information. Renderer codes are warnings.
|
|
32
|
+
const PDF_WARNINGS = new Set([
|
|
28
33
|
"pdf-font-substituted", "pdf-font-fallback", "pdf-glyph-missing", "pdf-raster-fallback", "pdf-image-skipped", "pdf-font-unreadable",
|
|
29
34
|
"pdf-font-embedding-restricted", "pdf-font-unsupported-format", "pdf-expansion-limit", "pdf-font-unavailable",
|
|
30
35
|
]);
|
|
@@ -32,7 +37,7 @@ const FONT_WARNINGS = new Set([
|
|
|
32
37
|
const yieldToHost = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
33
38
|
|
|
34
39
|
/**
|
|
35
|
-
* The
|
|
40
|
+
* The file name: the deck's `filename` (a trailing .pptx, .pdf, .png or .svg is dropped), else the slugified `name`, else
|
|
36
41
|
* "presentation", plus `suffix` and `.extension`. Characters a file system rejects are replaced, so the name is safe to save.
|
|
37
42
|
*/
|
|
38
43
|
export function exportFileName(deck, extension, suffix = "") {
|
|
@@ -43,12 +48,17 @@ export function exportFileName(deck, extension, suffix = "") {
|
|
|
43
48
|
return `${base}${suffix}.${extension}`;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The slide numbers (one-based, ascending) a conversion covers. `slides` is core's slide selection (`3`, `"1-3"`, `"1,3-5"`, `[1, 3]`;
|
|
53
|
+
* `parseSlideSelection`): exactly those slides, hidden or not. Without it every slide that is not hidden, or every slide with
|
|
54
|
+
* `includeHidden`. A deck without slides gives `[]`; a malformed selection or a slide past the end throws core's `OPFApiError`
|
|
55
|
+
* (`invalid-option`).
|
|
56
|
+
*/
|
|
57
|
+
export function slidesToConvert(deck, { slides, includeHidden = false } = {}) {
|
|
58
|
+
const list = Array.isArray(deck?.slides) ? deck.slides : [];
|
|
59
|
+
if (!list.length) return [];
|
|
60
|
+
if (slides !== undefined) return parseSlideSelection(slides, list.length);
|
|
61
|
+
return list.map((slide, index) => (includeHidden || slide?.hidden !== true ? index + 1 : 0)).filter(Boolean);
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
/**
|
|
@@ -72,7 +82,7 @@ function exportError(code, message, cause) {
|
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
// The faces a PDF may embed: the same permissive-license list as the SVG (`embeddableFonts`), as the bytes the converter reads. The fonts
|
|
75
|
-
// handle itself is never given to `
|
|
85
|
+
// handle itself is never given to `toPdf`: it would embed every face the registry holds, whatever its license.
|
|
76
86
|
function pdfFontData(faces) {
|
|
77
87
|
return faces.map((face) => {
|
|
78
88
|
const text = String(face.dataUrl ?? "");
|
|
@@ -85,48 +95,84 @@ function pdfFontData(faces) {
|
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
const fontLabel = (face) => `${face.family} ${face.weight}${face.italic ? " italic" : ""}`;
|
|
98
|
+
const scalar = (value) => value === null || ["string", "number", "boolean"].includes(typeof value);
|
|
88
99
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
let message = diagnostic.message ?? code;
|
|
100
|
+
// A renderer, PDF converter or editor note as core's Finding (finding.schema.json): `ruleId` is `<source>/<code>` and `category` the
|
|
101
|
+
// source, as core's conversion reports them (`render/`, `pdf/`, `fonts/`); `path` is a JSON Pointer (core's `pointerFromPath`); `slide`
|
|
102
|
+
// (zero-based, the Finding schema's convention) and `slideId` say which slide it belongs to; the note's own facts (font family, weight,
|
|
103
|
+
// glyph count, ...) are `measured`.
|
|
104
|
+
function findingOf(source, diagnostic, deck) {
|
|
105
|
+
const { code: rawCode, message: rawMessage, path: where, severity: _severity, ...details } = diagnostic ?? {};
|
|
106
|
+
const code = String(rawCode ?? "note").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "") || "note";
|
|
107
|
+
let message = rawMessage ?? code;
|
|
98
108
|
let severity = "warning";
|
|
99
109
|
if (code === "pdf-font-embedded") {
|
|
100
110
|
severity = "info";
|
|
101
|
-
message = `Embedded ${
|
|
111
|
+
message = `Embedded ${details.family} ${details.weight}${details.italic ? " italic" : ""} (${details.embedding}, ${details.glyphs} glyphs).`;
|
|
102
112
|
} else if (code === "pdf-font-substituted" || code === "pdf-font-fallback") {
|
|
103
|
-
message =
|
|
104
|
-
} else if (source === "pdf" && !
|
|
113
|
+
message = rawMessage ?? "A font was substituted in the PDF.";
|
|
114
|
+
} else if (source === "pdf" && !PDF_WARNINGS.has(code) && !/^pdf-unsupported-/.test(code)) {
|
|
105
115
|
severity = "info";
|
|
106
116
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
117
|
+
const path = typeof where === "string" && where ? pointerFromPath(where) : "";
|
|
118
|
+
const slideMatch = /^\/slides\/(\d+)(?:\/|$)/.exec(path);
|
|
119
|
+
const slide = slideMatch ? Number(slideMatch[1]) : undefined;
|
|
120
|
+
const slideId = slide === undefined ? undefined : deck?.slides?.[slide]?.id;
|
|
121
|
+
const measured = {};
|
|
122
|
+
// A long text (an image's data URI) is cut: the finding names the fact, the deck holds the value.
|
|
123
|
+
const short = (value) => (typeof value === "string" && value.length > 200 ? `${value.slice(0, 199)}…` : value);
|
|
124
|
+
for (const [key, value] of Object.entries(details)) {
|
|
125
|
+
if (scalar(value)) measured[key] = short(value);
|
|
126
|
+
else if (Array.isArray(value) && value.length && value.every(scalar)) measured[key] = short(value.join(", "));
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
ruleId: `${source}/${code}`,
|
|
130
|
+
severity,
|
|
131
|
+
category: source,
|
|
132
|
+
path,
|
|
133
|
+
scope: "document",
|
|
134
|
+
message: String(message),
|
|
135
|
+
...(slide === undefined ? {} : { slide }),
|
|
136
|
+
...(typeof slideId === "string" ? { slideId } : {}),
|
|
137
|
+
...(Object.keys(measured).length ? { measured } : {}),
|
|
138
|
+
};
|
|
109
139
|
}
|
|
110
140
|
|
|
141
|
+
// The picture size in pixels: the SVG's width and height attributes, a PNG's IHDR.
|
|
142
|
+
const svgSize = (svg) => {
|
|
143
|
+
const tag = /<svg\b[^>]*>/.exec(svg)?.[0] ?? "";
|
|
144
|
+
const width = Number(/\bwidth="([\d.]+)"/.exec(tag)?.[1]);
|
|
145
|
+
const height = Number(/\bheight="([\d.]+)"/.exec(tag)?.[1]);
|
|
146
|
+
return Number.isFinite(width) && Number.isFinite(height) ? { width, height } : {};
|
|
147
|
+
};
|
|
148
|
+
const pngSize = (bytes) => {
|
|
149
|
+
if (!(bytes instanceof Uint8Array) || bytes.length < 24) return {};
|
|
150
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
151
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
152
|
+
};
|
|
153
|
+
|
|
111
154
|
async function loadConverters(options) {
|
|
112
|
-
if (options.
|
|
155
|
+
if (options.converters) return options.converters;
|
|
113
156
|
try {
|
|
114
157
|
return await import("@openpresentation/opf-render/export-browser");
|
|
115
158
|
} catch (cause) {
|
|
116
|
-
throw exportError("export-unavailable", "PDF and PNG
|
|
159
|
+
throw exportError("export-unavailable", "PDF and PNG output need @openpresentation/opf-render with its export-browser entry. SVG works without it.", cause);
|
|
117
160
|
}
|
|
118
161
|
}
|
|
119
162
|
|
|
120
163
|
/**
|
|
121
|
-
* Convert the deck's slides to
|
|
164
|
+
* Convert the deck's slides to PDF, PNG or SVG files, in memory. The name and the result are core's in-memory `convert`.
|
|
122
165
|
*
|
|
123
166
|
* `options`:
|
|
124
167
|
* - `format`: "pdf", "png" or "svg" (required).
|
|
125
|
-
* - `slides`:
|
|
126
|
-
*
|
|
127
|
-
* - `
|
|
128
|
-
*
|
|
129
|
-
*
|
|
168
|
+
* - `slides`: core's slide selection, counted from 1 (`3`, `"1-3"`, `"1,3-5"`, `[1, 3]`): exactly those slides, hidden or not. Omitted:
|
|
169
|
+
* every slide that is not hidden, or every slide with `includeHidden`.
|
|
170
|
+
* - `raster`: PDF only; each page an image of its slide instead of selectable text and vector shapes.
|
|
171
|
+
* - `pdfLib`: for `raster: true` only, the pdf-lib module (`import * as pdfLib from "pdf-lib"`). The renderer's `export-browser` entry
|
|
172
|
+
* imports no PDF library (RR-63, opf-render 0.16), so a host that offers the raster PDF imports pdf-lib itself; without it a raster
|
|
173
|
+
* PDF rejects with the renderer's `converter-missing` (its `details.install` names the package). Vector PDF, PNG and SVG need no option.
|
|
174
|
+
* - `zip`: PNG and SVG only; one ZIP archive of the slides instead of one file per slide (even for one slide).
|
|
175
|
+
* - `name`: the base name of the files (`name.pdf`, `name-001.png`, `name.zip`). Omitted: the deck's `filename`, else its slugified `name`.
|
|
130
176
|
* - `scale`: PNG pixel density, 1 to 4 (default 2); also the raster PDF's density.
|
|
131
177
|
* - `renderOptions`: the render options the host draws with (`catalogs`, `date`, ...), the same as its preview.
|
|
132
178
|
* - `catalogs`: the host's registered catalogs (`Catalog[]`, merged with `renderOptions.catalogs`). The deck is embedded first (core's
|
|
@@ -135,74 +181,100 @@ async function loadConverters(options) {
|
|
|
135
181
|
* `textMeasurement` lays the slides out, the faces the deck needs load through its `ensure` before anything is drawn, and its registry's
|
|
136
182
|
* faces are what gets embedded: the SVG carries the faces its slides draw, and the PDF embeds the registry's faces (script faces included).
|
|
137
183
|
* Both apply the same license rule (OFL-1.1, Apache-2.0, MIT or UFL-1.0): a face whose license text names none of them is left out of the SVG
|
|
138
|
-
* and the PDF and reported once as `export-font-license`. Text that needed it is drawn with another face the registry holds and
|
|
139
|
-
* reported by the converter (`pdf-font-substituted`, `pdf-glyph-missing`); when no face is left at all the PDF rejects with
|
|
184
|
+
* and the PDF and reported once as `fonts/export-font-license`. Text that needed it is drawn with another face the registry holds and
|
|
185
|
+
* reported by the converter (`pdf/pdf-font-substituted`, `pdf/pdf-glyph-missing`); when no face is left at all the PDF rejects with
|
|
140
186
|
* `export-fonts-unlicensed` (the converter's error is its `cause`).
|
|
141
|
-
* - `
|
|
142
|
-
*
|
|
143
|
-
*
|
|
187
|
+
* - `converters`: `{ toPdf, toPng }` in place of the renderer's `export-browser` entry (tests, or a host with its own converter).
|
|
188
|
+
* - `signal`, `onProgress({ stage, done, total, message })`, `onFinding(finding)`.
|
|
189
|
+
* Resolves `{ files, findings }`. Rejects with an error whose `code` is `export-aborted`, `export-fonts-unlicensed`, `export-format`,
|
|
190
|
+
* `export-no-slides`, `export-unavailable`, `fonts-unavailable`, `invalid-option` (core's, for a malformed slide selection or a slide past
|
|
191
|
+
* the end; the editor's, for `zip` or `raster` with a format they do not apply to) or a renderer code (`converter-missing` for a raster
|
|
192
|
+
* PDF without `pdfLib`).
|
|
144
193
|
*/
|
|
145
|
-
export async function
|
|
146
|
-
const format = EXPORT_FORMATS[options
|
|
194
|
+
export async function convert(input, options = {}) {
|
|
195
|
+
const format = EXPORT_FORMATS[options?.format];
|
|
147
196
|
if (!format) throw exportError("export-format", `Choose a format: ${Object.keys(EXPORT_FORMATS).join(", ")}.`);
|
|
197
|
+
if (options.zip && format.extension === "pdf") throw exportError("invalid-option", "zip applies to format png and svg.");
|
|
198
|
+
if (options.raster && format.extension !== "pdf") throw exportError("invalid-option", "raster applies to format pdf.");
|
|
148
199
|
const { signal } = options;
|
|
149
|
-
const
|
|
150
|
-
const
|
|
200
|
+
const findings = [];
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
// One finding per rule, place and message: a deck of 200 slides reports one substituted font once, not 200 times.
|
|
203
|
+
const note = (finding) => {
|
|
204
|
+
const key = JSON.stringify([finding.ruleId, finding.path, finding.message]);
|
|
205
|
+
if (seen.has(key)) return;
|
|
206
|
+
seen.add(key);
|
|
207
|
+
findings.push(finding);
|
|
208
|
+
options.onFinding?.(finding);
|
|
209
|
+
};
|
|
151
210
|
const progress = (stage, done, total, message) => options.onProgress?.({ stage, done, total, message });
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
211
|
+
const aborted = () => exportError("export-aborted", "The export was cancelled.");
|
|
212
|
+
const check = () => { if (signal?.aborted) throw aborted(); };
|
|
213
|
+
const numbers = slidesToConvert(input, options);
|
|
214
|
+
if (!numbers.length) throw exportError("export-no-slides", "There are no slides to export.");
|
|
215
|
+
// A note about a slide that is not converted is left out (finding slides are zero-based).
|
|
216
|
+
const selected = new Set(numbers.map((number) => number - 1));
|
|
157
217
|
const scale = Math.min(MAX_PNG_SCALE, Math.max(1, Number(options.scale) || 2));
|
|
158
218
|
const catalogs = mergeCatalogs(options.catalogs, options.renderOptions?.catalogs);
|
|
159
219
|
const renderOptions = { ...(options.renderOptions ?? {}), catalogs };
|
|
160
220
|
const deck = prepareSave(input, { catalogs }).document;
|
|
221
|
+
const record = (source, diagnostic) => {
|
|
222
|
+
const finding = findingOf(source, diagnostic, deck);
|
|
223
|
+
if (finding.slide === undefined || selected.has(finding.slide)) note(finding);
|
|
224
|
+
};
|
|
161
225
|
|
|
162
226
|
check();
|
|
163
227
|
const gate = fontGate(options.fonts);
|
|
164
228
|
if (gate?.pending(deck, renderOptions).length) {
|
|
165
229
|
progress("fonts", 0, 1, "Loading fonts…");
|
|
166
230
|
try { await gate.ensure(deck, { signal, renderOptions }); }
|
|
167
|
-
catch (error) { if (signal?.aborted) throw
|
|
231
|
+
catch (error) { if (signal?.aborted) throw aborted(); throw error; }
|
|
168
232
|
}
|
|
169
233
|
check();
|
|
170
234
|
const converters = format.extension === "svg" ? null : await loadConverters(options);
|
|
171
235
|
|
|
172
236
|
const dropped = [];
|
|
173
|
-
const embeddedFonts = options.embeddedFonts ?? embeddableFonts(options.fonts?.registry, (face) => {
|
|
174
|
-
|
|
237
|
+
const embeddedFonts = options.embeddedFonts ?? embeddableFonts(options.fonts?.registry, (face) => {
|
|
238
|
+
dropped.push(face);
|
|
239
|
+
const detail = { family: face.family, weight: face.weight, ...(face.italic ? { italic: true } : {}) };
|
|
240
|
+
note(findingOf("fonts", { code: "export-font-license", message: `${fontLabel(face)} is not embedded: its license is not one of OFL-1.1, Apache-2.0, MIT or UFL-1.0.`, ...detail }, deck));
|
|
241
|
+
});
|
|
242
|
+
progress("render", 0, numbers.length, "Drawing slides…");
|
|
175
243
|
await yieldToHost();
|
|
176
|
-
const
|
|
244
|
+
const svgs = toSvg(deck, numbers, {
|
|
177
245
|
...renderOptions,
|
|
178
246
|
fonts: { textMeasurement: options.fonts?.textMeasurement, embeddedFonts },
|
|
179
247
|
// The PDF reports problems by element path; the SVG and PNG are the plain drawing.
|
|
180
248
|
trace: format.extension === "pdf",
|
|
181
249
|
onDiagnostic: (diagnostic) => {
|
|
182
|
-
|
|
183
|
-
if (keep(described)) note(described);
|
|
250
|
+
record("render", diagnostic);
|
|
184
251
|
renderOptions.onDiagnostic?.(diagnostic);
|
|
185
252
|
},
|
|
186
253
|
});
|
|
187
254
|
check();
|
|
188
|
-
|
|
189
|
-
progress("render", indexes.length, indexes.length, "Slides drawn");
|
|
255
|
+
progress("render", numbers.length, numbers.length, "Slides drawn");
|
|
190
256
|
|
|
191
|
-
const
|
|
192
|
-
|
|
257
|
+
const named = (extension, suffix = "") => exportFileName(options.name === undefined ? deck : { filename: String(options.name) }, extension, suffix);
|
|
258
|
+
// Slide files are numbered as core numbers them: three digits at least (`-001`), more for a longer deck.
|
|
259
|
+
const digits = Math.max(3, String(deck.slides.length).length);
|
|
260
|
+
const numbered = (number, extension) => named(extension, `-${String(number).padStart(digits, "0")}`);
|
|
261
|
+
const facts = (number) => {
|
|
262
|
+
const id = deck.slides[number - 1]?.id;
|
|
263
|
+
return { slide: number, ...(typeof id === "string" ? { id } : {}) };
|
|
264
|
+
};
|
|
193
265
|
let files;
|
|
194
266
|
if (format.extension === "svg") {
|
|
195
267
|
const header = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
196
|
-
files = svgs.map((svg, position) => ({ name: numbered(
|
|
268
|
+
files = svgs.map((svg, position) => ({ name: numbered(numbers[position], "svg"), type: format.type, bytes: new TextEncoder().encode(header + svg), ...facts(numbers[position]), ...svgSize(svg) }));
|
|
197
269
|
} else if (format.extension === "png") {
|
|
198
270
|
files = [];
|
|
199
271
|
for (const [position, svg] of svgs.entries()) {
|
|
200
272
|
check();
|
|
201
|
-
progress("convert", position, svgs.length, `Slide ${
|
|
273
|
+
progress("convert", position, svgs.length, `Slide ${numbers[position]} of ${deck.slides.length}`);
|
|
202
274
|
let bytes;
|
|
203
|
-
try { bytes = await converters.
|
|
204
|
-
catch (error) { if (signal?.aborted) throw
|
|
205
|
-
files.push({ name: numbered(
|
|
275
|
+
try { bytes = await converters.toPng(svg, { scale, signal }); }
|
|
276
|
+
catch (error) { if (signal?.aborted) throw aborted(); throw error; }
|
|
277
|
+
files.push({ name: numbered(numbers[position], "png"), type: format.type, bytes, ...facts(numbers[position]), ...pngSize(bytes) });
|
|
206
278
|
await yieldToHost();
|
|
207
279
|
}
|
|
208
280
|
progress("convert", svgs.length, svgs.length, "Images ready");
|
|
@@ -210,43 +282,35 @@ export async function exportDeck(input, options = {}) {
|
|
|
210
282
|
const metadata = { ...(deck.name ? { title: String(deck.name) } : {}), ...(deck.description ? { subject: String(deck.description) } : {}), ...(options.metadata ?? {}) };
|
|
211
283
|
let bytes;
|
|
212
284
|
try {
|
|
213
|
-
bytes = await converters.
|
|
214
|
-
|
|
285
|
+
bytes = await converters.toPdf(svgs, {
|
|
286
|
+
...(options.raster ? { raster: true } : {}),
|
|
215
287
|
scale,
|
|
216
288
|
metadata,
|
|
217
289
|
signal,
|
|
218
290
|
fontData: pdfFontData(embeddedFonts),
|
|
219
|
-
// RR-63: the renderer's browser entry imports no pdf-lib; raster
|
|
220
|
-
...(options.
|
|
291
|
+
// RR-63: the renderer's browser entry imports no pdf-lib; a raster PDF takes the module the host imported.
|
|
292
|
+
...(options.raster && options.pdfLib ? { pdfLib: options.pdfLib } : {}),
|
|
221
293
|
...(options.fallbackFamily ? { defaultFontFamily: options.fallbackFamily } : {}),
|
|
222
|
-
onDiagnostic: (diagnostic) =>
|
|
294
|
+
onDiagnostic: (diagnostic) => record("pdf", diagnostic),
|
|
223
295
|
onProgress: ({ page, pages }) => progress("convert", page, pages, `Page ${page} of ${pages}`),
|
|
224
296
|
});
|
|
225
297
|
} catch (error) {
|
|
226
|
-
if (signal?.aborted) throw
|
|
298
|
+
if (signal?.aborted) throw aborted();
|
|
227
299
|
// The license rule can leave the converter with no face for some text (every face of a family was dropped): say why, not only what.
|
|
228
300
|
if (dropped.length && /font face/i.test(String(error?.message))) {
|
|
229
301
|
throw exportError("export-fonts-unlicensed", `The PDF could not be written: ${dropped.map(fontLabel).join(", ")} ${dropped.length === 1 ? "is" : "are"} not embedded because the license is not one of OFL-1.1, Apache-2.0, MIT or UFL-1.0, and no other face is available. ${error.message}`, error);
|
|
230
302
|
}
|
|
231
303
|
throw error;
|
|
232
304
|
}
|
|
233
|
-
files = [{ name:
|
|
305
|
+
files = [{ name: named("pdf"), type: format.type, bytes, pages: numbers.length, slides: numbers }];
|
|
234
306
|
}
|
|
235
307
|
|
|
236
308
|
check();
|
|
237
|
-
|
|
238
|
-
if (files.length > 1) {
|
|
309
|
+
if (options.zip) {
|
|
239
310
|
progress("archive", 0, files.length, "Packing files…");
|
|
240
311
|
const bytes = await createZip(files.map(({ name, bytes: data }) => ({ name, bytes: data })), { signal });
|
|
241
|
-
|
|
312
|
+
files = [{ name: named("zip"), type: ZIP_TYPE, bytes, entries: files.map((file) => file.name) }];
|
|
242
313
|
}
|
|
243
314
|
progress("done", 1, 1, "Ready");
|
|
244
|
-
|
|
245
|
-
const unique = [];
|
|
246
|
-
const seen = new Set();
|
|
247
|
-
for (const diagnostic of diagnostics) {
|
|
248
|
-
const key = `${diagnostic.code}|${diagnostic.message}|${diagnostic.path ?? ""}`;
|
|
249
|
-
if (!seen.has(key)) { seen.add(key); unique.push(diagnostic); }
|
|
250
|
-
}
|
|
251
|
-
return { download, files, diagnostics: unique, slides: indexes };
|
|
315
|
+
return { files, findings };
|
|
252
316
|
}
|
package/dist/find-replace.js
CHANGED
|
@@ -21,6 +21,11 @@ const REGION_KEY = /^(?:top|middle|bottom|left|center|right)(?:[+:]|$)/;
|
|
|
21
21
|
const WORD_BEFORE = "(?<![\\p{L}\\p{N}_])";
|
|
22
22
|
const WORD_AFTER = "(?![\\p{L}\\p{N}_])";
|
|
23
23
|
|
|
24
|
+
// A variable token (`{{customer}}`, `{{slide.number}}`, `{{ deck.slideCount }}`, `{{revenue|0.0}}`). It is an instruction, not text: a match inside it is
|
|
25
|
+
// never offered, because replacing part of a name would either break the variable or silently change which value it shows (FA-31). An
|
|
26
|
+
// escaped `\{{` is literal text and is searched like the rest.
|
|
27
|
+
const VARIABLE_TOKEN = /(?<!\\)\{\{\s*[A-Za-z][A-Za-z0-9_.-]*\s*(?:\|[^{}]*)?\}\}/g;
|
|
28
|
+
|
|
24
29
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
25
30
|
const runText = (run) => (typeof run === "string" ? run : isObject(run) && typeof run.text === "string" ? run.text : "");
|
|
26
31
|
const isRuns = (value) => Array.isArray(value) && value.length > 0 && value.every((run) => typeof run === "string" || (isObject(run) && typeof run.text === "string"));
|
|
@@ -205,7 +210,8 @@ function snippet(text, start, end) {
|
|
|
205
210
|
* Search a document. Returns `{ matches, fieldCount, slideCount, truncated, error }`. A match is `{ id, pointer, path,
|
|
206
211
|
* slideIndex, label, runs, start, end, text, groups, named, context: { before, match, after } }` where `start` and `end`
|
|
207
212
|
* are offsets into the field's text. Options: those of `compileSearch`, plus `notes` and `slideIndex` of
|
|
208
|
-
* `collectSearchFields`. Matches that are empty (a regular expression that matches nothing) are skipped
|
|
213
|
+
* `collectSearchFields`. Matches that are empty (a regular expression that matches nothing) are skipped, and so is a match that
|
|
214
|
+
* touches a variable token (`{{slide.number}}`): the token is not text, and replacing part of it would break or change the variable.
|
|
209
215
|
*/
|
|
210
216
|
export function findMatches(presentation, query, options = {}) {
|
|
211
217
|
const compiled = compileSearch(query, options);
|
|
@@ -217,11 +223,13 @@ export function findMatches(presentation, query, options = {}) {
|
|
|
217
223
|
compiled.regex.lastIndex = 0;
|
|
218
224
|
let found;
|
|
219
225
|
let hit = false;
|
|
226
|
+
const tokens = item.text.includes("{{") ? [...item.text.matchAll(VARIABLE_TOKEN)].map((token) => [token.index, token.index + token[0].length]) : [];
|
|
220
227
|
while ((found = compiled.regex.exec(item.text))) {
|
|
221
228
|
if (found[0] === "") {
|
|
222
229
|
compiled.regex.lastIndex += 1;
|
|
223
230
|
continue;
|
|
224
231
|
}
|
|
232
|
+
if (tokens.some(([from, to]) => found.index < to && found.index + found[0].length > from)) continue;
|
|
225
233
|
if (result.matches.length >= MAX_MATCHES) {
|
|
226
234
|
result.truncated = true;
|
|
227
235
|
break outer;
|
package/dist/font-gate.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// fails with "Intos Display cannot display U+65E5"), so nothing may render or measure a new document before `ensure`
|
|
5
5
|
// resolves. The renderer's fonts handle (`loadFonts()` from `@openpresentation/opf-render/fonts-browser`) is the source of
|
|
6
6
|
// truth: its `pending(presentation, renderOptions)` and `ensure(presentation, options)` know what is missing and load it. The
|
|
7
|
-
// canvas and `
|
|
7
|
+
// canvas and `convert` (`/export`) take that handle as `fonts` and gate every document-replacing path through this module.
|
|
8
8
|
|
|
9
9
|
/** Error code of a document whose required faces are still loading. */
|
|
10
10
|
export const FONTS_PENDING = "fonts-pending";
|
package/dist/index.d.ts
CHANGED
|
@@ -72,6 +72,7 @@ export interface EditorHistory { undo: EditorHistoryEntry[]; redo: EditorHistory
|
|
|
72
72
|
|
|
73
73
|
export interface EditorSession {
|
|
74
74
|
paginateSlide(slideIndex: number, options?: PaginationOptions & EditorDiagnosticOptions, meta?: Record<string, unknown>): { change: EditorChange | null; pagination: PaginationResult };
|
|
75
|
+
/** Composes the slide with `{{slide.number}}`, `{{slide.section}}` and `{{deck.slideCount}}` substituted for `options.slideNumber` and `options.slideCount` (default: its place in the open deck and the deck's slide count); the document keeps the tokens. */
|
|
75
76
|
composeSlide(slideIndex: number, options?: ComposeSlideOptions & EditorDiagnosticOptions): SlideComposition;
|
|
76
77
|
setComposition(slideIndex: number, composition: Composition, meta?: Record<string, unknown>): EditorChange;
|
|
77
78
|
readonly presentation: unknown;
|
package/dist/index.js
CHANGED
|
@@ -32,12 +32,14 @@ export {
|
|
|
32
32
|
// theme, engine default), with the session's registered catalogs, so the editor measures and composes what the renderer draws
|
|
33
33
|
// and the exporter writes. `options.fonts` is the renderer's fonts handle (its `textMeasurement` measures); `options.catalogs`
|
|
34
34
|
// adds catalogs for this call; `options.onDiagnostic` hears each diagnostic (`unresolved-reference`);
|
|
35
|
-
// any other option overrides the resolved one (`layout`, ...).
|
|
35
|
+
// any other option overrides the resolved one (`layout`, ...). FA-31: `slide` is the slide to compose and draw, with `{{slide.number}}`,
|
|
36
|
+
// `{{slide.section}}` and `{{deck.slideCount}}` substituted for `slideNumber` and `slideCount` (default: its place in the open deck and the
|
|
37
|
+
// deck's length), the same numbers the options carry. Pagination takes the source slide instead and substitutes while it measures.
|
|
36
38
|
function slideContext(presentation, slideIndex, catalogs, { fonts, catalogs: _extra, onDiagnostic, ...overrides } = {}) {
|
|
37
39
|
if (!Number.isInteger(slideIndex) || !presentation.slides?.[slideIndex]) throw new OPFEditorError("slide-index-out-of-range", "Slide index is out of range.");
|
|
38
|
-
const { options, diagnostics } = resolveSlideContext(presentation, slideIndex, { fonts, catalogs });
|
|
40
|
+
const { slide, options, diagnostics } = resolveSlideContext(presentation, slideIndex, { fonts, catalogs, slideNumber: overrides.slideNumber, slideCount: overrides.slideCount });
|
|
39
41
|
for (const diagnostic of diagnostics) onDiagnostic?.(diagnostic);
|
|
40
|
-
return { ...options, ...overrides };
|
|
42
|
+
return { slide, options: { ...options, ...overrides } };
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export const packageName = "@openpresentation/opf-editor";
|
|
@@ -276,12 +278,13 @@ export function createEditorSession(input, options = {}) {
|
|
|
276
278
|
});
|
|
277
279
|
},
|
|
278
280
|
composeSlide(slideIndex, options = {}) {
|
|
279
|
-
|
|
281
|
+
const { slide, options: resolved } = slideContext(presentation, slideIndex, mergeCatalogs(catalogs, options.catalogs), options);
|
|
282
|
+
return composeSlide(slide, resolved);
|
|
280
283
|
},
|
|
281
284
|
paginateSlide(slideIndex, options = {}, meta = {}) {
|
|
282
285
|
const list = mergeCatalogs(catalogs, options.catalogs);
|
|
283
286
|
// Core pagination reads the measurement from `fonts`, so the resolved context hands it over that way.
|
|
284
|
-
const { textMeasurement, ...resolved } = slideContext(presentation, slideIndex, list, options);
|
|
287
|
+
const { textMeasurement, ...resolved } = slideContext(presentation, slideIndex, list, options).options;
|
|
285
288
|
const pagination = paginateSlide(presentation.slides[slideIndex], {
|
|
286
289
|
...resolved,
|
|
287
290
|
catalogs: list,
|