@openpresentation/opf 0.11.4 → 0.12.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 +16 -0
- package/dist/audit.d.ts +156 -0
- package/dist/audit.js +7 -0
- package/dist/{catalogs-CUClB3nl.d.ts → catalogs-u6TEeMcz.d.ts} +1 -1
- package/dist/catalogs.d.ts +2 -2
- package/dist/catalogs.js +2 -1
- package/dist/{chunk-NSVVQ52O.js → chunk-32DIB5BN.js} +8 -6
- package/dist/chunk-4FWCY4ZV.js +198 -0
- package/dist/{chunk-3H2U7FDR.js → chunk-6TSDIPSH.js} +1861 -247
- package/dist/chunk-FYLJVF7X.js +18448 -0
- package/dist/{chunk-HJL64ETN.js → chunk-GJKJL4YY.js} +14 -0
- package/dist/chunk-JENJZR3F.js +1690 -0
- package/dist/{chunk-3OJN7QAW.js → chunk-JVTN3HPP.js} +18 -4
- package/dist/{chunk-KJKASEPE.js → chunk-RVN5C2FX.js} +737 -26
- package/dist/chunk-S5W34SIJ.js +1 -0
- package/dist/chunk-S667QI3M.js +261 -0
- package/dist/chunk-UQKJWHSS.js +1332 -0
- package/dist/{chunk-RXNFDGPC.js → chunk-WXSPETQ6.js} +311 -40
- package/dist/{composition-Dl-d9FRj.d.ts → composition-DWbCiMwF.d.ts} +559 -10
- package/dist/composition.d.ts +1 -1
- package/dist/composition.js +2 -1
- package/dist/convert.d.ts +261 -0
- package/dist/convert.js +1107 -0
- package/dist/diff.d.ts +145 -0
- package/dist/diff.js +604 -0
- package/dist/docs.js +68 -14
- package/dist/examples.d.ts +1 -1
- package/dist/font-policy.d.ts +15 -0
- package/dist/font-policy.js +1 -1
- package/dist/format.d.ts +20 -0
- package/dist/format.js +83 -0
- package/dist/index.d.ts +173 -24
- package/dist/index.js +926 -262
- package/dist/lint.d.ts +4 -4
- package/dist/lint.js +6 -5
- package/dist/markdown.d.ts +74 -0
- package/dist/markdown.js +1941 -0
- package/dist/pagination.d.ts +1 -1
- package/dist/pagination.js +6 -5
- package/dist/patch.d.ts +99 -0
- package/dist/patch.js +6 -0
- package/dist/{presentation-j5PVrqfV.d.ts → presentation-DRx4hpNZ.d.ts} +451 -16
- package/dist/repo-readme.js +1 -1
- package/dist/{schemas-X_NniU4A.d.ts → schemas-BSjlG8-R.d.ts} +28 -0
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +1 -1
- package/dist/spec/reference/font-policy.json +311 -40
- package/dist/spec/reference/font-policy.schema.json +40 -0
- package/dist/spec/reference/symbol-font-encodings.json +18354 -0
- package/dist/spec/reference/symbol-font-encodings.schema.json +156 -0
- package/dist/spec/schemas/opf.schema.json +591 -22
- package/dist/spec-files.d.ts +1 -1
- package/dist/spec-files.js +1 -1
- package/dist/symbol-font-encodings.d.ts +128 -0
- package/dist/symbol-font-encodings.js +1 -0
- package/dist/types.d.ts +4 -4
- package/dist/validator-bu396ffT.d.ts +172 -0
- package/dist/validator.d.ts +4 -36
- package/dist/validator.js +5 -4
- package/package.json +31 -2
- package/dist/chunk-FDCWQHWO.js +0 -1366
- /package/dist/{chunk-AEUSVEUY.js → chunk-3ZGSWEHK.js} +0 -0
|
@@ -0,0 +1,1690 @@
|
|
|
1
|
+
import { lintSource } from './chunk-JVTN3HPP.js';
|
|
2
|
+
import { validatePresentation } from './chunk-UQKJWHSS.js';
|
|
3
|
+
import { chartPaletteForFill, tableGrid, readingRows, visualReadingOrder, visitContentPayloads, intrinsicImageSize, textWidthMeasurer, normalizeHexColor, resolveColorRef, resolveScriptFonts, OPFCompositionError, resolveFontSchemeReference, DEFAULT_FONT_SCHEME, resolveCanvasDimensions, resolveFontFamilies, composeSlide } from './chunk-6TSDIPSH.js';
|
|
4
|
+
import { catalogs } from './chunk-3ZGSWEHK.js';
|
|
5
|
+
import { parseTree } from 'jsonc-parser';
|
|
6
|
+
|
|
7
|
+
// src/audit-context.ts
|
|
8
|
+
var rule = (id, category, severity, summary, rationale, extra = {}) => ({ id: `audit/${id}`, name: id, category, severity, summary, rationale, ...extra });
|
|
9
|
+
|
|
10
|
+
// src/audit-design.ts
|
|
11
|
+
var rec = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
12
|
+
var toRgb = (hex) => {
|
|
13
|
+
const n = normalizeHexColor(hex) ?? "#000000";
|
|
14
|
+
return [1, 3, 5].map((at) => Number.parseInt(n.slice(at, at + 2), 16));
|
|
15
|
+
};
|
|
16
|
+
var toHex = (rgb) => `#${rgb.map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0")).join("").toUpperCase()}`;
|
|
17
|
+
var linear = (c) => {
|
|
18
|
+
const n = c / 255;
|
|
19
|
+
return n <= 0.04045 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
|
|
20
|
+
};
|
|
21
|
+
var delinear = (c) => {
|
|
22
|
+
const v = Math.max(0, Math.min(1, c));
|
|
23
|
+
return 255 * (v <= 31308e-7 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055);
|
|
24
|
+
};
|
|
25
|
+
function blend(fg, bg, alpha) {
|
|
26
|
+
const a = Math.max(0, Math.min(1, alpha)), f = toRgb(fg), b = toRgb(bg);
|
|
27
|
+
return toHex(f.map((c, i) => c * a + (b[i] ?? 0) * (1 - a)));
|
|
28
|
+
}
|
|
29
|
+
var relativeLuminance = (hex) => {
|
|
30
|
+
const [r, g, b] = toRgb(hex);
|
|
31
|
+
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
32
|
+
};
|
|
33
|
+
function contrastRatio(a, b) {
|
|
34
|
+
const x = relativeLuminance(a), y = relativeLuminance(b);
|
|
35
|
+
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
|
36
|
+
}
|
|
37
|
+
function lab(hex) {
|
|
38
|
+
const [r, g, b] = toRgb(hex).map(linear);
|
|
39
|
+
const x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047, y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b, z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883;
|
|
40
|
+
const f = (t) => t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116;
|
|
41
|
+
const fx = f(x), fy = f(y), fz = f(z);
|
|
42
|
+
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
|
43
|
+
}
|
|
44
|
+
function colorDifference(a, b) {
|
|
45
|
+
const [l1, a1, b1] = lab(a), [l2, a2, b2] = lab(b);
|
|
46
|
+
return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
|
|
47
|
+
}
|
|
48
|
+
var CVD_MATRICES = {
|
|
49
|
+
// Machado, Oliveira and Fernandes (2009), severity 1.0, applied to linear sRGB.
|
|
50
|
+
protanopia: [
|
|
51
|
+
[0.152286, 1.052583, -0.204868],
|
|
52
|
+
[0.114503, 0.786281, 0.099216],
|
|
53
|
+
[-3882e-6, -0.048116, 1.051998]
|
|
54
|
+
],
|
|
55
|
+
deuteranopia: [
|
|
56
|
+
[0.367322, 0.860646, -0.227968],
|
|
57
|
+
[0.280085, 0.672501, 0.047413],
|
|
58
|
+
[-0.01182, 0.04294, 0.968881]
|
|
59
|
+
],
|
|
60
|
+
tritanopia: [
|
|
61
|
+
[1.255528, -0.076749, -0.178779],
|
|
62
|
+
[-0.078411, 0.930809, 0.147602],
|
|
63
|
+
[4733e-6, 0.691367, 0.3039]
|
|
64
|
+
]
|
|
65
|
+
};
|
|
66
|
+
var visionModels = ["protanopia", "deuteranopia", "tritanopia", "greyscale"];
|
|
67
|
+
function simulateVision(hex, model) {
|
|
68
|
+
const rgb = toRgb(hex).map(linear);
|
|
69
|
+
if (model === "greyscale") {
|
|
70
|
+
const y = 0.2126 * (rgb[0] ?? 0) + 0.7152 * (rgb[1] ?? 0) + 0.0722 * (rgb[2] ?? 0);
|
|
71
|
+
const v = delinear(y);
|
|
72
|
+
return toHex([v, v, v]);
|
|
73
|
+
}
|
|
74
|
+
const m = CVD_MATRICES[model];
|
|
75
|
+
return toHex(m.map((row) => delinear(row[0] * (rgb[0] ?? 0) + row[1] * (rgb[1] ?? 0) + row[2] * (rgb[2] ?? 0))));
|
|
76
|
+
}
|
|
77
|
+
function createLookup(document, extra) {
|
|
78
|
+
return (kind, id) => {
|
|
79
|
+
const inline = rec(rec(document.catalogs)[kind]).records;
|
|
80
|
+
const found = (Array.isArray(inline) ? inline : []).find((r) => r?.id === id) ?? (extra?.[kind] ?? []).find((r) => rec(r).id === id) ?? catalogs[kind]?.find((r) => rec(r).id === id);
|
|
81
|
+
return found === void 0 ? void 0 : rec(found);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function resolveReference(kind, reference, lookup) {
|
|
85
|
+
if (typeof reference === "string") return lookup(kind, reference) ?? {};
|
|
86
|
+
if (reference && typeof reference === "object" && !Array.isArray(reference)) {
|
|
87
|
+
const id = reference.id;
|
|
88
|
+
return { ...typeof id === "string" ? lookup(kind, id) : void 0, ...reference };
|
|
89
|
+
}
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
var PAGE = "#FFFFFF";
|
|
93
|
+
var colorFrom = (scheme, slot, fallback) => typeof slot === "string" ? slot.startsWith("#") ? normalizeHexColor(slot) ?? fallback : normalizeHexColor(scheme[slot]) ?? fallback : fallback;
|
|
94
|
+
function backdropOf(definition, scheme, text) {
|
|
95
|
+
const resolve = (value, fallback) => normalizeHexColor(value) ?? fallback;
|
|
96
|
+
if (!definition) return { kind: "solid", color: colorFrom(scheme, "light1", PAGE) };
|
|
97
|
+
if (typeof definition === "string") return { kind: "solid", color: colorFrom(scheme, definition, PAGE) };
|
|
98
|
+
const bg = rec(definition), opacity = typeof bg.opacity === "number" ? Math.max(0, Math.min(1, bg.opacity)) : 1;
|
|
99
|
+
switch (bg.type) {
|
|
100
|
+
case "theme":
|
|
101
|
+
return { kind: "solid", color: blend(colorFrom(scheme, bg.slot, PAGE), PAGE, opacity) };
|
|
102
|
+
case "solid":
|
|
103
|
+
return { kind: "solid", color: blend(resolve(bg.color, PAGE), PAGE, opacity) };
|
|
104
|
+
case "gradient": {
|
|
105
|
+
const stops = Array.isArray(rec(bg.gradient).stops) ? rec(bg.gradient).stops : [];
|
|
106
|
+
return {
|
|
107
|
+
kind: "gradient",
|
|
108
|
+
angle: Number(rec(bg.gradient).angle ?? 0),
|
|
109
|
+
stops: stops.map((stop, index) => ({
|
|
110
|
+
color: blend(resolve(stop.color, colorFrom(scheme, "light1", PAGE)), PAGE, opacity),
|
|
111
|
+
position: typeof stop.position === "number" ? stop.position : index / Math.max(1, stops.length - 1)
|
|
112
|
+
}))
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
case "pattern": {
|
|
116
|
+
const pattern = rec(bg.pattern);
|
|
117
|
+
return {
|
|
118
|
+
kind: "pattern",
|
|
119
|
+
colors: [
|
|
120
|
+
blend(resolve(pattern.backgroundColor, PAGE), PAGE, opacity),
|
|
121
|
+
blend(resolve(pattern.foregroundColor, text), PAGE, opacity)
|
|
122
|
+
]
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
case "image":
|
|
126
|
+
return { kind: "image", opacity, base: PAGE };
|
|
127
|
+
default:
|
|
128
|
+
return { kind: "solid", color: colorFrom(scheme, "light1", PAGE) };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function decisionColor(definition, scheme) {
|
|
132
|
+
if (!definition) return colorFrom(scheme, "light1", PAGE);
|
|
133
|
+
if (typeof definition === "string") return colorFrom(scheme, definition, PAGE);
|
|
134
|
+
const bg = rec(definition);
|
|
135
|
+
switch (bg.type) {
|
|
136
|
+
case "theme":
|
|
137
|
+
return colorFrom(scheme, bg.slot, PAGE);
|
|
138
|
+
case "solid":
|
|
139
|
+
return normalizeHexColor(bg.color) ?? PAGE;
|
|
140
|
+
case "pattern":
|
|
141
|
+
return normalizeHexColor(rec(bg.pattern).backgroundColor) ?? PAGE;
|
|
142
|
+
case "gradient":
|
|
143
|
+
return PAGE;
|
|
144
|
+
default:
|
|
145
|
+
return colorFrom(scheme, "light1", PAGE);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function resolveDesign(document, slide, index, lookup) {
|
|
149
|
+
const deck = rec(document.design), own = rec(slide.design);
|
|
150
|
+
const theme = resolveReference("themes", own.theme ?? deck.theme ?? "minimal", lookup);
|
|
151
|
+
const colorScheme = resolveReference(
|
|
152
|
+
"colorSchemes",
|
|
153
|
+
own.colorScheme ?? deck.colorScheme ?? theme.colorScheme ?? "cool-horizon",
|
|
154
|
+
lookup
|
|
155
|
+
);
|
|
156
|
+
const fontPath = own.fontScheme !== void 0 ? `/slides/${index}/design/fontScheme` : deck.fontScheme !== void 0 ? "/design/fontScheme" : own.theme !== void 0 ? `/slides/${index}/design/theme` : "/design/theme";
|
|
157
|
+
const font = resolveFontSchemeReference(
|
|
158
|
+
own.fontScheme ?? deck.fontScheme ?? theme.fontScheme ?? DEFAULT_FONT_SCHEME,
|
|
159
|
+
(id) => lookup("fontSchemes", id),
|
|
160
|
+
fontPath
|
|
161
|
+
);
|
|
162
|
+
let dimensions;
|
|
163
|
+
try {
|
|
164
|
+
dimensions = resolveCanvasDimensions(own.dimensions ?? deck.dimensions ?? theme.dimensions);
|
|
165
|
+
} catch {
|
|
166
|
+
dimensions = resolveCanvasDimensions(void 0);
|
|
167
|
+
}
|
|
168
|
+
const variables = rec(document.variables);
|
|
169
|
+
const backgroundColor = decisionColor(own.background ?? deck.background ?? theme.background, colorScheme);
|
|
170
|
+
const dark = relativeLuminance(backgroundColor) < 0.179;
|
|
171
|
+
const text = colorFrom(colorScheme, dark ? "light1" : "dark1", dark ? "#FFFFFF" : "#111827");
|
|
172
|
+
const backdrop = backdropOf(own.background ?? deck.background ?? theme.background, colorScheme, text);
|
|
173
|
+
const definition = rec(own.background ?? deck.background ?? theme.background);
|
|
174
|
+
const backgroundImage = definition.type === "image" && own.background !== void 0 ? { source: rec(definition.image).src, fit: rec(definition.image).fit ?? "cover", path: `/slides/${index}/design/background/image` } : definition.type === "image" && deck.background !== void 0 ? { source: rec(definition.image).src, fit: rec(definition.image).fit ?? "cover", path: "/design/background/image" } : void 0;
|
|
175
|
+
return {
|
|
176
|
+
theme,
|
|
177
|
+
colorScheme,
|
|
178
|
+
fontScheme: font.scheme,
|
|
179
|
+
fonts: resolveFontFamilies(font.scheme),
|
|
180
|
+
dimensions,
|
|
181
|
+
backdrop,
|
|
182
|
+
dark,
|
|
183
|
+
colors: {
|
|
184
|
+
background: colorFrom(colorScheme, "light1", PAGE),
|
|
185
|
+
surface: colorFrom(colorScheme, dark ? "dark2" : "light2", dark ? "#1E293B" : "#F8FAFC"),
|
|
186
|
+
text,
|
|
187
|
+
mutedText: colorFrom(colorScheme, dark ? "light2" : "dark2", dark ? "#E2E8F0" : "#334155"),
|
|
188
|
+
primary: normalizeHexColor(colorScheme.primary) ?? colorFrom(colorScheme, "accent1", "#2563EB"),
|
|
189
|
+
secondary: normalizeHexColor(colorScheme.secondary) ?? colorFrom(colorScheme, "accent2", "#0F766E"),
|
|
190
|
+
accent: normalizeHexColor(colorScheme.accent) ?? colorFrom(colorScheme, "accent3", "#F59E0B")
|
|
191
|
+
},
|
|
192
|
+
variables,
|
|
193
|
+
...backgroundImage ? { backgroundImage } : {},
|
|
194
|
+
fontSchemePath: fontPath,
|
|
195
|
+
...font.diagnostic ? { unresolvedFontScheme: font.diagnostic.id } : {}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function resolveTextColor(reference, design, fallback) {
|
|
199
|
+
if (typeof reference !== "string" || !reference.trim()) return void 0;
|
|
200
|
+
const literal = normalizeHexColor(reference);
|
|
201
|
+
if (literal) return literal;
|
|
202
|
+
return resolveColorRef(reference.trim(), {
|
|
203
|
+
colorScheme: design.colorScheme,
|
|
204
|
+
roles: {
|
|
205
|
+
primary: design.colors.primary,
|
|
206
|
+
secondary: design.colors.secondary,
|
|
207
|
+
accent: design.colors.accent,
|
|
208
|
+
background: design.colors.background,
|
|
209
|
+
surface: design.colors.surface,
|
|
210
|
+
text: design.colors.text,
|
|
211
|
+
textSecondary: design.colors.mutedText
|
|
212
|
+
},
|
|
213
|
+
variables: design.variables,
|
|
214
|
+
fallback
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
var GREY_RAMP = [0, 32, 64, 96, 128, 160, 192, 224, 255].map((v) => toHex([v, v, v]));
|
|
218
|
+
function backdropColors(backdrop, box, size, layer) {
|
|
219
|
+
switch (backdrop.kind) {
|
|
220
|
+
case "solid":
|
|
221
|
+
return { colors: [backdrop.color], unknown: false };
|
|
222
|
+
case "pattern":
|
|
223
|
+
return { colors: [...backdrop.colors], unknown: false };
|
|
224
|
+
case "gradient": {
|
|
225
|
+
const stops = [...backdrop.stops].sort((a, b) => a.position - b.position);
|
|
226
|
+
if (!stops.length) return { colors: [PAGE], unknown: false };
|
|
227
|
+
const angle = backdrop.angle * Math.PI / 180, c = Math.cos(angle), s = Math.sin(angle), region = box ?? { x: 0, y: 0, width: size.width, height: size.height };
|
|
228
|
+
const colors = /* @__PURE__ */ new Set();
|
|
229
|
+
for (let i = 0; i <= 4; i++)
|
|
230
|
+
for (let j = 0; j <= 4; j++) {
|
|
231
|
+
const px = (region.x + region.width * i / 4) / size.width, py = (region.y + region.height * j / 4) / size.height, t = Math.max(0, Math.min(1, 0.5 + (px - 0.5) * c + (py - 0.5) * s));
|
|
232
|
+
const after = stops.findIndex((stop) => stop.position >= t);
|
|
233
|
+
if (after <= 0) colors.add(stops[Math.max(0, after)]?.color ?? PAGE);
|
|
234
|
+
else {
|
|
235
|
+
const lo = stops[after - 1], hi = stops[after], span = hi.position - lo.position || 1;
|
|
236
|
+
colors.add(blend(hi.color, lo.color, (t - lo.position) / span));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { colors: [...colors], unknown: false };
|
|
240
|
+
}
|
|
241
|
+
case "image": {
|
|
242
|
+
const opacity = layer?.opacity ?? backdrop.opacity;
|
|
243
|
+
const colors = GREY_RAMP.map((grey) => {
|
|
244
|
+
let color = blend(grey, backdrop.base, opacity);
|
|
245
|
+
if (layer?.overlay) color = blend(layer.overlay.color, color, layer.overlay.opacity);
|
|
246
|
+
return color;
|
|
247
|
+
});
|
|
248
|
+
return { colors, unknown: true };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function worstContrast(text, sample) {
|
|
253
|
+
let worst = { ratio: Infinity, against: sample.colors[0] ?? PAGE };
|
|
254
|
+
for (const color of sample.colors) {
|
|
255
|
+
const ratio = contrastRatio(text, color);
|
|
256
|
+
if (ratio < worst.ratio) worst = { ratio, against: color };
|
|
257
|
+
}
|
|
258
|
+
return worst;
|
|
259
|
+
}
|
|
260
|
+
function readableColor(candidates, sample, minimum) {
|
|
261
|
+
for (const candidate of [...candidates, "#000000", "#FFFFFF"])
|
|
262
|
+
if (worstContrast(candidate, sample).ratio >= minimum) return candidate;
|
|
263
|
+
return void 0;
|
|
264
|
+
}
|
|
265
|
+
var DEFAULT_CHART_PALETTE = [
|
|
266
|
+
"#2874A6",
|
|
267
|
+
"#1B4F72",
|
|
268
|
+
"#5499C7",
|
|
269
|
+
"#7BDBB2",
|
|
270
|
+
"#3AC67A",
|
|
271
|
+
"#24A89E",
|
|
272
|
+
"#F59E0B",
|
|
273
|
+
"#EF4444",
|
|
274
|
+
"#8B5CF6",
|
|
275
|
+
"#14B8A6",
|
|
276
|
+
"#0F172A",
|
|
277
|
+
"#64748B"
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
// src/audit-content.ts
|
|
281
|
+
var pointer = (...parts) => parts.length ? "/" + parts.map((part) => String(part).replaceAll("~", "~0").replaceAll("/", "~1")).join("/") : "";
|
|
282
|
+
var pointerOfDotted = (path) => "/" + path.split(".").map((part) => part.replaceAll("~", "~0").replaceAll("/", "~1")).join("/");
|
|
283
|
+
function slidePayloads(slide, slidePath) {
|
|
284
|
+
const out = [{ path: slidePath, node: slide, root: true }];
|
|
285
|
+
visitContentPayloads(slide, slidePath, (node, path) => out.push({ path, node, root: false }));
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
var isText = (value) => typeof value === "string" || Array.isArray(value);
|
|
289
|
+
function textValues(slide, slidePath) {
|
|
290
|
+
const out = [];
|
|
291
|
+
for (const field of ["title", "subtitle", "tag"])
|
|
292
|
+
if (typeof slide[field] === "string") out.push({ path: pointer(...splitPointer(slidePath), field), value: slide[field], role: field, payload: slidePath, field });
|
|
293
|
+
for (const payload of slidePayloads(slide, slidePath)) {
|
|
294
|
+
const node = payload.node, at = (...tail) => pointer(...splitPointer(payload.path), ...tail);
|
|
295
|
+
const push = (path, value, role, field) => {
|
|
296
|
+
if (isText(value)) out.push({ path, value, role, payload: payload.path, field });
|
|
297
|
+
};
|
|
298
|
+
push(at("text"), node.text, "body", "text");
|
|
299
|
+
for (const field of ["items", "bullets"])
|
|
300
|
+
if (Array.isArray(node[field]))
|
|
301
|
+
node[field].forEach((entry, index) => {
|
|
302
|
+
if (isText(entry)) push(at(field, index), entry, "list", field);
|
|
303
|
+
else if (entry && typeof entry === "object") {
|
|
304
|
+
push(at(field, index, "text"), entry.text, "list", field);
|
|
305
|
+
push(at(field, index, "description"), entry.description, "list-description", field);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
const table = rec(node.table);
|
|
309
|
+
if (Array.isArray(table.columns))
|
|
310
|
+
table.columns.forEach((cell, c) => {
|
|
311
|
+
const styled = rec(cell);
|
|
312
|
+
const value = Object.hasOwn(styled, "value") ? styled.value : cell;
|
|
313
|
+
push(at("table", "columns", c, ...Object.hasOwn(styled, "value") ? ["value"] : []), value, "table-header", "table");
|
|
314
|
+
});
|
|
315
|
+
if (Array.isArray(table.rows))
|
|
316
|
+
table.rows.forEach((row, r) => {
|
|
317
|
+
if (!Array.isArray(row)) return;
|
|
318
|
+
row.forEach((cell, c) => {
|
|
319
|
+
const styled = rec(cell);
|
|
320
|
+
const value = Object.hasOwn(styled, "value") ? styled.value : cell;
|
|
321
|
+
push(at("table", "rows", r, c, ...Object.hasOwn(styled, "value") ? ["value"] : []), value, "table", "table");
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
const quote = node.quote;
|
|
325
|
+
if (typeof quote === "string") push(at("quote"), quote, "quote", "quote");
|
|
326
|
+
else if (quote && typeof quote === "object") {
|
|
327
|
+
push(at("quote", "text"), quote.text, "quote", "quote");
|
|
328
|
+
push(at("quote", "attribution"), quote.attribution, "quote-footer", "quote");
|
|
329
|
+
push(at("quote", "source"), quote.source, "quote-footer", "quote");
|
|
330
|
+
}
|
|
331
|
+
const metric = node.metric;
|
|
332
|
+
if (typeof metric === "string") push(at("metric"), metric, "metric", "metric");
|
|
333
|
+
else if (metric && typeof metric === "object")
|
|
334
|
+
for (const key of ["value", "label", "unit", "delta"]) {
|
|
335
|
+
const value = metric[key];
|
|
336
|
+
if (typeof value === "string") push(at("metric", key), value, "metric", "metric");
|
|
337
|
+
}
|
|
338
|
+
const timeline = node.timeline, events = Array.isArray(timeline) ? timeline : Array.isArray(rec(timeline).events) ? rec(timeline).events : [];
|
|
339
|
+
if (typeof rec(timeline).name === "string") push(at("timeline", "name"), rec(timeline).name, "timeline", "timeline");
|
|
340
|
+
events.forEach((event, index) => {
|
|
341
|
+
const base = Array.isArray(timeline) ? ["timeline", index] : ["timeline", "events", index];
|
|
342
|
+
push(at(...base, "when"), event?.when, "timeline", "timeline");
|
|
343
|
+
push(at(...base, "what"), event?.what, "timeline", "timeline");
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
function splitPointer(path) {
|
|
349
|
+
return path === "" ? [] : path.slice(1).split("/").map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
350
|
+
}
|
|
351
|
+
var plainText = (value) => typeof value === "string" ? value : value.map((run) => typeof run === "string" ? run : typeof rec(run).text === "string" ? rec(run).text : "").join("");
|
|
352
|
+
function runsOf(tv) {
|
|
353
|
+
if (typeof tv.value === "string") return [{ path: tv.path, text: tv.value, style: {}, index: -1, valuePath: tv.path }];
|
|
354
|
+
return tv.value.map((run, index) => ({
|
|
355
|
+
path: pointer(...splitPointer(tv.path), index),
|
|
356
|
+
text: typeof run === "string" ? run : typeof rec(run).text === "string" ? rec(run).text : "",
|
|
357
|
+
style: typeof run === "string" ? {} : rec(run),
|
|
358
|
+
index,
|
|
359
|
+
valuePath: tv.path
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
var segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "word" }) : void 0;
|
|
363
|
+
function countWords(text) {
|
|
364
|
+
if (segmenter) {
|
|
365
|
+
let count = 0;
|
|
366
|
+
for (const part of segmenter.segment(text)) if (part.isWordLike) count++;
|
|
367
|
+
return count;
|
|
368
|
+
}
|
|
369
|
+
return text.split(/\s+/).filter((word) => /[\p{L}\p{N}]/u.test(word)).length;
|
|
370
|
+
}
|
|
371
|
+
function altOf(value, assets) {
|
|
372
|
+
const own = typeof value === "object" && value !== null ? value.alt : void 0;
|
|
373
|
+
if (typeof own === "string") return own;
|
|
374
|
+
let source = typeof value === "string" ? value : rec(value).src;
|
|
375
|
+
for (let hop = 0; typeof source === "string" && source.startsWith("asset:") && hop < 8; hop++) {
|
|
376
|
+
const entry = rec(assets)[source.slice(6)];
|
|
377
|
+
if (typeof rec(entry).alt === "string") return rec(entry).alt;
|
|
378
|
+
source = typeof entry === "string" ? entry : rec(entry).src;
|
|
379
|
+
}
|
|
380
|
+
return void 0;
|
|
381
|
+
}
|
|
382
|
+
var sourceOfAsset = (value) => {
|
|
383
|
+
const source = typeof value === "string" ? value : rec(value).src;
|
|
384
|
+
return typeof source === "string" ? source : void 0;
|
|
385
|
+
};
|
|
386
|
+
var LOGO_VARIANTS = ["default", "light", "dark", "stacked", "stackedLight", "stackedDark", "icon", "iconLight", "iconDark", "wordmark", "wordmarkLight", "wordmarkDark"];
|
|
387
|
+
function assetRefs(document, slide, slidePath, includeDeck) {
|
|
388
|
+
const out = [];
|
|
389
|
+
for (const payload of slidePayloads(slide, slidePath)) {
|
|
390
|
+
const at = (...tail) => pointer(...splitPointer(payload.path), ...tail);
|
|
391
|
+
if (payload.node.image !== void 0) out.push({ path: at("image"), value: payload.node.image, kind: "image" });
|
|
392
|
+
if (payload.node.video !== void 0) out.push({ path: at("video"), value: payload.node.video, kind: "video" });
|
|
393
|
+
}
|
|
394
|
+
const designRefs = (design, base) => {
|
|
395
|
+
const slideImage = design.slideImage;
|
|
396
|
+
if (slideImage && (typeof slideImage === "string" || typeof slideImage.src === "string"))
|
|
397
|
+
out.push({ path: `${base}/slideImage`, value: slideImage, kind: "slide-image" });
|
|
398
|
+
const logo = design.logo;
|
|
399
|
+
if (typeof logo === "string" || logo && typeof logo.src === "string") out.push({ path: `${base}/logo`, value: logo, kind: "logo" });
|
|
400
|
+
else if (logo && typeof logo === "object") {
|
|
401
|
+
for (const variant of LOGO_VARIANTS)
|
|
402
|
+
if (logo[variant] !== void 0) out.push({ path: `${base}/logo/${variant}`, value: logo[variant], kind: "logo" });
|
|
403
|
+
}
|
|
404
|
+
for (const kind of ["header", "footer"])
|
|
405
|
+
for (const zone of ["left", "center", "right"]) {
|
|
406
|
+
const item = rec(rec(design[kind])[zone]);
|
|
407
|
+
if (item.image !== void 0) out.push({ path: `${base}/${kind}/${zone}/image`, value: item.image, kind: "furniture" });
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
designRefs(rec(slide.design), `${slidePath}/design`);
|
|
411
|
+
if (includeDeck) {
|
|
412
|
+
designRefs(rec(document.design), "/design");
|
|
413
|
+
const organizations = Array.isArray(document.organization) ? document.organization : document.organization ? [document.organization] : [];
|
|
414
|
+
organizations.forEach((organization, index) => {
|
|
415
|
+
if (organization?.logo !== void 0)
|
|
416
|
+
out.push({
|
|
417
|
+
path: Array.isArray(document.organization) ? `/organization/${index}/logo` : "/organization/logo",
|
|
418
|
+
value: organization.logo,
|
|
419
|
+
kind: "organization"
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
const speakers = Array.isArray(document.speaker) ? document.speaker : document.speaker ? [document.speaker] : [];
|
|
423
|
+
speakers.forEach((speaker, index) => {
|
|
424
|
+
if (speaker?.photo !== void 0)
|
|
425
|
+
out.push({ path: Array.isArray(document.speaker) ? `/speaker/${index}/photo` : "/speaker/photo", value: speaker.photo, kind: "speaker" });
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// src/audit-rules-a11y.ts
|
|
432
|
+
var PX_TO_PT = 0.75;
|
|
433
|
+
var isLarge = (sizePx, bold) => {
|
|
434
|
+
const pt = sizePx * PX_TO_PT;
|
|
435
|
+
return pt >= 18 || bold && pt >= 14;
|
|
436
|
+
};
|
|
437
|
+
function slideBackdrop(context, box) {
|
|
438
|
+
const { design, composition } = context;
|
|
439
|
+
const image = composition?.slideImage;
|
|
440
|
+
if (image?.position === "background") {
|
|
441
|
+
const overlay = image.overlay && image.overlay.box.width >= composition.width * 0.99 && image.overlay.box.height >= composition.height * 0.99 ? { color: resolveTextColor(image.overlay.color, design, "#000000") ?? "#000000", opacity: image.overlay.opacity } : void 0;
|
|
442
|
+
return backdropColors({ kind: "image", opacity: image.opacity ?? 1, base: design.backdrop.kind === "solid" ? design.backdrop.color : "#FFFFFF" }, box, design.dimensions, { overlay });
|
|
443
|
+
}
|
|
444
|
+
return backdropColors(design.backdrop, box, design.dimensions);
|
|
445
|
+
}
|
|
446
|
+
function inkBox(item, measurement) {
|
|
447
|
+
const fit = item.text;
|
|
448
|
+
if (!fit || !Array.isArray(fit.lines) || !fit.lines.length || !item.textStyle || item.quoteLayout || item.metricLayout || item.timelineLayout) return item.box;
|
|
449
|
+
try {
|
|
450
|
+
const measure = textWidthMeasurer(item.textStyle, measurement);
|
|
451
|
+
const width = Math.min(item.box.width, Math.max(...fit.lines.map((line) => measure(line, fit.fontSize))));
|
|
452
|
+
const height = Math.min(item.box.height, fit.lines.length * fit.lineHeight);
|
|
453
|
+
const x = item.alignment === "center" ? item.box.x + (item.box.width - width) / 2 : item.alignment === "right" ? item.box.x + item.box.width - width : item.box.x;
|
|
454
|
+
return { x, y: item.box.y, width, height };
|
|
455
|
+
} catch {
|
|
456
|
+
return item.box;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function collectSamples(context) {
|
|
460
|
+
const measurement = context.measurement;
|
|
461
|
+
const { design, composition } = context;
|
|
462
|
+
if (!composition) return [];
|
|
463
|
+
const samples = [];
|
|
464
|
+
const surface = { colors: [design.colors.surface], unknown: false };
|
|
465
|
+
const scale = Math.min(design.dimensions.width, design.dimensions.height) / 720;
|
|
466
|
+
for (const item of composition.items) {
|
|
467
|
+
if (["chart", "image", "video", "code"].includes(item.field) || item.type === "chart" || item.type === "image" || item.type === "video" || item.type === "code") continue;
|
|
468
|
+
const backdrop = item.frameBox ? surface : slideBackdrop(context, inkBox(item, measurement));
|
|
469
|
+
const itemPath = pointerOfDotted(item.path);
|
|
470
|
+
const heading = item.field === "title" || item.field === "subtitle" || item.field === "tag";
|
|
471
|
+
const baseSize = (item.text?.fontSize ?? 25 * scale) / scale;
|
|
472
|
+
const bold = item.field === "title";
|
|
473
|
+
const label = item.field === "title" ? "Title" : item.field === "subtitle" ? "Subtitle" : item.field === "tag" ? "Tag" : "Text";
|
|
474
|
+
if (item.field === "table") {
|
|
475
|
+
const grid = tableGrid(item.value, item.path);
|
|
476
|
+
for (const row of grid.rows)
|
|
477
|
+
for (const cell of row) {
|
|
478
|
+
const fillRef = resolveTextColor(cell.style.fill, design, "");
|
|
479
|
+
const fill = fillRef || (cell.header ? design.colors.primary : design.colors.surface);
|
|
480
|
+
const cellBackdrop = { colors: [fill], unknown: false };
|
|
481
|
+
const cellPath = pointerOfDotted(cell.valuePath);
|
|
482
|
+
const stylePath = pointerOfDotted(cell.path) + "/style/color";
|
|
483
|
+
if (cell.style.color != null) {
|
|
484
|
+
const color = resolveTextColor(cell.style.color, design, design.colors.text);
|
|
485
|
+
if (color) samples.push({ path: stylePath, color, sizePx: 15, bold: cell.header, backdrop: cellBackdrop, source: "override", colorPath: stylePath, label: "Table cell text" });
|
|
486
|
+
}
|
|
487
|
+
if (Array.isArray(cell.value))
|
|
488
|
+
cell.value.forEach((run, index) => {
|
|
489
|
+
const colorRef = rec(run).color;
|
|
490
|
+
if (typeof colorRef !== "string") return;
|
|
491
|
+
const color = resolveTextColor(colorRef, design, design.colors.text);
|
|
492
|
+
if (color) samples.push({ path: `${cellPath}/${index}/color`, color, sizePx: rec(run).fontSize ? Number(rec(run).fontSize) / PX_TO_PT : 15, bold: Boolean(rec(run).bold) || cell.header, backdrop: cellBackdrop, source: "override", colorPath: `${cellPath}/${index}/color`, label: "Table cell text" });
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const parts = [];
|
|
498
|
+
const layoutOf = item.quoteLayout ?? item.metricLayout ?? item.timelineLayout;
|
|
499
|
+
if (layoutOf)
|
|
500
|
+
for (const part of layoutOf.parts) {
|
|
501
|
+
if (!part.fit || part.visible === false) continue;
|
|
502
|
+
const role = String(part.role ?? "");
|
|
503
|
+
parts.push({
|
|
504
|
+
path: typeof part.path === "string" && part.path.startsWith("slides.") ? pointerOfDotted(part.path) : itemPath,
|
|
505
|
+
sizePx: part.fit.fontSize / scale,
|
|
506
|
+
color: role === "footer" ? design.colors.mutedText : role === "value" && item.metricLayout ? design.colors.primary : design.colors.text,
|
|
507
|
+
label: item.field === "quote" ? role === "footer" ? "Quote attribution" : "Quote" : item.field === "metric" ? role === "value" ? "Metric value" : "Metric label" : "Timeline text"
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
else parts.push({ path: itemPath, sizePx: baseSize, color: design.colors.text, label: heading || item.field === "text" ? label : item.field === "items" || item.field === "bullets" ? "List text" : label });
|
|
511
|
+
for (const part of parts) samples.push({ path: part.path, color: part.color, sizePx: part.sizePx, bold, backdrop, source: "default", label: part.label });
|
|
512
|
+
const owners = textValues({ [item.field]: item.value }, "");
|
|
513
|
+
for (const owner of owners) {
|
|
514
|
+
if (typeof owner.value === "string") continue;
|
|
515
|
+
const relative = splitPointer(owner.path).slice(1);
|
|
516
|
+
const base = pointer(...splitPointer(itemPath), ...relative);
|
|
517
|
+
for (const run of runsOf({ ...owner, path: base })) {
|
|
518
|
+
if (typeof run.style.color !== "string") continue;
|
|
519
|
+
const color = resolveTextColor(run.style.color, design, design.colors.text);
|
|
520
|
+
if (!color) continue;
|
|
521
|
+
const size = typeof run.style.fontSize === "number" ? run.style.fontSize / PX_TO_PT : baseSize;
|
|
522
|
+
samples.push({ path: `${run.path}/color`, color, sizePx: size, bold: Boolean(run.style.bold) || bold, backdrop, source: "override", colorPath: `${run.path}/color`, label: "Coloured text" });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
for (const part of composition.furniture?.parts ?? [])
|
|
527
|
+
if (part.type === "text")
|
|
528
|
+
samples.push({ path: pointerOfDotted(part.sourcePath ?? part.path), color: design.colors.mutedText, sizePx: (part.fit?.fontSize ?? 13 * scale) / scale, bold: false, backdrop: slideBackdrop(context, part.box), source: "default", label: part.kind === "header" ? "Header text" : "Footer text" });
|
|
529
|
+
return samples;
|
|
530
|
+
}
|
|
531
|
+
var contrastRule = rule(
|
|
532
|
+
"text-contrast",
|
|
533
|
+
"accessibility",
|
|
534
|
+
"warning",
|
|
535
|
+
"Text colour has too little contrast against the background it sits on.",
|
|
536
|
+
"Low-contrast text is hard or impossible to read for people with low vision, colour-vision differences, glare on a projector or a poor display. WCAG sets 4.5:1 for normal text and 3:1 for large text.",
|
|
537
|
+
{
|
|
538
|
+
standard: "WCAG 2.2 SC 1.4.3 Contrast (Minimum), level AA",
|
|
539
|
+
thresholds: ["contrastNormal", "contrastLarge"],
|
|
540
|
+
approximations: "Computed on sRGB colours with the WCAG relative-luminance formula, against the background the preview draws: a solid or theme colour, the card surface, a table cell fill, every colour a gradient takes under the text box (sampled on a 5x5 grid, angle respected), or both colours of a pattern. Anti-aliasing, text shadows and font weight are not modelled. Text colour is the preview's (the scheme's dark1 or light1 chosen from the background luminance, where a gradient background counts as light), so a default can fail on a dark gradient."
|
|
541
|
+
}
|
|
542
|
+
);
|
|
543
|
+
var onImageRule = rule(
|
|
544
|
+
"text-on-image",
|
|
545
|
+
"accessibility",
|
|
546
|
+
"info",
|
|
547
|
+
"Text sits on a background picture whose pixels cannot be measured.",
|
|
548
|
+
"Contrast over a photograph depends on the photograph. Without a full-frame overlay that guarantees readability for every possible image, the result cannot be certified from the document.",
|
|
549
|
+
{
|
|
550
|
+
standard: "WCAG 2.2 SC 1.4.3 Contrast (Minimum), level AA",
|
|
551
|
+
thresholds: ["contrastNormal", "contrastLarge"],
|
|
552
|
+
approximations: "Core never reads picture pixels. The picture is bounded by a grey ramp from black to white, composited through the image opacity and a full-frame design.slideImage.overlay; the text passes only when every step of that ramp passes. An edge-banded overlay is not counted."
|
|
553
|
+
}
|
|
554
|
+
);
|
|
555
|
+
var contrastRules = [
|
|
556
|
+
{
|
|
557
|
+
info: contrastRule,
|
|
558
|
+
also: [onImageRule],
|
|
559
|
+
run(context) {
|
|
560
|
+
const { thresholds } = context;
|
|
561
|
+
for (const slide of context.slides) {
|
|
562
|
+
const aggregated = /* @__PURE__ */ new Map();
|
|
563
|
+
const imageReported = { done: false };
|
|
564
|
+
for (const sample of collectSamples(slide)) {
|
|
565
|
+
const large = isLarge(sample.sizePx, sample.bold), needed = large ? thresholds.contrastLarge : thresholds.contrastNormal;
|
|
566
|
+
const worst = worstContrast(sample.color, sample.backdrop);
|
|
567
|
+
if (worst.ratio >= needed) continue;
|
|
568
|
+
if (sample.backdrop.unknown) {
|
|
569
|
+
if (!imageReported.done) {
|
|
570
|
+
imageReported.done = true;
|
|
571
|
+
context.report(onImageRule, {
|
|
572
|
+
path: sample.path,
|
|
573
|
+
slide,
|
|
574
|
+
message: `${sample.label} sits on a background picture. Contrast cannot be guaranteed from the document (against the lightest or darkest possible pixels it is as low as ${worst.ratio.toFixed(1)}:1; ${needed}:1 is needed at this size).`,
|
|
575
|
+
help: "Add a full-frame overlay to design.slideImage (overlay colour and an opacity high enough that text passes on any picture), use a solid or gradient background behind text, or place text on a card.",
|
|
576
|
+
measured: { ratio: round(worst.ratio), needed, textColor: sample.color }
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const key = `${sample.source === "default" ? "default" : sample.path}|${sample.color}|${worst.against}|${large}`;
|
|
582
|
+
const known = aggregated.get(key);
|
|
583
|
+
if (known && sample.source === "default") {
|
|
584
|
+
known.extra++;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const fixes = [];
|
|
588
|
+
if (sample.colorPath) {
|
|
589
|
+
const preferred = readableColor([slide.design.colors.text], sample.backdrop, needed);
|
|
590
|
+
if (preferred !== void 0) {
|
|
591
|
+
const value = preferred.toUpperCase() === slide.design.colors.text.toUpperCase() ? "text" : preferred;
|
|
592
|
+
fixes.push({ id: "use-readable-color", label: value === "text" ? "Use the slide text colour" : `Use ${preferred}`, kind: "patch", safe: true, patch: [{ op: "replace", path: sample.colorPath, value }] });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const diagnostic = context.report(contrastRule, {
|
|
596
|
+
path: sample.path,
|
|
597
|
+
slide,
|
|
598
|
+
message: `${sample.label} colour ${sample.color} on ${worst.against} has a contrast ratio of ${worst.ratio.toFixed(2)}:1; ${needed}:1 is needed for ${large ? "large" : "normal"} text.`,
|
|
599
|
+
help: sample.source === "override" ? "Pick a colour with enough contrast against the background, or remove this colour override so the theme chooses a readable text colour." : "The theme text colour is unreadable on this background. Change the slide or deck background, the colour scheme (dark1/light1), or give the text an explicit readable colour.",
|
|
600
|
+
measured: { ratio: round(worst.ratio), needed, large, sizePt: round(sample.sizePx * PX_TO_PT), textColor: sample.color, backgroundColor: worst.against },
|
|
601
|
+
fixes
|
|
602
|
+
});
|
|
603
|
+
if (sample.source === "default") aggregated.set(key, { diagnostic, extra: 0 });
|
|
604
|
+
}
|
|
605
|
+
for (const { diagnostic, extra } of aggregated.values())
|
|
606
|
+
if (extra) {
|
|
607
|
+
diagnostic.message += ` ${extra} more text element${extra === 1 ? "" : "s"} on this slide ${extra === 1 ? "has" : "have"} the same problem.`;
|
|
608
|
+
if (diagnostic.measured) diagnostic.measured.alsoAffected = extra;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
];
|
|
614
|
+
var round = (value) => Math.round(value * 100) / 100;
|
|
615
|
+
var altRule = rule(
|
|
616
|
+
"missing-alt-text",
|
|
617
|
+
"accessibility",
|
|
618
|
+
"warning",
|
|
619
|
+
"A picture has no alt text and is not marked decorative.",
|
|
620
|
+
'People using a screen reader get nothing for a picture without alternative text. A picture that is purely decorative should say so with an empty alt (alt: ""), which is an explicit, reviewed choice instead of an omission.',
|
|
621
|
+
{
|
|
622
|
+
standard: "WCAG 2.2 SC 1.1.1 Non-text Content, level A",
|
|
623
|
+
approximations: "Checks the alt field of images, video, the slide image, logos (design.logo and each LogoSet variant, organization.logo), header/footer images and speaker photos, following asset: references to the assets registry. Whether the text describes the picture well is not judged here (see audit/poor-alt-text). Charts have no alt field in OPF; see audit/chart-text-alternative. Background images and watermarks are decorative by definition and are not checked."
|
|
624
|
+
}
|
|
625
|
+
);
|
|
626
|
+
var poorAltRule = rule(
|
|
627
|
+
"poor-alt-text",
|
|
628
|
+
"accessibility",
|
|
629
|
+
"info",
|
|
630
|
+
"Alt text is a file name, a URL, a generic word or very long.",
|
|
631
|
+
'Alt text such as "image", "IMG_2041.png" or a 400-character paragraph does not do the job of describing a picture: it is read out and adds noise without information.',
|
|
632
|
+
{ standard: "WCAG 2.2 SC 1.1.1 Non-text Content, level A", approximations: 'Pattern checks only: file extensions and camera-style names, a bare generic word, a URL, a leading "image of", and more than 250 characters. It cannot tell whether a plausible sentence is accurate.' }
|
|
633
|
+
);
|
|
634
|
+
var GENERIC_ALT = /^(image|picture|photo|photograph|graphic|img|icon|figure|screenshot|untitled|alt|alt text|logo)$/i;
|
|
635
|
+
var FILE_ALT = /(\.(png|jpe?g|gif|svg|webp|bmp|tiff?|heic|avif)$)|^(img|dsc|image|screenshot|screen shot|photo|pic)[ _-]?\d+/i;
|
|
636
|
+
var kindLabel = (kind) => ({ image: "Image", video: "Video", "slide-image": "Slide image", logo: "Logo", furniture: "Header/footer image", speaker: "Speaker photo", organization: "Organization logo" })[kind] ?? "Picture";
|
|
637
|
+
function altFixes(path, value) {
|
|
638
|
+
const decorative = typeof value === "string" ? { id: "mark-decorative", label: "Mark as decorative (empty alt)", kind: "patch", safe: false, patch: [{ op: "replace", path, value: { src: value, alt: "" } }] } : { id: "mark-decorative", label: "Mark as decorative (empty alt)", kind: "patch", safe: false, patch: [{ op: "add", path: `${path}/alt`, value: "" }] };
|
|
639
|
+
return [{ id: "focus-alt", label: "Write alt text", kind: "focus", safe: true, focus: { path, field: "alt", value } }, decorative];
|
|
640
|
+
}
|
|
641
|
+
var altRules = [
|
|
642
|
+
{
|
|
643
|
+
info: altRule,
|
|
644
|
+
run(context) {
|
|
645
|
+
const seen = /* @__PURE__ */ new Set();
|
|
646
|
+
context.slides.forEach((slide, index) => {
|
|
647
|
+
for (const ref of assetRefs(context.document, slide.slide, slide.path, index === 0)) {
|
|
648
|
+
if (seen.has(ref.path)) continue;
|
|
649
|
+
seen.add(ref.path);
|
|
650
|
+
let alt = altOf(ref.value, context.document.assets);
|
|
651
|
+
if (ref.kind === "video" && !alt) alt = typeof rec(ref.value).title === "string" ? rec(ref.value).title : void 0;
|
|
652
|
+
if (alt !== void 0 && alt.trim() !== "") continue;
|
|
653
|
+
if (alt === "") continue;
|
|
654
|
+
const hasText = alt !== void 0;
|
|
655
|
+
const organization = ref.kind === "organization" || ref.kind === "logo" ? rec(Array.isArray(context.document.organization) ? context.document.organization[0] : context.document.organization).name : void 0;
|
|
656
|
+
context.report(altRule, {
|
|
657
|
+
path: ref.path,
|
|
658
|
+
slide: ref.path.startsWith("/slides/") ? slide : void 0,
|
|
659
|
+
message: `${kindLabel(ref.kind)} has ${hasText ? "blank" : "no"} alt text.`,
|
|
660
|
+
help: `Describe what the ${ref.kind === "logo" || ref.kind === "organization" ? "logo shows" : "picture shows or why it is here"}${typeof organization === "string" ? ` (for a logo, the organization name, ${organization}, is usually right)` : ""}, or set alt to "" if it is purely decorative.`,
|
|
661
|
+
fixes: altFixes(ref.path, ref.value)
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
info: poorAltRule,
|
|
669
|
+
run(context) {
|
|
670
|
+
const seen = /* @__PURE__ */ new Set();
|
|
671
|
+
context.slides.forEach((slide, index) => {
|
|
672
|
+
for (const ref of assetRefs(context.document, slide.slide, slide.path, index === 0)) {
|
|
673
|
+
if (seen.has(ref.path)) continue;
|
|
674
|
+
seen.add(ref.path);
|
|
675
|
+
const alt = altOf(ref.value, context.document.assets)?.trim();
|
|
676
|
+
if (!alt) continue;
|
|
677
|
+
const source = sourceOfAsset(ref.value);
|
|
678
|
+
const problem = GENERIC_ALT.test(alt) ? "is a generic word" : FILE_ALT.test(alt) ? "looks like a file name" : /^(https?:\/\/|data:|www\.)/i.test(alt) || source && alt === source ? "is a URL" : /^(image|picture|photo|graphic) of\b/i.test(alt) ? 'starts with "image of", which a screen reader already announces' : alt.length > 250 ? `is ${alt.length} characters long; aim for a sentence or two` : void 0;
|
|
679
|
+
if (!problem) continue;
|
|
680
|
+
context.report(poorAltRule, {
|
|
681
|
+
path: `${ref.path}${typeof ref.value === "object" ? "/alt" : ""}`,
|
|
682
|
+
slide: ref.path.startsWith("/slides/") ? slide : void 0,
|
|
683
|
+
message: `${kindLabel(ref.kind)} alt text ${JSON.stringify(alt.length > 60 ? `${alt.slice(0, 57)}...` : alt)} ${problem}.`,
|
|
684
|
+
help: 'Write what a person who cannot see the picture needs to know, in plain words and without a leading "image of".',
|
|
685
|
+
fixes: [{ id: "focus-alt", label: "Edit alt text", kind: "focus", safe: true, focus: { path: ref.path, field: "alt", value: ref.value } }]
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
];
|
|
692
|
+
var missingTitleRule = rule(
|
|
693
|
+
"missing-slide-title",
|
|
694
|
+
"accessibility",
|
|
695
|
+
"warning",
|
|
696
|
+
"A slide has no title.",
|
|
697
|
+
"Slide titles are how people using a screen reader, an outline view or keyboard navigation find and tell slides apart; PowerPoint's own accessibility checker reports a missing title too.",
|
|
698
|
+
{ standard: 'WCAG 2.2 SC 2.4.2 Page Titled and SC 2.4.6 Headings and Labels, level AA (PowerPoint: "Missing slide title")', approximations: "Only the slide-level title field counts. Text that merely looks like a heading inside a block does not." }
|
|
699
|
+
);
|
|
700
|
+
var duplicateTitleRule = rule(
|
|
701
|
+
"duplicate-slide-title",
|
|
702
|
+
"accessibility",
|
|
703
|
+
"info",
|
|
704
|
+
"Two slides have the same title.",
|
|
705
|
+
"Identical titles make slides indistinguishable in an outline or a screen reader's slide list.",
|
|
706
|
+
{ approximations: 'Titles are compared case-insensitively with whitespace collapsed. Slides that continue one another are not exempt; give a continuation a distinct title such as "(continued)".' }
|
|
707
|
+
);
|
|
708
|
+
var titleRules = [
|
|
709
|
+
{
|
|
710
|
+
info: missingTitleRule,
|
|
711
|
+
run(context) {
|
|
712
|
+
for (const slide of context.slides) {
|
|
713
|
+
const title = slide.slide.title;
|
|
714
|
+
if (typeof title === "string" && title.trim() !== "") continue;
|
|
715
|
+
context.report(missingTitleRule, {
|
|
716
|
+
path: slide.path,
|
|
717
|
+
slide,
|
|
718
|
+
message: `Slide ${slide.index + 1} has no title.`,
|
|
719
|
+
help: "Give the slide a short, specific title. If the design has no room for a visible title, keep it short; the title also names the slide for assistive technology.",
|
|
720
|
+
fixes: [{ id: "focus-title", label: "Write a title", kind: "focus", safe: true, focus: { path: `${slide.path}/title`, field: "title" } }]
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
info: duplicateTitleRule,
|
|
727
|
+
run(context) {
|
|
728
|
+
const first = /* @__PURE__ */ new Map();
|
|
729
|
+
for (const slide of context.slides) {
|
|
730
|
+
const title = typeof slide.slide.title === "string" ? slide.slide.title.trim().replace(/\s+/g, " ").toLowerCase() : "";
|
|
731
|
+
if (!title) continue;
|
|
732
|
+
const earlier = first.get(title);
|
|
733
|
+
if (earlier === void 0) first.set(title, slide.index);
|
|
734
|
+
else
|
|
735
|
+
context.report(duplicateTitleRule, {
|
|
736
|
+
path: `${slide.path}/title`,
|
|
737
|
+
slide,
|
|
738
|
+
message: `Slide ${slide.index + 1} has the same title as slide ${earlier + 1}: ${JSON.stringify(String(slide.slide.title).trim())}.`,
|
|
739
|
+
help: "Make the titles distinct so each slide can be told apart in an outline or by assistive technology.",
|
|
740
|
+
fixes: [{ id: "focus-title", label: "Edit the title", kind: "focus", safe: true, focus: { path: `${slide.path}/title`, field: "title" } }]
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
];
|
|
746
|
+
var readingOrderRule = rule(
|
|
747
|
+
"reading-order",
|
|
748
|
+
"accessibility",
|
|
749
|
+
"warning",
|
|
750
|
+
"The order content is read differs from the order it appears on the slide.",
|
|
751
|
+
"Screen readers, keyboard focus and PowerPoint's selection pane follow the composed order. When it differs from the visual order (top to bottom, then start to end of the reading direction), the slide is read out of sequence.",
|
|
752
|
+
{
|
|
753
|
+
standard: 'WCAG 2.2 SC 1.3.2 Meaningful Sequence, level A (PowerPoint: "Check reading order")',
|
|
754
|
+
approximations: "Compares the composed content order with a visual order recomputed from the composed boxes: items whose vertical centres fall in the same row are ordered along the reading direction (a right-to-left deck is checked by rows only), rows from top to bottom. Headings are expected first. Free-form overlap is not analysed."
|
|
755
|
+
}
|
|
756
|
+
);
|
|
757
|
+
var readingOrderRules = [
|
|
758
|
+
{
|
|
759
|
+
info: readingOrderRule,
|
|
760
|
+
run(context) {
|
|
761
|
+
for (const slide of context.slides) {
|
|
762
|
+
const composition = slide.composition;
|
|
763
|
+
if (!composition) continue;
|
|
764
|
+
const body = composition.items.filter((item) => !["title", "subtitle", "tag"].includes(item.field));
|
|
765
|
+
const title = composition.items.find((item) => item.field === "title");
|
|
766
|
+
if (title && body.some((item) => item.box.y + item.box.height <= title.box.y + 1)) {
|
|
767
|
+
const above = body.find((item) => item.box.y + item.box.height <= title.box.y + 1);
|
|
768
|
+
context.report(readingOrderRule, {
|
|
769
|
+
path: pointerOfDotted(above.path),
|
|
770
|
+
slide,
|
|
771
|
+
message: `Content ${JSON.stringify(shortField(above.path))} is drawn above the title but is read after it.`,
|
|
772
|
+
help: "Move the title above the content or change the layout so the title is the first thing on the slide."
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
if (body.length < 2) continue;
|
|
776
|
+
const visual = visualOrder(body, slide.rtl);
|
|
777
|
+
const composed = body.map((item) => item.path);
|
|
778
|
+
const index = composed.findIndex((path, i) => path !== visual[i]);
|
|
779
|
+
if (index < 0) continue;
|
|
780
|
+
const name = (path) => shortField(path);
|
|
781
|
+
context.report(readingOrderRule, {
|
|
782
|
+
path: pointerOfDotted(composed[index]),
|
|
783
|
+
slide,
|
|
784
|
+
message: `Reading order differs from visual order: ${JSON.stringify(name(composed[index]))} is read at ${ordinal(index)} but appears at ${ordinal(visual.indexOf(composed[index]))}. Composed order: ${composed.map(name).join(", ")}; visual order: ${visual.map(name).join(", ")}.`,
|
|
785
|
+
help: "Reorder the blocks (or rename promoted regions) so the composed order follows the visual order, or use blocks with an explicit composition instead of region keys.",
|
|
786
|
+
measured: { firstMismatch: index, composed: composed.length }
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
];
|
|
792
|
+
function visualOrder(items, rtl) {
|
|
793
|
+
if (rtl) return readingRows(items).flatMap((row) => row.map((item) => item.path));
|
|
794
|
+
return visualReadingOrder(items).map((item) => item.path);
|
|
795
|
+
}
|
|
796
|
+
var shortField = (path) => path.replace(/^slides\.\d+\./, "");
|
|
797
|
+
var ordinal = (index) => `position ${index + 1}`;
|
|
798
|
+
var linkRule = rule(
|
|
799
|
+
"link-text",
|
|
800
|
+
"accessibility",
|
|
801
|
+
"warning",
|
|
802
|
+
"Link text does not say where the link goes.",
|
|
803
|
+
`People who scan links out of context (a screen reader's links list, a link tab order) hear only the text. "click here", "read more" or a long raw URL tells them nothing about the destination.`,
|
|
804
|
+
{ standard: 'WCAG 2.2 SC 2.4.4 Link Purpose (In Context), level A; PowerPoint: "Hyperlink text is not meaningful"', approximations: "Matches a short list of generic phrases in English (after lower-casing and removing punctuation), blank link text, and raw URLs longer than 40 characters. Adjacent runs sharing one link are read as one link." }
|
|
805
|
+
);
|
|
806
|
+
var GENERIC_LINKS = /* @__PURE__ */ new Set(["click here", "click", "here", "link", "this link", "this", "read more", "more", "learn more", "see more", "more info", "more information", "details", "download", "url", "website", "web site", "page", "this page", "visit", "go"]);
|
|
807
|
+
var linkRules = [
|
|
808
|
+
{
|
|
809
|
+
info: linkRule,
|
|
810
|
+
run(context) {
|
|
811
|
+
for (const slide of context.slides)
|
|
812
|
+
for (const tv of slide.texts) {
|
|
813
|
+
if (typeof tv.value === "string") continue;
|
|
814
|
+
const runs = runsOf(tv);
|
|
815
|
+
for (let i = 0; i < runs.length; i++) {
|
|
816
|
+
const run = runs[i];
|
|
817
|
+
const link = run.style.link;
|
|
818
|
+
if (typeof link !== "string") continue;
|
|
819
|
+
let text = run.text;
|
|
820
|
+
let last = i;
|
|
821
|
+
while (runs[last + 1] && runs[last + 1].style.link === link) text += runs[++last].text;
|
|
822
|
+
const normalized = text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, "").replace(/\s+/g, " ").trim();
|
|
823
|
+
const problem = !text.trim() ? "is blank" : GENERIC_LINKS.has(normalized) ? "is generic" : /^(https?:\/\/|www\.)\S{30,}$/i.test(text.trim()) ? "is a long raw URL" : void 0;
|
|
824
|
+
i = last;
|
|
825
|
+
if (!problem) continue;
|
|
826
|
+
context.report(linkRule, {
|
|
827
|
+
path: run.path,
|
|
828
|
+
slide,
|
|
829
|
+
message: `Link text ${JSON.stringify(text.trim().slice(0, 50))} ${problem}; the destination is ${link.length > 60 ? `${link.slice(0, 57)}...` : link}.`,
|
|
830
|
+
help: 'Use text that names the destination or action, for example "Q3 financial results (PDF)", instead of "click here".',
|
|
831
|
+
fixes: [{ id: "focus-link", label: "Edit the text", kind: "focus", safe: true, focus: { path: run.valuePath, field: "link", value: link } }]
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
];
|
|
838
|
+
var chartColorRule = rule(
|
|
839
|
+
"chart-color-only",
|
|
840
|
+
"accessibility",
|
|
841
|
+
"info",
|
|
842
|
+
"Chart series may be indistinguishable without colour vision.",
|
|
843
|
+
"OPF charts have no data labels or patterns, so series are told apart by colour alone (and legend order). Colours that look alike to someone with colour-vision deficiency, or when printed in greyscale, make series impossible to tell apart.",
|
|
844
|
+
{
|
|
845
|
+
standard: "WCAG 2.2 SC 1.4.1 Use of Color, level A",
|
|
846
|
+
thresholds: ["minSeriesColorDifference"],
|
|
847
|
+
approximations: "Uses the engine's series palette (AuditOptions.chartPalette; default the opf-render/opf-pptx palette, adjusted for the card surface like the preview does) in series order: series i takes colour i, pie/doughnut/treemap/funnel slices take colours per category. Pairs are compared by CIE76 distance after simulating protanopia, deuteranopia, tritanopia (Machado 2009, severity 1) and greyscale. Single-series charts and chart types without a series legend are skipped."
|
|
848
|
+
}
|
|
849
|
+
);
|
|
850
|
+
var chartAltRule = rule(
|
|
851
|
+
"chart-text-alternative",
|
|
852
|
+
"accessibility",
|
|
853
|
+
"info",
|
|
854
|
+
"A chart is the only content on its slide besides the title.",
|
|
855
|
+
"A chart conveys a message; people who cannot see it need the message and ideally the numbers in text. OPF has no alt field for charts, so a sentence or table next to the chart is the text alternative.",
|
|
856
|
+
{ standard: "WCAG 2.2 SC 1.1.1 Non-text Content, level A", approximations: "A chart passes when the slide has any other text, list, table, quote or metric content besides title and tag, or a subtitle. It does not judge whether that text states the chart's point." }
|
|
857
|
+
);
|
|
858
|
+
var chartSkip = /(histogram|box|pareto|waterfall|world|united-|canada|australia|map|sparkline|dot-plot|bullet|progress|dumbbell)/;
|
|
859
|
+
var perCategory = /(pie|doughnut|treemap|funnel)/;
|
|
860
|
+
var chartRules = [
|
|
861
|
+
{
|
|
862
|
+
info: chartColorRule,
|
|
863
|
+
run(context) {
|
|
864
|
+
for (const slide of context.slides)
|
|
865
|
+
for (const payload of slidePayloads(slide.slide, slide.path)) {
|
|
866
|
+
const chart = rec(payload.node.chart);
|
|
867
|
+
const data = rec(chart.data);
|
|
868
|
+
if (!Array.isArray(data.columns) || !Array.isArray(data.rows)) continue;
|
|
869
|
+
const type = String(chart.type ?? "").toLowerCase();
|
|
870
|
+
if (chartSkip.test(type)) continue;
|
|
871
|
+
const slices = perCategory.test(type);
|
|
872
|
+
const count = slices ? data.rows.length : data.columns.length - 1;
|
|
873
|
+
if (count < 2) continue;
|
|
874
|
+
const surface = slide.design.colors.surface;
|
|
875
|
+
const palette = chartPaletteForFill(surface, context.chartPalette);
|
|
876
|
+
if (!palette.length) continue;
|
|
877
|
+
const used = Array.from({ length: Math.min(count, 24) }, (_, i) => palette[i % palette.length]);
|
|
878
|
+
const pairs = [];
|
|
879
|
+
for (let a = 0; a < used.length; a++)
|
|
880
|
+
for (let b = a + 1; b < used.length; b++) {
|
|
881
|
+
let worst = { model: "normal vision", difference: colorDifference(used[a], used[b]) };
|
|
882
|
+
for (const model of visionModels) {
|
|
883
|
+
const difference = colorDifference(simulateVision(used[a], model), simulateVision(used[b], model));
|
|
884
|
+
if (difference < worst.difference) worst = { model, difference };
|
|
885
|
+
}
|
|
886
|
+
if (worst.difference < context.thresholds.minSeriesColorDifference) pairs.push({ a, b, ...worst });
|
|
887
|
+
}
|
|
888
|
+
const cycles = count > palette.length;
|
|
889
|
+
if (!pairs.length && !cycles) continue;
|
|
890
|
+
pairs.sort((x, y) => x.difference - y.difference);
|
|
891
|
+
const names = slices ? data.rows.map((row) => String(row?.[0] ?? "")) : data.columns.slice(1).map(String);
|
|
892
|
+
const describe = (p) => `${JSON.stringify(names[p.a] ?? `#${p.a + 1}`)} and ${JSON.stringify(names[p.b] ?? `#${p.b + 1}`)} (${p.model}, difference ${p.difference.toFixed(1)})`;
|
|
893
|
+
context.report(chartColorRule, {
|
|
894
|
+
path: `${payload.path === slide.path ? slide.path : payload.path}/chart`,
|
|
895
|
+
slide,
|
|
896
|
+
message: `${slices ? "Slices" : "Series"} may be hard to tell apart without colour: ${pairs.slice(0, 3).map(describe).join("; ")}${cycles ? `${pairs.length ? "; " : ""}${count} ${slices ? "slices" : "series"} exceed the ${palette.length}-colour palette, so colours repeat` : ""}.`,
|
|
897
|
+
help: "Show fewer series per chart (split into two charts), state the key values in the slide text or a table next to the chart, or choose a chart type whose categories sit on an axis instead of in a legend.",
|
|
898
|
+
measured: { series: count, confusablePairs: pairs.length, minDifference: pairs[0] ? round(pairs[0].difference) : null }
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
info: chartAltRule,
|
|
905
|
+
run(context) {
|
|
906
|
+
for (const slide of context.slides) {
|
|
907
|
+
const payloads = slidePayloads(slide.slide, slide.path).filter((p) => !p.node.blocks && !p.node.composition || p.root);
|
|
908
|
+
const charts = payloads.filter((p) => p.node.chart !== void 0);
|
|
909
|
+
if (!charts.length) continue;
|
|
910
|
+
const textual = (p) => ["text", "items", "bullets", "table", "quote", "metric", "timeline"].some((field) => p.node[field] !== void 0 && p.node[field] !== "" && !(Array.isArray(p.node[field]) && p.node[field].length === 0));
|
|
911
|
+
if (payloads.some(textual) || typeof slide.slide.subtitle === "string" && slide.slide.subtitle.trim()) continue;
|
|
912
|
+
const chart = charts[0];
|
|
913
|
+
context.report(chartAltRule, {
|
|
914
|
+
path: `${chart.path}/chart`,
|
|
915
|
+
slide,
|
|
916
|
+
message: `The chart on slide ${slide.index + 1} has no text beside it that states what it shows.`,
|
|
917
|
+
help: "Add a subtitle or text block with the chart's takeaway (and key numbers), or a table with the data, so the message does not depend on seeing the chart.",
|
|
918
|
+
fixes: [{ id: "focus-subtitle", label: "Write a subtitle", kind: "focus", safe: true, focus: { path: `${slide.path}/subtitle`, field: "text" } }]
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
];
|
|
924
|
+
var languageRule = rule(
|
|
925
|
+
"missing-language",
|
|
926
|
+
"accessibility",
|
|
927
|
+
"info",
|
|
928
|
+
"The presentation does not declare its language.",
|
|
929
|
+
"Screen readers and text-to-speech choose pronunciation and hyphenation from the declared language; spell checkers and translation tools use it too.",
|
|
930
|
+
{ standard: "WCAG 2.2 SC 3.1.1 Language of Page, level A", approximations: "Only the presentation-level `language` is checked, not the language of individual runs (OPF has no per-run language)." }
|
|
931
|
+
);
|
|
932
|
+
var languageRules = [
|
|
933
|
+
{
|
|
934
|
+
info: languageRule,
|
|
935
|
+
run(context) {
|
|
936
|
+
if (context.document.language !== void 0) return;
|
|
937
|
+
context.report(languageRule, {
|
|
938
|
+
path: "",
|
|
939
|
+
message: "The presentation has no language set.",
|
|
940
|
+
help: 'Set `language` to a language id from the languages catalog or a BCP 47 tag, for example "en-US".',
|
|
941
|
+
fixes: [{ id: "focus-language", label: "Set the language", kind: "focus", safe: true, focus: { path: "/language", field: "language" } }]
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
];
|
|
946
|
+
var accessibilityRules = [
|
|
947
|
+
...contrastRules,
|
|
948
|
+
...altRules,
|
|
949
|
+
...titleRules,
|
|
950
|
+
...readingOrderRules,
|
|
951
|
+
...linkRules,
|
|
952
|
+
...chartRules,
|
|
953
|
+
...languageRules
|
|
954
|
+
];
|
|
955
|
+
|
|
956
|
+
// src/audit-rules-design.ts
|
|
957
|
+
var PX_TO_PT2 = 0.75;
|
|
958
|
+
var round2 = (value, places = 1) => Math.round(value * 10 ** places) / 10 ** places;
|
|
959
|
+
var overflowRule = rule(
|
|
960
|
+
"text-overflow",
|
|
961
|
+
"design",
|
|
962
|
+
"warning",
|
|
963
|
+
"Text or a table does not fit its space at the smallest allowed size.",
|
|
964
|
+
"Core composition shrinks text to the readable minimum and then reports what still does not fit. Overflowing text is clipped or runs over other content in the preview and in PowerPoint.",
|
|
965
|
+
{
|
|
966
|
+
approximations: `Uses composeSlide at the deck's slide size with the fonts of the resolved font scheme. Without a host-supplied text measurement (AuditOptions.textMeasurement) widths are core's portable estimate, which can differ from the real font by a few percent; pass the renderer's measurement for font-exact results. Content in a composition that sets overflow: "error" is reported as an error, as the author asked.`
|
|
967
|
+
}
|
|
968
|
+
);
|
|
969
|
+
var smallCellRule = rule(
|
|
970
|
+
"small-cell",
|
|
971
|
+
"design",
|
|
972
|
+
"info",
|
|
973
|
+
"A content cell is too small for comfortable reading.",
|
|
974
|
+
"Cells narrower than about 100 px or shorter than 60 px (at 720 px slide height) leave no room for readable content.",
|
|
975
|
+
{ approximations: "The composeSlide threshold, in reference pixels scaled to the slide size." }
|
|
976
|
+
);
|
|
977
|
+
var unresolvedRule = rule(
|
|
978
|
+
"unresolved-content",
|
|
979
|
+
"design",
|
|
980
|
+
"warning",
|
|
981
|
+
"Content cannot be drawn as authored.",
|
|
982
|
+
"composeSlide reports content it cannot place or an effect it does not support (for example a picture bullet without a logo, a date field without a date, or an unsupported image treatment). The preview and the export fall back."
|
|
983
|
+
);
|
|
984
|
+
var layoutFailedRule = rule(
|
|
985
|
+
"layout-failed",
|
|
986
|
+
"design",
|
|
987
|
+
"warning",
|
|
988
|
+
"The slide layout could not be computed.",
|
|
989
|
+
"Composition threw for a slide that passed schema validation, so geometry-based rules (contrast, overflow, reading order, resolution) were skipped for it."
|
|
990
|
+
);
|
|
991
|
+
var minFontRule = rule(
|
|
992
|
+
"min-font-size",
|
|
993
|
+
"design",
|
|
994
|
+
"warning",
|
|
995
|
+
"Text is drawn smaller than the readable minimum.",
|
|
996
|
+
"Small type is unreadable from the back of a room and on a phone. The engine's own default floor is 12 pt (16 px); anything much below that was lowered on purpose or by a composition that could not fit the text.",
|
|
997
|
+
{
|
|
998
|
+
standard: "Common presentation guidance (12 pt minimum for body text; 18 pt or more is easier to read from a distance).",
|
|
999
|
+
thresholds: ["minFontSizePt"],
|
|
1000
|
+
approximations: "Sizes are those composeSlide fitted, expressed on the 13.33 x 7.5 in reference slide (96 px per inch, so 1 px is 0.75 pt; a smaller canvas scales type down with it) and explicit run fontSize values in points. Per payload, the smallest part (a metric label or a quote attribution) is reported. Table cell text (default 11.25 pt), code and header/footer furniture are not checked."
|
|
1001
|
+
}
|
|
1002
|
+
);
|
|
1003
|
+
var layoutRules = [
|
|
1004
|
+
{
|
|
1005
|
+
info: overflowRule,
|
|
1006
|
+
also: [smallCellRule, unresolvedRule, layoutFailedRule, minFontRule],
|
|
1007
|
+
run(context) {
|
|
1008
|
+
const deckLevel = /* @__PURE__ */ new Set();
|
|
1009
|
+
for (const slide of context.slides) {
|
|
1010
|
+
if (slide.layoutError)
|
|
1011
|
+
context.report(layoutFailedRule, { path: slide.path, slide, message: `Layout failed: ${slide.layoutError}`, help: "Fix the composition settings or content structure named in the message; geometry-based audit rules were skipped for this slide." });
|
|
1012
|
+
for (const { diagnostic, strict } of slide.layoutDiagnostics) {
|
|
1013
|
+
const path = pointerOfDotted(diagnostic.path);
|
|
1014
|
+
if (!path.startsWith("/slides/")) {
|
|
1015
|
+
const key = `${diagnostic.code}|${path}|${diagnostic.message}`;
|
|
1016
|
+
if (deckLevel.has(key)) continue;
|
|
1017
|
+
deckLevel.add(key);
|
|
1018
|
+
}
|
|
1019
|
+
if (diagnostic.code === "text-overflow")
|
|
1020
|
+
context.report(overflowRule, { path, slide, message: diagnostic.message, help: "Shorten the text, give it more space (fewer blocks, a different composition) or split the slide (paginate); raising the readable minimum makes it worse.", severity: strict ? "error" : void 0 });
|
|
1021
|
+
else if (diagnostic.code === "small-cell")
|
|
1022
|
+
context.report(smallCellRule, { path, slide, message: diagnostic.message, help: "Use fewer blocks on the slide or a layout with larger cells." });
|
|
1023
|
+
else context.report(unresolvedRule, { path, slide: path.startsWith("/slides/") ? slide : void 0, message: diagnostic.message, help: "Supply what the message asks for, or remove the setting that cannot be honoured." });
|
|
1024
|
+
}
|
|
1025
|
+
for (const tv of slide.texts)
|
|
1026
|
+
for (const run of runsOf(tv))
|
|
1027
|
+
if (typeof run.style.fontSize === "number" && run.style.fontSize < context.thresholds.minFontSizePt)
|
|
1028
|
+
context.report(minFontRule, {
|
|
1029
|
+
path: `${run.path}/fontSize`,
|
|
1030
|
+
slide,
|
|
1031
|
+
message: `Text run is set to ${run.style.fontSize} pt, below the ${context.thresholds.minFontSizePt} pt minimum.`,
|
|
1032
|
+
help: "Remove the size override so the text uses the fitted size, or raise it.",
|
|
1033
|
+
measured: { sizePt: run.style.fontSize, minimumPt: context.thresholds.minFontSizePt },
|
|
1034
|
+
fixes: [{ id: "focus-font-size", label: "Edit the size", kind: "focus", safe: true, focus: { path: `${run.path}/fontSize`, field: "fontSize" } }]
|
|
1035
|
+
});
|
|
1036
|
+
const composition = slide.composition;
|
|
1037
|
+
if (!composition) continue;
|
|
1038
|
+
const scale = Math.min(composition.width, composition.height) / 720;
|
|
1039
|
+
const limitPx = context.thresholds.minFontSizePt / PX_TO_PT2;
|
|
1040
|
+
const small = (path, sizes, label) => {
|
|
1041
|
+
const known = sizes.filter((size) => size !== void 0);
|
|
1042
|
+
const sizePx = known.length ? Math.min(...known) / scale : void 0;
|
|
1043
|
+
if (sizePx === void 0 || sizePx >= limitPx - 1e-6) return;
|
|
1044
|
+
context.report(minFontRule, {
|
|
1045
|
+
path,
|
|
1046
|
+
slide,
|
|
1047
|
+
message: `${label} is drawn at ${round2(sizePx * PX_TO_PT2)} pt${scale === 1 ? "" : " (reference slide size)"}, below the ${context.thresholds.minFontSizePt} pt minimum.`,
|
|
1048
|
+
help: "Shorten the text or give it more room so it can be drawn larger; check composition.minFontSize and run fontSize settings that force small type.",
|
|
1049
|
+
measured: { sizePt: round2(sizePx * PX_TO_PT2, 2), minimumPt: context.thresholds.minFontSizePt }
|
|
1050
|
+
});
|
|
1051
|
+
};
|
|
1052
|
+
for (const item of composition.items) {
|
|
1053
|
+
if (item.field === "code" || item.field === "table" || item.type === "code" || item.type === "table") continue;
|
|
1054
|
+
const layout = item.quoteLayout ?? item.metricLayout ?? item.timelineLayout;
|
|
1055
|
+
if (layout) small(pointerOfDotted(item.path), layout.parts.map((part) => part.visible === false ? void 0 : part.fit?.fontSize), "The smallest text");
|
|
1056
|
+
else small(pointerOfDotted(item.path), [item.text?.fontSize], item.field === "title" ? "Title" : "Text");
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
];
|
|
1062
|
+
var outsideRule = rule(
|
|
1063
|
+
"font-outside-scheme",
|
|
1064
|
+
"design",
|
|
1065
|
+
"warning",
|
|
1066
|
+
"Text uses a font family that is not in the deck's font scheme.",
|
|
1067
|
+
"The font scheme is the deck's font choice. A run that names another family will not follow a font-scheme change, may be missing on the viewer's machine, and breaks the deck's typographic consistency.",
|
|
1068
|
+
{ approximations: "Compares the run's fontFamily (case-insensitively) with the heading, body, code and accent families of the slide's resolved font scheme and the scheme's major/minor fonts. Families that the host substitutes are still different names here." }
|
|
1069
|
+
);
|
|
1070
|
+
var countRule = rule(
|
|
1071
|
+
"font-family-count",
|
|
1072
|
+
"design",
|
|
1073
|
+
"info",
|
|
1074
|
+
"The deck uses more font families than the recommended maximum.",
|
|
1075
|
+
"More than two or three families (heading, body, plus code where used) makes a deck look unplanned and increases the font bytes a viewer needs.",
|
|
1076
|
+
{ thresholds: ["maxFontFamilies"], approximations: "Counts the distinct heading and body families of every slide's resolved scheme, the code family on slides with code, the accent family on slides with a tag, and every run fontFamily." }
|
|
1077
|
+
);
|
|
1078
|
+
var fontRules = [
|
|
1079
|
+
{
|
|
1080
|
+
info: outsideRule,
|
|
1081
|
+
run(context) {
|
|
1082
|
+
for (const slide of context.slides) {
|
|
1083
|
+
const allowed = new Set(
|
|
1084
|
+
[slide.design.fonts.heading, slide.design.fonts.body, slide.design.fonts.code, slide.design.fonts.accent, slide.design.fontScheme.major, slide.design.fontScheme.minor].filter((family) => typeof family === "string").map((family) => family.toLowerCase())
|
|
1085
|
+
);
|
|
1086
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1087
|
+
for (const tv of slide.texts)
|
|
1088
|
+
for (const run of runsOf(tv)) {
|
|
1089
|
+
const family = run.style.fontFamily;
|
|
1090
|
+
if (typeof family !== "string" || !family.trim() || allowed.has(family.trim().toLowerCase()) || reported.has(family.toLowerCase())) continue;
|
|
1091
|
+
reported.add(family.toLowerCase());
|
|
1092
|
+
context.report(outsideRule, {
|
|
1093
|
+
path: `${run.path}/fontFamily`,
|
|
1094
|
+
slide,
|
|
1095
|
+
message: `Text uses ${JSON.stringify(family)}, which is not in the font scheme (${[.../* @__PURE__ */ new Set([slide.design.fonts.heading, slide.design.fonts.body])].join(" / ")}).`,
|
|
1096
|
+
help: "Remove the fontFamily override to use the scheme font, or change the deck's font scheme so the family is part of it.",
|
|
1097
|
+
fixes: [{ id: "remove-font-family", label: "Use the scheme font", kind: "patch", safe: false, patch: [{ op: "remove", path: `${run.path}/fontFamily` }] }]
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
1103
|
+
{
|
|
1104
|
+
info: countRule,
|
|
1105
|
+
run(context) {
|
|
1106
|
+
const families = /* @__PURE__ */ new Map();
|
|
1107
|
+
const add = (family) => {
|
|
1108
|
+
if (typeof family === "string" && family.trim()) families.set(family.trim().toLowerCase(), family.trim());
|
|
1109
|
+
};
|
|
1110
|
+
for (const slide of context.slides) {
|
|
1111
|
+
add(slide.design.fonts.heading);
|
|
1112
|
+
add(slide.design.fonts.body);
|
|
1113
|
+
if (slide.payloads.some((p) => p.node.code !== void 0)) add(slide.design.fonts.code);
|
|
1114
|
+
if (typeof slide.slide.tag === "string") add(slide.design.fonts.accent);
|
|
1115
|
+
for (const tv of slide.texts) for (const run of runsOf(tv)) add(run.style.fontFamily);
|
|
1116
|
+
}
|
|
1117
|
+
if (families.size <= context.thresholds.maxFontFamilies) return;
|
|
1118
|
+
context.report(countRule, {
|
|
1119
|
+
path: "/design",
|
|
1120
|
+
message: `The deck uses ${families.size} font families (${[...families.values()].join(", ")}); the recommended maximum is ${context.thresholds.maxFontFamilies}.`,
|
|
1121
|
+
help: "Use one heading and one body family, add a code family only where code appears, and remove run-level fontFamily overrides.",
|
|
1122
|
+
measured: { families: families.size, maximum: context.thresholds.maxFontFamilies }
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
];
|
|
1127
|
+
var titlePositionRule = rule(
|
|
1128
|
+
"title-position",
|
|
1129
|
+
"design",
|
|
1130
|
+
"info",
|
|
1131
|
+
"Titles of slides with the same layout sit in different places.",
|
|
1132
|
+
"A title that jumps between slides of one layout looks like a mistake when the deck is clicked through. Slides of one layout should hold their titles still.",
|
|
1133
|
+
{
|
|
1134
|
+
thresholds: ["titlePositionTolerance"],
|
|
1135
|
+
approximations: "Compares the composed title box origin of slides that share a layout id, a header presence and a slide-image position; covers (no body content) are skipped because their heading group is centred on purpose. The reference is the most common position, ties going to the earliest slide."
|
|
1136
|
+
}
|
|
1137
|
+
);
|
|
1138
|
+
var titleRules2 = [
|
|
1139
|
+
{
|
|
1140
|
+
info: titlePositionRule,
|
|
1141
|
+
run(context) {
|
|
1142
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1143
|
+
for (const slide of context.slides) {
|
|
1144
|
+
const composition = slide.composition;
|
|
1145
|
+
const title = composition?.items.find((item) => item.field === "title");
|
|
1146
|
+
if (!composition || !title) continue;
|
|
1147
|
+
if (!composition.items.some((item) => !["title", "subtitle", "tag"].includes(item.field))) continue;
|
|
1148
|
+
const key = [slide.layout?.id ?? (typeof slide.slide.layout === "string" ? slide.slide.layout : "(inferred)"), (composition.furniture?.headerBottom ?? 0) > 0, composition.slideImage?.position ?? "-"].join("|");
|
|
1149
|
+
const list = groups.get(key) ?? [];
|
|
1150
|
+
list.push({ slide, x: title.box.x / composition.width, y: title.box.y / composition.height });
|
|
1151
|
+
groups.set(key, list);
|
|
1152
|
+
}
|
|
1153
|
+
const tolerance = context.thresholds.titlePositionTolerance;
|
|
1154
|
+
for (const [key, members] of groups) {
|
|
1155
|
+
if (members.length < 2) continue;
|
|
1156
|
+
const counts = [];
|
|
1157
|
+
for (const member of members) {
|
|
1158
|
+
const near = counts.find((c) => Math.abs(c.x - member.x) <= tolerance && Math.abs(c.y - member.y) <= tolerance);
|
|
1159
|
+
if (near) near.n++;
|
|
1160
|
+
else counts.push({ x: member.x, y: member.y, n: 1 });
|
|
1161
|
+
}
|
|
1162
|
+
const reference = counts.reduce((best, c) => c.n > best.n ? c : best);
|
|
1163
|
+
for (const member of members)
|
|
1164
|
+
if (Math.abs(member.x - reference.x) > tolerance || Math.abs(member.y - reference.y) > tolerance)
|
|
1165
|
+
context.report(titlePositionRule, {
|
|
1166
|
+
path: `${member.slide.path}/title`,
|
|
1167
|
+
slide: member.slide,
|
|
1168
|
+
message: `The title is ${round2(Math.abs(member.x - reference.x) * 100)}% (across) and ${round2(Math.abs(member.y - reference.y) * 100)}% (down) away from where other "${key.split("|")[0]}" slides place it.`,
|
|
1169
|
+
help: "Slides of one layout should hold the title in the same place: check the header, the slide image, the layout and composition overrides on this slide.",
|
|
1170
|
+
measured: { offsetX: round2(member.x - reference.x, 3), offsetY: round2(member.y - reference.y, 3) }
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
];
|
|
1176
|
+
var wordRule = rule(
|
|
1177
|
+
"slide-word-count",
|
|
1178
|
+
"design",
|
|
1179
|
+
"info",
|
|
1180
|
+
"A slide holds a lot of text.",
|
|
1181
|
+
"Slides that carry a page of prose are read instead of presented, and are hard to scan on a screen reader or a phone. Split them or move detail to notes.",
|
|
1182
|
+
{ thresholds: ["maxWordsPerSlide"], approximations: "Counts word-like segments (Unicode word boundaries, so Chinese and Japanese count by word) in the title, subtitle, tag, text, lists, tables, quotes, metrics and timelines. Code and speaker notes are excluded." }
|
|
1183
|
+
);
|
|
1184
|
+
var wordRules = [
|
|
1185
|
+
{
|
|
1186
|
+
info: wordRule,
|
|
1187
|
+
run(context) {
|
|
1188
|
+
for (const slide of context.slides) {
|
|
1189
|
+
const words = slide.texts.reduce((sum, tv) => sum + countWords(plainText(tv.value)), 0);
|
|
1190
|
+
if (words <= context.thresholds.maxWordsPerSlide) continue;
|
|
1191
|
+
context.report(wordRule, {
|
|
1192
|
+
path: slide.path,
|
|
1193
|
+
slide,
|
|
1194
|
+
message: `Slide ${slide.index + 1} has ${words} words; more than ${context.thresholds.maxWordsPerSlide} is hard to read as a slide.`,
|
|
1195
|
+
help: "Cut it to the points that matter, split it over several slides, or move the detail into speaker notes or a handout.",
|
|
1196
|
+
measured: { words, maximum: context.thresholds.maxWordsPerSlide }
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
];
|
|
1202
|
+
var resolutionRule = rule(
|
|
1203
|
+
"image-resolution",
|
|
1204
|
+
"design",
|
|
1205
|
+
"warning",
|
|
1206
|
+
"An image has too few pixels for the size it is shown at.",
|
|
1207
|
+
"An image stretched beyond its pixel size looks blurry or blocky on a projector or a high-density screen.",
|
|
1208
|
+
{
|
|
1209
|
+
thresholds: ["minImagePpi"],
|
|
1210
|
+
approximations: "Only embedded data: images (and asset: references to them) have readable pixel sizes; URLs and files are never fetched, so they are not checked. The displayed size is the composed box (cropped images are measured as the cover scale, fitted ones as the contain scale). Effective ppi is the image pixels per inch of the 96 px/inch reference slide. SVG is vector and exempt."
|
|
1211
|
+
}
|
|
1212
|
+
);
|
|
1213
|
+
var resolutionRules = [
|
|
1214
|
+
{
|
|
1215
|
+
info: resolutionRule,
|
|
1216
|
+
run(context) {
|
|
1217
|
+
const assets = context.document.assets;
|
|
1218
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1219
|
+
const check = (path, value, box, cover, slide) => {
|
|
1220
|
+
if (seen.has(path)) return;
|
|
1221
|
+
seen.add(path);
|
|
1222
|
+
const size = intrinsicImageSize(value, assets);
|
|
1223
|
+
if (!size || size.svg || box.width <= 0 || box.height <= 0) return;
|
|
1224
|
+
const scale = cover ? Math.max(box.width / size.width, box.height / size.height) : Math.min(box.width / size.width, box.height / size.height);
|
|
1225
|
+
const ppi = 96 / scale;
|
|
1226
|
+
if (ppi >= context.thresholds.minImagePpi) return;
|
|
1227
|
+
context.report(resolutionRule, {
|
|
1228
|
+
path,
|
|
1229
|
+
slide,
|
|
1230
|
+
message: `The ${size.width}x${size.height} px image is shown ${round2(box.width / 96)} x ${round2(box.height / 96)} in, about ${round2(ppi, 0)} pixels per inch; ${context.thresholds.minImagePpi} are needed to look sharp.`,
|
|
1231
|
+
help: "Use a larger source image, or show it smaller. Re-exporting a small image at a higher size does not add detail.",
|
|
1232
|
+
measured: { widthPx: size.width, heightPx: size.height, ppi: round2(ppi, 0), minimumPpi: context.thresholds.minImagePpi }
|
|
1233
|
+
});
|
|
1234
|
+
};
|
|
1235
|
+
for (const slide of context.slides) {
|
|
1236
|
+
const composition = slide.composition;
|
|
1237
|
+
const fill = rec(slide.slide.design).imageFill ?? rec(context.document.design).imageFill;
|
|
1238
|
+
if (composition) {
|
|
1239
|
+
for (const item of composition.items) if (item.field === "image") check(pointerOfDotted(item.path), item.value, item.box, fill === "crop", slide);
|
|
1240
|
+
if (composition.slideImage) check(pointerOfDotted(composition.slideImage.sourcePath), composition.slideImage.value, composition.slideImage.box, composition.slideImage.fill === "crop", slide);
|
|
1241
|
+
}
|
|
1242
|
+
const background = slide.design.backgroundImage;
|
|
1243
|
+
if (background && background.fit !== "tile") check(background.path, rec({ src: background.source }), slide.design.dimensions, background.fit === "cover", background.path.startsWith("/slides/") ? slide : void 0);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
];
|
|
1248
|
+
var placeholderRule = rule(
|
|
1249
|
+
"placeholder-text",
|
|
1250
|
+
"content",
|
|
1251
|
+
"warning",
|
|
1252
|
+
"Placeholder text was left in the deck.",
|
|
1253
|
+
'"Lorem ipsum", "Click to add title", "TBD" and bracketed prompts are scaffolding. Shipping them reads as unfinished work.',
|
|
1254
|
+
{ approximations: 'Case-insensitive patterns for lorem ipsum, template prompts ("Click to add...", "Your title here", "[Insert ...]", a bare "Title" or "Text"), and the markers TBD, TBC, TODO, FIXME and XXX in capitals. A deliberate use of the words is flagged too; disable the rule for that slide with ignorePaths.' }
|
|
1255
|
+
);
|
|
1256
|
+
var emptyTextRule = rule(
|
|
1257
|
+
"empty-text",
|
|
1258
|
+
"content",
|
|
1259
|
+
"info",
|
|
1260
|
+
"A text field is present but empty.",
|
|
1261
|
+
"An empty title, text block or list item draws nothing, and leaves a hole in the outline and for assistive technology."
|
|
1262
|
+
);
|
|
1263
|
+
var emptySlideRule = rule(
|
|
1264
|
+
"empty-slide",
|
|
1265
|
+
"content",
|
|
1266
|
+
"warning",
|
|
1267
|
+
"A slide has no content at all.",
|
|
1268
|
+
"A slide with no title, no content and no picture is almost always an accident of editing. (A deliberately blank slide can use the blank layout.)"
|
|
1269
|
+
);
|
|
1270
|
+
var PLACEHOLDERS = [
|
|
1271
|
+
{ pattern: /lorem ipsum|dolor sit amet|consectetur adipiscing/i, label: "lorem ipsum" },
|
|
1272
|
+
{ pattern: /\b(click|tap) to (add|edit|enter)\b|\b(add|enter|insert|type) (a |your |the )?(title|subtitle|text|heading|caption|content|name|description)( here)?\s*$/i, label: "a template prompt" },
|
|
1273
|
+
{ pattern: /\b(your|the) (title|subtitle|name|company|text|logo|content) (goes )?here\b|\b(title|subtitle|text|heading|content|description) (goes )?here\b/i, label: "a template prompt" },
|
|
1274
|
+
{ pattern: /\[(insert|add|your|enter|name|title|date|company|tbd|tbc|todo|placeholder)[^\]]{0,40}\]/i, label: "a bracketed prompt" },
|
|
1275
|
+
{ pattern: /\b(TBD|TBC|TODO|FIXME)\b|\bXXX+\b|\?\?\?/, label: "a TBD/TODO marker" },
|
|
1276
|
+
{ pattern: /^\s*(title|subtitle|heading|text|slide title|new slide|untitled( slide| presentation)?|presentation title|sample text|placeholder)\s*$/i, label: "a template default" }
|
|
1277
|
+
];
|
|
1278
|
+
var VARIABLE_TOKEN = /\\\{\{|\{\{\s*([a-z][a-z0-9-]*)\s*(?:\|[^{}]*)?\}\}/g;
|
|
1279
|
+
var contentRules = [
|
|
1280
|
+
{
|
|
1281
|
+
info: placeholderRule,
|
|
1282
|
+
run(context) {
|
|
1283
|
+
for (const slide of context.slides)
|
|
1284
|
+
for (const tv of slide.texts) {
|
|
1285
|
+
const text = plainText(tv.value);
|
|
1286
|
+
const hit = PLACEHOLDERS.find(({ pattern }) => pattern.test(text));
|
|
1287
|
+
if (!hit) continue;
|
|
1288
|
+
context.report(placeholderRule, {
|
|
1289
|
+
path: tv.path,
|
|
1290
|
+
slide,
|
|
1291
|
+
message: `Text looks like ${hit.label}: ${JSON.stringify(text.length > 60 ? `${text.slice(0, 57)}...` : text)}.`,
|
|
1292
|
+
help: "Replace it with the real content, or delete the element.",
|
|
1293
|
+
fixes: [{ id: "focus-text", label: "Edit the text", kind: "focus", safe: true, focus: { path: tv.path, field: "text" } }]
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1298
|
+
{
|
|
1299
|
+
info: emptyTextRule,
|
|
1300
|
+
run(context) {
|
|
1301
|
+
for (const slide of context.slides)
|
|
1302
|
+
for (const tv of slide.texts) {
|
|
1303
|
+
if (["table", "table-header", "list-description"].includes(tv.role)) continue;
|
|
1304
|
+
if (plainText(tv.value).trim() !== "") continue;
|
|
1305
|
+
context.report(emptyTextRule, {
|
|
1306
|
+
path: tv.path,
|
|
1307
|
+
slide,
|
|
1308
|
+
message: `The ${tv.role === "list" ? "list item" : tv.role === "body" ? "text" : tv.role} is empty.`,
|
|
1309
|
+
help: "Write the text, or remove the field so the slide does not carry an empty element.",
|
|
1310
|
+
fixes: [{ id: "focus-text", label: "Edit the text", kind: "focus", safe: true, focus: { path: tv.path, field: "text" } }]
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
{
|
|
1316
|
+
info: emptySlideRule,
|
|
1317
|
+
run(context) {
|
|
1318
|
+
for (const slide of context.slides) {
|
|
1319
|
+
if (slide.slide.layout === "blank") continue;
|
|
1320
|
+
const has = (value) => value !== void 0 && value !== null && !(typeof value === "string" && value.trim() === "") && !(Array.isArray(value) && value.length === 0);
|
|
1321
|
+
const hasText = slide.texts.some((tv) => plainText(tv.value).trim() !== "");
|
|
1322
|
+
const hasObject = slidePayloads(slide.slide, slide.path).some((p) => ["image", "video", "chart", "table", "code", "metric", "quote", "timeline"].some((field) => has(p.node[field])));
|
|
1323
|
+
const hasSlideImage = has(rec(slide.slide.design).slideImage);
|
|
1324
|
+
if (hasText || hasObject || hasSlideImage) continue;
|
|
1325
|
+
context.report(emptySlideRule, { path: slide.path, slide, message: `Slide ${slide.index + 1} has no title and no content.`, help: "Add content, delete the slide, or give it the blank layout if it is intentionally empty." });
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
];
|
|
1330
|
+
var variableRule = rule(
|
|
1331
|
+
"unfilled-variable",
|
|
1332
|
+
"content",
|
|
1333
|
+
"warning",
|
|
1334
|
+
"A template variable was never filled in.",
|
|
1335
|
+
"`{{name}}` tokens, `var:` colour references to undeclared variables and declared variables without a value are template scaffolding. In a finished deck they show up literally, or fall back to a default colour.",
|
|
1336
|
+
{
|
|
1337
|
+
approximations: "Feature-detected from the document alone: `{{id}}` / `{{id|format}}` tokens (the RR-32 template syntax; `\\{{` escapes) in any string outside `variables`, `extensions` and `catalogs`; `var:<id>` references with no declaration in `variables`; and declared non-colour variables with no value that are not `required: false`. A template (`template: true`) reports its tokens as findings too: ignore the rule for a template on purpose, or fill it first. Core's `resolveVariables` (RR-32) is the authority once it is released; this check needs no import of it."
|
|
1338
|
+
}
|
|
1339
|
+
);
|
|
1340
|
+
var variableRules = [
|
|
1341
|
+
{
|
|
1342
|
+
info: variableRule,
|
|
1343
|
+
run(context) {
|
|
1344
|
+
const doc = context.document;
|
|
1345
|
+
const declared = rec(doc.variables);
|
|
1346
|
+
const found = [];
|
|
1347
|
+
const walk = (value, path, depth) => {
|
|
1348
|
+
if (depth > 64) return;
|
|
1349
|
+
if (typeof value === "string") {
|
|
1350
|
+
if (value.startsWith("data:")) return;
|
|
1351
|
+
const reference = /^var:([a-z][a-z0-9-]*)$/.exec(value);
|
|
1352
|
+
if (reference && !(reference[1] in declared)) found.push({ path: pointer(...path), message: `Colour reference ${JSON.stringify(value)} names a variable that is not declared in variables.` });
|
|
1353
|
+
for (const match of value.matchAll(VARIABLE_TOKEN))
|
|
1354
|
+
if (match[1]) found.push({ path: pointer(...path), message: `Template variable ${JSON.stringify(match[0])} is still in the text.` });
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
if (Array.isArray(value)) {
|
|
1358
|
+
value.forEach((entry, index) => {
|
|
1359
|
+
walk(entry, [...path, String(index)], depth + 1);
|
|
1360
|
+
});
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
if (value && typeof value === "object")
|
|
1364
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1365
|
+
if (path.length === 0 && ["variables", "extensions", "catalogs", "assets"].includes(key)) continue;
|
|
1366
|
+
walk(entry, [...path, key], depth + 1);
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
walk(doc, [], 0);
|
|
1370
|
+
for (const [id, entry] of Object.entries(declared)) {
|
|
1371
|
+
const declaration = rec(entry);
|
|
1372
|
+
if (typeof entry === "object" && entry !== null && declaration.type !== void 0 && declaration.type !== "color" && declaration.value === void 0 && declaration.required !== false)
|
|
1373
|
+
found.push({ path: pointer("variables", id), message: `Variable ${JSON.stringify(id)} (${String(declaration.type)}) has no value.` });
|
|
1374
|
+
}
|
|
1375
|
+
const slides = context.slides;
|
|
1376
|
+
for (const finding of found) {
|
|
1377
|
+
const parts = splitPointer(finding.path);
|
|
1378
|
+
const slide = parts[0] === "slides" ? slides[Number(parts[1])] : void 0;
|
|
1379
|
+
context.report(variableRule, {
|
|
1380
|
+
path: finding.path,
|
|
1381
|
+
slide,
|
|
1382
|
+
message: finding.message,
|
|
1383
|
+
help: doc.template === true ? "This document is a template: fill it with its values (opf fill, or resolveVariables) before auditing, or ignore this rule for templates." : "Fill the variable (or declare it), or replace the token with the real value."
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
];
|
|
1389
|
+
var designRules = [
|
|
1390
|
+
...layoutRules,
|
|
1391
|
+
...fontRules,
|
|
1392
|
+
...titleRules2,
|
|
1393
|
+
...wordRules,
|
|
1394
|
+
...resolutionRules,
|
|
1395
|
+
...contentRules,
|
|
1396
|
+
...variableRules
|
|
1397
|
+
];
|
|
1398
|
+
|
|
1399
|
+
// src/audit.ts
|
|
1400
|
+
var DEFAULT_AUDIT_THRESHOLDS = Object.freeze({
|
|
1401
|
+
contrastNormal: 4.5,
|
|
1402
|
+
contrastLarge: 3,
|
|
1403
|
+
minFontSizePt: 11,
|
|
1404
|
+
maxWordsPerSlide: 120,
|
|
1405
|
+
maxFontFamilies: 3,
|
|
1406
|
+
minImagePpi: 96,
|
|
1407
|
+
titlePositionTolerance: 0.01,
|
|
1408
|
+
minSeriesColorDifference: 10
|
|
1409
|
+
});
|
|
1410
|
+
var rules = [...accessibilityRules, ...designRules];
|
|
1411
|
+
var infos = rules.flatMap((entry) => [entry.info, ...entry.also ?? []]);
|
|
1412
|
+
var auditRules = Object.freeze(infos.map((info) => Object.freeze({ ...info })));
|
|
1413
|
+
var DOCS = "https://github.com/OpenPresentation/opf/blob/main/docs/audit.md";
|
|
1414
|
+
var severities = ["error", "warning", "info"];
|
|
1415
|
+
function findAuditRule(name) {
|
|
1416
|
+
return auditRules.find((info) => info.id === name || info.name === name);
|
|
1417
|
+
}
|
|
1418
|
+
function ruleFor(name, what) {
|
|
1419
|
+
const found = findAuditRule(name);
|
|
1420
|
+
if (!found) throw new TypeError(`Unknown audit rule ${JSON.stringify(name)} in ${what}. Known rules: ${auditRules.map((info) => info.id).join(", ")}.`);
|
|
1421
|
+
return found;
|
|
1422
|
+
}
|
|
1423
|
+
function resolveOptions(options) {
|
|
1424
|
+
const object = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1425
|
+
if (!object(options)) throw new TypeError("Audit options must be an object.");
|
|
1426
|
+
const allowed = ["rules", "ignore", "only", "ignorePaths", "thresholds", "catalogs", "textMeasurement", "chartPalette"];
|
|
1427
|
+
for (const key of Object.keys(options)) if (!allowed.includes(key)) throw new TypeError(`Unknown audit option ${JSON.stringify(key)}. Options: ${allowed.join(", ")}.`);
|
|
1428
|
+
const severity = /* @__PURE__ */ new Map();
|
|
1429
|
+
if (options.rules !== void 0) {
|
|
1430
|
+
if (!object(options.rules)) throw new TypeError('Audit rules must map rule ids to a severity or "off".');
|
|
1431
|
+
for (const [name, value] of Object.entries(options.rules)) {
|
|
1432
|
+
if (typeof value !== "string" || value !== "off" && !severities.includes(value)) throw new TypeError(`Audit rule ${JSON.stringify(name)}: severity must be error, warning, info or off.`);
|
|
1433
|
+
severity.set(ruleFor(name, "rules").id, value);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
const list = (value, what) => {
|
|
1437
|
+
if (value === void 0) return void 0;
|
|
1438
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) throw new TypeError(`Audit ${what} must be an array of rule ids.`);
|
|
1439
|
+
return new Set(value.map((name) => ruleFor(name, what).id));
|
|
1440
|
+
};
|
|
1441
|
+
const ignore = list(options.ignore, "ignore"), only = list(options.only, "only");
|
|
1442
|
+
const ignorePaths = [];
|
|
1443
|
+
if (options.ignorePaths !== void 0) {
|
|
1444
|
+
if (!Array.isArray(options.ignorePaths)) throw new TypeError("Audit ignorePaths must be an array of {rule, path}.");
|
|
1445
|
+
for (const entry of options.ignorePaths) {
|
|
1446
|
+
if (!object(entry) || typeof entry.rule !== "string" || typeof entry.path !== "string" || entry.path !== "" && !entry.path.startsWith("/"))
|
|
1447
|
+
throw new TypeError('Each audit ignorePaths entry needs a rule id (or "*") and a JSON Pointer path.');
|
|
1448
|
+
ignorePaths.push({ rule: entry.rule === "*" ? "*" : ruleFor(entry.rule, "ignorePaths").id, path: splitPointer(entry.path) });
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const thresholds = { ...DEFAULT_AUDIT_THRESHOLDS };
|
|
1452
|
+
if (options.thresholds !== void 0) {
|
|
1453
|
+
if (!object(options.thresholds)) throw new TypeError("Audit thresholds must be an object.");
|
|
1454
|
+
for (const [key, value] of Object.entries(options.thresholds)) {
|
|
1455
|
+
if (!(key in DEFAULT_AUDIT_THRESHOLDS)) throw new TypeError(`Unknown audit threshold ${JSON.stringify(key)}. Thresholds: ${Object.keys(DEFAULT_AUDIT_THRESHOLDS).join(", ")}.`);
|
|
1456
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new TypeError(`Audit threshold ${key} must be a positive number.`);
|
|
1457
|
+
thresholds[key] = value;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (options.chartPalette !== void 0 && (!Array.isArray(options.chartPalette) || !options.chartPalette.every((color) => typeof color === "string" && /^#[0-9a-fA-F]{6}$/.test(color))))
|
|
1461
|
+
throw new TypeError("Audit chartPalette must be an array of #RRGGBB colours.");
|
|
1462
|
+
return {
|
|
1463
|
+
enabled: (info) => severity.get(info.id) !== "off" && !ignore?.has(info.id) && (!only || only.has(info.id)),
|
|
1464
|
+
severity: (info) => {
|
|
1465
|
+
const value = severity.get(info.id);
|
|
1466
|
+
return value === "off" ? void 0 : value;
|
|
1467
|
+
},
|
|
1468
|
+
ignorePaths,
|
|
1469
|
+
thresholds
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
var measurementFor = (options, index) => typeof options.textMeasurement === "function" ? options.textMeasurement(index) : options.textMeasurement;
|
|
1473
|
+
var slideCompositionOptions = (document, slide, index, count, design, layout, options) => {
|
|
1474
|
+
const merged = { ...rec(document.design), ...rec(slide.design) };
|
|
1475
|
+
return {
|
|
1476
|
+
...design.dimensions,
|
|
1477
|
+
layout,
|
|
1478
|
+
presentation: document,
|
|
1479
|
+
slideIndex: index,
|
|
1480
|
+
slideNumber: index + 1,
|
|
1481
|
+
slideCount: count,
|
|
1482
|
+
fonts: design.fonts,
|
|
1483
|
+
contentAlignment: merged.contentAlignment,
|
|
1484
|
+
titleAlignment: merged.titleAlignment,
|
|
1485
|
+
contentBox: merged.contentBox,
|
|
1486
|
+
textMeasurement: measurementFor(options, index),
|
|
1487
|
+
socialPlatforms: catalogs.socialPlatforms,
|
|
1488
|
+
// Core never consults a clock; a fixed date only lets `date: true` furniture be measured.
|
|
1489
|
+
date: "2000-01-01"
|
|
1490
|
+
};
|
|
1491
|
+
};
|
|
1492
|
+
function relax(value) {
|
|
1493
|
+
const copy = JSON.parse(JSON.stringify(value));
|
|
1494
|
+
const walk = (node) => {
|
|
1495
|
+
if (Array.isArray(node)) return node.forEach(walk);
|
|
1496
|
+
if (node && typeof node === "object") {
|
|
1497
|
+
const object = node;
|
|
1498
|
+
if (object.overflow === "error") object.overflow = "warn";
|
|
1499
|
+
for (const child of Object.values(object)) walk(child);
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
walk(copy);
|
|
1503
|
+
return copy;
|
|
1504
|
+
}
|
|
1505
|
+
function buildSlides(document, options) {
|
|
1506
|
+
const lookup = createLookup(document, options.catalogs);
|
|
1507
|
+
const slides = Array.isArray(document.slides) ? document.slides : [];
|
|
1508
|
+
return slides.map((slide, index) => {
|
|
1509
|
+
const design = resolveDesign(document, slide, index, lookup);
|
|
1510
|
+
const layout = typeof slide.layout === "string" ? lookup("layouts", slide.layout) : void 0;
|
|
1511
|
+
const path = pointer("slides", index);
|
|
1512
|
+
const context = {
|
|
1513
|
+
index,
|
|
1514
|
+
slide,
|
|
1515
|
+
path,
|
|
1516
|
+
...typeof slide.id === "string" ? { id: slide.id } : {},
|
|
1517
|
+
design,
|
|
1518
|
+
...layout ? { layout } : {},
|
|
1519
|
+
layoutDiagnostics: [],
|
|
1520
|
+
measurement: measurementFor(options, index),
|
|
1521
|
+
payloads: slidePayloads(slide, path),
|
|
1522
|
+
texts: textValues(slide, path),
|
|
1523
|
+
rtl: false
|
|
1524
|
+
};
|
|
1525
|
+
try {
|
|
1526
|
+
context.rtl = resolveScriptFonts(document, { slideIndex: index }).rtl === true;
|
|
1527
|
+
} catch {
|
|
1528
|
+
}
|
|
1529
|
+
const compose = (input, withLayout) => composeSlide(input, slideCompositionOptions(document, input, index, slides.length, design, withLayout, options));
|
|
1530
|
+
let composition;
|
|
1531
|
+
try {
|
|
1532
|
+
composition = compose(slide, layout);
|
|
1533
|
+
context.layoutDiagnostics.push(...composition.diagnostics.map((diagnostic) => ({ diagnostic, strict: false })));
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
if (error instanceof OPFCompositionError) {
|
|
1536
|
+
context.layoutDiagnostics.push(...error.diagnostics.map((diagnostic) => ({ diagnostic, strict: true })));
|
|
1537
|
+
try {
|
|
1538
|
+
composition = compose(relax(slide), layout ? relax(layout) : void 0);
|
|
1539
|
+
} catch (second) {
|
|
1540
|
+
context.layoutError = second instanceof Error ? second.message : String(second);
|
|
1541
|
+
}
|
|
1542
|
+
} else context.layoutError = error instanceof Error ? error.message : String(error);
|
|
1543
|
+
}
|
|
1544
|
+
if (composition) context.composition = composition;
|
|
1545
|
+
return context;
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
function locator(source) {
|
|
1549
|
+
const tree = parseTree(source.startsWith("\uFEFF") ? ` ${source.slice(1)}` : source, [], { disallowComments: true, allowTrailingComma: false });
|
|
1550
|
+
const lineStarts = [0];
|
|
1551
|
+
for (let i = 0; i < source.length; i++) {
|
|
1552
|
+
if (source[i] === "\r") {
|
|
1553
|
+
if (source[i + 1] === "\n") i++;
|
|
1554
|
+
lineStarts.push(i + 1);
|
|
1555
|
+
} else if (source[i] === "\n") lineStarts.push(i + 1);
|
|
1556
|
+
}
|
|
1557
|
+
const at = (offset, length) => {
|
|
1558
|
+
let lo = 0, hi = lineStarts.length;
|
|
1559
|
+
while (lo + 1 < hi) {
|
|
1560
|
+
const mid = lo + hi >> 1;
|
|
1561
|
+
if ((lineStarts[mid] ?? 0) <= offset) lo = mid;
|
|
1562
|
+
else hi = mid;
|
|
1563
|
+
}
|
|
1564
|
+
return { offset, length, line: lo + 1, column: offset - (lineStarts[lo] ?? 0) + 1 };
|
|
1565
|
+
};
|
|
1566
|
+
return (path) => {
|
|
1567
|
+
let node = tree;
|
|
1568
|
+
const found = [];
|
|
1569
|
+
for (const part of splitPointer(path)) {
|
|
1570
|
+
const next = node?.type === "array" ? node.children?.[Number(part)] : node?.type === "object" ? node.children?.find((property) => property.children?.[0]?.value === part)?.children?.[1] : void 0;
|
|
1571
|
+
if (!next) break;
|
|
1572
|
+
node = next;
|
|
1573
|
+
found.push(next);
|
|
1574
|
+
}
|
|
1575
|
+
const target = found.length === splitPointer(path).length ? node : found[found.length - 1] ?? tree;
|
|
1576
|
+
return target ? at(target.offset, target.length) : void 0;
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
function emptyReport(options, thresholds) {
|
|
1580
|
+
return {
|
|
1581
|
+
valid: true,
|
|
1582
|
+
documentValid: true,
|
|
1583
|
+
diagnostics: [],
|
|
1584
|
+
counts: { error: 0, warning: 0, info: 0 },
|
|
1585
|
+
rulesRun: [],
|
|
1586
|
+
slideCount: 0,
|
|
1587
|
+
thresholds,
|
|
1588
|
+
checks: { textMeasurement: options.textMeasurement !== void 0 ? "provided" : "estimated", backgroundPixels: "not-read", imageBytes: "embedded-only", nativeExport: "not-checked" }
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
function auditObject(document, options, locate) {
|
|
1592
|
+
const resolved = resolveOptions(options);
|
|
1593
|
+
const report = emptyReport(options, resolved.thresholds);
|
|
1594
|
+
const { diagnostics, counts } = report;
|
|
1595
|
+
const validation = validatePresentation(document);
|
|
1596
|
+
if (!validation.valid) {
|
|
1597
|
+
report.documentValid = false;
|
|
1598
|
+
for (const issue of validation.errors.slice(0, 50))
|
|
1599
|
+
diagnostics.push({
|
|
1600
|
+
ruleId: "audit/invalid-document",
|
|
1601
|
+
severity: "error",
|
|
1602
|
+
category: "content",
|
|
1603
|
+
scope: "document",
|
|
1604
|
+
path: issue.path === "/" ? "" : issue.path,
|
|
1605
|
+
message: `The document does not pass schema validation, so it was not audited: ${issue.message}`,
|
|
1606
|
+
help: "Fix the document first (see `opf lint`), then audit again.",
|
|
1607
|
+
...locate ? { location: locate(issue.path === "/" ? "" : issue.path) } : {}
|
|
1608
|
+
});
|
|
1609
|
+
for (const diagnostic of diagnostics) counts[diagnostic.severity]++;
|
|
1610
|
+
report.valid = counts.error === 0;
|
|
1611
|
+
return report;
|
|
1612
|
+
}
|
|
1613
|
+
const doc = document;
|
|
1614
|
+
const slides = buildSlides(doc, options);
|
|
1615
|
+
const order = new Map(infos.map((info, index) => [info.id, index]));
|
|
1616
|
+
const context = {
|
|
1617
|
+
document: doc,
|
|
1618
|
+
slides,
|
|
1619
|
+
thresholds: resolved.thresholds,
|
|
1620
|
+
options,
|
|
1621
|
+
lookup: createLookup(doc, options.catalogs),
|
|
1622
|
+
chartPalette: options.chartPalette ?? DEFAULT_CHART_PALETTE,
|
|
1623
|
+
report(info, finding) {
|
|
1624
|
+
const severity = resolved.severity(info) ?? finding.severity ?? info.severity;
|
|
1625
|
+
const diagnostic = {
|
|
1626
|
+
ruleId: info.id,
|
|
1627
|
+
severity,
|
|
1628
|
+
category: info.category,
|
|
1629
|
+
scope: "document",
|
|
1630
|
+
path: finding.path,
|
|
1631
|
+
message: finding.message,
|
|
1632
|
+
help: finding.help,
|
|
1633
|
+
definition: `${DOCS}#${info.id.replace("/", "")}`,
|
|
1634
|
+
...finding.slide ? { slide: finding.slide.index, ...finding.slide.id ? { slideId: finding.slide.id } : {} } : {},
|
|
1635
|
+
...finding.measured ? { measured: finding.measured } : {},
|
|
1636
|
+
...finding.fixes?.length ? { fixes: finding.fixes } : {},
|
|
1637
|
+
...finding.suggestions ? { suggestions: finding.suggestions } : {}
|
|
1638
|
+
};
|
|
1639
|
+
if (!resolved.enabled(info)) return diagnostic;
|
|
1640
|
+
const parts = splitPointer(finding.path);
|
|
1641
|
+
const ignored = resolved.ignorePaths.some((entry) => (entry.rule === "*" || entry.rule === info.id) && entry.path.every((part, index) => parts[index] === part));
|
|
1642
|
+
if (!ignored) diagnostics.push(diagnostic);
|
|
1643
|
+
return diagnostic;
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
const rulesRun = [];
|
|
1647
|
+
for (const entry of rules) {
|
|
1648
|
+
const all = [entry.info, ...entry.also ?? []];
|
|
1649
|
+
if (!all.some((info) => resolved.enabled(info))) continue;
|
|
1650
|
+
rulesRun.push(...all.filter((info) => resolved.enabled(info)).map((info) => info.id));
|
|
1651
|
+
entry.run(context);
|
|
1652
|
+
}
|
|
1653
|
+
diagnostics.sort((a, b) => (a.slide ?? -1) - (b.slide ?? -1) || (order.get(a.ruleId) ?? 0) - (order.get(b.ruleId) ?? 0) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
1654
|
+
if (locate) for (const diagnostic of diagnostics) {
|
|
1655
|
+
const location = locate(diagnostic.path);
|
|
1656
|
+
if (location) diagnostic.location = location;
|
|
1657
|
+
}
|
|
1658
|
+
for (const diagnostic of diagnostics) counts[diagnostic.severity]++;
|
|
1659
|
+
report.valid = counts.error === 0;
|
|
1660
|
+
report.rulesRun = rulesRun;
|
|
1661
|
+
report.slideCount = slides.length;
|
|
1662
|
+
return report;
|
|
1663
|
+
}
|
|
1664
|
+
function auditPresentation(document, options = {}) {
|
|
1665
|
+
return auditObject(document, options);
|
|
1666
|
+
}
|
|
1667
|
+
function auditSource(source, options = {}) {
|
|
1668
|
+
const syntax = lintSource(source);
|
|
1669
|
+
if (syntax.schemaValid === null) {
|
|
1670
|
+
const report = emptyReport(options, resolveOptions(options).thresholds);
|
|
1671
|
+
report.documentValid = false;
|
|
1672
|
+
for (const diagnostic of syntax.diagnostics)
|
|
1673
|
+
report.diagnostics.push({
|
|
1674
|
+
ruleId: "audit/invalid-document",
|
|
1675
|
+
severity: "error",
|
|
1676
|
+
category: "content",
|
|
1677
|
+
scope: "document",
|
|
1678
|
+
path: diagnostic.path,
|
|
1679
|
+
message: `The source is not valid JSON, so it was not audited: ${diagnostic.message}`,
|
|
1680
|
+
help: diagnostic.help,
|
|
1681
|
+
...diagnostic.location ? { location: diagnostic.location } : {}
|
|
1682
|
+
});
|
|
1683
|
+
report.counts = { error: report.diagnostics.length, warning: 0, info: 0 };
|
|
1684
|
+
report.valid = false;
|
|
1685
|
+
return report;
|
|
1686
|
+
}
|
|
1687
|
+
return auditObject(JSON.parse(source.replace(/^/, "")), options, locator(source));
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
export { DEFAULT_AUDIT_THRESHOLDS, DEFAULT_CHART_PALETTE, auditPresentation, auditRules, auditSource, findAuditRule };
|