@openpresentation/opf-editor 0.17.0 → 0.18.1
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 +74 -42
- package/dist/assets.js +1 -1
- package/dist/canvas.d.ts +7 -5
- package/dist/canvas.js +17 -5
- package/dist/data-grid.js +3 -2
- package/dist/data.d.ts +3 -3
- package/dist/data.js +3 -3
- package/dist/design-controls.js +157 -44
- package/dist/design-options.d.ts +78 -22
- package/dist/design-options.js +240 -110
- package/dist/export.d.ts +49 -42
- package/dist/export.js +169 -89
- package/dist/find-panel.js +3 -2
- package/dist/font-gate.js +1 -1
- package/dist/review-panel.js +8 -5
- package/dist/review.d.ts +6 -1
- package/dist/review.js +37 -1
- package/dist/slide-manager.js +13 -8
- package/dist/template-panel.d.ts +1 -1
- package/dist/template-panel.js +7 -4
- package/dist/templates.js +1 -1
- package/package.json +15 -14
package/dist/export.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
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. A browser handle made with
|
|
7
|
+
// `loadFonts({ subsetWasm, shapeWasm })` (opf-editor#141) also cuts each SVG's faces to the slide's glyphs and draws
|
|
8
|
+
// `renderOptions.text: "paths"` with HarfBuzz-shaped outlines.
|
|
6
9
|
//
|
|
7
|
-
// const
|
|
8
|
-
// //
|
|
10
|
+
// const { files, findings } = await convert(editor.presentation, { format: "pdf", fonts, renderOptions, signal, onProgress });
|
|
11
|
+
// // files: one PDF; one PNG or SVG per slide; or, with `zip: true`, one archive of them.
|
|
9
12
|
|
|
10
|
-
import {
|
|
13
|
+
import { parseSlideSelection } from "@openpresentation/opf";
|
|
14
|
+
import { pointerFromPath } from "@openpresentation/opf/patch";
|
|
15
|
+
import { toSvg } from "@openpresentation/opf-render/svg";
|
|
11
16
|
import { mergeCatalogs, prepareSave } from "./catalogs.js";
|
|
12
17
|
import { fontGate } from "./font-gate.js";
|
|
13
18
|
import { createZip } from "./zip.js";
|
|
@@ -17,14 +22,16 @@ export const EXPORT_FORMATS = Object.freeze({
|
|
|
17
22
|
png: Object.freeze({ extension: "png", type: "image/png", label: "PNG" }),
|
|
18
23
|
svg: Object.freeze({ extension: "svg", type: "image/svg+xml", label: "SVG" }),
|
|
19
24
|
});
|
|
25
|
+
const ZIP_TYPE = "application/zip";
|
|
20
26
|
|
|
21
27
|
/** Largest PNG scale offered; a 1280 x 720 slide at 4x is 5120 x 2880 pixels. */
|
|
22
28
|
export const MAX_PNG_SCALE = 4;
|
|
23
29
|
|
|
24
30
|
// 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
|
|
31
|
+
// face's own license text; a text that names none of these keeps the face out of the file.
|
|
26
32
|
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
|
-
|
|
33
|
+
// PDF converter codes that are warnings; every other `pdf-` code (embedded faces, page notes) is information. Renderer codes are warnings.
|
|
34
|
+
const PDF_WARNINGS = new Set([
|
|
28
35
|
"pdf-font-substituted", "pdf-font-fallback", "pdf-glyph-missing", "pdf-raster-fallback", "pdf-image-skipped", "pdf-font-unreadable",
|
|
29
36
|
"pdf-font-embedding-restricted", "pdf-font-unsupported-format", "pdf-expansion-limit", "pdf-font-unavailable",
|
|
30
37
|
]);
|
|
@@ -32,7 +39,7 @@ const FONT_WARNINGS = new Set([
|
|
|
32
39
|
const yieldToHost = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
33
40
|
|
|
34
41
|
/**
|
|
35
|
-
* The
|
|
42
|
+
* The file name: the deck's `filename` (a trailing .pptx, .pdf, .png or .svg is dropped), else the slugified `name`, else
|
|
36
43
|
* "presentation", plus `suffix` and `.extension`. Characters a file system rejects are replaced, so the name is safe to save.
|
|
37
44
|
*/
|
|
38
45
|
export function exportFileName(deck, extension, suffix = "") {
|
|
@@ -43,12 +50,17 @@ export function exportFileName(deck, extension, suffix = "") {
|
|
|
43
50
|
return `${base}${suffix}.${extension}`;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The slide numbers (one-based, ascending) a conversion covers. `slides` is core's slide selection (`3`, `"1-3"`, `"1,3-5"`, `[1, 3]`;
|
|
55
|
+
* `parseSlideSelection`): exactly those slides, hidden or not. Without it every slide that is not hidden, or every slide with
|
|
56
|
+
* `includeHidden`. A deck without slides gives `[]`; a malformed selection or a slide past the end throws core's `OPFApiError`
|
|
57
|
+
* (`invalid-option`).
|
|
58
|
+
*/
|
|
59
|
+
export function slidesToConvert(deck, { slides, includeHidden = false } = {}) {
|
|
60
|
+
const list = Array.isArray(deck?.slides) ? deck.slides : [];
|
|
61
|
+
if (!list.length) return [];
|
|
62
|
+
if (slides !== undefined) return parseSlideSelection(slides, list.length);
|
|
63
|
+
return list.map((slide, index) => (includeHidden || slide?.hidden !== true ? index + 1 : 0)).filter(Boolean);
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
/**
|
|
@@ -72,7 +84,7 @@ function exportError(code, message, cause) {
|
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
// 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 `
|
|
87
|
+
// handle itself is never given to `toPdf`: it would embed every face the registry holds, whatever its license.
|
|
76
88
|
function pdfFontData(faces) {
|
|
77
89
|
return faces.map((face) => {
|
|
78
90
|
const text = String(face.dataUrl ?? "");
|
|
@@ -85,48 +97,84 @@ function pdfFontData(faces) {
|
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
const fontLabel = (face) => `${face.family} ${face.weight}${face.italic ? " italic" : ""}`;
|
|
100
|
+
const scalar = (value) => value === null || ["string", "number", "boolean"].includes(typeof value);
|
|
88
101
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
let message = diagnostic.message ?? code;
|
|
102
|
+
// A renderer, PDF converter or editor note as core's Finding (finding.schema.json): `ruleId` is `<source>/<code>` and `category` the
|
|
103
|
+
// source, as core's conversion reports them (`render/`, `pdf/`, `fonts/`); `path` is a JSON Pointer (core's `pointerFromPath`); `slide`
|
|
104
|
+
// (zero-based, the Finding schema's convention) and `slideId` say which slide it belongs to; the note's own facts (font family, weight,
|
|
105
|
+
// glyph count, ...) are `measured`.
|
|
106
|
+
function findingOf(source, diagnostic, deck) {
|
|
107
|
+
const { code: rawCode, message: rawMessage, path: where, severity: _severity, ...details } = diagnostic ?? {};
|
|
108
|
+
const code = String(rawCode ?? "note").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "") || "note";
|
|
109
|
+
let message = rawMessage ?? code;
|
|
98
110
|
let severity = "warning";
|
|
99
111
|
if (code === "pdf-font-embedded") {
|
|
100
112
|
severity = "info";
|
|
101
|
-
message = `Embedded ${
|
|
113
|
+
message = `Embedded ${details.family} ${details.weight}${details.italic ? " italic" : ""} (${details.embedding}, ${details.glyphs} glyphs).`;
|
|
102
114
|
} else if (code === "pdf-font-substituted" || code === "pdf-font-fallback") {
|
|
103
|
-
message =
|
|
104
|
-
} else if (source === "pdf" && !
|
|
115
|
+
message = rawMessage ?? "A font was substituted in the PDF.";
|
|
116
|
+
} else if (source === "pdf" && !PDF_WARNINGS.has(code) && !/^pdf-unsupported-/.test(code)) {
|
|
105
117
|
severity = "info";
|
|
106
118
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
119
|
+
const path = typeof where === "string" && where ? pointerFromPath(where) : "";
|
|
120
|
+
const slideMatch = /^\/slides\/(\d+)(?:\/|$)/.exec(path);
|
|
121
|
+
const slide = slideMatch ? Number(slideMatch[1]) : undefined;
|
|
122
|
+
const slideId = slide === undefined ? undefined : deck?.slides?.[slide]?.id;
|
|
123
|
+
const measured = {};
|
|
124
|
+
// A long text (an image's data URI) is cut: the finding names the fact, the deck holds the value.
|
|
125
|
+
const short = (value) => (typeof value === "string" && value.length > 200 ? `${value.slice(0, 199)}…` : value);
|
|
126
|
+
for (const [key, value] of Object.entries(details)) {
|
|
127
|
+
if (scalar(value)) measured[key] = short(value);
|
|
128
|
+
else if (Array.isArray(value) && value.length && value.every(scalar)) measured[key] = short(value.join(", "));
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
ruleId: `${source}/${code}`,
|
|
132
|
+
severity,
|
|
133
|
+
category: source,
|
|
134
|
+
path,
|
|
135
|
+
scope: "document",
|
|
136
|
+
message: String(message),
|
|
137
|
+
...(slide === undefined ? {} : { slide }),
|
|
138
|
+
...(typeof slideId === "string" ? { slideId } : {}),
|
|
139
|
+
...(Object.keys(measured).length ? { measured } : {}),
|
|
140
|
+
};
|
|
109
141
|
}
|
|
110
142
|
|
|
143
|
+
// The picture size in pixels: the SVG's width and height attributes, a PNG's IHDR.
|
|
144
|
+
const svgSize = (svg) => {
|
|
145
|
+
const tag = /<svg\b[^>]*>/.exec(svg)?.[0] ?? "";
|
|
146
|
+
const width = Number(/\bwidth="([\d.]+)"/.exec(tag)?.[1]);
|
|
147
|
+
const height = Number(/\bheight="([\d.]+)"/.exec(tag)?.[1]);
|
|
148
|
+
return Number.isFinite(width) && Number.isFinite(height) ? { width, height } : {};
|
|
149
|
+
};
|
|
150
|
+
const pngSize = (bytes) => {
|
|
151
|
+
if (!(bytes instanceof Uint8Array) || bytes.length < 24) return {};
|
|
152
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
153
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
154
|
+
};
|
|
155
|
+
|
|
111
156
|
async function loadConverters(options) {
|
|
112
|
-
if (options.
|
|
157
|
+
if (options.converters) return options.converters;
|
|
113
158
|
try {
|
|
114
159
|
return await import("@openpresentation/opf-render/export-browser");
|
|
115
160
|
} catch (cause) {
|
|
116
|
-
throw exportError("export-unavailable", "PDF and PNG
|
|
161
|
+
throw exportError("export-unavailable", "PDF and PNG output need @openpresentation/opf-render with its export-browser entry. SVG works without it.", cause);
|
|
117
162
|
}
|
|
118
163
|
}
|
|
119
164
|
|
|
120
165
|
/**
|
|
121
|
-
* Convert the deck's slides to
|
|
166
|
+
* 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
167
|
*
|
|
123
168
|
* `options`:
|
|
124
169
|
* - `format`: "pdf", "png" or "svg" (required).
|
|
125
|
-
* - `slides`:
|
|
126
|
-
*
|
|
127
|
-
* - `
|
|
128
|
-
*
|
|
129
|
-
*
|
|
170
|
+
* - `slides`: core's slide selection, counted from 1 (`3`, `"1-3"`, `"1,3-5"`, `[1, 3]`): exactly those slides, hidden or not. Omitted:
|
|
171
|
+
* every slide that is not hidden, or every slide with `includeHidden`.
|
|
172
|
+
* - `raster`: PDF only; each page an image of its slide instead of selectable text and vector shapes.
|
|
173
|
+
* - `pdfLib`: for `raster: true` only, the pdf-lib module (`import * as pdfLib from "pdf-lib"`). The renderer's `export-browser` entry
|
|
174
|
+
* 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
|
|
175
|
+
* PDF rejects with the renderer's `converter-missing` (its `details.install` names the package). Vector PDF, PNG and SVG need no option.
|
|
176
|
+
* - `zip`: PNG and SVG only; one ZIP archive of the slides instead of one file per slide (even for one slide).
|
|
177
|
+
* - `name`: the base name of the files (`name.pdf`, `name-001.png`, `name.zip`). Omitted: the deck's `filename`, else its slugified `name`.
|
|
130
178
|
* - `scale`: PNG pixel density, 1 to 4 (default 2); also the raster PDF's density.
|
|
131
179
|
* - `renderOptions`: the render options the host draws with (`catalogs`, `date`, ...), the same as its preview.
|
|
132
180
|
* - `catalogs`: the host's registered catalogs (`Catalog[]`, merged with `renderOptions.catalogs`). The deck is embedded first (core's
|
|
@@ -135,74 +183,114 @@ async function loadConverters(options) {
|
|
|
135
183
|
* `textMeasurement` lays the slides out, the faces the deck needs load through its `ensure` before anything is drawn, and its registry's
|
|
136
184
|
* 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
185
|
* 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
|
|
186
|
+
* and the PDF and reported once as `fonts/export-font-license`. Text that needed it is drawn with another face the registry holds and
|
|
187
|
+
* reported by the converter (`pdf/pdf-font-substituted`, `pdf/pdf-glyph-missing`); when no face is left at all the PDF rejects with
|
|
140
188
|
* `export-fonts-unlicensed` (the converter's error is its `cause`).
|
|
141
|
-
* - `
|
|
142
|
-
*
|
|
143
|
-
*
|
|
189
|
+
* - `converters`: `{ toPdf, toPng }` in place of the renderer's `export-browser` entry (tests, or a host with its own converter).
|
|
190
|
+
* - `signal`, `onProgress({ stage, done, total, message })`, `onFinding(finding)`.
|
|
191
|
+
* Resolves `{ files, findings }`. Rejects with an error whose `code` is `export-aborted`, `export-fonts-unlicensed`, `export-format`,
|
|
192
|
+
* `export-no-slides`, `export-unavailable`, `fonts-unavailable`, `invalid-option` (core's, for a malformed slide selection or a slide past
|
|
193
|
+
* 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
|
|
194
|
+
* PDF without `pdfLib`).
|
|
144
195
|
*/
|
|
145
|
-
export async function
|
|
146
|
-
const format = EXPORT_FORMATS[options
|
|
196
|
+
export async function convert(input, options = {}) {
|
|
197
|
+
const format = EXPORT_FORMATS[options?.format];
|
|
147
198
|
if (!format) throw exportError("export-format", `Choose a format: ${Object.keys(EXPORT_FORMATS).join(", ")}.`);
|
|
199
|
+
if (options.zip && format.extension === "pdf") throw exportError("invalid-option", "zip applies to format png and svg.");
|
|
200
|
+
if (options.raster && format.extension !== "pdf") throw exportError("invalid-option", "raster applies to format pdf.");
|
|
148
201
|
const { signal } = options;
|
|
149
|
-
const
|
|
150
|
-
const
|
|
202
|
+
const findings = [];
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
// One finding per rule, place and message: a deck of 200 slides reports one substituted font once, not 200 times.
|
|
205
|
+
const note = (finding) => {
|
|
206
|
+
const key = JSON.stringify([finding.ruleId, finding.path, finding.message]);
|
|
207
|
+
if (seen.has(key)) return;
|
|
208
|
+
seen.add(key);
|
|
209
|
+
findings.push(finding);
|
|
210
|
+
options.onFinding?.(finding);
|
|
211
|
+
};
|
|
151
212
|
const progress = (stage, done, total, message) => options.onProgress?.({ stage, done, total, message });
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
213
|
+
const aborted = () => exportError("export-aborted", "The export was cancelled.");
|
|
214
|
+
const check = () => { if (signal?.aborted) throw aborted(); };
|
|
215
|
+
const numbers = slidesToConvert(input, options);
|
|
216
|
+
if (!numbers.length) throw exportError("export-no-slides", "There are no slides to export.");
|
|
217
|
+
// A note about a slide that is not converted is left out (finding slides are zero-based).
|
|
218
|
+
const selected = new Set(numbers.map((number) => number - 1));
|
|
157
219
|
const scale = Math.min(MAX_PNG_SCALE, Math.max(1, Number(options.scale) || 2));
|
|
158
220
|
const catalogs = mergeCatalogs(options.catalogs, options.renderOptions?.catalogs);
|
|
159
221
|
const renderOptions = { ...(options.renderOptions ?? {}), catalogs };
|
|
160
222
|
const deck = prepareSave(input, { catalogs }).document;
|
|
223
|
+
const record = (source, diagnostic) => {
|
|
224
|
+
const finding = findingOf(source, diagnostic, deck);
|
|
225
|
+
if (finding.slide === undefined || selected.has(finding.slide)) note(finding);
|
|
226
|
+
};
|
|
161
227
|
|
|
162
228
|
check();
|
|
163
229
|
const gate = fontGate(options.fonts);
|
|
164
230
|
if (gate?.pending(deck, renderOptions).length) {
|
|
165
231
|
progress("fonts", 0, 1, "Loading fonts…");
|
|
166
232
|
try { await gate.ensure(deck, { signal, renderOptions }); }
|
|
167
|
-
catch (error) { if (signal?.aborted) throw
|
|
233
|
+
catch (error) { if (signal?.aborted) throw aborted(); throw error; }
|
|
168
234
|
}
|
|
169
235
|
check();
|
|
170
236
|
const converters = format.extension === "svg" ? null : await loadConverters(options);
|
|
171
237
|
|
|
172
238
|
const dropped = [];
|
|
173
|
-
const embeddedFonts = options.embeddedFonts ?? embeddableFonts(options.fonts?.registry, (face) => {
|
|
174
|
-
|
|
239
|
+
const embeddedFonts = options.embeddedFonts ?? embeddableFonts(options.fonts?.registry, (face) => {
|
|
240
|
+
dropped.push(face);
|
|
241
|
+
const detail = { family: face.family, weight: face.weight, ...(face.italic ? { italic: true } : {}) };
|
|
242
|
+
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));
|
|
243
|
+
});
|
|
244
|
+
progress("render", 0, numbers.length, "Drawing slides…");
|
|
175
245
|
await yieldToHost();
|
|
176
|
-
|
|
246
|
+
// RR-65, RR-64: the handle's subset engine (a browser handle created with `subsetWasm`) cuts each face a standalone SVG embeds to the
|
|
247
|
+
// slide's glyphs, and its outline engine (`shapeWasm` shapes them with HarfBuzz) draws `renderOptions.text: "paths"`. Both are carried
|
|
248
|
+
// from the handle by hand because the face list above is the editor's licensed one, not the handle's. A PDF reads whole faces and
|
|
249
|
+
// the SVG's own @font-face data (its converter cuts its own subsets), and selectable text is its point, so it gets neither: a
|
|
250
|
+
// `text: "paths"` render option draws a PDF's text as text. A PNG is a picture of the SVG: it takes the outlines, not the subsets.
|
|
251
|
+
const toPdf = format.extension === "pdf";
|
|
252
|
+
const handle = options.fonts;
|
|
253
|
+
const drawFonts = {
|
|
254
|
+
textMeasurement: handle?.textMeasurement,
|
|
255
|
+
embeddedFonts,
|
|
256
|
+
...(format.extension === "svg" && handle?.subsets ? { subsets: handle.subsets } : {}),
|
|
257
|
+
...(!toPdf && handle?.outlines ? { outlines: handle.outlines } : {}),
|
|
258
|
+
};
|
|
259
|
+
const svgs = toSvg(deck, numbers, {
|
|
177
260
|
...renderOptions,
|
|
178
|
-
|
|
261
|
+
...(toPdf && renderOptions.text === "paths" ? { text: "fonts" } : {}),
|
|
262
|
+
fonts: drawFonts,
|
|
179
263
|
// The PDF reports problems by element path; the SVG and PNG are the plain drawing.
|
|
180
|
-
trace:
|
|
264
|
+
trace: toPdf,
|
|
181
265
|
onDiagnostic: (diagnostic) => {
|
|
182
|
-
|
|
183
|
-
if (keep(described)) note(described);
|
|
266
|
+
record("render", diagnostic);
|
|
184
267
|
renderOptions.onDiagnostic?.(diagnostic);
|
|
185
268
|
},
|
|
186
269
|
});
|
|
187
270
|
check();
|
|
188
|
-
|
|
189
|
-
progress("render", indexes.length, indexes.length, "Slides drawn");
|
|
271
|
+
progress("render", numbers.length, numbers.length, "Slides drawn");
|
|
190
272
|
|
|
191
|
-
const
|
|
192
|
-
|
|
273
|
+
const named = (extension, suffix = "") => exportFileName(options.name === undefined ? deck : { filename: String(options.name) }, extension, suffix);
|
|
274
|
+
// Slide files are numbered as core numbers them: three digits at least (`-001`), more for a longer deck.
|
|
275
|
+
const digits = Math.max(3, String(deck.slides.length).length);
|
|
276
|
+
const numbered = (number, extension) => named(extension, `-${String(number).padStart(digits, "0")}`);
|
|
277
|
+
const facts = (number) => {
|
|
278
|
+
const id = deck.slides[number - 1]?.id;
|
|
279
|
+
return { slide: number, ...(typeof id === "string" ? { id } : {}) };
|
|
280
|
+
};
|
|
193
281
|
let files;
|
|
194
282
|
if (format.extension === "svg") {
|
|
195
283
|
const header = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
196
|
-
files = svgs.map((svg, position) => ({ name: numbered(
|
|
284
|
+
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
285
|
} else if (format.extension === "png") {
|
|
198
286
|
files = [];
|
|
199
287
|
for (const [position, svg] of svgs.entries()) {
|
|
200
288
|
check();
|
|
201
|
-
progress("convert", position, svgs.length, `Slide ${
|
|
289
|
+
progress("convert", position, svgs.length, `Slide ${numbers[position]} of ${deck.slides.length}`);
|
|
202
290
|
let bytes;
|
|
203
|
-
try { bytes = await converters.
|
|
204
|
-
catch (error) { if (signal?.aborted) throw
|
|
205
|
-
files.push({ name: numbered(
|
|
291
|
+
try { bytes = await converters.toPng(svg, { scale, signal }); }
|
|
292
|
+
catch (error) { if (signal?.aborted) throw aborted(); throw error; }
|
|
293
|
+
files.push({ name: numbered(numbers[position], "png"), type: format.type, bytes, ...facts(numbers[position]), ...pngSize(bytes) });
|
|
206
294
|
await yieldToHost();
|
|
207
295
|
}
|
|
208
296
|
progress("convert", svgs.length, svgs.length, "Images ready");
|
|
@@ -210,43 +298,35 @@ export async function exportDeck(input, options = {}) {
|
|
|
210
298
|
const metadata = { ...(deck.name ? { title: String(deck.name) } : {}), ...(deck.description ? { subject: String(deck.description) } : {}), ...(options.metadata ?? {}) };
|
|
211
299
|
let bytes;
|
|
212
300
|
try {
|
|
213
|
-
bytes = await converters.
|
|
214
|
-
|
|
301
|
+
bytes = await converters.toPdf(svgs, {
|
|
302
|
+
...(options.raster ? { raster: true } : {}),
|
|
215
303
|
scale,
|
|
216
304
|
metadata,
|
|
217
305
|
signal,
|
|
218
306
|
fontData: pdfFontData(embeddedFonts),
|
|
219
|
-
// RR-63: the renderer's browser entry imports no pdf-lib; raster
|
|
220
|
-
...(options.
|
|
307
|
+
// RR-63: the renderer's browser entry imports no pdf-lib; a raster PDF takes the module the host imported.
|
|
308
|
+
...(options.raster && options.pdfLib ? { pdfLib: options.pdfLib } : {}),
|
|
221
309
|
...(options.fallbackFamily ? { defaultFontFamily: options.fallbackFamily } : {}),
|
|
222
|
-
onDiagnostic: (diagnostic) =>
|
|
310
|
+
onDiagnostic: (diagnostic) => record("pdf", diagnostic),
|
|
223
311
|
onProgress: ({ page, pages }) => progress("convert", page, pages, `Page ${page} of ${pages}`),
|
|
224
312
|
});
|
|
225
313
|
} catch (error) {
|
|
226
|
-
if (signal?.aborted) throw
|
|
314
|
+
if (signal?.aborted) throw aborted();
|
|
227
315
|
// 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
316
|
if (dropped.length && /font face/i.test(String(error?.message))) {
|
|
229
317
|
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
318
|
}
|
|
231
319
|
throw error;
|
|
232
320
|
}
|
|
233
|
-
files = [{ name:
|
|
321
|
+
files = [{ name: named("pdf"), type: format.type, bytes, pages: numbers.length, slides: numbers }];
|
|
234
322
|
}
|
|
235
323
|
|
|
236
324
|
check();
|
|
237
|
-
|
|
238
|
-
if (files.length > 1) {
|
|
325
|
+
if (options.zip) {
|
|
239
326
|
progress("archive", 0, files.length, "Packing files…");
|
|
240
327
|
const bytes = await createZip(files.map(({ name, bytes: data }) => ({ name, bytes: data })), { signal });
|
|
241
|
-
|
|
328
|
+
files = [{ name: named("zip"), type: ZIP_TYPE, bytes, entries: files.map((file) => file.name) }];
|
|
242
329
|
}
|
|
243
330
|
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 };
|
|
331
|
+
return { files, findings };
|
|
252
332
|
}
|
package/dist/find-panel.js
CHANGED
|
@@ -147,8 +147,9 @@ export function createFindPanel(container, options) {
|
|
|
147
147
|
matches.slice(0, MAX_LISTED).forEach((match, index) => {
|
|
148
148
|
const mark = h(doc, "mark", { text: match.context.match });
|
|
149
149
|
const text = h(doc, "span", { class: "opf-find-text" }, doc.createTextNode(match.context.before), mark, doc.createTextNode(match.context.after));
|
|
150
|
-
const button = h(doc, "button", { type: "button", class: "opf-find-result", "aria-current": index === current ? "true" : "false", "data-index": String(index)
|
|
151
|
-
|
|
150
|
+
const button = h(doc, "button", { type: "button", class: "opf-find-result", "aria-current": index === current ? "true" : "false", "data-index": String(index) },
|
|
151
|
+
// WCAG 2.5.3 (label in name): no aria-label. The name is the visible text, the place and then the matching line, kept apart by a space.
|
|
152
|
+
h(doc, "span", { class: "opf-find-where", text: whereLabel(match) }), " ", text);
|
|
152
153
|
list.append(h(doc, "li", {}, button));
|
|
153
154
|
});
|
|
154
155
|
if (matches.length > MAX_LISTED) list.append(h(doc, "li", { class: "opf-find-more", text: `Showing the first ${MAX_LISTED} of ${matches.length}${result.truncated ? "+" : ""} matches.` }));
|
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/review-panel.js
CHANGED
|
@@ -159,7 +159,8 @@ export function createReviewPanel(container, options) {
|
|
|
159
159
|
const list = h(doc, "div", { class: "opf-review-list", role: "group", "aria-label": "Review findings" });
|
|
160
160
|
const empty = h(doc, "p", { class: "opf-review-empty" });
|
|
161
161
|
const ignoredBox = h(doc, "details", { class: "opf-review-ignored", hidden: true });
|
|
162
|
-
|
|
162
|
+
const gotoHint = h(doc, "span", { id: `${id}-goto-hint`, hidden: true, text: "Go to it." });
|
|
163
|
+
root.append(head, ...(hookStatus ? [hookStatus] : []), filters, list, empty, ignoredBox, gotoHint);
|
|
163
164
|
container.append(root);
|
|
164
165
|
|
|
165
166
|
const say = (message, error = false) => {
|
|
@@ -310,11 +311,13 @@ export function createReviewPanel(container, options) {
|
|
|
310
311
|
type: "button",
|
|
311
312
|
class: "opf-review-goto",
|
|
312
313
|
"data-finding-id": finding.id,
|
|
313
|
-
|
|
314
|
+
// WCAG 2.5.3 (label in name): no aria-label. The name is the visible text (severity, message, where and rule), kept apart by spaces;
|
|
315
|
+
// what the button does is its description.
|
|
316
|
+
"aria-describedby": `${id}-goto-hint`,
|
|
314
317
|
onclick: () => go(finding),
|
|
315
318
|
},
|
|
316
|
-
h(doc, "span", { class: "opf-review-sev", text: SEVERITY_LABELS[finding.severity] }),
|
|
317
|
-
h(doc, "span", { class: "opf-review-msg", text: finding.message }),
|
|
319
|
+
h(doc, "span", { class: "opf-review-sev", text: SEVERITY_LABELS[finding.severity] }), " ",
|
|
320
|
+
h(doc, "span", { class: "opf-review-msg", text: finding.message }), " ",
|
|
318
321
|
h(doc, "span", { class: "opf-review-meta", text: `${where} · ${finding.ruleId}${finding.source && finding.source !== CORE_SOURCE ? ` · ${finding.source}` : ""}` }));
|
|
319
322
|
const li = h(doc, "li", { class: "opf-review-item", "data-finding-id": finding.id, "data-rule": finding.ruleId, "data-source": finding.source ?? CORE_SOURCE, "data-severity": finding.severity, "aria-current": String(finding.id === currentId) }, go_,
|
|
320
323
|
finding.help ? h(doc, "p", { class: "opf-review-help", text: finding.help }) : undefined);
|
|
@@ -359,7 +362,7 @@ export function createReviewPanel(container, options) {
|
|
|
359
362
|
ignoredBox.replaceChildren(
|
|
360
363
|
h(doc, "summary", { text: `Hidden checks (${ignored.size})` }),
|
|
361
364
|
h(doc, "ul", { class: "opf-review-ignored-list", style: "list-style:none;padding:0" }, ...[...ignored].map((ruleId) => h(doc, "li", {}, h(doc, "span", { text: ruleId }),
|
|
362
|
-
h(doc, "button", { type: "button", class: "opf-review-fix quiet", "data-restore": ruleId, text: "Show again", "aria-label": `Show ${ruleId} findings
|
|
365
|
+
h(doc, "button", { type: "button", class: "opf-review-fix quiet", "data-restore": ruleId, text: "Show again", "aria-label": `Show again, ${ruleId} findings`, onclick: () => setIgnored(ruleId, false) })))),
|
|
363
366
|
);
|
|
364
367
|
}
|
|
365
368
|
|
package/dist/review.d.ts
CHANGED
|
@@ -61,8 +61,13 @@ export declare function groupFindings(findings: readonly ReviewFinding[]): { cat
|
|
|
61
61
|
export declare function findingTarget(presentation: unknown, finding: Pick<ReviewFinding, "path">): ReviewTarget;
|
|
62
62
|
/** Apply a `patch` fix as one undoable, validated session change; refuses a stale or unsafe patch. */
|
|
63
63
|
export declare function applyReviewFix(editor: EditorSession, finding: Pick<ReviewFinding, "ruleId"> | undefined, fix: ReviewFix, meta?: Record<string, unknown>): EditorChange;
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* The patch that sets (or, for `""`, marks decorative) the alt text of the picture at `pointer`; null when nothing changes. A
|
|
66
|
+
* logo reference (`var:organization.logo.icon`) stays a whole-field string: its alt is written on the organization logo
|
|
67
|
+
* asset(s) it draws, and a reference that names no logo is refused (`unresolved-logo-alt`).
|
|
68
|
+
*/
|
|
65
69
|
export declare function altTextPatch(presentation: unknown, pointer: string, text: string): JsonPatchOperation[] | null;
|
|
66
70
|
export declare function setReviewAltText(editor: EditorSession, pointer: string, text: string, meta?: Record<string, unknown>): { changed: boolean } & Partial<EditorChange>;
|
|
67
71
|
export declare function markDecorative(editor: EditorSession, pointer: string, meta?: Record<string, unknown>): { changed: boolean } & Partial<EditorChange>;
|
|
72
|
+
/** The alt text a picture has now; for a logo reference, the alt of the organization asset it draws. */
|
|
68
73
|
export declare function currentAltText(presentation: unknown, pointer: string): string;
|
package/dist/review.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
// module needs no DOM.
|
|
8
8
|
import { validationCategories } from "@openpresentation/opf";
|
|
9
9
|
import { OPFEditorError, getValueAtPath, hasValueAtPath, jsonPointerToOpfPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
|
|
10
|
+
import { resolveLogo } from "@openpresentation/opf/composition";
|
|
10
11
|
import { assetIdOf, prepareAssetAlt } from "./assets.js";
|
|
12
|
+
import { parseLogoReference } from "./design-options.js";
|
|
11
13
|
|
|
12
14
|
function fail(code, message, details) {
|
|
13
15
|
return new OPFEditorError(code, message, details);
|
|
@@ -158,10 +160,36 @@ export function applyReviewFix(editor, finding, fix, meta = {}) {
|
|
|
158
160
|
|
|
159
161
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
160
162
|
|
|
161
|
-
|
|
163
|
+
// RR-71: a logo reference (`var:organization.logo.icon`) is a whole-field string, so its alt text cannot be written beside
|
|
164
|
+
// it ({ src: "var:…", alt } is an ordinary image whose source is no longer the logo). The logo's alt lives on the
|
|
165
|
+
// organization's asset (Organization.logo, each shape and onLight/onDark value a path or Asset): these are the OPF paths of
|
|
166
|
+
// the assets the reference draws, on a light and on a dark background (one path when both resolve to the same asset).
|
|
167
|
+
function logoAltPaths(presentation, value) {
|
|
168
|
+
if (typeof value !== "string" || !parseLogoReference(value)) return null;
|
|
169
|
+
const paths = new Set();
|
|
170
|
+
for (const onDark of [false, true]) {
|
|
171
|
+
const logo = resolveLogo(presentation, undefined, { reference: value, onDark });
|
|
172
|
+
if (logo) paths.add(logo.path);
|
|
173
|
+
}
|
|
174
|
+
if (!paths.size) {
|
|
175
|
+
throw fail("unresolved-logo-alt", `${value} names no logo yet. Add the organization's logo first; its alt text is written on the logo in the organization.`, { reference: value });
|
|
176
|
+
}
|
|
177
|
+
return [...paths];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The patch operations that give the asset value at `pointer` alt text (`""` marks it decorative), or `null` when it already
|
|
182
|
+
* has exactly this alt. For a logo reference the alt is written on the organization logo asset(s) it draws.
|
|
183
|
+
*/
|
|
162
184
|
export function altTextPatch(presentation, pointer, text) {
|
|
163
185
|
const value = getValueAtPath(presentation, pointer);
|
|
164
186
|
if (value === undefined) throw fail("stale-finding", "The picture this finding is about no longer exists.", { pointer });
|
|
187
|
+
const logoPaths = logoAltPaths(presentation, value);
|
|
188
|
+
if (logoPaths) {
|
|
189
|
+
// Both backgrounds may name one registry asset (asset:<id>): write it once.
|
|
190
|
+
const operations = [...new Map(logoPaths.flatMap((path) => altTextPatch(presentation, opfPathToJsonPointer(splitOpfPath(path)), text) ?? []).map((operation) => [JSON.stringify(operation), operation])).values()];
|
|
191
|
+
return operations.length ? operations : null;
|
|
192
|
+
}
|
|
165
193
|
const alt = String(text ?? "");
|
|
166
194
|
const id = assetIdOf(value);
|
|
167
195
|
const entry = id === undefined ? undefined : getValueAtPath(presentation, ["assets", id]);
|
|
@@ -202,6 +230,14 @@ export function markDecorative(editor, pointer, meta = {}) {
|
|
|
202
230
|
/** The alt text a picture has now (own or through the assets registry), for pre-filling the field. */
|
|
203
231
|
export function currentAltText(presentation, pointer) {
|
|
204
232
|
const value = getValueAtPath(presentation, pointer);
|
|
233
|
+
// A logo reference shows the alt of the organization asset it draws (on a light background first).
|
|
234
|
+
if (typeof value === "string" && parseLogoReference(value)) {
|
|
235
|
+
for (const onDark of [false, true]) {
|
|
236
|
+
const logo = resolveLogo(presentation, undefined, { reference: value, onDark });
|
|
237
|
+
if (logo) return currentAltText(presentation, opfPathToJsonPointer(splitOpfPath(logo.path)));
|
|
238
|
+
}
|
|
239
|
+
return "";
|
|
240
|
+
}
|
|
205
241
|
if (isObject(value) && typeof value.alt === "string") return value.alt;
|
|
206
242
|
const id = assetIdOf(value);
|
|
207
243
|
const entry = id === undefined ? undefined : getValueAtPath(presentation, ["assets", id]);
|
package/dist/slide-manager.js
CHANGED
|
@@ -139,10 +139,12 @@ export function createSlideManager(container, options) {
|
|
|
139
139
|
const selected = selection.has(index);
|
|
140
140
|
const title = slideTitle(slide) || "Untitled slide";
|
|
141
141
|
const hidden = slide.hidden === true;
|
|
142
|
+
// WCAG 2.5.3 (label in name): the card has no aria-label. Its name is its own content (the number, the title and the "Hidden" badge;
|
|
143
|
+
// the thumbnail is aria-hidden), so the name always contains the visible text. The position and the selection are the description.
|
|
144
|
+
const position = `Slide ${index + 1} of ${slideCount}${selected && selection.size > 1 ? ", selected" : ""}`;
|
|
142
145
|
const button = el("button", {
|
|
143
|
-
type: "button", class: "slide-card", draggable: "true", "data-index": index, "aria-current": String(index === current()),
|
|
144
|
-
"aria-
|
|
145
|
-
"aria-describedby": `${id}-hint`, "aria-keyshortcuts": "Alt+ArrowUp Alt+ArrowDown Delete", tabindex: index === focusIndex ? "0" : "-1",
|
|
146
|
+
type: "button", class: "slide-card", draggable: "true", "data-index": index, "data-menu-label": `Slide ${index + 1} of ${slideCount}: ${title}`, "aria-current": String(index === current()),
|
|
147
|
+
"aria-describedby": `${id}-${index}-position ${id}-hint`, "aria-keyshortcuts": "Alt+ArrowUp Alt+ArrowDown Delete", tabindex: index === focusIndex ? "0" : "-1",
|
|
146
148
|
});
|
|
147
149
|
button.dataset.selected = String(selected);
|
|
148
150
|
button.dataset.hidden = String(hidden);
|
|
@@ -156,8 +158,9 @@ export function createSlideManager(container, options) {
|
|
|
156
158
|
thumbnail.querySelectorAll("[tabindex]").forEach((node) => node.removeAttribute("tabindex"));
|
|
157
159
|
const label = el("span", { class: "thumbnail-title", text: slideTitle(slide) || "Untitled slide" });
|
|
158
160
|
const content = el("span", { class: "thumbnail-content", draggable: "true" }, thumbnail, label);
|
|
159
|
-
if (hidden) content.append(el("span", { class: "slide-badge", text: "Hidden" }));
|
|
160
|
-
|
|
161
|
+
if (hidden) content.append(" ", el("span", { class: "slide-badge", text: "Hidden" }));
|
|
162
|
+
// The explicit spaces keep the number, title and badge apart in the computed name; hidden elements still describe the button.
|
|
163
|
+
button.append(number, " ", content, el("span", { id: `${id}-${index}-position`, hidden: true, text: position }));
|
|
161
164
|
button.addEventListener("click", (event) => choose(index, event));
|
|
162
165
|
button.addEventListener("keydown", (event) => keydown(event, index));
|
|
163
166
|
button.addEventListener("focus", () => { focusIndex = index; });
|
|
@@ -172,9 +175,11 @@ export function createSlideManager(container, options) {
|
|
|
172
175
|
const name = sectionLabel(section.name);
|
|
173
176
|
const header = el("div", { class: "slide-section", "data-section": section.index, "data-unnamed": String(section.unnamed) });
|
|
174
177
|
const toggle = el("button", {
|
|
175
|
-
type: "button", class: "section-toggle", "aria-expanded": String(!isCollapsed), "aria-
|
|
178
|
+
type: "button", class: "section-toggle", "aria-expanded": String(!isCollapsed), "aria-describedby": `${id}-section-${section.index}-count`,
|
|
176
179
|
onclick: () => { if (isCollapsed) collapsed.delete(key); else collapsed.add(key); render(); },
|
|
177
|
-
}, el("span", { class: "section-caret", "aria-hidden": "true", text: isCollapsed ? "▸" : "▾" }), el("span", { class: "section-name", text: name }), el("span", { class: "section-count", "aria-hidden": "true", text: String(section.count) })
|
|
180
|
+
}, el("span", { class: "section-caret", "aria-hidden": "true", text: isCollapsed ? "▸" : "▾" }), " ", el("span", { class: "section-name", text: name }), " ", el("span", { class: "section-count", "aria-hidden": "true", text: String(section.count) }),
|
|
181
|
+
// WCAG 2.5.3: no aria-label. The name is the visible section name; the slide count is the description.
|
|
182
|
+
el("span", { id: `${id}-section-${section.index}-count`, hidden: true, text: plural(section.count, "slide") }));
|
|
178
183
|
const more = el("button", { type: "button", class: "section-more", "aria-label": `Section actions: ${name}`, "aria-haspopup": "menu", title: "Section actions" });
|
|
179
184
|
more.append(icon("more"));
|
|
180
185
|
more.addEventListener("click", () => { const box = more.getBoundingClientRect(); openMenu(box.left, box.bottom, more, sectionMenu(section)); });
|
|
@@ -424,7 +429,7 @@ export function createSlideManager(container, options) {
|
|
|
424
429
|
}
|
|
425
430
|
function openMenu(x, y, opener, items = slideMenu()) {
|
|
426
431
|
closeMenu(false);
|
|
427
|
-
const root = el("div", { class: "opf-menu", role: "menu", "aria-label": opener.getAttribute?.("aria-label") ?? "Slide actions", tabindex: "-1" });
|
|
432
|
+
const root = el("div", { class: "opf-menu", role: "menu", "aria-label": opener.getAttribute?.("aria-label") ?? opener.dataset?.menuLabel ?? "Slide actions", tabindex: "-1" });
|
|
428
433
|
const buttons = [];
|
|
429
434
|
for (const item of items) {
|
|
430
435
|
if (item.separator) { root.append(el("div", { class: "opf-menu-separator", role: "separator" })); continue; }
|