@openpresentation/opf-pptx 0.11.8 → 0.11.9

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.
@@ -0,0 +1,354 @@
1
+ // Native SVG pictures (PowerPoint 2016 and Microsoft 365): a `p:pic` whose `a:blip` embeds a PNG fallback raster and
2
+ // carries the `asvg:svgBlip` extension that points at the SVG media part. This module holds the parts that need no
3
+ // I/O: reading an SVG data URI, validating and sanitizing the XML (nothing executes, nothing is fetched), measuring
4
+ // the intrinsic size, choosing the fallback raster size and writing the extension into a generated package.
5
+
6
+ export const SVG_BLIP_URI = '{96DAC541-7B7A-43D3-8B79-37D633B846F1}';
7
+ export const SVG_BLIP_NS = 'http://schemas.microsoft.com/office/drawing/2016/SVG/main';
8
+ const SVG_NS = 'http://www.w3.org/2000/svg';
9
+ const IMAGE_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image';
10
+ const MAX_SVG_BYTES = 8 * 1024 * 1024;
11
+ // The fallback raster: 192 dpi at the displayed size, rounded up to a multiple of 128 px, never beyond 2304 px.
12
+ const RASTER_DPI = 192, RASTER_STEP = 128, RASTER_MIN = 128, RASTER_MAX = 2304;
13
+
14
+ const decoder = new TextDecoder('utf-8', {fatal: true}), encoder = new TextEncoder();
15
+
16
+ // Elements that can run code or load a document; they are removed with their content.
17
+ const REMOVED_ELEMENTS = new Set(['script', 'foreignObject', 'handler', 'listener']);
18
+ // Raster images a nested `href` / `url()` may carry inline; every other reference is external or executable.
19
+ const INLINE_RASTER = /^data:image\/(?:png|jpe?g|gif|webp)[;,]/i;
20
+
21
+ class SvgError extends Error {
22
+ constructor(reason, message) { super(message); this.reason = reason; }
23
+ }
24
+
25
+ /**
26
+ * The bytes of a `data:` URI whose media type is an SVG, or null when the URI is not an SVG (a raster, any other type,
27
+ * or not a data URI). Base64 and percent-encoded text payloads are read; `{error}` marks a payload that is not decodable.
28
+ */
29
+ export function svgDataUriBytes(uri) {
30
+ const match = /^data:([^;,]*)((?:;[^;,]*)*),/i.exec(uri);
31
+ if (!match || !/^image\/svg/i.test(match[1])) return null;
32
+ const payload = uri.slice(match[0].length);
33
+ try {
34
+ if (/;base64/i.test(match[2])) {
35
+ const binary = atob(payload.replace(/\s+/g, ''));
36
+ return Uint8Array.from(binary, char => char.charCodeAt(0));
37
+ }
38
+ return encoder.encode(decodeURIComponent(payload));
39
+ } catch {
40
+ return {error: 'The SVG data URI payload is not valid base64 or percent-encoded text.'};
41
+ }
42
+ }
43
+
44
+ export function svgDataUri(bytes) {
45
+ let binary = '';
46
+ for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
47
+ return `data:image/svg+xml;base64,${btoa(binary)}`;
48
+ }
49
+
50
+ function decodeText(bytes) {
51
+ let start = 0, label = 'utf-8';
52
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) start = 3;
53
+ else if (bytes[0] === 0xff && bytes[1] === 0xfe) label = 'utf-16le', start = 2;
54
+ else if (bytes[0] === 0xfe && bytes[1] === 0xff) label = 'utf-16be', start = 2;
55
+ else {
56
+ const declared = /^\s*<\?xml\b[^>]*\bencoding\s*=\s*["']([A-Za-z0-9._-]+)["']/.exec(String.fromCharCode(...bytes.subarray(0, 200)))?.[1];
57
+ if (declared && !/^utf-?8$/i.test(declared)) label = declared;
58
+ }
59
+ try {
60
+ return new TextDecoder(label, {fatal: true}).decode(bytes.subarray(start));
61
+ } catch {
62
+ throw new SvgError('svg-malformed', `The SVG text is not valid ${label}.`);
63
+ }
64
+ }
65
+
66
+ const number = value => {
67
+ const match = /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(px|pt|pc|mm|cm|in|q)?\s*$/i.exec(value ?? '');
68
+ if (!match) return null;
69
+ const unit = {px: 1, pt: 96 / 72, pc: 16, mm: 96 / 25.4, cm: 96 / 2.54, in: 96, q: 96 / 101.6}[(match[2] ?? 'px').toLowerCase()];
70
+ const result = Number(match[1]) * unit;
71
+ return Number.isFinite(result) && result > 0 ? result : null;
72
+ };
73
+
74
+ /**
75
+ * Intrinsic size in CSS pixels from the root's width, height and viewBox, following the SVG rules a browser
76
+ * uses for an image: both lengths absolute; one absolute and a viewBox (the other follows its ratio); a viewBox alone.
77
+ * Percentage, relative and missing lengths without a viewBox have no size.
78
+ */
79
+ export function svgIntrinsicSize(attributes) {
80
+ const width = number(attributes.width), height = number(attributes.height);
81
+ const box = (attributes.viewBox ?? '').trim().split(/[\s,]+/).map(Number);
82
+ const view = box.length === 4 && box.every(Number.isFinite) && box[2] > 0 && box[3] > 0 ? {width: box[2], height: box[3]} : null;
83
+ let size = null;
84
+ if (width && height) size = {width, height};
85
+ else if (view && width) size = {width, height: width * view.height / view.width};
86
+ else if (view && height) size = {width: height * view.width / view.height, height};
87
+ else if (view) size = view;
88
+ return size && size.width <= 1e7 && size.height <= 1e7 && size.width / size.height <= 1e4 && size.height / size.width <= 1e4 ? size : null;
89
+ }
90
+
91
+ const filterUrls = value => value.replace(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/gi, (reference, double, single, bare) => {
92
+ const target = (double ?? single ?? bare ?? '').trim();
93
+ return target.startsWith('#') || INLINE_RASTER.test(target) ? reference : 'none';
94
+ });
95
+ const filterCss = value => filterUrls(value.replace(/@import\b[^;{}]*(?:;|$)/gi, ''));
96
+
97
+ const START_TAG = /<([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)((?:\s+[^\s=\/>"'<]+\s*=\s*(?:"[^"<]*"|'[^'<]*'))*)\s*(\/?)>/y;
98
+ const ATTRIBUTE = /\s+([^\s=\/>"'<]+)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/y;
99
+ const END_TAG = /<\/([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)\s*>/y;
100
+ const escapeAttribute = value => value.replace(/"/g, '&quot;');
101
+
102
+ function parseAttributes(source) {
103
+ const result = [], names = new Set();
104
+ ATTRIBUTE.lastIndex = 0;
105
+ for (let match; source && (match = ATTRIBUTE.exec(source));) {
106
+ if (names.has(match[1])) throw new SvgError('svg-malformed', `The SVG repeats the attribute ${match[1]}.`);
107
+ names.add(match[1]);
108
+ result.push([match[1], match[2] ?? match[3]]);
109
+ }
110
+ return result;
111
+ }
112
+
113
+ /**
114
+ * Validate and sanitize an SVG. A well-formed document with an SVG root, an `xmlns` and an intrinsic size passes;
115
+ * scripts, `foreignObject`, event handlers, animations of links, every reference that is not a fragment (`#id`) or an
116
+ * inline raster, `@import` rules, `xml-stylesheet` instructions and DOCTYPE declarations are removed (entities a
117
+ * DOCTYPE declares as plain text are expanded; external entities are refused). Nothing is fetched or executed.
118
+ * Returns `{bytes, text, hasText, width, height, removed}`: `bytes` are the original bytes when nothing had to be removed,
119
+ * otherwise the sanitized document encoded as UTF-8; `text` is the sanitized document (what is drawn). `{error: {reason, message}}` for a document that cannot be exported.
120
+ */
121
+ export function prepareSvg(input) {
122
+ try {
123
+ if (!(input instanceof Uint8Array)) throw new SvgError('svg-malformed', 'The SVG source is not text.');
124
+ if (input.byteLength > MAX_SVG_BYTES) throw new SvgError('svg-too-large', `The SVG is larger than ${MAX_SVG_BYTES / 1024 / 1024} MiB.`);
125
+ return sanitize(input, decodeText(input));
126
+ } catch (error) {
127
+ if (error instanceof SvgError) return {error: {reason: error.reason, message: error.message}};
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ function sanitize(input, text) {
133
+ const out = [], stack = [], entities = new Map(), removed = new Set();
134
+ let index = 0, root = null, skipping = 0, expansion = 0, hasText = false;
135
+ const malformed = message => { throw new SvgError('svg-malformed', `The SVG is not well-formed XML: ${message}`); };
136
+ // Entity and character references in text and attribute values: only the five predefined entities, numeric
137
+ // references and plain-text entities the DOCTYPE declared; anything else is not well-formed XML.
138
+ const references = value => value.replace(/&([^;\s&<]*);?/g, (all, name) => {
139
+ if (!all.endsWith(';')) malformed('an ampersand starts no reference');
140
+ if (/^(?:amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-fA-F]{1,6})$/.test(name)) return all;
141
+ if (!entities.has(name)) malformed(`the entity &${name}; is not declared`);
142
+ const expanded = entities.get(name);
143
+ if ((expansion += expanded.length) > MAX_SVG_BYTES) throw new SvgError('svg-unsafe', 'The SVG expands its entities beyond the size limit.');
144
+ return expanded;
145
+ });
146
+
147
+ while (index < text.length) {
148
+ if (text[index] !== '<') {
149
+ const end = text.indexOf('<', index), chunk = text.slice(index, end < 0 ? text.length : end);
150
+ index += chunk.length;
151
+ if (!stack.length) {
152
+ if (chunk.trim()) malformed('text outside the root element');
153
+ } else if (!skipping) {
154
+ if (chunk.includes(']]>')) malformed('a stray CDATA terminator');
155
+ const style = stack.at(-1).name === 'style';
156
+ out.push(references(style ? filterCss(chunk) : chunk));
157
+ }
158
+ continue;
159
+ }
160
+ if (text.startsWith('<!--', index)) {
161
+ const end = text.indexOf('-->', index + 4);
162
+ if (end < 0) malformed('an unterminated comment');
163
+ index = end + 3;
164
+ continue;
165
+ }
166
+ if (text.startsWith('<![CDATA[', index)) {
167
+ const end = text.indexOf(']]>', index + 9);
168
+ if (end < 0) malformed('an unterminated CDATA section');
169
+ if (!stack.length) malformed('CDATA outside the root element');
170
+ if (!skipping) out.push(`<![CDATA[${stack.at(-1).name === 'style' ? filterCss(text.slice(index + 9, end)) : text.slice(index + 9, end)}]]>`);
171
+ index = end + 3;
172
+ continue;
173
+ }
174
+ if (text.startsWith('<?', index)) {
175
+ const end = text.indexOf('?>', index + 2);
176
+ if (end < 0) malformed('an unterminated processing instruction');
177
+ if (!/^<\?xml[\s?]/i.test(text.slice(index, index + 6)) && /^<\?xml-stylesheet\b/i.test(text.slice(index, index + 20))) removed.add('a stylesheet instruction');
178
+ index = end + 2;
179
+ continue;
180
+ }
181
+ if (text.startsWith('<!DOCTYPE', index)) {
182
+ if (root || stack.length) malformed('a DOCTYPE after the root element');
183
+ const match = /^<!DOCTYPE\s+[^\[>]*(?:\[([\s\S]*?)\]\s*)?>/.exec(text.slice(index));
184
+ if (!match) malformed('an unreadable DOCTYPE');
185
+ for (const declaration of (match[1] ?? '').matchAll(/<!ENTITY\b[^>]*>/g)) {
186
+ const entity = /^<!ENTITY\s+([A-Za-z_][\w.-]*)\s+(?:"([^"]*)"|'([^']*)')\s*>$/.exec(declaration[0]);
187
+ const value = entity?.[2] ?? entity?.[3];
188
+ if (!entity || value.includes('&') || value.includes('<')) throw new SvgError('svg-unsafe', 'The SVG declares an external or markup entity, which is not read.');
189
+ entities.set(entity[1], value);
190
+ }
191
+ removed.add('a DOCTYPE declaration');
192
+ index += match[0].length;
193
+ continue;
194
+ }
195
+ if (text.startsWith('</', index)) {
196
+ END_TAG.lastIndex = index;
197
+ const match = END_TAG.exec(text);
198
+ if (!match) malformed('an unreadable closing tag');
199
+ const open = stack.pop();
200
+ if (!open || open.qualified !== match[1]) malformed(`</${match[1]}> does not close <${open?.qualified ?? 'nothing'}>`);
201
+ index = END_TAG.lastIndex;
202
+ if (open.skip) skipping--;
203
+ else out.push(`</${match[1]}>`);
204
+ if (!stack.length) root = root ?? true;
205
+ continue;
206
+ }
207
+ START_TAG.lastIndex = index;
208
+ const match = START_TAG.exec(text);
209
+ if (!match) malformed('an unreadable tag');
210
+ index = START_TAG.lastIndex;
211
+ const [, qualified, source, selfClosing] = match, name = qualified.slice(qualified.indexOf(':') + 1);
212
+ const attributes = parseAttributes(source);
213
+ if (!stack.length) {
214
+ if (root) malformed('more than one root element');
215
+ if (name !== 'svg') throw new SvgError('svg-malformed', `The root element is <${qualified}>, not <svg>.`);
216
+ const prefix = qualified.includes(':') ? `xmlns:${qualified.slice(0, qualified.indexOf(':'))}` : 'xmlns';
217
+ if (!attributes.some(([key, value]) => key === prefix && references(value) === SVG_NS)) throw new SvgError('svg-malformed', `The root <svg> has no ${prefix}="${SVG_NS}".`);
218
+ root = {attributes: Object.fromEntries(attributes.map(([key, value]) => [key, references(value)]))};
219
+ }
220
+ const drop = skipping > 0 || REMOVED_ELEMENTS.has(name)
221
+ || (['animate', 'set'].includes(name) && attributes.some(([key, value]) => key === 'attributeName' && /(?:^|:)href$/i.test(value)));
222
+ if (!skipping && drop) removed.add(name === 'script' ? 'scripts' : `<${name}> elements`);
223
+ if (!drop) {
224
+ if (name === 'text') hasText = true;
225
+ let tag = `<${qualified}`;
226
+ for (const [key, raw] of attributes) {
227
+ const local = key.slice(key.indexOf(':') + 1);
228
+ let value = references(raw);
229
+ if (/^on/i.test(local)) { removed.add('event handlers'); continue; }
230
+ if (key === 'xml:base') { removed.add('external references'); continue; }
231
+ if (/^href$/i.test(local)) {
232
+ if (!(value.trim().startsWith('#') || INLINE_RASTER.test(value.trim()))) { removed.add('external references'); continue; }
233
+ } else if (/url\(/i.test(value) || key === 'style') {
234
+ const filtered = key === 'style' ? filterCss(value) : filterUrls(value);
235
+ if (filtered !== value) removed.add('external references');
236
+ value = filtered;
237
+ }
238
+ tag += ` ${key}="${escapeAttribute(value)}"`;
239
+ }
240
+ out.push(selfClosing ? `${tag}/>` : `${tag}>`);
241
+ }
242
+ if (!selfClosing) {
243
+ stack.push({qualified, name, skip: drop});
244
+ if (drop) skipping++;
245
+ } else if (!stack.length) root = root ?? true;
246
+ }
247
+ if (stack.length) malformed(`<${stack.at(-1).qualified}> is never closed`);
248
+ if (!root) malformed('there is no root element');
249
+ const size = svgIntrinsicSize(root.attributes);
250
+ if (!size) throw new SvgError('svg-no-size', 'The SVG has no width and height, and no viewBox, so it has no intrinsic size.');
251
+ const list = [...removed], clean = out.join('');
252
+ return {bytes: list.length || entities.size ? encoder.encode(clean) : input, text: clean, hasText, width: size.width, height: size.height, removed: list};
253
+ }
254
+
255
+ /**
256
+ * The fallback raster's size: the SVG's own aspect, at 192 dpi of the displayed size (the SVG contained in `box`, or
257
+ * covering it when `cover`), rounded up to a multiple of 128 px (so equal pictures of different box sizes often share one
258
+ * raster), within 128 to 2304 px on the long side. `box` is in inches; without it the SVG's own CSS size is used.
259
+ */
260
+ export function svgRasterScale(size, box, cover = false) {
261
+ const covered = box ? (cover ? Math.max : Math.min)(box.w / size.width, box.h / size.height) * 96 : 1;
262
+ const long = Math.max(size.width, size.height) * covered * RASTER_DPI / 96;
263
+ const target = Math.min(RASTER_MAX, Math.max(RASTER_MIN, Math.ceil(long / RASTER_STEP) * RASTER_STEP));
264
+ return target / Math.max(size.width, size.height);
265
+ }
266
+
267
+ const tagAttributes = tag => Object.fromEntries([...tag.matchAll(/\s([^\s=/>]+)\s*=\s*"([^"]*)"/g)].map(match => [match[1], match[2]]));
268
+
269
+ /**
270
+ * The relationship id of a picture blip's SVG, from its parsed XML (`a:blip` node), or undefined.
271
+ */
272
+ export function svgBlipRelationship(blip) {
273
+ const extensions = blip?.['a:extLst']?.['a:ext'];
274
+ for (const extension of Array.isArray(extensions) ? extensions : extensions ? [extensions] : []) {
275
+ if (String(extension?.uri ?? '').toUpperCase() === SVG_BLIP_URI) {
276
+ const embed = extension['asvg:svgBlip']?.['r:embed'];
277
+ if (typeof embed === 'string' && embed) return embed;
278
+ }
279
+ }
280
+ return undefined;
281
+ }
282
+
283
+ /**
284
+ * A parsed `a:blip` without its SVG extension: the identity a provenance manifest compares. Whether a picture
285
+ * carries an SVG is read from the package itself (the part may be rewritten by PowerPoint), not from the manifest.
286
+ */
287
+ export function withoutSvgBlip(blip) {
288
+ const extensions = blip?.['a:extLst']?.['a:ext'];
289
+ if (!extensions) return blip;
290
+ const kept = (Array.isArray(extensions) ? extensions : [extensions]).filter(extension => String(extension?.uri ?? '').toUpperCase() !== SVG_BLIP_URI);
291
+ const {['a:extLst']: _extensions, ...rest} = blip;
292
+ return kept.length ? {...rest, 'a:extLst': {...blip['a:extLst'], 'a:ext': kept.length === 1 ? kept[0] : kept}} : rest;
293
+ }
294
+
295
+ /**
296
+ * A parsed `p:blipFill` (or a stored identity of one) with its crop in canonical form: PowerPoint drops the zero sides of an
297
+ * `a:srcRect` when it saves (`l="25000" t="0" r="25000" b="0"` becomes `l="25000" r="25000"`), and an all-zero crop is no crop.
298
+ */
299
+ export function normalizeCrop(blipFill) {
300
+ const crop = blipFill?.['a:srcRect'];
301
+ if (!blipFill || typeof crop !== 'object' || crop === null) return blipFill;
302
+ const kept = Object.fromEntries(Object.entries(crop).filter(([, value]) => !(Number(value) === 0)));
303
+ const {['a:srcRect']: _crop, ...rest} = blipFill;
304
+ return Object.keys(kept).length ? {...rest, 'a:srcRect': kept} : rest;
305
+ }
306
+
307
+ /**
308
+ * Add the SVG pictures to a generated package. `pictures` maps a picture's shape name (or `slidePart|name` for names
309
+ * a slide repeats) to `{bytes, width, height}`. For each matching `p:pic` this writes the SVG as a media part, a
310
+ * relationship from the slide, and `a:extLst/a:ext/asvg:svgBlip` inside the picture's `a:blip` (after any effect
311
+ * children, as the schema orders them); the PNG the blip already embeds stays the fallback.
312
+ * Returns Map<fallback media part, {width, height}> so fitting can use the SVG's own proportions.
313
+ */
314
+ export function attachSvgPictures(entries, pictures) {
315
+ const sizes = new Map();
316
+ if (!pictures.size) return sizes;
317
+ const read = path => new TextDecoder().decode(entries[path]), write = (path, xml) => { entries[path] = encoder.encode(xml); };
318
+ let media = 0, needsType = false;
319
+ for (const part of Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path)).sort()) {
320
+ const relationshipPart = part.replace('/slides/', '/slides/_rels/') + '.rels';
321
+ let relationships = read(relationshipPart);
322
+ const targets = new Map([...relationships.matchAll(/<Relationship\b[^>]*>/g)].map(match => {
323
+ const attributes = tagAttributes(match[0]);
324
+ return [attributes.Id, attributes.Target];
325
+ }));
326
+ let changed = false;
327
+ const xml = read(part).replace(/<p:pic>[\s\S]*?<\/p:pic>/g, picture => {
328
+ const name = /<p:cNvPr\b[^>]*\bname="([^"]*)"/.exec(picture)?.[1];
329
+ const svg = pictures.get(`${part}|${name}`) ?? pictures.get(name);
330
+ if (!svg) return picture;
331
+ const blip = /<a:blip\b([^>]*?)(?:\/>|>([\s\S]*?)<\/a:blip>)/.exec(picture);
332
+ const fallback = /\br:embed="([^"]+)"/.exec(blip?.[1] ?? '')?.[1];
333
+ if (!blip || !fallback || (blip[2] ?? '').includes('a:extLst')) throw new Error('A generated picture has no plain fallback blip for its SVG.');
334
+ let path;
335
+ do path = `ppt/media/svg-${/\d+/.exec(part)[0]}-${++media}.svg`; while (entries[path]);
336
+ entries[path] = svg.bytes;
337
+ const taken = new Set([...relationships.matchAll(/\bId="([^"]+)"/g)].map(match => match[1]));
338
+ let id = `rIdOpfSvg${media}`;
339
+ while (taken.has(id)) id += '_';
340
+ relationships = relationships.replace('</Relationships>', `<Relationship Id="${id}" Type="${IMAGE_REL}" Target="../media/${path.slice('ppt/media/'.length)}"/></Relationships>`);
341
+ const fallbackPart = targets.get(fallback)?.replace(/^\.\.\//, 'ppt/');
342
+ if (fallbackPart) sizes.set(fallbackPart, {width: svg.width, height: svg.height});
343
+ changed = needsType = true;
344
+ const extension = `<a:extLst><a:ext uri="${SVG_BLIP_URI}"><asvg:svgBlip xmlns:asvg="${SVG_BLIP_NS}" r:embed="${id}"/></a:ext></a:extLst>`;
345
+ return picture.replace(blip[0], () => `<a:blip${blip[1]}>${blip[2] ?? ''}${extension}</a:blip>`);
346
+ });
347
+ if (changed) { write(part, xml); write(relationshipPart, relationships); }
348
+ }
349
+ if (needsType) {
350
+ const types = read('[Content_Types].xml');
351
+ if (!/<Default\b[^>]*\bExtension="svg"/i.test(types)) write('[Content_Types].xml', types.replace('</Types>', '<Default Extension="svg" ContentType="image/svg+xml"/></Types>'));
352
+ }
353
+ return sizes;
354
+ }
@@ -1,4 +1,5 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
+ import {normalizeCrop, withoutSvgBlip} from './svg-image.js';
2
3
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
3
4
  import {pictureTransform} from './image-geometry.js';
4
5
 
@@ -30,8 +31,8 @@ export function watermarkOpacity(value) {
30
31
  }
31
32
 
32
33
  function blipFillIdentity(blipFill) {
33
- const {['a:blip']: blip, ...rest} = blipFill ?? {};
34
- const {['r:embed']: _embed, ...blipRest} = blip ?? {};
34
+ const {['a:blip']: blip, ...rest} = normalizeCrop(blipFill) ?? {};
35
+ const {['r:embed']: _embed, ...blipRest} = withoutSvgBlip(blip) ?? {};
35
36
  return {...rest, 'a:blip': blipRest};
36
37
  }
37
38
 
@@ -134,7 +135,7 @@ export function importWatermark(pictures, relationships, entries, slideIndex, re
134
135
  const manifest = decodeTextTag(own[0].val);
135
136
  if (manifest?.v !== 1 || manifest.slide !== `slides.${slideIndex}` || typeof manifest.opacity !== 'number' || !(manifest.opacity >= 0 && manifest.opacity <= 1)) throw Error('Invalid watermark manifest.');
136
137
  if (picture['p:nvPicPr']?.['p:cNvPr']?.name !== watermarkName()) throw Error('Watermark identity changed.');
137
- if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(manifest.blipFill)) throw Error('Watermark geometry or opacity changed.');
138
+ if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(normalizeCrop(manifest.blipFill))) throw Error('Watermark geometry or opacity changed.');
138
139
  const item = readPicture(picture);
139
140
  const src = item?.payload?.image?.src;
140
141
  if (typeof src !== 'string') throw Error('Watermark bytes are unavailable.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.11.8",
3
+ "version": "0.11.9",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,8 +37,8 @@
37
37
  "sideEffects": false,
38
38
  "scripts": {
39
39
  "build": "node scripts/build.mjs",
40
- "typecheck": "node --check src/index.js && node --check src/chart-types.js && node --check src/chartex.js && node --check test/chartex.mjs && node --check test/chart-text-size.mjs && node --check test/chart-types.mjs && node --check test/design-alignment.mjs && node --check src/color-ref.js && node --check src/theme-colors.js && node --check test/theme-colors.mjs && node --check test/tag-colour.mjs && node --check test/table-theme-colors.mjs && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check src/slide-image-provenance.js && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check src/watermark-provenance.js && node --check src/logo-provenance.js && node --check test/design-fields.mjs && node --check src/media-dedupe.js && node --check test/media-dedupe.mjs && node --check test/watermark.mjs && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/single-column-chart.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check src/quote-provenance.js && node --check test/quote-provenance.mjs && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check src/document-provenance.js && node --check src/content-topology.js && node --check src/sections.js && node --check test/document-provenance.mjs && node --check test/content-topology.mjs && node --check test/sections.mjs && node --check test/provenance-interop.mjs && node --check test/furniture-provenance.mjs && node --check src/furniture-fields.js && node --check test/furniture-fields.mjs && node --check test/furniture-wrapped-date.mjs && node --check test/native-picture.mjs && node --check test/compare-native-picture.mjs && node --check test/compare-native-picture-edit.mjs && node --check test/compare-native-picture-edit-controls.mjs && node --check test/native-font-embed-audit.mjs && node --check test/native-font-embed-controls.mjs && node --check test/native-font-embed-fixture-source.mjs && node --check test/native-font-edit-fixture.mjs && node --check test/native-mixed-edit-audit.mjs && node --check test/native-mixed-edit-controls.mjs && node --check test/master-bullet-font.mjs && node --check test/native-font-inventory-audit.mjs && node --check test/native-font-inventory-controls.mjs && node --check test/default-font-scheme.mjs && node --check test/export-chosen-fonts.mjs && node --check test/background-fills.mjs && node --check src/typeface-inventory.js && node --check src/package-fonts.js && node --check test/typeface-inventory.mjs && node --check src/script-fonts.js && node --check test/script-fonts.mjs && node --check test/theme-script-slots.mjs && node --check test/powershell-scan.mjs && node --check test/powershell-scan-probes.mjs && node --check test/socials-furniture.mjs && node --check src/media-provenance.js && node --check test/layout-parity.mjs && node --check test/cover-centering.mjs && node --check test/media-placeholder.mjs && node --check test/image-placeholder.mjs && node --check test/svg-image-degrade.mjs && node --check test/background-colorref.mjs && node --check test/import-fabrications.mjs && node --check test/list-shape-names.mjs && node --check test/layout-intent.mjs && node --check test/chart-cache-import.mjs && node --check test/zip-date.mjs && node --check test/native-text-whitespace.mjs && node --check test/native-underline.mjs && node --check src/native-text-style.js && node --check src/font-weights.js && node --check test/font-weight-import.mjs && node --check src/body-text-import.js && node --check test/native-body-rich-text.mjs && node --check test/native-body-rich-text-browser.js && node --check test/native-quote-import-contract.mjs && node --check test/native-quote-import-controls.mjs && node --check test/native-quote.mjs && node --check test/determinism.mjs && node --check test/determinism-fixtures.mjs && node --check test/table-frame-furniture-order.mjs && node --check test/shape-border-widths.mjs && node --check test/gallery-layout-alignment.mjs",
41
- "test": "npm run build && node test/chart-types.mjs && node test/chartex.mjs && node test/chart-text-size.mjs && node test/zip-date.mjs && node test/background-fills.mjs && npm run test:furniture && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/watermark.mjs && node test/design-fields.mjs && node test/media-dedupe.mjs && node test/document-provenance.mjs && node test/content-topology.mjs && node test/sections.mjs && node test/layout-intent.mjs && node test/master-bullet-font.mjs && node test/default-font-scheme.mjs && node test/script-fonts.mjs && node test/theme-script-slots.mjs && node test/export-chosen-fonts.mjs && node test/typeface-inventory.mjs && node test/color-ref-export.mjs && node test/theme-colors.mjs && node test/tag-colour.mjs && node test/table-theme-colors.mjs && node test/plain-whitespace.mjs && node test/native-text-whitespace.mjs && node test/native-body-rich-text.mjs && node test/native-underline.mjs && node test/font-weight-import.mjs && node test/design-alignment.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/native-quote-import-controls.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/single-column-chart.mjs && node --test test/chart-cache-import.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/layout-parity.mjs && node test/gallery-layout-alignment.mjs && node test/cover-centering.mjs && node test/table-frame-furniture-order.mjs && node test/media-placeholder.mjs && node test/shape-border-widths.mjs && node test/image-placeholder.mjs && node test/svg-image-degrade.mjs && node test/background-colorref.mjs && node test/import-fabrications.mjs && node test/list-shape-names.mjs && node test/shared-quote.mjs && node test/quote-provenance.mjs && npm run test:timeline && npm run test:determinism",
40
+ "typecheck": "node --check src/index.js && node --check src/svg-image.js && node --check test/svg-image.mjs && node --check test/native-svg-decks.mjs && node --check src/chart-types.js && node --check src/chartex.js && node --check test/chartex.mjs && node --check test/chart-text-size.mjs && node --check test/chart-types.mjs && node --check test/design-alignment.mjs && node --check src/color-ref.js && node --check src/theme-colors.js && node --check test/theme-colors.mjs && node --check test/tag-colour.mjs && node --check test/table-theme-colors.mjs && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check src/slide-image-provenance.js && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check src/watermark-provenance.js && node --check src/logo-provenance.js && node --check test/design-fields.mjs && node --check src/media-dedupe.js && node --check test/media-dedupe.mjs && node --check test/watermark.mjs && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/single-column-chart.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check src/quote-provenance.js && node --check test/quote-provenance.mjs && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check src/document-provenance.js && node --check src/content-topology.js && node --check src/sections.js && node --check test/document-provenance.mjs && node --check test/content-topology.mjs && node --check test/sections.mjs && node --check test/provenance-interop.mjs && node --check test/furniture-provenance.mjs && node --check src/furniture-fields.js && node --check test/furniture-fields.mjs && node --check src/native-furniture.js && node --check test/native-furniture.mjs && node --check test/furniture-wrapped-date.mjs && node --check test/native-picture.mjs && node --check test/compare-native-picture.mjs && node --check test/compare-native-picture-edit.mjs && node --check test/compare-native-picture-edit-controls.mjs && node --check test/native-font-embed-audit.mjs && node --check test/native-font-embed-controls.mjs && node --check test/native-font-embed-fixture-source.mjs && node --check test/native-font-edit-fixture.mjs && node --check test/native-mixed-edit-audit.mjs && node --check test/native-mixed-edit-controls.mjs && node --check test/master-bullet-font.mjs && node --check test/native-font-inventory-audit.mjs && node --check test/native-font-inventory-controls.mjs && node --check test/default-font-scheme.mjs && node --check test/export-chosen-fonts.mjs && node --check test/background-fills.mjs && node --check src/typeface-inventory.js && node --check src/package-fonts.js && node --check test/typeface-inventory.mjs && node --check src/script-fonts.js && node --check test/script-fonts.mjs && node --check test/theme-script-slots.mjs && node --check test/powershell-scan.mjs && node --check test/powershell-scan-probes.mjs && node --check test/socials-furniture.mjs && node --check src/media-provenance.js && node --check test/layout-parity.mjs && node --check test/cover-centering.mjs && node --check test/media-placeholder.mjs && node --check test/image-placeholder.mjs && node --check test/svg-image-degrade.mjs && node --check test/background-colorref.mjs && node --check test/import-fabrications.mjs && node --check test/list-shape-names.mjs && node --check test/layout-intent.mjs && node --check test/chart-cache-import.mjs && node --check test/zip-date.mjs && node --check test/native-text-whitespace.mjs && node --check test/native-underline.mjs && node --check src/native-text-style.js && node --check src/font-weights.js && node --check test/font-weight-import.mjs && node --check src/body-text-import.js && node --check test/native-body-rich-text.mjs && node --check test/native-body-rich-text-browser.js && node --check test/native-quote-import-contract.mjs && node --check test/native-quote-import-controls.mjs && node --check test/native-quote.mjs && node --check test/determinism.mjs && node --check test/determinism-fixtures.mjs && node --check test/table-frame-furniture-order.mjs && node --check test/shape-border-widths.mjs && node --check test/gallery-layout-alignment.mjs && node --check src/import-signals.js && node --check test/import-signals.mjs && node --check test/signals-deck.mjs",
41
+ "test": "npm run build && node test/chart-types.mjs && node test/chartex.mjs && node test/chart-text-size.mjs && node test/zip-date.mjs && node test/background-fills.mjs && npm run test:furniture && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/watermark.mjs && node test/design-fields.mjs && node test/media-dedupe.mjs && node test/document-provenance.mjs && node test/content-topology.mjs && node test/sections.mjs && node test/layout-intent.mjs && node test/master-bullet-font.mjs && node test/default-font-scheme.mjs && node test/script-fonts.mjs && node test/theme-script-slots.mjs && node test/export-chosen-fonts.mjs && node test/typeface-inventory.mjs && node test/color-ref-export.mjs && node test/theme-colors.mjs && node test/tag-colour.mjs && node test/table-theme-colors.mjs && node test/plain-whitespace.mjs && node test/native-text-whitespace.mjs && node test/native-body-rich-text.mjs && node test/native-underline.mjs && node test/font-weight-import.mjs && node test/design-alignment.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/native-quote-import-controls.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/single-column-chart.mjs && node --test test/chart-cache-import.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/layout-parity.mjs && node test/gallery-layout-alignment.mjs && node test/cover-centering.mjs && node test/table-frame-furniture-order.mjs && node test/media-placeholder.mjs && node test/shape-border-widths.mjs && node test/image-placeholder.mjs && node test/svg-image-degrade.mjs && node test/svg-image.mjs && node test/background-colorref.mjs && node test/import-fabrications.mjs && node test/list-shape-names.mjs && node test/shared-quote.mjs && node test/quote-provenance.mjs && node test/import-signals.mjs && npm run test:timeline && npm run test:determinism",
42
42
  "validate": "node scripts/validate-package.mjs",
43
43
  "test:packed": "node test/packed-install.mjs && node test/provenance-interop.mjs",
44
44
  "test:compare-published": "node test/compare-published.mjs",
@@ -51,7 +51,7 @@
51
51
  "test:metric": "node test/shared-metric.mjs && node test/metric-provenance.mjs",
52
52
  "test:font-variants": "node test/font-variants.mjs",
53
53
  "test:timeline": "node test/shared-timeline.mjs",
54
- "test:furniture": "node test/shared-furniture.mjs && node test/furniture-provenance.mjs && node test/furniture-fields.mjs && node test/furniture-wrapped-date.mjs && node test/socials-furniture.mjs",
54
+ "test:furniture": "node test/shared-furniture.mjs && node test/furniture-provenance.mjs && node test/furniture-fields.mjs && node test/furniture-wrapped-date.mjs && node test/socials-furniture.mjs && node test/native-furniture.mjs",
55
55
  "test:native-picture-edit-controls": "node test/compare-native-picture-edit-controls.mjs",
56
56
  "test:native-font-embed-controls": "node test/native-font-embed-controls.mjs",
57
57
  "test:native-mixed-edit-controls": "node test/native-mixed-edit-controls.mjs",