@openpresentation/opf-pptx 0.20.3 → 0.20.4
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/dist/annotation-provenance.js +11 -2
- package/dist/chart-workbook.js +7 -5
- package/dist/errors.js +11 -0
- package/dist/index.js +24 -24
- package/dist/input-safety.js +115 -0
- package/dist/svg-image.js +9 -2
- package/dist/typeface-inventory.js +5 -5
- package/package.json +4 -4
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import {XMLParser} from 'fast-xml-parser';
|
|
19
19
|
import {attachTextTags, decodeTextTag} from './code-provenance.js';
|
|
20
20
|
import {sourceLineParagraphs} from './text-provenance.js';
|
|
21
|
-
import {CAPTION_TAG, FOOTNOTES_TAG} from './annotation-export.js';
|
|
21
|
+
import {CAPTION_TAG, FOOTNOTES_TAG, footnotesRuleName} from './annotation-export.js';
|
|
22
22
|
|
|
23
23
|
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
24
24
|
const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
|
|
@@ -141,7 +141,16 @@ export function importAnnotations({shapes, paragraphs, relationships, entries, s
|
|
|
141
141
|
if (footnote === undefined) continue;
|
|
142
142
|
consumed.add(shape);
|
|
143
143
|
if (!footnote || !['rule', 'entry'].includes(footnote.role)) { report({code: 'invalid-footnote-provenance', path: `slides.${slideIndex}`, message: 'A footnote tag is unreadable or invalid; its text stays an ordinary text block.'}); consumed.delete(shape); continue; }
|
|
144
|
-
if (footnote.role === 'rule')
|
|
144
|
+
if (footnote.role === 'rule') {
|
|
145
|
+
const current = paragraphs[index] ?? [];
|
|
146
|
+
if (footnote.slide !== slideIndex || name(shape) !== footnotesRuleName(slideIndex)
|
|
147
|
+
|| shape['p:spPr']?.['a:prstGeom']?.prst !== 'line'
|
|
148
|
+
|| current.some(paragraph => paragraph.text || paragraph.bullet || paragraph.math || paragraph.fields?.length)) {
|
|
149
|
+
consumed.delete(shape);
|
|
150
|
+
report({code: 'invalid-footnote-provenance', path: `slides.${slideIndex}`, message: 'An edited or incompatible tagged footnote separator stays ordinary native content; its current text is retained.'});
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
145
154
|
if (!Number.isSafeInteger(footnote.entry) || !Number.isSafeInteger(footnote.number) || footnote.number < 1 || !['reference', 'footnote'].includes(footnote.kind) || (footnote.kind === 'reference' && typeof footnote.id !== 'string') || footnote.line === undefined) { report({code: 'invalid-footnote-provenance', path: `slides.${slideIndex}`, message: 'A footnote entry tag is invalid; its text stays an ordinary text block.'}); consumed.delete(shape); continue; }
|
|
146
155
|
if (!entryGroups.has(footnote.entry)) entryGroups.set(footnote.entry, []);
|
|
147
156
|
entryGroups.get(footnote.entry).push({index, shape, record: footnote});
|
package/dist/chart-workbook.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {unzipSync,zipSync} from 'fflate';
|
|
2
2
|
import {XMLParser} from 'fast-xml-parser';
|
|
3
|
+
import {readNestedInputZip} from './input-safety.js';
|
|
3
4
|
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false,removeNSPrefix:true,htmlEntities:{amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}});
|
|
4
5
|
const decoder=new TextDecoder('utf-8',{fatal:true}),encoder=new TextEncoder();
|
|
5
6
|
const array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
@@ -25,11 +26,12 @@ function workbookContext(entries,chartPart){
|
|
|
25
26
|
const ref=relationship(entries,chartPart,(chart?.externalData??chart?.chartData?.externalData)?.id);
|
|
26
27
|
if(!ref?.type?.endsWith('/package')||!entries[ref.path])return undefined;
|
|
27
28
|
// Read only bounded spreadsheet metadata. Never follow external workbook links.
|
|
29
|
+
const nested=readNestedInputZip(entries,ref.path);
|
|
28
30
|
let size=0;
|
|
29
|
-
const workbook=
|
|
30
|
-
if(!/^xl\/(workbook\.xml|_rels\/workbook\.xml\.rels|sharedStrings\.xml|worksheets\/[^/]+\.xml)$/.test(
|
|
31
|
-
size+=
|
|
32
|
-
}
|
|
31
|
+
const workbook=Object.fromEntries(Object.entries(nested).filter(([name,bytes])=>{
|
|
32
|
+
if(!/^xl\/(workbook\.xml|_rels\/workbook\.xml\.rels|sharedStrings\.xml|worksheets\/[^/]+\.xml)$/.test(name))return false;
|
|
33
|
+
size+=bytes.length;if(size>16*1024*1024)throw Error('Chart workbook metadata exceeds the limit.');return true;
|
|
34
|
+
}));
|
|
33
35
|
// The category range: c:cat/c:xVal references, or the chartex cx:strDim type="cat" formula.
|
|
34
36
|
const category=find(chart,'cat')[0]??find(chart,'xVal')[0]??find(chart,'strDim').find(dim=>dim?.type==='cat'),formula=category?.strRef?.f??category?.multiLvlStrRef?.f??category?.numRef?.f??(category?.type==='cat'?category?.f:undefined);
|
|
35
37
|
// Infer a heading only for one contiguous vertical category range with a row
|
|
@@ -58,7 +60,7 @@ export function readChartCategoryHeading(entries,chartPart){
|
|
|
58
60
|
// Error, boolean, date and formula results are not literal text headings.
|
|
59
61
|
if(cell.t!==undefined&&!['str','n'].includes(cell.t))return undefined;
|
|
60
62
|
return cell.v===undefined?undefined:text(cell.v);
|
|
61
|
-
}catch{return undefined;}
|
|
63
|
+
}catch(error){if(error?.code==='pptx-limit-exceeded')throw error;return undefined;}
|
|
62
64
|
}
|
|
63
65
|
export function writeChartCategoryHeading(entries,chartPart,heading){
|
|
64
66
|
const context=workbookContext(entries,chartPart);
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class OPFPptxError extends Error {
|
|
2
|
+
constructor(code, message, details = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "OPFPptxError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.details = details;
|
|
7
|
+
if (details.findings) this.findings = details.findings;
|
|
8
|
+
if (details.diagnostics) this.diagnostics = details.diagnostics;
|
|
9
|
+
if (details.path) this.path = details.path;
|
|
10
|
+
}
|
|
11
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -68,6 +68,9 @@ import {
|
|
|
68
68
|
validate
|
|
69
69
|
} from "@openpresentation/opf";
|
|
70
70
|
import {checkFormat, isValidFormat} from "./format-check.js";
|
|
71
|
+
import {OPFPptxError} from './errors.js';
|
|
72
|
+
import {readInputZip} from './input-safety.js';
|
|
73
|
+
export {OPFPptxError} from './errors.js';
|
|
71
74
|
|
|
72
75
|
export {checkTypefaces, inventoryTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
|
|
73
76
|
export {DEFAULT_SIGNAL_LIMITS, SIGNALS_VERSION, isMonospaceFamily} from './import-signals.js';
|
|
@@ -91,18 +94,6 @@ export const runtimePolicy = Object.freeze({
|
|
|
91
94
|
deterministicLocalExecution: true
|
|
92
95
|
});
|
|
93
96
|
|
|
94
|
-
export class OPFPptxError extends Error {
|
|
95
|
-
constructor(code, message, details = {}) {
|
|
96
|
-
super(message);
|
|
97
|
-
this.name = "OPFPptxError";
|
|
98
|
-
this.code = code;
|
|
99
|
-
this.details = details;
|
|
100
|
-
if (details.findings) this.findings = details.findings;
|
|
101
|
-
if (details.diagnostics) this.diagnostics = details.diagnostics;
|
|
102
|
-
if (details.path) this.path = details.path;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
97
|
const FIXED_TIMESTAMP = "1980-01-01T00:00:00Z";
|
|
107
98
|
// fflate derives the DOS zip date from local-time fields, so build this from
|
|
108
99
|
// local components: a UTC instant rolls back to 1979 west of UTC (below
|
|
@@ -642,8 +633,9 @@ function readPptxZip(input) {
|
|
|
642
633
|
}
|
|
643
634
|
|
|
644
635
|
try {
|
|
645
|
-
return
|
|
636
|
+
return readInputZip(bytes);
|
|
646
637
|
} catch (error) {
|
|
638
|
+
if (error instanceof OPFPptxError) throw error;
|
|
647
639
|
throw new OPFPptxError("invalid-pptx", "PPTX input is not a readable ZIP archive.", {
|
|
648
640
|
cause: errorMessage(error)
|
|
649
641
|
});
|
|
@@ -2215,7 +2207,7 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
|
|
|
2215
2207
|
// FA-10: a TextRun[] heading draws and exports like rich body text; its lines are named and tagged as a heading (the body's are not).
|
|
2216
2208
|
const heading = item.field !== 'text';
|
|
2217
2209
|
addRichFitLines(slide, item.text, item.box, itemContext, {
|
|
2218
|
-
value: item.value, alignment: item.text.placement?.alignment ?? item.alignment ?? 'left',
|
|
2210
|
+
value: item.value, path: item.path, alignment: item.text.placement?.alignment ?? item.alignment ?? 'left',
|
|
2219
2211
|
fallback: item.field === 'tag' ? tagColor(itemContext) : itemContext.textColor,
|
|
2220
2212
|
...(heading ? {
|
|
2221
2213
|
objectName: index => `OPF heading ${item.path} line ${index}`,
|
|
@@ -2301,7 +2293,7 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
2301
2293
|
const kind = inferPayloadKind(payload);
|
|
2302
2294
|
switch (kind) {
|
|
2303
2295
|
case "text":
|
|
2304
|
-
addTextPayload(slide, payload.text ?? payload.bullets, region, context);
|
|
2296
|
+
addTextPayload(slide, payload.text ?? payload.bullets, region, context, path);
|
|
2305
2297
|
break;
|
|
2306
2298
|
case "list":
|
|
2307
2299
|
addListPayload(slide, payload.items ?? payload.bullets, region, context);
|
|
@@ -2346,9 +2338,9 @@ function inferPayloadKind(payload) {
|
|
|
2346
2338
|
return "unknown";
|
|
2347
2339
|
}
|
|
2348
2340
|
|
|
2349
|
-
function addTextPayload(slide, value, region, context) {
|
|
2341
|
+
function addTextPayload(slide, value, region, context, path) {
|
|
2350
2342
|
if (Array.isArray(value)) {
|
|
2351
|
-
slide.addText(textRuns(value, context, 18), textBoxOptions(region, context, 18));
|
|
2343
|
+
slide.addText(textRuns(value, context, 18, path), textBoxOptions(region, context, 18));
|
|
2352
2344
|
return;
|
|
2353
2345
|
}
|
|
2354
2346
|
if (Array.isArray(value?.bullets)) {
|
|
@@ -2366,9 +2358,10 @@ function listRunLook(fit,style,context){
|
|
|
2366
2358
|
if(text.size!==1||description.size>1)return undefined;
|
|
2367
2359
|
return {text:JSON.parse([...text][0]),...(description.size?{description:JSON.parse([...description][0])}:{})};
|
|
2368
2360
|
}
|
|
2369
|
-
function richLineRuns(line,color,native,context) {
|
|
2361
|
+
function richLineRuns(line,color,native,context,path) {
|
|
2370
2362
|
const fallback=color.replace(/^#/,'');
|
|
2371
2363
|
return line.fragments.map(fragment=>{
|
|
2364
|
+
assertXmlText(fragment.run.text ?? fragment.text, path);
|
|
2372
2365
|
const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
|
|
2373
2366
|
return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:(fragment.nominalSize??fragment.fontSize)*.75,color:linkColor(fragment.run,color,context),underline:fragment.run.underline?{style:'sng',color:linkColor(fragment.run,color,context)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/(fragment.nominalSize??fragment.fontSize)*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:|tel:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined,...langOptions(context,fragment.style.lang)}};
|
|
2374
2367
|
});
|
|
@@ -2416,7 +2409,7 @@ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,s
|
|
|
2416
2409
|
const number=first?entry.marker.number:undefined;
|
|
2417
2410
|
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture:number?false:picture});
|
|
2418
2411
|
const paragraph=first?{bullet:number?{type:'number',style:autoNumScheme(number.style,number.suffix),startAt:number.value,indent:entry.marker.indent*.75}:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
|
|
2419
|
-
const runs=richLineRuns(line,color,native,context);
|
|
2412
|
+
const runs=richLineRuns(line,color,native,context,path);
|
|
2420
2413
|
if(!runs.length)runs.push({text:'',options:{}});
|
|
2421
2414
|
// Keep paragraph intent identical across runs. ZIP normalization below
|
|
2422
2415
|
// removes the duplicate paragraph-property nodes emitted by PptxGenJS.
|
|
@@ -2876,6 +2869,7 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
|
|
|
2876
2869
|
const defaultText = cellStyle.color === undefined ? tableTextSchemeValue(fillValue, baseColor, context) : undefined;
|
|
2877
2870
|
const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
|
|
2878
2871
|
const text = stringifyText(cell.value), style = cell.textStyle;
|
|
2872
|
+
assertXmlText(text, cell.path ?? path);
|
|
2879
2873
|
const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
|
|
2880
2874
|
const runs = rich ? cell.value.flatMap((value, index) => {
|
|
2881
2875
|
const run = typeof value === 'string' ? {text:value} : value;
|
|
@@ -2955,10 +2949,11 @@ function pixelBox(region) {
|
|
|
2955
2949
|
// super/subscript (a citation marker is a baseline run) and http(s)/mailto links. The body's lines stay untagged; a heading or quote
|
|
2956
2950
|
// body passes `objectName` and `tag`, which name and tag every line with the boundary between wrapped lines (a soft wrap inserts
|
|
2957
2951
|
// nothing, a hard line break keeps its separator), recovered from the flattened text. `fallback` is the color of runs without one.
|
|
2958
|
-
function addRichFitLines(slide, fit, box, context, {value, alignment: logicalAlignment, fallback, objectName, tag}) {
|
|
2952
|
+
function addRichFitLines(slide, fit, box, context, {value, path, alignment: logicalAlignment, fallback, objectName, tag}) {
|
|
2959
2953
|
const region = {x: box.x / 96, y: box.y / 96, w: box.width / 96, h: box.height / 96};
|
|
2960
2954
|
const runText = run => typeof run === 'string' ? run : run.text;
|
|
2961
2955
|
const whole = typeof value === 'string' ? value : value.map(runText).join('');
|
|
2956
|
+
assertXmlText(whole, path);
|
|
2962
2957
|
const count = fit.richLines.length;
|
|
2963
2958
|
let cursor = 0;
|
|
2964
2959
|
for (const [index, line] of fit.richLines.entries()) {
|
|
@@ -2984,11 +2979,15 @@ function addRichFitLines(slide, fit, box, context, {value, alignment: logicalAli
|
|
|
2984
2979
|
}
|
|
2985
2980
|
}
|
|
2986
2981
|
|
|
2982
|
+
function assertXmlText(text, path) {
|
|
2983
|
+
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
|
|
2984
|
+
if(invalid)throw new OPFPptxError('invalid-text',`Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which DrawingML XML cannot represent.`,{path});
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
2987
|
// Match the published renderer's payload geometry and shared core text fitting.
|
|
2988
2988
|
// Each fitted line remains native editable text, without PowerPoint rewrapping it.
|
|
2989
2989
|
function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
2990
|
-
|
|
2991
|
-
if(invalid)throw new OPFPptxError('invalid-text',`Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which DrawingML XML cannot represent.`,{path:config.path});
|
|
2990
|
+
assertXmlText(text, config.path);
|
|
2992
2991
|
const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
|
|
2993
2992
|
const style = config.textStyle ?? resolveTextStyle({fontFamily: config.fontFamily ?? context.fonts.body, fontWeight: config.fontWeight ?? 400, path: config.path}, options.textMeasurement);
|
|
2994
2993
|
const fit = config.fit ?? fitText(String(text ?? ''), box, config.fontSize * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
|
|
@@ -3230,7 +3229,7 @@ async function addQuotePayload(slide, presentation, value, layout, context, opti
|
|
|
3230
3229
|
// FA-10: a TextRun[] quote body (core's rich lines, quotation marks joined to its first and last run) is exported line by line like rich body text, with the same quote tags.
|
|
3231
3230
|
if (part.runs && part.fit.richLines) {
|
|
3232
3231
|
addRichFitLines(slide, part.fit, part.box, context, {
|
|
3233
|
-
value: part.text, alignment: part.fit.placement?.alignment ?? context.contentAlignment ?? 'left', fallback: context.textColor,
|
|
3232
|
+
value: part.text, path: part.path, alignment: part.fit.placement?.alignment ?? context.contentAlignment ?? 'left', fallback: context.textColor,
|
|
3234
3233
|
objectName: line => `OPF quote ${group} part ${index} line ${line}`,
|
|
3235
3234
|
tag: (name, line, count, boundary) => context.quoteTags.set(name, {v: 1, role: 'text', group, part: index, line, count, ...boundary, base: richBase(part.fit, part.style, context.colors.text), ...(index === 0 && line === 0 ? {anchor} : {})})
|
|
3236
3235
|
});
|
|
@@ -3574,9 +3573,10 @@ function textBoxOptions(region, context, fontSize) {
|
|
|
3574
3573
|
// The private text helpers annotation-export.js writes captions and footnote lines with (RR-34).
|
|
3575
3574
|
const exportHelpers = {textBoxOptions, nativeFontOptions, exportColor, nativeColor};
|
|
3576
3575
|
|
|
3577
|
-
function textRuns(value, context, fallbackFontSize) {
|
|
3576
|
+
function textRuns(value, context, fallbackFontSize, path) {
|
|
3578
3577
|
const runs = Array.isArray(value) ? value : [value];
|
|
3579
3578
|
return runs.map((run) => {
|
|
3579
|
+
assertXmlText(typeof run === "string" ? run : run?.text, path);
|
|
3580
3580
|
if (typeof run === "string") {
|
|
3581
3581
|
return {
|
|
3582
3582
|
text: run,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Internal import safety policy, shared by PPTX and nested workbook/introspection readers.
|
|
2
|
+
// No caller-provided limits and no claim that ZIP CRC/provenance authenticates the source.
|
|
3
|
+
import {unzipSync, Unzip, UnzipInflate} from 'fflate';
|
|
4
|
+
import {OPFPptxError} from './errors.js';
|
|
5
|
+
const MiB=1024*1024;
|
|
6
|
+
export const INPUT_LIMITS=Object.freeze({compressedBytes:64*MiB,members:10000,memberBytes:64*MiB,expandedBytes:256*MiB,xmlBytes:16*MiB,xmlDepth:256,nestedDepth:16});
|
|
7
|
+
const states=new WeakMap();
|
|
8
|
+
const xmlPart=name=>/\.(?:xml|rels)$/i.test(name);
|
|
9
|
+
const invalid=(message,path)=>new OPFPptxError('invalid-pptx',message,path===undefined?{}:{path});
|
|
10
|
+
function limit(name,actual,path){
|
|
11
|
+
const maximum=INPUT_LIMITS[name];
|
|
12
|
+
if(actual>maximum)throw new OPFPptxError('pptx-limit-exceeded',`PPTX ${name} exceeds the supported limit (${maximum}).`,{limit:name,maximum,actual,...(path===undefined?{}:{path})});
|
|
13
|
+
}
|
|
14
|
+
function memberName(name){
|
|
15
|
+
if(typeof name!=='string'||!name||/[\\\x00-\x1f]/.test(name)||name.startsWith('/')||/^[a-z]:/i.test(name)||name.split('/').some((p,i,a)=>p==='.'||p==='..'||p===''&&i!==a.length-1))throw invalid('PPTX contains a noncanonical ZIP member name.',name);
|
|
16
|
+
}
|
|
17
|
+
function checkXml(bytes,path){
|
|
18
|
+
limit('xmlBytes',bytes.length,path);
|
|
19
|
+
// Check nesting before any recursive parser/tree walk; skip comments, CDATA and PIs.
|
|
20
|
+
// Reject DTDs entirely: OOXML parts do not need document entities or external declarations.
|
|
21
|
+
let text;
|
|
22
|
+
const encoding=bytes[0]===0xff&&bytes[1]===0xfe?'utf-16le':bytes[0]===0xfe&&bytes[1]===0xff?'utf-16be':'utf-8';
|
|
23
|
+
try{text=new TextDecoder(encoding,{fatal:true}).decode(bytes);}catch{throw invalid('PPTX XML is not valid encoded text.',path);}
|
|
24
|
+
const declarations=text.replace(/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>/g,'');
|
|
25
|
+
if(/<!DOCTYPE\b|<!ENTITY\b/i.test(declarations))throw invalid('PPTX XML document entities are not supported.',path);
|
|
26
|
+
const stack=[];
|
|
27
|
+
// Count broad XML name tokens, including Unicode/namespace names the OOXML parser
|
|
28
|
+
// accepts. This is a resource preflight, not a replacement XML grammar validator.
|
|
29
|
+
const tokens=/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<\/?[^\s<>\/"'=!?]+(?:[^<>"']|"[^"]*"|'[^']*')*>/g;
|
|
30
|
+
for(const [token]of text.matchAll(tokens)){
|
|
31
|
+
if(token.startsWith('<?')||token.startsWith('<!'))continue;
|
|
32
|
+
const name=/^<\/?([^\s<>\/"'=!?]+)/.exec(token)[1];
|
|
33
|
+
if(token.startsWith('</')){if(stack.pop()!==name)throw invalid('PPTX XML has an unmatched closing element.',path);}
|
|
34
|
+
else {limit('xmlDepth',stack.length+1,path);if(!/\/\s*>$/.test(token))stack.push(name);}
|
|
35
|
+
}
|
|
36
|
+
if(stack.length)throw invalid('PPTX XML has unclosed elements.',path);
|
|
37
|
+
}
|
|
38
|
+
function stateOf(entries){
|
|
39
|
+
let state=states.get(entries);
|
|
40
|
+
if(!state){
|
|
41
|
+
const budget={members:0,expanded:0};state={budget,depth:0,cache:new Map()};
|
|
42
|
+
for(const [name,bytes]of Object.entries(entries)){
|
|
43
|
+
memberName(name);if(!(bytes instanceof Uint8Array))throw invalid('PPTX member is not bytes.',name);
|
|
44
|
+
limit('members',++budget.members,name);limit('memberBytes',bytes.length,name);budget.expanded+=bytes.length;limit('expandedBytes',budget.expanded,name);
|
|
45
|
+
if(xmlPart(name))checkXml(bytes,name);
|
|
46
|
+
}
|
|
47
|
+
states.set(entries,state);
|
|
48
|
+
}
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
export function checkInputEntries(entries){stateOf(entries);return entries;}
|
|
52
|
+
|
|
53
|
+
export function readInputZip(input,{budget={members:0,expanded:0},depth=0,path}={}){
|
|
54
|
+
const bytes=input instanceof ArrayBuffer?new Uint8Array(input):input;
|
|
55
|
+
if(!(bytes instanceof Uint8Array))throw invalid('PPTX input must be a Uint8Array or ArrayBuffer.',path);
|
|
56
|
+
limit('compressedBytes',bytes.length,path);limit('nestedDepth',depth,path);
|
|
57
|
+
const metadata=new Map();let declared=0;
|
|
58
|
+
try{
|
|
59
|
+
// fflate's central-directory iterator supplies names/sizes without allocating expanded data
|
|
60
|
+
// when filter always returns false. Do not use its fixed-size inflate output for untrusted data:
|
|
61
|
+
// a dishonest originalSize can silently truncate a DEFLATE stream in unzipSync.
|
|
62
|
+
unzipSync(bytes,{filter(file){
|
|
63
|
+
memberName(file.name);
|
|
64
|
+
if(metadata.has(file.name))throw invalid('PPTX repeats a ZIP member name.',file.name);
|
|
65
|
+
limit('members',budget.members+metadata.size+1,file.name);
|
|
66
|
+
if(!Number.isSafeInteger(file.size)||file.size<0||file.size>bytes.length||!Number.isSafeInteger(file.originalSize)||file.originalSize<0)throw invalid('PPTX has invalid ZIP member sizes.',file.name);
|
|
67
|
+
if(![0,8].includes(file.compression))throw invalid('PPTX uses an unsupported ZIP compression method.',file.name);
|
|
68
|
+
limit('memberBytes',file.originalSize,file.name);
|
|
69
|
+
if(xmlPart(file.name))limit('xmlBytes',file.originalSize,file.name);
|
|
70
|
+
declared+=file.originalSize;limit('expandedBytes',budget.expanded+declared,file.name);
|
|
71
|
+
metadata.set(file.name,file);return false;
|
|
72
|
+
}});
|
|
73
|
+
const entries=Object.create(null),seen=new Set();let failure;
|
|
74
|
+
const unzip=new Unzip(file=>{
|
|
75
|
+
const expected=metadata.get(file.name);
|
|
76
|
+
if(!expected||seen.has(file.name))throw invalid('PPTX local ZIP members do not match its directory.',file.name);
|
|
77
|
+
seen.add(file.name);
|
|
78
|
+
if(file.compression!==expected.compression||file.size!==undefined&&file.size!==expected.size||file.originalSize!==undefined&&file.originalSize!==expected.originalSize)throw invalid('PPTX local and directory sizes or methods disagree.',file.name);
|
|
79
|
+
let count=0;const chunks=[];
|
|
80
|
+
file.ondata=(error,chunk,final)=>{
|
|
81
|
+
if(failure)return;
|
|
82
|
+
try{
|
|
83
|
+
if(error)throw error;
|
|
84
|
+
count+=chunk.length;limit('memberBytes',count,file.name);
|
|
85
|
+
if(count>expected.originalSize)throw invalid('PPTX expands beyond its declared member size.',file.name);
|
|
86
|
+
budget.expanded+=chunk.length;limit('expandedBytes',budget.expanded,file.name);
|
|
87
|
+
chunks.push(chunk);
|
|
88
|
+
if(final){
|
|
89
|
+
if(count!==expected.originalSize)throw invalid('PPTX expanded member size differs from its directory.',file.name);
|
|
90
|
+
const data=new Uint8Array(count);let at=0;for(const part of chunks){data.set(part,at);at+=part.length;}chunks.length=0;
|
|
91
|
+
if(xmlPart(file.name))checkXml(data,file.name);
|
|
92
|
+
entries[file.name]=data;
|
|
93
|
+
}
|
|
94
|
+
}catch(error){failure=error;}
|
|
95
|
+
};
|
|
96
|
+
file.start();
|
|
97
|
+
});
|
|
98
|
+
unzip.register(UnzipInflate);
|
|
99
|
+
// A 4KiB compressed feed bounds an individual inflater emission (~4MiB at DEFLATE's
|
|
100
|
+
// maximum expansion) before our actual-output counter can reject dishonest metadata.
|
|
101
|
+
for(let at=0;at<bytes.length;at+=4096){unzip.push(bytes.subarray(at,at+4096),at+4096>=bytes.length);if(failure)throw failure;}
|
|
102
|
+
if(seen.size!==metadata.size||Object.keys(entries).length!==metadata.size)throw invalid('PPTX ZIP members are missing or incomplete.',path);
|
|
103
|
+
budget.members+=metadata.size;states.set(entries,{budget,depth,cache:new Map()});return entries;
|
|
104
|
+
}catch(error){
|
|
105
|
+
if(error instanceof OPFPptxError)throw error;
|
|
106
|
+
throw new OPFPptxError('invalid-pptx','PPTX input is not a readable ZIP archive.',{...(path===undefined?{}:{path}),cause:error instanceof Error?error.message:String(error)});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function readNestedInputZip(entries,path){
|
|
110
|
+
const state=stateOf(entries);
|
|
111
|
+
const cached=state.cache.get(path),source=entries[path];
|
|
112
|
+
if(cached?.source===source)return cached.entries;
|
|
113
|
+
const nested=readInputZip(source,{budget:state.budget,depth:state.depth+1,path});
|
|
114
|
+
state.cache.set(path,{source,entries:nested});return nested;
|
|
115
|
+
}
|
package/dist/svg-image.js
CHANGED
|
@@ -153,7 +153,9 @@ function sanitize(input, text) {
|
|
|
153
153
|
} else if (!skipping) {
|
|
154
154
|
if (chunk.includes(']]>')) malformed('a stray CDATA terminator');
|
|
155
155
|
const style = stack.at(-1).name === 'style';
|
|
156
|
-
|
|
156
|
+
const filtered = style ? filterCss(chunk) : chunk;
|
|
157
|
+
if (filtered !== chunk) removed.add('external references');
|
|
158
|
+
out.push(references(filtered));
|
|
157
159
|
}
|
|
158
160
|
continue;
|
|
159
161
|
}
|
|
@@ -167,7 +169,12 @@ function sanitize(input, text) {
|
|
|
167
169
|
const end = text.indexOf(']]>', index + 9);
|
|
168
170
|
if (end < 0) malformed('an unterminated CDATA section');
|
|
169
171
|
if (!stack.length) malformed('CDATA outside the root element');
|
|
170
|
-
if (!skipping)
|
|
172
|
+
if (!skipping) {
|
|
173
|
+
const chunk = text.slice(index + 9, end);
|
|
174
|
+
const filtered = stack.at(-1).name === 'style' ? filterCss(chunk) : chunk;
|
|
175
|
+
if (filtered !== chunk) removed.add('external references');
|
|
176
|
+
out.push(`<![CDATA[${filtered}]]>`);
|
|
177
|
+
}
|
|
171
178
|
index = end + 3;
|
|
172
179
|
continue;
|
|
173
180
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {checkInputEntries,readInputZip,readNestedInputZip} from './input-safety.js';
|
|
2
2
|
|
|
3
3
|
// Typeface inventory for a PPTX package (FF-08, font-fidelity-everywhere).
|
|
4
4
|
//
|
|
@@ -45,7 +45,7 @@ export function inventoryTypefaces(input, options = {}) {
|
|
|
45
45
|
for (const path of Object.keys(entries).sort()) {
|
|
46
46
|
const bytes = entries[path];
|
|
47
47
|
if (NESTED_PACKAGE.test(path)) {
|
|
48
|
-
if (options.nested !== false) visit(
|
|
48
|
+
if (options.nested !== false) visit(readNestedInputZip(entries,path), `${prefix}${path}!/`);
|
|
49
49
|
continue;
|
|
50
50
|
}
|
|
51
51
|
if (!XML_PART.test(path)) continue;
|
|
@@ -162,9 +162,9 @@ export function compareFontNames(left, right) {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
function toEntries(input) {
|
|
165
|
-
if (input instanceof Uint8Array) return
|
|
166
|
-
if (input instanceof ArrayBuffer) return
|
|
167
|
-
if (input && typeof input === 'object') return input;
|
|
165
|
+
if (input instanceof Uint8Array) return readInputZip(input);
|
|
166
|
+
if (input instanceof ArrayBuffer) return readInputZip(new Uint8Array(input));
|
|
167
|
+
if (input && typeof input === 'object') return checkInputEntries(input);
|
|
168
168
|
throw new TypeError('Expected PPTX bytes or a map of package parts.');
|
|
169
169
|
}
|
|
170
170
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.4",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"@node-projects/jszip": "4.3.0",
|
|
68
68
|
"@openpresentation/gallery": "^3.0.0",
|
|
69
|
-
"@openpresentation/opf": "^0.20.
|
|
69
|
+
"@openpresentation/opf": "^0.20.5",
|
|
70
70
|
"@rgrove/parse-xml": "4.2.3",
|
|
71
71
|
"fast-xml-parser": "^5.8.0",
|
|
72
72
|
"fflate": "^0.8.3",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"sharp": "0.35.5"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
|
-
"@openpresentation/opf-render": "^0.20.
|
|
77
|
+
"@openpresentation/opf-render": "^0.20.4"
|
|
78
78
|
},
|
|
79
79
|
"peerDependenciesMeta": {
|
|
80
80
|
"@openpresentation/opf-render": {
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
"@expo-google-fonts/roboto": "0.4.3",
|
|
99
99
|
"@expo-google-fonts/roboto-mono": "0.4.2",
|
|
100
100
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
101
|
-
"@openpresentation/opf-render": "0.20.
|
|
101
|
+
"@openpresentation/opf-render": "0.20.4",
|
|
102
102
|
"@resvg/resvg-js": "2.6.2",
|
|
103
103
|
"esbuild": "0.28.2",
|
|
104
104
|
"playwright": "1.64.0"
|