@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.
@@ -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') continue;
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});
@@ -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=unzipSync(entries[ref.path],{filter:file=>{
30
- if(!/^xl\/(workbook\.xml|_rels\/workbook\.xml\.rels|sharedStrings\.xml|worksheets\/[^/]+\.xml)$/.test(file.name))return false;
31
- size+=file.originalSize;if(size>16*1024*1024)throw Error('Chart workbook metadata exceeds the limit.');return true;
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 unzipSync(bytes);
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
- const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
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
- out.push(references(style ? filterCss(chunk) : chunk));
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) out.push(`<![CDATA[${stack.at(-1).name === 'style' ? filterCss(text.slice(index + 9, end)) : text.slice(index + 9, end)}]]>`);
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 {unzipSync} from 'fflate';
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(unzipSync(bytes), `${prefix}${path}!/`);
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 unzipSync(input);
166
- if (input instanceof ArrayBuffer) return unzipSync(new Uint8Array(input));
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",
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.2",
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.2"
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.2",
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"