@openpresentation/opf-pptx 0.6.0 → 0.8.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/DEPENDENCY-NOTES.md +3 -1
- package/README.md +19 -3
- package/dist/card-provenance.js +51 -0
- package/dist/chart-workbook.js +81 -0
- package/dist/code-provenance.js +180 -0
- package/dist/heading-provenance.js +44 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +241 -82
- package/dist/metric-provenance.js +94 -0
- package/dist/text-provenance.js +54 -0
- package/dist/timeline-provenance.js +81 -0
- package/package.json +12 -8
package/DEPENDENCY-NOTES.md
CHANGED
|
@@ -8,9 +8,11 @@ The inspected PptxGenJS 4.0.1 ESM/CommonJS distributions do not import `image-si
|
|
|
8
8
|
|
|
9
9
|
Every build checks the vendored code and license against `UPSTREAM.json`. `node scripts/vendor-pptxgenjs.mjs` is an explicit maintainer command that fetches the pinned official npm archive, verifies its SHA-512 integrity and reads only the named distribution/license entries. It is never run by builds or installations. Review the upstream source, actual imports, license and audit before changing versions; retain tests and reassess [GHSA-w3rx-r6r6-pgpr](https://github.com/advisories/GHSA-w3rx-r6r6-pgpr) and [GHSA-5p2g-fcmc-qvqq](https://github.com/advisories/GHSA-5p2g-fcmc-qvqq). Do not accept npm's incompatible PptxGenJS downgrade suggestion.
|
|
10
10
|
|
|
11
|
+
Package normalization removes PptxGenJS 4.0.1 content-type declarations for generated slide-master parts that do not exist, without changing the vendor bytes or existing package parts. The smoke regression requires every override to target an actual part. This ports the independently audited correction from [commit 1363a65](https://github.com/OpenPresentation/opf-pptx/commit/1363a653c8b0e80c50d970bc30db3587bcf6f14e). Its notes-master SDK/native-order disagreement remains separate; the native ordering is unchanged.
|
|
12
|
+
|
|
11
13
|
## Compatible raster decoding
|
|
12
14
|
|
|
13
|
-
Sharp 0.35.4 (Apache-2.0) is pinned for Node-only WebP-to-PNG conversion and loads lazily only when conversion is needed. Its platform packages include libvips and codec licenses; retain the upstream license files distributed by npm. Node
|
|
15
|
+
Sharp 0.35.4 (Apache-2.0) is pinned for Node-only WebP-to-PNG conversion and loads lazily only when conversion is needed. Its platform packages include libvips and codec licenses; retain the upstream license files distributed by npm. This package requires Node 24 and normal optional-platform dependency installation. Browser builds use the browser's decoder/canvas and a conditional package import; a bundling check rejects Sharp or the Node adapter in browser output. Ordinary/preserved image exports are tested with Sharp loading blocked.
|
|
14
16
|
|
|
15
17
|
The candidate's complete npm audit reports no known vulnerabilities in the installed graph. npm does not audit vendored source as an installed PptxGenJS package: maintainers must also review upstream PptxGenJS advisories and releases when updating or auditing the vendor directory. The browser bundler esbuild 0.28.2 and Playwright are development-only. No hosted service, paid API or telemetry was added.
|
|
16
18
|
|
package/README.md
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
# OPF PPTX
|
|
2
2
|
|
|
3
|
+
Version 0.8.0 and this checkout require Node 24 (`24.x`). Use `.nvmrc` for local development. Earlier published versions retain their original engine declarations. Browser entrypoints remain browser-safe; native application compatibility is verified separately.
|
|
4
|
+
|
|
5
|
+
Version 0.8.0 consumes shared heading/scalar/rich line placement, including `textRasterPadding`, through editable native line boxes without autofit. Complete heading tags retain title/subtitle/tag roles, line ordering and current edited text during import. Source boundary metadata also records hard-line separators, so complete scalar/heading groups recover current native text with spaces, tabs and exact authored line endings. Incomplete, duplicate, ambiguous or bulleted groups fall back to ordinary import with diagnostics. Legacy heading tags retain their prior native-line behavior; arbitrary formatting, nesting and geometry are not reconstructed. See the [source contract and limits](https://github.com/OpenPresentation/opf/blob/f94125a1ff95bf0974a055fe1c081d348dad54f2/docs/plans/text-placement.md). Native raster certification remains separate.
|
|
6
|
+
|
|
3
7
|
Pure local PowerPoint conversion tooling for Open Presentation Format documents. This repo owns the Phase 3 and Phase 4 toolkit lanes: OPF to PPTX export and PPTX to OPF import.
|
|
4
8
|
|
|
9
|
+
Version 0.7.0's [shared-code integration](docs/code-roundtrip.md) preserves exact source/metadata through guarded native tags and editable lines. XML-forbidden code characters reject export with `invalid-code-text`, the OPF field path and UTF-16 offset; schema validation alone does not establish XML representability. Native source recovery does not reconstruct formatting, geometry or font theme.
|
|
10
|
+
|
|
11
|
+
Version 0.8.0 includes [shared metric integration](docs/metric-roundtrip.md). Native alignment and exact tested field recovery are implemented; tab-position and raster fidelity gates remain open.
|
|
12
|
+
|
|
13
|
+
The [native text verifier](docs/native-text-checks.md) has completed 24 separately bounded cases on each supported Node runtime against its recorded source baseline. All 72 imports and 96 original-text ink masks per runtime pass; parent-owned temporary fonts are removed after success, failure and timeout controls. Those historical results do not cover subsequent font-selection changes, edited-text reflow, arbitrary native fidelity or the unresolved chart and metric gates.
|
|
14
|
+
|
|
15
|
+
Version 0.8.0 physical-font selection consumes optional `fontFace` metadata on accepted text styles. The provider supplies the physical family and its boolean bold/italic style-link flags independently of logical numeric weight. This lets a resolved Roboto SemiBold or ExtraBold face keep its regular legacy-family style rather than requesting a second bold style. Providers without metadata retain the previous numeric-weight behavior; malformed metadata fails with `invalid-font-selection`. Use core 0.10.0 and renderer 0.8.0. [Verification and limits](docs/evidence/font-variants/README.md) distinguish native selection flags from actual Office paint.
|
|
16
|
+
|
|
17
|
+
Version 0.8.0 also measures `design.contentBox` cards through core's padded interior and exports editable rounded frames at their outer allocation. Native tags identify unchanged empty generated frames on reimport, with a `content-card-reflow` diagnostic: frame appearance and placement are not reconstructed. A name alone never hides a shape; edited, untagged or ambiguous frames use ordinary native import, retaining text or an unsupported-shape description. These source checks do not establish native PowerPoint raster fidelity.
|
|
18
|
+
|
|
5
19
|
## Scope
|
|
6
20
|
|
|
7
|
-
Version 0.
|
|
21
|
+
Version 0.8.0 requires core 0.10.0 and uses renderer 0.8.0 for coordinated preview/font measurement. Quotes and code export their accepted internal lines and styles without another fitting pass. [Controlled Windows PowerPoint quote evidence](docs/evidence/shared-quote-integration/comparison.json) records glyph containment, separation, save/reopen and text reimport against its exact source/font hashes. Native quote import returns editable text blocks and does not restore the original OPF quote structure, typography or readability policy. Native chart geometry and general scalar-text wrapping also remain different from preview; editability and valid reimport do not establish raster equivalence.
|
|
8
22
|
|
|
9
23
|
- Package: `@openpresentation/opf-pptx`
|
|
10
24
|
- Repository: `OpenPresentation/opf-pptx`
|
|
@@ -65,7 +79,7 @@ This pass did not require an OPF schema change. The deferred full OOXML placehol
|
|
|
65
79
|
The first importer is mechanical and schema-compatible:
|
|
66
80
|
|
|
67
81
|
- Presentation core properties map to OPF `name`, `description`, and `author`.
|
|
68
|
-
-
|
|
82
|
+
- Native title/subtitle placeholders retain their roles. On slides without complete OPF heading tags, recognizable text-box positions and sizes provide a fallback. If any complete OPF heading role is recovered, untagged body text stays in `blocks[]` instead of being promoted into an absent heading role. Damaged tags retain visible text through ordinary import and diagnostics.
|
|
69
83
|
- Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position.
|
|
70
84
|
- PowerPoint tables map to OPF table blocks, embedded images map to data URI image blocks, and cached chart series map to basic OPF chart blocks.
|
|
71
85
|
- Table imports retain empty rows. A native `firstRow` flag of `1` or `true` maps the first row to column labels; absent/false flags retain every row as data. New exports set this flag from OPF columns. Older exports without the flag retain their labels as the first data row rather than inferring headers.
|
|
@@ -155,7 +169,7 @@ Native viewer check: Keynote 14.4 displays the PNG/JPEG/GIF media-type specimens
|
|
|
155
169
|
|
|
156
170
|
Set `imageFormat: "preserve"` to embed WebP unchanged when the receiving application supports it. Other image formats keep their existing export behavior. Conversion errors include the OPF image path; images above 40 megapixels are rejected before compatible conversion. Decoder differences can affect color/alpha rounding, so byte identity across platforms is not promised.
|
|
157
171
|
|
|
158
|
-
Node conversion lazily loads the pinned open-source Sharp dependency and requires Node
|
|
172
|
+
Node conversion lazily loads the pinned open-source Sharp dependency and requires Node 24. Normal package installation must include platform optional dependencies for its native binaries. Browser bundles select a separate browser decoder using local Blob/image/canvas APIs; Sharp and Node code are excluded. Neither path uploads images or fetches asset URLs. Source-preserving export and ordinary PNG/JPEG/GIF operations do not load Sharp.
|
|
159
173
|
|
|
160
174
|
`npm test` includes the Node pixel-reference cases and verifies browser bundling. To run the browser pixel checks, run `npm run build:browser-check`, serve this repository locally, and open `/artifacts/webp-fallback/browser/index.html`. The page reports 13 checks covering embedded PNG pixels, alpha, EXIF, the first animation frame, fit/crop, resolver calls and DOM canvas fallback. These are browser export checks, separate from the recorded Keynote viewing evidence.
|
|
161
175
|
|
|
@@ -216,3 +230,5 @@ Conditional borders keep the whole-table outer frame separate from interior hori
|
|
|
216
230
|
Export consumes the same core geometry as SVG. It writes native merged cells and corrects PptxGenJS's padding-unit heuristic and missing dotted/transparent-border options in generated XML. Rich runs retain their own resolved opacity, including opaque overrides within a translucent cell. Tests inspect actual native XML and repeated conversions; native PowerPoint raster parity is not established.
|
|
217
231
|
|
|
218
232
|
Version 0.5.0 requires core 0.7.0 and renderer 0.5.0 for coordinated previews. `npm run test:styled-table` runs export/import regressions. For local browser verification, set `OPF_CORE_ROOT` to the core repository and `OPF_RENDER_ROOT` to the coordinated renderer worktree when running `npm run build:browser-check`. Serve the repository and open `/artifacts/native-styled-table-import/browser/index.html`. Omit those variables to exercise installed package dependencies.
|
|
233
|
+
|
|
234
|
+
Version 0.8.0 exports editable native lines with explicit four-space tab stops. Boundary tags contain no original source words, so native edits and deletions remain authoritative. XML-unrepresentable scalar controls reject with an actionable path rather than silently losing characters. Twenty wide/portrait measured/estimated export cases, current-text mutation controls and damaged-tag fallbacks pass; native Office acceptance remains separate. The coordinated core browser workflow also verifies editing, undo and exact reimport.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
+
import {attachTextTags,decodeTextTag} from './code-provenance.js';
|
|
3
|
+
|
|
4
|
+
const TAG='OPF_CARD_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
5
|
+
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
6
|
+
const decoder=new TextDecoder('utf-8',{fatal:true}),array=v=>v===undefined?[]:Array.isArray(v)?v:[v];
|
|
7
|
+
const canonical=value=>JSON.stringify(value,(_key,item)=>item&&typeof item==='object'&&!Array.isArray(item)?Object.fromEntries(Object.keys(item).sort().map(key=>[key,item[key]])):item);
|
|
8
|
+
|
|
9
|
+
// A name alone is never sufficient to remove a shape during import. Bind the
|
|
10
|
+
// generated empty frame to its serialized properties through native shape tags.
|
|
11
|
+
export function attachCardTags(entries,names) {
|
|
12
|
+
if(!names.size)return;
|
|
13
|
+
const records=new Map();
|
|
14
|
+
for(const path of Object.keys(entries).filter(path=>/^ppt\/slides\/slide\d+\.xml$/.test(path))){
|
|
15
|
+
const tree=parser.parse(decoder.decode(entries[path]))['p:sld']['p:cSld']['p:spTree'];
|
|
16
|
+
for(const shape of array(tree['p:sp'])){
|
|
17
|
+
const name=shape['p:nvSpPr']?.['p:cNvPr']?.name;
|
|
18
|
+
if(names.has(name)){
|
|
19
|
+
if(records.has(name))throw Error('Duplicate generated card frame.');
|
|
20
|
+
records.set(name,{v:1,path:names.get(name),properties:shape['p:spPr'],style:shape['p:style']??null});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if(records.size!==names.size)throw Error('Missing generated card frame.');
|
|
25
|
+
attachTextTags(entries,records,TAG,'opfCard','card');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function importCardFrames(shapes,paragraphs,relationships,entries,report) {
|
|
29
|
+
const consumed=new Set();
|
|
30
|
+
for(const [index,shape]of shapes.entries()){
|
|
31
|
+
const properties=shape['p:spPr'],identity=shape['p:nvSpPr']?.['p:cNvPr'],tags=[];
|
|
32
|
+
let unreadable=false;
|
|
33
|
+
for(const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])){
|
|
34
|
+
const rel=relationships.get(link['r:id']);
|
|
35
|
+
if(rel?.type!==REL||rel.targetMode==='External'||!entries[rel.path])continue;
|
|
36
|
+
try{tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));}catch{unreadable=true;}
|
|
37
|
+
}
|
|
38
|
+
const candidates=tags.filter(tag=>tag.name?.toUpperCase()===TAG);
|
|
39
|
+
if(!candidates.length)continue;
|
|
40
|
+
try{
|
|
41
|
+
if(unreadable||candidates.length!==1||tags.some(tag=>/^OPF_/i.test(tag.name)&&tag.name.toUpperCase()!==TAG))throw Error('Ambiguous frame identity.');
|
|
42
|
+
const manifest=decodeTextTag(candidates[0].val);
|
|
43
|
+
if(manifest.v!==1||typeof manifest.path!=='string'||!manifest.path.startsWith('slides.')||identity?.name!==`OPF card ${manifest.path}`)throw Error('Invalid frame identity.');
|
|
44
|
+
if((paragraphs[index]??[]).some(p=>p.text.length)||Object.keys(identity).some(key=>!['id','name'].includes(key)))throw Error('Frame has native content or metadata.');
|
|
45
|
+
if(properties?.['a:prstGeom']?.prst!=='roundRect'||canonical(properties)!==canonical(manifest.properties)||canonical(shape['p:style']??null)!==canonical(manifest.style))throw Error('Frame styling or geometry changed.');
|
|
46
|
+
consumed.add(shape);
|
|
47
|
+
report({code:'content-card-reflow',message:'An unchanged tagged OPF content card was recognized as decoration. Its native frame styling and placement are not reconstructed in the reflowed document.'});
|
|
48
|
+
}catch{report({code:'invalid-card-provenance',message:'Edited, ambiguous or invalid card frame passed to ordinary native import, which retains its text or an unsupported-shape description. Inspect the original PPTX for frame appearance and metadata.'});}
|
|
49
|
+
}
|
|
50
|
+
return consumed;
|
|
51
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {unzipSync,zipSync} from 'fflate';
|
|
2
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
3
|
+
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false,removeNSPrefix:true,htmlEntities:{amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}});
|
|
4
|
+
const decoder=new TextDecoder('utf-8',{fatal:true}),encoder=new TextEncoder();
|
|
5
|
+
const array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
6
|
+
const xml=(entries,path)=>entries[path]?parser.parse(decoder.decode(entries[path])):undefined;
|
|
7
|
+
const find=(value,key)=>!value||typeof value!=='object'?[]:Array.isArray(value)?value.flatMap(item=>find(item,key)):Object.entries(value).flatMap(([name,child])=>name===key?array(child):find(child,key));
|
|
8
|
+
const text=value=>typeof value==='string'?value:value&&typeof value==='object'?String(value['#text']??''):value===undefined?'':String(value);
|
|
9
|
+
const richText=value=>value?.t!==undefined?text(value.t):array(value?.r).map(run=>text(run.t)).join('');
|
|
10
|
+
const escape=value=>value.replace(/[&<>"'\r\n\t]/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":''','\r':' ','\n':' ','\t':'	'}[char]));
|
|
11
|
+
function relationship(entries,source,id){
|
|
12
|
+
if(typeof id!=='string'||!id)return undefined;
|
|
13
|
+
const slash=source.lastIndexOf('/'),part=`${source.slice(0,slash+1)}_rels/${source.slice(slash+1)}.rels`;
|
|
14
|
+
const rel=array(xml(entries,part)?.Relationships?.Relationship).find(rel=>rel.Id===id);
|
|
15
|
+
if(!rel||(rel.TargetMode!==undefined&&rel.TargetMode!=='Internal')||typeof rel.Target!=='string'||/^[a-z]+:/i.test(rel.Target)||/[\\?#]/.test(rel.Target))return undefined;
|
|
16
|
+
const pieces=[];
|
|
17
|
+
for(const component of (rel.Target.startsWith('/')?rel.Target.slice(1):source.slice(0,slash+1)+rel.Target).split('/')){
|
|
18
|
+
if(component==='..'){if(!pieces.length)return undefined;pieces.pop();}else if(component&&component!=='.')pieces.push(component);
|
|
19
|
+
}
|
|
20
|
+
return {path:pieces.join('/'),type:rel.Type};
|
|
21
|
+
}
|
|
22
|
+
function workbookContext(entries,chartPart){
|
|
23
|
+
const chart=xml(entries,chartPart)?.chartSpace;
|
|
24
|
+
const ref=relationship(entries,chartPart,chart?.externalData?.id);
|
|
25
|
+
if(!ref?.type?.endsWith('/package')||!entries[ref.path])return undefined;
|
|
26
|
+
// Read only bounded spreadsheet metadata. Never follow external workbook links.
|
|
27
|
+
let size=0;
|
|
28
|
+
const workbook=unzipSync(entries[ref.path],{filter:file=>{
|
|
29
|
+
if(!/^xl\/(workbook\.xml|_rels\/workbook\.xml\.rels|sharedStrings\.xml|worksheets\/[^/]+\.xml)$/.test(file.name))return false;
|
|
30
|
+
size+=file.originalSize;if(size>16*1024*1024)throw Error('Chart workbook metadata exceeds the limit.');return true;
|
|
31
|
+
}});
|
|
32
|
+
const category=find(chart,'cat')[0],formula=category?.strRef?.f??category?.multiLvlStrRef?.f??category?.numRef?.f;
|
|
33
|
+
// Infer a heading only for one contiguous vertical category range with a row
|
|
34
|
+
// above it. Other chart/workbook arrangements remain explicitly unrecovered.
|
|
35
|
+
const range=/^(?:'((?:[^']|'')+)'|([^'!]+))!\$?([A-Z]+)\$?(\d+):\$?([A-Z]+)\$?(\d+)$/i.exec(text(formula));
|
|
36
|
+
if(!range||range[3].toUpperCase()!==range[5].toUpperCase()||Number(range[4])<2||Number(range[6])<Number(range[4])||Number(range[6])>1048576)return undefined;
|
|
37
|
+
const column=range[3].toUpperCase();
|
|
38
|
+
if(column.length>3||[...column].reduce((value,letter)=>value*26+letter.charCodeAt(0)-64,0)>16384)return undefined;
|
|
39
|
+
const name=(range[1]?.replaceAll("''","'")??range[2]);
|
|
40
|
+
const sheet=array(xml(workbook,'xl/workbook.xml')?.workbook?.sheets?.sheet).find(sheet=>sheet.name===name);
|
|
41
|
+
const sheetRef=relationship(workbook,'xl/workbook.xml',sheet?.id);
|
|
42
|
+
if(!sheetRef?.type?.endsWith('/worksheet')||!workbook[sheetRef.path])return undefined;
|
|
43
|
+
const cellRef=`${column}${Number(range[4])-1}`;
|
|
44
|
+
const cell=find(xml(workbook,sheetRef.path)?.worksheet,'c').find(cell=>cell.r===cellRef);
|
|
45
|
+
return {workbookPart:ref.path,workbook,sheetPart:sheetRef.path,cellRef,cell};
|
|
46
|
+
}
|
|
47
|
+
export function readChartCategoryHeading(entries,chartPart){
|
|
48
|
+
try{
|
|
49
|
+
const context=workbookContext(entries,chartPart),cell=context?.cell;if(!cell||cell.f!==undefined)return undefined;
|
|
50
|
+
if(cell.t==='inlineStr')return richText(cell.is);
|
|
51
|
+
if(cell.t==='s'){
|
|
52
|
+
const value=text(cell.v);if(!/^\d+$/.test(value))return undefined;
|
|
53
|
+
const item=array(xml(context.workbook,'xl/sharedStrings.xml')?.sst?.si)[Number(value)];
|
|
54
|
+
return item===undefined?undefined:richText(item);
|
|
55
|
+
}
|
|
56
|
+
// Error, boolean, date and formula results are not literal text headings.
|
|
57
|
+
if(cell.t!==undefined&&!['str','n'].includes(cell.t))return undefined;
|
|
58
|
+
return cell.v===undefined?undefined:text(cell.v);
|
|
59
|
+
}catch{return undefined;}
|
|
60
|
+
}
|
|
61
|
+
export function writeChartCategoryHeading(entries,chartPart,heading){
|
|
62
|
+
const context=workbookContext(entries,chartPart);
|
|
63
|
+
if(!context?.cell||typeof heading!=='string')throw Error('Generated chart has no editable category heading cell.');
|
|
64
|
+
for(const character of heading){const point=character.codePointAt(0);if(![9,10,13].includes(point)&&(point<32||point>=0xD800&&point<=0xDFFF||point===0xFFFE||point===0xFFFF))throw Error('Chart category heading contains an XML-invalid character.');}
|
|
65
|
+
const workbook=unzipSync(entries[context.workbookPart]);
|
|
66
|
+
const source=decoder.decode(workbook[context.sheetPart]),pattern=new RegExp(`<c\\b(?=[^>]*\\br="${context.cellRef}")[^>]*(?:\\/>|>[\\s\\S]*?<\\/c>)`);
|
|
67
|
+
if(!pattern.test(source))throw Error('Generated category heading cell is missing.');
|
|
68
|
+
workbook[context.sheetPart]=encoder.encode(source.replace(pattern,()=>`<c r="${context.cellRef}" t="inlineStr"><is><t xml:space="preserve">${escape(heading)}</t></is></c>`));
|
|
69
|
+
// Update only table metadata related to this generated sheet and starting at
|
|
70
|
+
// this header. Other embedded sheets/tables are never rewritten by inference.
|
|
71
|
+
const slash=context.sheetPart.lastIndexOf('/');
|
|
72
|
+
const relationships=xml(workbook,`${context.sheetPart.slice(0,slash+1)}_rels/${context.sheetPart.slice(slash+1)}.rels`);
|
|
73
|
+
for(const rel of array(relationships?.Relationships?.Relationship)){
|
|
74
|
+
const ref=relationship(workbook,context.sheetPart,rel.Id);
|
|
75
|
+
if(!ref?.type?.endsWith('/table')||!workbook[ref.path])continue;
|
|
76
|
+
const table=decoder.decode(workbook[ref.path]);
|
|
77
|
+
if(xml(workbook,ref.path)?.table?.ref?.split(':')[0]!==context.cellRef)continue;
|
|
78
|
+
workbook[ref.path]=encoder.encode(table.replace(/(<tableColumn\b[^>]*\bid="1"[^>]*\bname=")[^"]*(")/,(_match,prefix,suffix)=>`${prefix}${escape(heading)}${suffix}`));
|
|
79
|
+
}
|
|
80
|
+
entries[context.workbookPart]=zipSync(workbook);
|
|
81
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
2
|
+
|
|
3
|
+
// Native shape tags are standard PresentationML customer data. Uppercase hex
|
|
4
|
+
// protects case-sensitive source from PowerPoint's case-insensitive Tags API.
|
|
5
|
+
const TAG = 'OPF_CODE_V1';
|
|
6
|
+
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
7
|
+
const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
|
|
8
|
+
const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal:true});
|
|
9
|
+
const parser = new XMLParser({ignoreAttributes:false, attributeNamePrefix:'', parseTagValue:false, trimValues:false});
|
|
10
|
+
const ordered = new XMLParser({ignoreAttributes:false, attributeNamePrefix:'', parseTagValue:false, trimValues:false, preserveOrder:true});
|
|
11
|
+
const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
12
|
+
const hex = value => [...enc.encode(JSON.stringify(value))].map(byte=>byte.toString(16).padStart(2,'0')).join('').toUpperCase();
|
|
13
|
+
function unhex(value) {
|
|
14
|
+
if (typeof value !== 'string' || !/^(?:[0-9A-Fa-f]{2})+$/.test(value)) throw new Error('Invalid code tag encoding.');
|
|
15
|
+
return JSON.parse(dec.decode(Uint8Array.from(value.match(/../g),byte=>parseInt(byte,16))));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function codeManifest(value, layout, group) {
|
|
19
|
+
return {v:1, group, role:'panel', value, parts:layout.parts.map(part=>({role:part.role, generated:part.generated === true,
|
|
20
|
+
lines:part.fit.sourceLines.map(({start,end,nextStart,boundary})=>({start,end,nextStart,boundary}))}))};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function attachCodeTags(entries, records) {
|
|
24
|
+
return attachTextTags(entries,records,TAG,'opfCode','code');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export {unhex as decodeTextTag};
|
|
28
|
+
|
|
29
|
+
export function attachTextTags(entries, records, tagName, prefix, kind) {
|
|
30
|
+
if (!records.size) return;
|
|
31
|
+
let count = 0;
|
|
32
|
+
const seen = new Set(), types = [];
|
|
33
|
+
for (const path of Object.keys(entries).filter(path=>/^ppt\/slides\/slide\d+\.xml$/.test(path)).sort()) {
|
|
34
|
+
const relPath = path.replace('/slides/','/slides/_rels/') + '.rels';
|
|
35
|
+
let rels = dec.decode(entries[relPath]);
|
|
36
|
+
const ids = new Set([...rels.matchAll(/\bId="([^"]+)"/g)].map(match=>match[1]));
|
|
37
|
+
const xml = dec.decode(entries[path]).replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape=>{
|
|
38
|
+
const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
39
|
+
if (!records.has(name)) return shape;
|
|
40
|
+
if (seen.has(name)) throw new Error(`Duplicate generated ${kind} shape.`);
|
|
41
|
+
seen.add(name);
|
|
42
|
+
const part = `ppt/tags/${prefix}${++count}.xml`;
|
|
43
|
+
let id = `rId${prefix[0].toUpperCase()+prefix.slice(1)}${count}`;
|
|
44
|
+
while (ids.has(id)) id += '_';
|
|
45
|
+
ids.add(id);
|
|
46
|
+
entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${tagName}" val="${hex(records.get(name))}"/></p:tagLst>`);
|
|
47
|
+
types.push(`<Override PartName="/${part}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
|
|
48
|
+
rels = rels.replace('</Relationships>',`<Relationship Id="${id}" Type="${REL}" Target="../tags/${prefix}${count}.xml"/></Relationships>`);
|
|
49
|
+
shape = shape.replace(/<p:nvPr\s*\/>/,'<p:nvPr></p:nvPr>');
|
|
50
|
+
if (!shape.includes('</p:nvPr>')) throw new Error(`Generated ${kind} shape has no native application properties.`);
|
|
51
|
+
return shape.replace('</p:nvPr>',`<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst></p:nvPr>`);
|
|
52
|
+
});
|
|
53
|
+
entries[path] = enc.encode(xml);
|
|
54
|
+
entries[relPath] = enc.encode(rels);
|
|
55
|
+
}
|
|
56
|
+
if (seen.size !== records.size) throw new Error(`Missing generated ${kind} shapes.`);
|
|
57
|
+
entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>',types.join('')+'</Types>'));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const children = (nodes, name) => array(nodes).flatMap(node=>node[name] === undefined ? [] : [node[name]]);
|
|
61
|
+
const plainText = nodes => array(nodes).map(node=>node['#text'] ?? '').join('');
|
|
62
|
+
export const nativeTextShapes = tree => [...array(tree?.['p:sp']),...array(tree?.['p:grpSp']).flatMap(nativeTextShapes)];
|
|
63
|
+
const orderedShapes = tree => [...children(tree,'p:sp'),...children(tree,'p:grpSp').flatMap(orderedShapes)];
|
|
64
|
+
// Preserve the order of runs, fields and explicit line breaks. A keyed XML
|
|
65
|
+
// object groups all a:r before a:fld and cannot represent their original order.
|
|
66
|
+
export function nativeShapeParagraphs(xml) {
|
|
67
|
+
const root = children(ordered.parse(xml),'p:sld')[0];
|
|
68
|
+
const tree = children(children(root,'p:cSld')[0],'p:spTree')[0];
|
|
69
|
+
return orderedShapes(tree).map(shape=>children(children(shape,'p:txBody')[0],'a:p').map(paragraph=>{
|
|
70
|
+
let text = '', maxFontSize = 0, bullet = false, level = 0;
|
|
71
|
+
for (const child of paragraph) {
|
|
72
|
+
if (child['a:br'] !== undefined) text += '\n';
|
|
73
|
+
for (const key of ['a:r','a:fld']) if (child[key] !== undefined) {
|
|
74
|
+
text += children(child[key],'a:t').map(plainText).join('');
|
|
75
|
+
for (const run of child[key]) {
|
|
76
|
+
const size = Number(run[':@']?.sz);
|
|
77
|
+
if (run['a:rPr'] !== undefined && Number.isFinite(size)) maxFontSize = Math.max(maxFontSize,size/100);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (child['a:pPr'] !== undefined) {
|
|
81
|
+
level = Number(child[':@']?.lvl ?? 0);
|
|
82
|
+
bullet = child['a:pPr'].some(node=>node['a:buChar'] !== undefined || node['a:buAutoNum'] !== undefined);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {text,maxFontSize,bullet,level};
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sourceFor(value, role, generated) {
|
|
90
|
+
if (generated) return 'code';
|
|
91
|
+
if (role === 'body') return typeof value === 'string' ? value : value.source;
|
|
92
|
+
return value[role];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateManifest(manifest) {
|
|
96
|
+
const {value,parts} = manifest;
|
|
97
|
+
const object = value && typeof value === 'object' && !Array.isArray(value);
|
|
98
|
+
if (typeof value !== 'string' && (!object || typeof value.source !== 'string' || Object.keys(value).some(key=>!['source','filename','language'].includes(key)) || ['filename','language'].some(key=>value[key] !== undefined && typeof value[key] !== 'string'))) throw new Error('Invalid source value.');
|
|
99
|
+
// Empty metadata is preserved in the manifest but does not produce a label.
|
|
100
|
+
const roles = [...(object && value.filename ? ['filename'] : []), ...(object && value.language ? ['language'] : []), 'body'];
|
|
101
|
+
if (roles.length === 1) roles.unshift('language');
|
|
102
|
+
if (!Array.isArray(parts) || parts.length !== roles.length) throw new Error('Invalid code parts.');
|
|
103
|
+
parts.forEach((part,i)=>{
|
|
104
|
+
const generated = roles.length === 2 && roles[0] === 'language' && !(object && (value.filename || value.language)) && i === 0;
|
|
105
|
+
if (part.role !== roles[i] || part.generated !== generated || !Array.isArray(part.lines) || !part.lines.length) throw new Error('Invalid source part.');
|
|
106
|
+
const source = sourceFor(value,part.role,generated);
|
|
107
|
+
let cursor = 0;
|
|
108
|
+
part.lines.forEach((line,index)=>{
|
|
109
|
+
if (![line.start,line.end,line.nextStart].every(Number.isSafeInteger) || line.start !== cursor || line.end < line.start || line.nextStart < line.end || line.nextStart > source.length) throw new Error('Invalid source range.');
|
|
110
|
+
const separator = source.slice(line.end,line.nextStart);
|
|
111
|
+
const last = index === part.lines.length - 1;
|
|
112
|
+
if (/\r|\n/.test(source.slice(line.start,line.end)) || (line.boundary === 'hard' ? !/^(?:\r\n|\r|\n)$/.test(separator) || last : separator !== '' || line.boundary !== (last ? 'end' : 'soft'))) throw new Error('Invalid source boundary.');
|
|
113
|
+
cursor = line.nextStart;
|
|
114
|
+
});
|
|
115
|
+
if (cursor !== source.length) throw new Error('Incomplete source range.');
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Only complete, unambiguous sets are coalesced. Visible text always wins over
|
|
120
|
+
// old source text; metadata supplies only source boundaries and payload roles.
|
|
121
|
+
export function importCodeGroups(shapes, paragraphs, relationships, entries, report) {
|
|
122
|
+
const groups = new Map(), consumed = new Set(), items = [], tagCounts = new Map();
|
|
123
|
+
for (const [index,shape] of shapes.entries()) {
|
|
124
|
+
const links = array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags']);
|
|
125
|
+
for (const link of links) {
|
|
126
|
+
const rel = relationships.get(link['r:id']);
|
|
127
|
+
if (rel?.type !== REL || rel.targetMode === 'External' || !rel.path || !entries[rel.path]) continue;
|
|
128
|
+
let tags;
|
|
129
|
+
try {
|
|
130
|
+
const all=array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']);
|
|
131
|
+
tagCounts.set(shape,(tagCounts.get(shape)??0)+all.filter(tag=>/^OPF_/i.test(tag.name)).length);
|
|
132
|
+
tags=all.filter(tag=>tag.name?.toUpperCase()===TAG);
|
|
133
|
+
}
|
|
134
|
+
catch { report({code:'invalid-code-provenance',message:'Unreadable code tags; visible native shapes were retained.'}); continue; }
|
|
135
|
+
for (const tag of tags) {
|
|
136
|
+
try {
|
|
137
|
+
const data = unhex(tag.val);
|
|
138
|
+
if (data.v !== 1 || typeof data.group !== 'string' || !/^\d+$/.test(data.group)) throw new Error('Invalid code identity.');
|
|
139
|
+
const group = groups.get(data.group) ?? [];
|
|
140
|
+
group.push({data,shape,index,text:paragraphs[index].map(p=>p.text).join('\n')});groups.set(data.group,group);
|
|
141
|
+
} catch { report({code:'invalid-code-provenance',message:'Invalid code tags; visible native shapes were retained.'}); }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const group of groups.values()) {
|
|
146
|
+
try {
|
|
147
|
+
if (group.some(item=>tagCounts.get(item.shape)!==1)) throw new Error('Multiple source identities on one shape.');
|
|
148
|
+
const panels = group.filter(item=>item.data.role === 'panel');
|
|
149
|
+
if (panels.length !== 1 || panels[0].text !== '') throw new Error('Missing, duplicated or edited panel.');
|
|
150
|
+
const panel = panels[0], manifest = panel.data;
|
|
151
|
+
validateManifest(manifest);
|
|
152
|
+
const expectedCount = 1 + manifest.parts.reduce((sum,part)=>sum+part.lines.length,0);
|
|
153
|
+
if (group.length !== expectedCount || new Set(group.map(item=>item.shape)).size !== group.length) throw new Error('Incomplete or duplicated code shapes.');
|
|
154
|
+
const nativeLines = new Map();
|
|
155
|
+
for (const item of group) if (item !== panel) {
|
|
156
|
+
const {role,part,line} = item.data, key = `${part}:${line}`;
|
|
157
|
+
if (role !== 'line' || !Number.isSafeInteger(part) || !Number.isSafeInteger(line) || !manifest.parts[part]?.lines[line] || nativeLines.has(key)) throw new Error('Ambiguous source line.');
|
|
158
|
+
nativeLines.set(key,item.text);
|
|
159
|
+
}
|
|
160
|
+
const value = typeof manifest.value === 'string' ? {source:manifest.value} : {...manifest.value};
|
|
161
|
+
for (const [partIndex,part] of manifest.parts.entries()) {
|
|
162
|
+
const source = sourceFor(manifest.value,part.role,part.generated), newline = source.match(/\r\n|\r|\n/)?.[0] ?? '\n';
|
|
163
|
+
let rebuilt = '';
|
|
164
|
+
for (const [lineIndex,line] of part.lines.entries()) {
|
|
165
|
+
const actual = nativeLines.get(`${partIndex}:${lineIndex}`);
|
|
166
|
+
if (actual === undefined) throw new Error('Missing source line.');
|
|
167
|
+
if (part.generated && actual !== source.slice(line.start,line.end)) throw new Error('Generated label was edited.');
|
|
168
|
+
rebuilt += actual.replace(/\r\n|\r|\n/g,newline) + source.slice(line.end,line.nextStart);
|
|
169
|
+
}
|
|
170
|
+
if (!part.generated) value[part.role === 'body' ? 'source' : part.role] = rebuilt;
|
|
171
|
+
}
|
|
172
|
+
for (const item of group) consumed.add(item.shape);
|
|
173
|
+
items.push({shape:panel.shape,payload:{type:'code',code:typeof manifest.value === 'string' ? value.source : value}});
|
|
174
|
+
report({code:'code-import-reflow',message:'Code source boundaries and native text were recovered. Native positioning, formatting and font theme are not reconstructed; review the reflowed OPF.'});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
report({code:'invalid-code-provenance',message:`${error.message} Visible native shapes were retained without restoring old source text.`});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {consumed,items};
|
|
180
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
+
import {attachTextTags,decodeTextTag} from './code-provenance.js';
|
|
3
|
+
import {sourceLineParagraphs} from './text-provenance.js';
|
|
4
|
+
const TAG='OPF_HEADING_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
5
|
+
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
6
|
+
const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
7
|
+
export const attachHeadingTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfHeading','heading');
|
|
8
|
+
|
|
9
|
+
// Tags carry only roles and line ordering. Current native text always wins;
|
|
10
|
+
// neither original source text nor old geometry can be restored from these tags.
|
|
11
|
+
export function importHeadingGroups(shapes,paragraphs,relationships,entries,report) {
|
|
12
|
+
const groups=new Map(),consumed=new Set(),items=[];
|
|
13
|
+
for(const [index,shape] of shapes.entries()) {
|
|
14
|
+
const tags=[];let unreadable=false;
|
|
15
|
+
for(const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])) {
|
|
16
|
+
const rel=relationships.get(link['r:id']);
|
|
17
|
+
if(rel?.type!==REL||rel.targetMode==='External'||!entries[rel.path])continue;
|
|
18
|
+
try{tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));}catch{unreadable=true;}
|
|
19
|
+
}
|
|
20
|
+
const candidates=tags.filter(tag=>tag.name?.toUpperCase()===TAG);
|
|
21
|
+
if(!candidates.length)continue;
|
|
22
|
+
for(const tag of candidates)try {
|
|
23
|
+
const data=decodeTextTag(tag.val);
|
|
24
|
+
if(data?.v!==1||!['title','subtitle','tag'].includes(data.field)||typeof data.group!=='string'||!/^slides\.\d+\.(title|subtitle|tag)$/.test(data.group)||!data.group.endsWith('.'+data.field))throw Error('Invalid heading identity.');
|
|
25
|
+
const group=groups.get(data.field)??[];
|
|
26
|
+
group.push({shape,index,data,ambiguous:unreadable||tags.filter(tag=>/^OPF_/i.test(tag.name)).length!==1});groups.set(data.field,group);
|
|
27
|
+
}catch{report({code:'invalid-heading-provenance',message:'Invalid heading tags; ordinary import retains visible native text.'});}
|
|
28
|
+
}
|
|
29
|
+
for(const group of groups.values())try {
|
|
30
|
+
const first=group[0].data,count=first.count;
|
|
31
|
+
if(!Number.isSafeInteger(count)||count<1||count>512||group.length!==count||new Set(group.map(item=>item.shape)).size!==count)throw Error('Incomplete or duplicated heading lines.');
|
|
32
|
+
const ordered=Array(count);
|
|
33
|
+
for(const item of group) {
|
|
34
|
+
const data=item.data;
|
|
35
|
+
if(item.ambiguous||data.group!==first.group||data.count!==count||!Number.isSafeInteger(data.line)||data.line<0||data.line>=count||ordered[data.line])throw Error('Ambiguous heading line.');
|
|
36
|
+
ordered[data.line]=item;
|
|
37
|
+
}
|
|
38
|
+
const restored=sourceLineParagraphs(ordered,paragraphs,{legacy:true});
|
|
39
|
+
for(const item of ordered)consumed.add(item.shape);
|
|
40
|
+
items.push({field:first.field,shapes:ordered.map(item=>item.shape),paragraphs:restored});
|
|
41
|
+
report({code:'heading-import-reflow',message:'Complete tagged heading lines retain their roles, order and current native text. New boundary tags also retain authored whitespace and line endings; legacy tags retain native line breaks. Formatting and geometry are not reconstructed.'});
|
|
42
|
+
}catch(error){report({code:'invalid-heading-provenance',message:`${error.message} Ordinary import retains visible native text.`});}
|
|
43
|
+
return {consumed,items};
|
|
44
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface ToPptxOptions {
|
|
|
37
37
|
/** Default compatible converts WebP to a static PNG. Preserve embeds original WebP bytes. */
|
|
38
38
|
imageFormat?: "compatible" | "preserve";
|
|
39
39
|
textMeasurement?: TextMeasurement;
|
|
40
|
+
/** Match preview/pagination clearance around supplied vector text outlines; default 1. */
|
|
41
|
+
textRasterPadding?: number;
|
|
40
42
|
onDiagnostic?: (diagnostic: LayoutDiagnostic) => void;
|
|
41
43
|
baseDir?: string;
|
|
42
44
|
compressionLevel?: number;
|
|
@@ -48,7 +50,7 @@ export interface ToPptxOptions {
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export interface FromPptxOptions {
|
|
51
|
-
/** Reports native
|
|
53
|
+
/** Reports native details that import cannot preserve, including code provenance fallback/reflow and grouped text transforms. Table paths identify native frame and row/cell indexes (including headers). */
|
|
52
54
|
onDiagnostic?: (diagnostic: {code: string; path: string; message: string}) => void;
|
|
53
55
|
fallbackName?: string;
|
|
54
56
|
schema?: string;
|