@openpresentation/opf-pptx 0.11.8 → 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 +69 -9
- package/dist/annotation-export.js +83 -0
- package/dist/annotation-provenance.js +246 -0
- package/dist/body-text-import.js +4 -1
- package/dist/chart-options.js +276 -0
- package/dist/chartex.js +42 -11
- package/dist/code-provenance.js +34 -10
- package/dist/content-topology.js +280 -53
- package/dist/document-provenance.js +83 -10
- package/dist/furniture-provenance.js +67 -3
- package/dist/image-fallback-browser.js +12 -0
- package/dist/image-fallback-node.js +22 -0
- package/dist/import-signals.js +809 -0
- package/dist/index.d.ts +302 -4
- package/dist/index.js +486 -102
- package/dist/logo-provenance.js +8 -6
- package/dist/metric-provenance.js +6 -3
- package/dist/native-furniture.js +297 -0
- package/dist/native-text-style.js +17 -1
- package/dist/numbered-list.js +114 -0
- package/dist/run-colors.js +118 -0
- package/dist/script-fonts.js +90 -14
- package/dist/slide-image-provenance.js +6 -4
- package/dist/svg-image.js +354 -0
- package/dist/table-cell-import.js +3 -1
- package/dist/table-import.js +3 -2
- package/dist/watermark-provenance.js +4 -3
- package/package.json +7 -7
package/dist/script-fonts.js
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
import * as opfCore from "@openpresentation/opf";
|
|
14
14
|
|
|
15
15
|
const resolver = typeof opfCore.resolveScriptFonts === "function" ? opfCore.resolveScriptFonts : null;
|
|
16
|
+
// RR-05: authored alignment is logical for right-to-left text (`left` is the start edge). Core owns the rule; a core without it
|
|
17
|
+
// reports no line directions either, so the fallback never has anything to flip.
|
|
18
|
+
export const physicalAlignment = typeof opfCore.physicalAlignment === "function" ? opfCore.physicalAlignment : alignment => alignment;
|
|
16
19
|
// The one paragraph-direction rule shared with the renderer (core FF-07).
|
|
17
20
|
const paragraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
|
|
18
21
|
|
|
@@ -126,24 +129,89 @@ const decodeText = value => value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|(amp|lt|gt|q
|
|
|
126
129
|
decimal ? String.fromCodePoint(Number(decimal)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : {amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'"}[name.toLowerCase()]);
|
|
127
130
|
|
|
128
131
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* right-to-left. Alignment is left as composed.
|
|
132
|
+
* RR-05 bidi runs. PowerPoint treats the digits of a run tagged with a right-to-left language as numbers of that language and
|
|
133
|
+
* orders them as their own item, so "v2.0" or "PowerPoint 365" inside an Arabic or Hebrew run reads "2.0v" or "365PowerPoint".
|
|
134
|
+
* Splitting each left-to-right Latin phrase into its own run tagged en-US keeps it one item that PowerPoint orders left to right.
|
|
133
135
|
*/
|
|
134
|
-
|
|
136
|
+
const LATIN_WORD = /[0-9\p{Script=Latin}]/u, LATIN_LETTER = /\p{Script=Latin}/u, PHRASE_JOINER = /[ \u00a0.,:;&'\u2019/+*_@#=-]/;
|
|
137
|
+
|
|
138
|
+
/** Ranges [start, end) of the Latin phrases of a decoded string: a Latin letter through the last Latin letter or digit joined by spaces and word punctuation. */
|
|
139
|
+
export function latinPhrases(text) {
|
|
140
|
+
const ranges = [];
|
|
141
|
+
for (let index = 0; index < text.length;) {
|
|
142
|
+
const char = String.fromCodePoint(text.codePointAt(index));
|
|
143
|
+
if (!LATIN_LETTER.test(char)) { index += char.length; continue; }
|
|
144
|
+
let end = index + char.length;
|
|
145
|
+
for (let cursor = end; cursor < text.length;) {
|
|
146
|
+
const next = String.fromCodePoint(text.codePointAt(cursor));
|
|
147
|
+
if (LATIN_WORD.test(next)) { cursor += next.length; end = cursor; continue; }
|
|
148
|
+
if (!PHRASE_JOINER.test(next)) break;
|
|
149
|
+
// Joiners count only when a Latin letter or digit follows them directly.
|
|
150
|
+
let after = cursor + next.length;
|
|
151
|
+
while (after < text.length && PHRASE_JOINER.test(text[after])) after += 1;
|
|
152
|
+
if (after < text.length && LATIN_WORD.test(String.fromCodePoint(text.codePointAt(after)))) cursor = after; else break;
|
|
153
|
+
}
|
|
154
|
+
ranges.push([index, end]);
|
|
155
|
+
index = end;
|
|
156
|
+
}
|
|
157
|
+
return ranges;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const encodeText = value => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
161
|
+
const RUN = /<a:r>(<a:rPr\b[^>]*\/>|<a:rPr\b[^>]*[^/]>[\s\S]*?<\/a:rPr>)<a:t>([^<]*)<\/a:t><\/a:r>/g;
|
|
162
|
+
|
|
163
|
+
/** Split the runs of a right-to-left paragraph so each Latin phrase is its own en-US run. */
|
|
164
|
+
function splitLatinRuns(body) {
|
|
165
|
+
return body.replace(RUN, (run, properties, escaped) => {
|
|
166
|
+
const text = decodeText(escaped), phrases = latinPhrases(text);
|
|
167
|
+
if (!phrases.length) return run;
|
|
168
|
+
const make = (value, lang) => `<a:r>${lang ? properties.replace(/\slang="[^"]*"/, ` lang="${lang}"`) : properties}<a:t>${encodeText(value)}</a:t></a:r>`;
|
|
169
|
+
const pieces = [];
|
|
170
|
+
let cursor = 0;
|
|
171
|
+
for (const [from, to] of phrases) {
|
|
172
|
+
if (from > cursor) pieces.push(make(text.slice(cursor, from)));
|
|
173
|
+
pieces.push(make(text.slice(from, to), "en-US"));
|
|
174
|
+
cursor = to;
|
|
175
|
+
}
|
|
176
|
+
if (cursor < text.length) pieces.push(make(text.slice(cursor)));
|
|
177
|
+
return pieces.join("");
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* In a right-to-left deck every paragraph states its direction. The writers mark the lines of a paragraph they laid out
|
|
183
|
+
* (rtl="1", the direction core computed once for the whole paragraph, so wrapped lines never differ); any other paragraph takes
|
|
184
|
+
* core's paragraphDirection() over its own text: rtl="1" when it is right-to-left, else an explicit rtl="0", because the master
|
|
185
|
+
* default levels start right-to-left. A right-to-left paragraph with no explicit alignment starts at the right edge (logical
|
|
186
|
+
* alignment: the master default is left), and its Latin phrases become en-US runs (see splitLatinRuns). With `notes` the
|
|
187
|
+
* alignment is also set on an explicit left paragraph, because the notes writers pass no alignment of their own.
|
|
188
|
+
*/
|
|
189
|
+
function paragraphRtl(xml, deckDirection, {notes = false} = {}) {
|
|
135
190
|
return xml.replace(/<a:p>([\s\S]*?)<\/a:p>/g, (paragraph, body) => {
|
|
136
191
|
const text = [...body.matchAll(/<a:t>([^<]*)<\/a:t>|<a:br\b/g)].map(match => match[1] === undefined ? "\n" : decodeText(match[1])).join("");
|
|
137
|
-
const value = paragraphDirection(text, deckDirection) === "rtl" ? "1" : "0";
|
|
138
192
|
const properties = /^(\s*)<a:pPr\b([^>]*?)(\/?)>/.exec(body);
|
|
139
|
-
|
|
140
|
-
const
|
|
193
|
+
const marked = properties && / rtl="([01])"/.exec(properties[2]);
|
|
194
|
+
const rtl = marked ? marked[1] === "1" : paragraphDirection(text, deckDirection) === "rtl";
|
|
195
|
+
const value = rtl ? "1" : "0";
|
|
196
|
+
if (rtl) body = splitLatinRuns(body);
|
|
197
|
+
if (!properties) return `<a:p><a:pPr rtl="${value}"${rtl ? " algn=\"r\"" : ""}/>${body}</a:p>`;
|
|
198
|
+
let attributes = / rtl="[^"]*"/.test(properties[2]) ? properties[2].replace(/ rtl="[^"]*"/, ` rtl="${value}"`) : `${properties[2]} rtl="${value}"`;
|
|
199
|
+
if (rtl) {
|
|
200
|
+
if (!/\salgn="/.test(attributes)) attributes += " algn=\"r\"";
|
|
201
|
+
else if (notes) attributes = attributes.replace(/ algn="l"/, " algn=\"r\"");
|
|
202
|
+
}
|
|
141
203
|
return `<a:p>${properties[1]}<a:pPr${attributes}${properties[3]}>${body.slice(properties[0].length)}</a:p>`;
|
|
142
204
|
});
|
|
143
205
|
}
|
|
144
206
|
|
|
145
|
-
/**
|
|
146
|
-
const
|
|
207
|
+
/** Tables of a right-to-left deck run right to left: the first column is the rightmost, as core's layout draws it. */
|
|
208
|
+
const tableRtl = xml => xml.replace(/<a:tblPr\b([^>]*?)(\/?)>/g, (match, attributes, close) => / rtl="/.test(attributes) ? match : `<a:tblPr rtl="1"${attributes}${close}>`);
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Master, layout and presentation default paragraph levels start right-to-left, and a left-aligned level starts at the right edge
|
|
212
|
+
* (logical alignment): text typed later in an Arabic or Hebrew deck then reads and aligns as the exported slides do.
|
|
213
|
+
*/
|
|
214
|
+
const levelRtl = xml => xml.replace(/<a:(?:lvl\dpPr|defPPr)\b[^>]*?\srtl="0"[^>]*>/g, level => level.replace(/\srtl="0"/, " rtl=\"1\"").replace(/\salgn="l"/, " algn=\"r\""));
|
|
147
215
|
|
|
148
216
|
/**
|
|
149
217
|
* Apply the plan to one generated XML part. `slideIndex` names the slide a
|
|
@@ -157,10 +225,10 @@ export function partScriptFonts(path, xml, plan, slideIndex) {
|
|
|
157
225
|
if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
|
|
158
226
|
xml = xml.replace(/<p:(sp|graphicFrame)>[\s\S]*?<\/p:\1>/g, shape =>
|
|
159
227
|
runScriptFonts(shape, resolved, /<p:cNvPr\b[^>]*\bname="OPF heading /.test(shape)));
|
|
160
|
-
if (plan.rtl) xml = paragraphRtl(xml, plan.deck.direction);
|
|
228
|
+
if (plan.rtl) xml = tableRtl(paragraphRtl(xml, plan.deck.direction));
|
|
161
229
|
} else if (/^ppt\/(?:charts\/chart(?:Ex)?|notesSlides\/notesSlide)\d+\.xml$/.test(path)) {
|
|
162
230
|
xml = runScriptFonts(xml, resolved, false);
|
|
163
|
-
if (plan.rtl && path.startsWith("ppt/notesSlides/")) xml = paragraphRtl(xml, plan.deck.direction);
|
|
231
|
+
if (plan.rtl && path.startsWith("ppt/notesSlides/")) xml = paragraphRtl(xml, plan.deck.direction, {notes: true});
|
|
164
232
|
} else if (plan.rtl && /^ppt\/(?:slideMasters\/slideMaster\d+|slideLayouts\/slideLayout\d+|notesMasters\/notesMaster\d+|presentation)\.xml$/.test(path)) {
|
|
165
233
|
xml = levelRtl(xml);
|
|
166
234
|
}
|
|
@@ -252,6 +320,12 @@ export function reconcileLanguage(groups, observed, report) {
|
|
|
252
320
|
* language, and theme East Asian/complex-script fonts the document does not
|
|
253
321
|
* reproduce.
|
|
254
322
|
*/
|
|
323
|
+
/** Whether the language observed in the runs is written right to left. */
|
|
324
|
+
export function observedRtl(observed) {
|
|
325
|
+
if (observed.lang === undefined) return false;
|
|
326
|
+
try { return resolver ? resolver({language: observed.language}).rtl === true : observed.match?.record.direction === "rtl"; } catch { return false; }
|
|
327
|
+
}
|
|
328
|
+
|
|
255
329
|
export function languageDiagnostics(imported, observed, report) {
|
|
256
330
|
if (!report) return;
|
|
257
331
|
const {lang, match, ranked, rtlParagraphs, theme} = observed;
|
|
@@ -259,9 +333,11 @@ export function languageDiagnostics(imported, observed, report) {
|
|
|
259
333
|
if (rtlParagraphs) report({code: "rtl-language-mismatch", path: "language", message: `${rtlParagraphs} right-to-left paragraph(s) carry no run language, so no presentation language was imported.`});
|
|
260
334
|
return;
|
|
261
335
|
}
|
|
262
|
-
|
|
336
|
+
// RR-05: a right-to-left deck's Latin phrases are their own en-US runs; they are not a second presentation language.
|
|
337
|
+
const languages = observedRtl(observed) ? ranked.filter(([tag]) => tag === lang || tag !== "en-US") : ranked;
|
|
338
|
+
if (languages.length > 1) {
|
|
263
339
|
report({code: "mixed-run-languages", path: "language",
|
|
264
|
-
message: `Runs use ${
|
|
340
|
+
message: `Runs use ${languages.length} languages (${languages.map(([tag, count]) => `${tag} x${count}`).join(", ")}). OPF has one presentation language, so ${lang} was imported.`});
|
|
265
341
|
}
|
|
266
342
|
const kept = imported.language === observed.language;
|
|
267
343
|
if (kept && !match) report({code: "language-uncatalogued", path: "language", message: `Run language ${lang} matches no languages catalog record; it was imported as a BCP-47 tag.`});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
+
import {normalizeCrop, withoutSvgBlip} from './svg-image.js';
|
|
2
3
|
import {attachTextTags, decodeTextTag} from './code-provenance.js';
|
|
3
4
|
import {framedPictureTransform} from './image-geometry.js';
|
|
4
5
|
|
|
@@ -10,8 +11,9 @@ const EMUS_PER_INCH = 914400;
|
|
|
10
11
|
const parser = new XMLParser({ignoreAttributes:false, attributeNamePrefix:'', parseTagValue:false, trimValues:false});
|
|
11
12
|
const decoder = new TextDecoder('utf-8', {fatal:true}), encoder = new TextEncoder();
|
|
12
13
|
const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
14
|
+
// Whitespace-only text between elements is not identity: the vendored writer indents, PowerPoint drops it on save.
|
|
13
15
|
const canonical = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
14
|
-
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
|
|
16
|
+
? Object.fromEntries(Object.keys(item).sort().filter(key => !(key === '#text' && typeof item[key] === 'string' && !item[key].trim())).map(key => [key, item[key]])) : item);
|
|
15
17
|
const POSITIONS = ['background', 'top', 'bottom', 'left', 'right'];
|
|
16
18
|
|
|
17
19
|
export const slideImageName = slidePath => `OPF slide image ${slidePath}`;
|
|
@@ -35,8 +37,8 @@ function blipEffects(effects) {
|
|
|
35
37
|
|
|
36
38
|
// Native blip fill without its package-local relationship id.
|
|
37
39
|
function blipFillIdentity(blipFill) {
|
|
38
|
-
const {['a:blip']: blip, ...rest} = blipFill ?? {};
|
|
39
|
-
const {['r:embed']: _embed, ...blipRest} = blip ?? {};
|
|
40
|
+
const {['a:blip']: blip, ...rest} = normalizeCrop(blipFill) ?? {};
|
|
41
|
+
const {['r:embed']: _embed, ...blipRest} = withoutSvgBlip(blip) ?? {};
|
|
40
42
|
return {...rest, 'a:blip': blipRest};
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -132,7 +134,7 @@ export function importSlideImage(pictures, shapes, relationships, entries, slide
|
|
|
132
134
|
if (manifest?.v !== 1 || manifest.slide !== `slides.${slideIndex}` || !['crop', 'fit'].includes(manifest.fill) || !validTreatment(manifest.treatment, manifest.position)) throw Error('Invalid slide image manifest.');
|
|
133
135
|
if (manifest.content !== undefined && manifest.content !== true) throw Error('Invalid slide image content flag.');
|
|
134
136
|
if (picture['p:nvPicPr']?.['p:cNvPr']?.name !== slideImageName(manifest.slide)) throw Error('Slide image identity changed.');
|
|
135
|
-
if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(manifest.blipFill)) throw Error('Slide image geometry or effects changed.');
|
|
137
|
+
if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(normalizeCrop(manifest.blipFill))) throw Error('Slide image geometry or effects changed.');
|
|
136
138
|
const item = readPicture(picture);
|
|
137
139
|
const src = item?.payload?.image?.src;
|
|
138
140
|
if (typeof src !== 'string') throw Error('Slide image bytes are unavailable.');
|
|
@@ -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, '"');
|
|
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
|
+
}
|
|
@@ -63,7 +63,9 @@ export function nativeCellStyle(properties, body, context, scale, report) {
|
|
|
63
63
|
const listStyle = drawingObject(child(body,'a:lstStyle'));
|
|
64
64
|
const alignments = nodes(body,'a:p').map(paragraph => {
|
|
65
65
|
const p = drawingObject(paragraph['a:p'])['a:pPr'] ?? {};
|
|
66
|
-
|
|
66
|
+
const algn = p.algn ?? listStyle[`a:lvl${Number(p.lvl ?? 0)+1}pPr`]?.algn ?? listStyle['a:defPPr']?.algn ?? 'l';
|
|
67
|
+
// Alignment is logical for right-to-left text (RR-05): an rtl paragraph aligned right starts at the start edge.
|
|
68
|
+
return p.rtl === '1' || p.rtl === 1 ? ({l:'r',r:'l'}[algn] ?? algn) : algn;
|
|
67
69
|
});
|
|
68
70
|
const alignment = {l:'left',ctr:'center',r:'right'}[alignments[0] ?? 'l'];
|
|
69
71
|
if (alignment) style.align = alignment;
|
package/dist/table-import.js
CHANGED
|
@@ -121,7 +121,7 @@ function conditionalProperties(style, properties, row, column, rowCount, columnC
|
|
|
121
121
|
|
|
122
122
|
// Use the same direct graphic-frame ordering as the slide collector. The ordered
|
|
123
123
|
// reader retains interleaved runs, fields, breaks, empty paragraphs and spaces.
|
|
124
|
-
export function importTableFrames(slidePath, archive, relationships, report, dimensions) {
|
|
124
|
+
export function importTableFrames(slidePath, archive, relationships, report, dimensions, {rtlDeck = false} = {}) {
|
|
125
125
|
const context = readSlideTheme(slidePath, archive);
|
|
126
126
|
const scale = Math.min(dimensions.widthInches,dimensions.heightInches) * 96 / 720;
|
|
127
127
|
const root = child(context.parsedPart(slidePath)?.tree, 'p:sld');
|
|
@@ -133,7 +133,8 @@ export function importTableFrames(slidePath, archive, relationships, report, dim
|
|
|
133
133
|
const style = tableStyle(props, archive, context, (code, message) => report(frameIndex, '', code, message));
|
|
134
134
|
if (style?.['a:tblBg']) report(frameIndex, '', 'unsupported-table-cell-style', 'The native table style background is not represented; supported character styles are retained.');
|
|
135
135
|
const properties = attrs(table, 'a:tblPr');
|
|
136
|
-
|
|
136
|
+
// A right-to-left table of a right-to-left deck keeps its logical column order (RR-05); the deck language carries the direction.
|
|
137
|
+
if (boolean(properties.rtl) === true && !rtlDeck) report(frameIndex, '', 'unsupported-table-direction', 'Right-to-left table geometry is not represented; cells retain native source order.');
|
|
137
138
|
const headers = boolean(properties.firstRow) === true;
|
|
138
139
|
const nativeRows = nodes(table, 'a:tr');
|
|
139
140
|
const columnCount = nodes(child(table, 'a:tblGrid'), 'a:gridCol').length;
|
|
@@ -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.');
|