@openpresentation/opf-pptx 0.7.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 +17 -3
- package/dist/card-provenance.js +51 -0
- package/dist/chart-workbook.js +81 -0
- package/dist/code-provenance.js +18 -9
- package/dist/heading-provenance.js +44 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +199 -62
- package/dist/metric-provenance.js +94 -0
- package/dist/text-provenance.js +54 -0
- package/dist/timeline-provenance.js +81 -0
- package/package.json +11 -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,12 +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
|
|
|
5
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.
|
|
6
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
|
+
|
|
7
19
|
## Scope
|
|
8
20
|
|
|
9
|
-
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.
|
|
10
22
|
|
|
11
23
|
- Package: `@openpresentation/opf-pptx`
|
|
12
24
|
- Repository: `OpenPresentation/opf-pptx`
|
|
@@ -67,7 +79,7 @@ This pass did not require an OPF schema change. The deferred full OOXML placehol
|
|
|
67
79
|
The first importer is mechanical and schema-compatible:
|
|
68
80
|
|
|
69
81
|
- Presentation core properties map to OPF `name`, `description`, and `author`.
|
|
70
|
-
-
|
|
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.
|
|
71
83
|
- Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position.
|
|
72
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.
|
|
73
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.
|
|
@@ -157,7 +169,7 @@ Native viewer check: Keynote 14.4 displays the PNG/JPEG/GIF media-type specimens
|
|
|
157
169
|
|
|
158
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.
|
|
159
171
|
|
|
160
|
-
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.
|
|
161
173
|
|
|
162
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.
|
|
163
175
|
|
|
@@ -218,3 +230,5 @@ Conditional borders keep the whole-table outer frame separate from interior hori
|
|
|
218
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.
|
|
219
231
|
|
|
220
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
|
+
}
|
package/dist/code-provenance.js
CHANGED
|
@@ -21,6 +21,12 @@ export function codeManifest(value, layout, group) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
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) {
|
|
24
30
|
if (!records.size) return;
|
|
25
31
|
let count = 0;
|
|
26
32
|
const seen = new Set(), types = [];
|
|
@@ -31,23 +37,23 @@ export function attachCodeTags(entries, records) {
|
|
|
31
37
|
const xml = dec.decode(entries[path]).replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape=>{
|
|
32
38
|
const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
33
39
|
if (!records.has(name)) return shape;
|
|
34
|
-
if (seen.has(name)) throw new Error(
|
|
40
|
+
if (seen.has(name)) throw new Error(`Duplicate generated ${kind} shape.`);
|
|
35
41
|
seen.add(name);
|
|
36
|
-
const part = `ppt/tags
|
|
37
|
-
let id = `
|
|
42
|
+
const part = `ppt/tags/${prefix}${++count}.xml`;
|
|
43
|
+
let id = `rId${prefix[0].toUpperCase()+prefix.slice(1)}${count}`;
|
|
38
44
|
while (ids.has(id)) id += '_';
|
|
39
45
|
ids.add(id);
|
|
40
|
-
entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${
|
|
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>`);
|
|
41
47
|
types.push(`<Override PartName="/${part}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
|
|
42
|
-
rels = rels.replace('</Relationships>',`<Relationship Id="${id}" Type="${REL}" Target="../tags
|
|
48
|
+
rels = rels.replace('</Relationships>',`<Relationship Id="${id}" Type="${REL}" Target="../tags/${prefix}${count}.xml"/></Relationships>`);
|
|
43
49
|
shape = shape.replace(/<p:nvPr\s*\/>/,'<p:nvPr></p:nvPr>');
|
|
44
|
-
if (!shape.includes('</p:nvPr>')) throw new Error(
|
|
50
|
+
if (!shape.includes('</p:nvPr>')) throw new Error(`Generated ${kind} shape has no native application properties.`);
|
|
45
51
|
return shape.replace('</p:nvPr>',`<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst></p:nvPr>`);
|
|
46
52
|
});
|
|
47
53
|
entries[path] = enc.encode(xml);
|
|
48
54
|
entries[relPath] = enc.encode(rels);
|
|
49
55
|
}
|
|
50
|
-
if (seen.size !== records.size) throw new Error(
|
|
56
|
+
if (seen.size !== records.size) throw new Error(`Missing generated ${kind} shapes.`);
|
|
51
57
|
entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>',types.join('')+'</Types>'));
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -120,10 +126,13 @@ export function importCodeGroups(shapes, paragraphs, relationships, entries, rep
|
|
|
120
126
|
const rel = relationships.get(link['r:id']);
|
|
121
127
|
if (rel?.type !== REL || rel.targetMode === 'External' || !rel.path || !entries[rel.path]) continue;
|
|
122
128
|
let tags;
|
|
123
|
-
try {
|
|
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
|
+
}
|
|
124
134
|
catch { report({code:'invalid-code-provenance',message:'Unreadable code tags; visible native shapes were retained.'}); continue; }
|
|
125
135
|
for (const tag of tags) {
|
|
126
|
-
tagCounts.set(shape,(tagCounts.get(shape) ?? 0)+1);
|
|
127
136
|
try {
|
|
128
137
|
const data = unhex(tag.val);
|
|
129
138
|
if (data.v !== 1 || typeof data.group !== 'string' || !/^\d+$/.test(data.group)) throw new Error('Invalid code identity.');
|
|
@@ -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;
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import {importTableFrames} from './table-import.js';
|
|
2
|
+
import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
|
|
2
3
|
import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
4
|
+
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
5
|
+
import {attachCardTags,importCardFrames} from './card-provenance.js';
|
|
6
|
+
import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
|
|
7
|
+
import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
|
|
8
|
+
import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
|
|
3
9
|
import {importImageOrientation} from './image-import.js';
|
|
4
10
|
import {nativeBackgroundFill} from './background.js';
|
|
5
11
|
import {importBackground} from './background-import.js';
|
|
6
12
|
import { webpToPng } from '#image-fallback';
|
|
7
13
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
8
|
-
import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
|
|
14
|
+
import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
|
|
9
15
|
import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
|
|
10
16
|
import { unzipSync, zipSync } from "fflate";
|
|
11
17
|
import { XMLParser } from "fast-xml-parser";
|
|
@@ -171,7 +177,13 @@ export async function toPptx(input, options = {}) {
|
|
|
171
177
|
context.tableCells = new Map();
|
|
172
178
|
context.imagePlacements = new Map();
|
|
173
179
|
context.backgroundFills = new Map();
|
|
180
|
+
context.cardTags = new Map();
|
|
181
|
+
context.headingTags = new Map();
|
|
182
|
+
context.plainTextTags = new Map();
|
|
183
|
+
context.timelineTags = new Map();
|
|
174
184
|
context.codeTags = new Map();
|
|
185
|
+
context.metricTags = new Map();
|
|
186
|
+
context.chartHeadings = new Map();
|
|
175
187
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
176
188
|
const pptx = new PptxGenJS();
|
|
177
189
|
configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
|
|
@@ -384,9 +396,15 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
384
396
|
|
|
385
397
|
const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex)
|
|
386
398
|
.sort(comparePositionedItems);
|
|
387
|
-
|
|
399
|
+
// Complete OPF heading roles are authoritative. Nearby body lines must not
|
|
400
|
+
// fill an absent role by geometry; explicit native placeholders still apply.
|
|
401
|
+
const inferHeadings = !items.some(item => item.heading||item.sourceText);
|
|
402
|
+
const heading = field => {const index=items.findIndex(item=>item.heading===field);return index<0?null:items.splice(index,1)[0];};
|
|
403
|
+
const tagItem=heading('tag');
|
|
404
|
+
if(tagItem)slide.tag=tagItem.text;
|
|
405
|
+
const titleItem = heading('title')??takeTitleItem(items, dimensions, inferHeadings);
|
|
388
406
|
if (titleItem) slide.title = titleItem.text;
|
|
389
|
-
const subtitleItem = takeSubtitleItem(items, titleItem, dimensions);
|
|
407
|
+
const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
|
|
390
408
|
if (subtitleItem) slide.subtitle = subtitleItem.text;
|
|
391
409
|
|
|
392
410
|
const blocks = mergeAdjacentBulletShapes(items)
|
|
@@ -407,9 +425,28 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
407
425
|
if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text is retained, but group transforms and non-text group members are not reconstructed; review the reflowed OPF.'});
|
|
408
426
|
const paragraphs = nativeShapeParagraphs(decodeText(entries[slidePath]));
|
|
409
427
|
const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
|
|
428
|
+
const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
|
|
429
|
+
const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
430
|
+
const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
431
|
+
const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
432
|
+
const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
433
|
+
for(const group of [...headings.items,...plainText.items]) {
|
|
434
|
+
const item=importShape(group.shapes[0],dimensions,group.paragraphs,true);
|
|
435
|
+
if(item){
|
|
436
|
+
const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
|
|
437
|
+
if(bounds.length){const x=Math.min(...bounds.map(b=>b.x)),y=Math.min(...bounds.map(b=>b.y));item.bounds={x,y,w:Math.max(...bounds.map(b=>b.x+b.w))-x,h:Math.max(...bounds.map(b=>b.y+b.h))-y};}
|
|
438
|
+
items.push({...item,heading:group.field,sourceText:!group.field});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
for(const group of timelines.items){
|
|
442
|
+
const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
|
|
443
|
+
let union;for(const bound of bounds){if(!union)union={...bound};else{const x=Math.min(union.x,bound.x),y=Math.min(union.y,bound.y);union={x,y,w:Math.max(union.x+union.w,bound.x+bound.w)-x,h:Math.max(union.y+union.h,bound.y+bound.h)-y};}}
|
|
444
|
+
items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload});
|
|
445
|
+
}
|
|
410
446
|
for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
447
|
+
for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
411
448
|
for (const [index,shape] of shapes.entries()) {
|
|
412
|
-
if (code.consumed.has(shape)) continue;
|
|
449
|
+
if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)) continue;
|
|
413
450
|
const item = importShape(shape, dimensions, paragraphs[index]);
|
|
414
451
|
if (item) items.push(item);
|
|
415
452
|
}
|
|
@@ -433,13 +470,13 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
433
470
|
return items;
|
|
434
471
|
}
|
|
435
472
|
|
|
436
|
-
function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txBody"])) {
|
|
473
|
+
function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txBody"]), allowEmptyText = false) {
|
|
437
474
|
const text = paragraphs.map((paragraph) => paragraph.text).join("\n");
|
|
438
475
|
const placeholder = shapePlaceholderType(shape);
|
|
439
476
|
const bounds = shapeBounds(shape["p:spPr"]?.["a:xfrm"]);
|
|
440
477
|
const name = scalarText(shape["p:nvSpPr"]?.["p:cNvPr"]?.name).trim();
|
|
441
478
|
|
|
442
|
-
if (text) {
|
|
479
|
+
if (text || allowEmptyText) {
|
|
443
480
|
return {
|
|
444
481
|
kind: "text",
|
|
445
482
|
text,
|
|
@@ -572,13 +609,14 @@ function shapeBounds(xfrm) {
|
|
|
572
609
|
return { x, y, w, h };
|
|
573
610
|
}
|
|
574
611
|
|
|
575
|
-
function takeTitleItem(items, dimensions) {
|
|
612
|
+
function takeTitleItem(items, dimensions, inferHeadings) {
|
|
576
613
|
const explicitIndex = items.findIndex((item) => ["title", "ctrTitle"].includes(item.placeholder));
|
|
577
614
|
if (explicitIndex >= 0) return items.splice(explicitIndex, 1)[0];
|
|
615
|
+
if (!inferHeadings) return null;
|
|
578
616
|
|
|
579
617
|
const titleLimit = dimensions.heightInches * 0.28;
|
|
580
618
|
const candidateIndex = items.findIndex((item) => {
|
|
581
|
-
if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
|
|
619
|
+
if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
|
|
582
620
|
const y = item.bounds?.y ?? 0;
|
|
583
621
|
return y <= titleLimit && (item.maxFontSize >= 20 || /^title\b/i.test(item.name ?? ""));
|
|
584
622
|
});
|
|
@@ -586,15 +624,15 @@ function takeTitleItem(items, dimensions) {
|
|
|
586
624
|
return null;
|
|
587
625
|
}
|
|
588
626
|
|
|
589
|
-
function takeSubtitleItem(items, titleItem, dimensions) {
|
|
627
|
+
function takeSubtitleItem(items, titleItem, dimensions, inferHeadings) {
|
|
590
628
|
const explicitIndex = items.findIndex((item) => item.placeholder === "subTitle");
|
|
591
629
|
if (explicitIndex >= 0) return items.splice(explicitIndex, 1)[0];
|
|
592
|
-
if (!titleItem) return null;
|
|
630
|
+
if (!inferHeadings || !titleItem) return null;
|
|
593
631
|
|
|
594
632
|
const titleBottom = (titleItem.bounds?.y ?? 0) + (titleItem.bounds?.h ?? 0);
|
|
595
633
|
const subtitleLimit = Math.min(dimensions.heightInches * 0.34, 1.45);
|
|
596
634
|
const candidateIndex = items.findIndex((item) => {
|
|
597
|
-
if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
|
|
635
|
+
if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
|
|
598
636
|
const y = item.bounds?.y ?? 0;
|
|
599
637
|
const h = item.bounds?.h ?? 0;
|
|
600
638
|
return y >= titleBottom - 0.05
|
|
@@ -674,7 +712,7 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
674
712
|
return {
|
|
675
713
|
type: chartNode.type,
|
|
676
714
|
data: {
|
|
677
|
-
columns: ["Category", ...names],
|
|
715
|
+
columns: [readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names],
|
|
678
716
|
rows
|
|
679
717
|
}
|
|
680
718
|
};
|
|
@@ -888,31 +926,47 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
888
926
|
const { widthInches, heightInches } = slideContext.dimensions;
|
|
889
927
|
const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
|
|
890
928
|
if (opfSlide.layout && layout.id !== opfSlide.layout) throw new OPFPptxError("catalog-resolution-failed", `Layout '${opfSlide.layout}' needs an inline or bundled catalog record.`, { path: `slides.${slideIndex}.layout` });
|
|
891
|
-
const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, slideIndex, fonts: slideContext.fonts, textMeasurement: options.textMeasurement });
|
|
929
|
+
const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, slideIndex, fonts: slideContext.fonts, contentAlignment:opfSlide.design?.contentAlignment??presentation.design?.contentAlignment, titleAlignment:opfSlide.design?.titleAlignment??presentation.design?.titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement });
|
|
892
930
|
for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
893
931
|
for (const item of geometry.items) {
|
|
894
932
|
const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
|
|
895
|
-
if (
|
|
933
|
+
if (item.frameBox) {
|
|
934
|
+
const frame=item.frameBox;
|
|
935
|
+
context.cardTags.set(`OPF card ${item.path}`,item.path);
|
|
936
|
+
const paint=value=>({color:normalizeHex(value),transparency:/^#[0-9a-f]{8}$/i.test(value)?(1-parseInt(value.slice(7),16)/255)*100:0});
|
|
937
|
+
const surface=slideContext.colorScheme[isDarkHex(slideContext.colors.background)?'dark2':'light2']??`#${slideContext.colors.surface}`;
|
|
938
|
+
slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
|
|
939
|
+
rectRadius:8*Math.min(widthInches,heightInches)/720,
|
|
940
|
+
fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),pt:.75},objectName:`OPF card ${item.path}`});
|
|
941
|
+
}
|
|
942
|
+
if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
|
|
943
|
+
addMeasuredPayloadText(slide,item.value,item.box,slideContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?slideContext.colors.accent:slideContext.colors.text});
|
|
944
|
+
} else if (["title", "subtitle", "tag"].includes(item.field)) {
|
|
945
|
+
// Estimated-font headings use one native text box, which still needs a
|
|
946
|
+
// role tag. Role recovery must not depend on outline measurement support.
|
|
947
|
+
const objectName = `OPF heading ${item.path} line 0`;
|
|
948
|
+
context.headingTags.set(objectName,{v:1,group:item.path,field:item.field,line:0,count:1});
|
|
896
949
|
slide.addText(item.text.lines.join("\n"), {
|
|
897
950
|
...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
|
|
898
|
-
|
|
899
|
-
bold: item.textStyle.fontWeight >= 600,
|
|
900
|
-
italic: item.textStyle.italic,
|
|
951
|
+
...nativeFontOptions(item.textStyle),
|
|
901
952
|
color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
|
|
953
|
+
objectName,
|
|
902
954
|
breakLine: false
|
|
903
955
|
});
|
|
904
956
|
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
905
957
|
addMeasuredList(slide,item.text,slideContext);
|
|
906
958
|
} else if (item.field === "text" && item.text?.richLines) {
|
|
907
|
-
const alignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
|
|
908
|
-
for(const line of item.text.richLines){
|
|
909
|
-
const runs=line.fragments.map(fragment=>({text:fragment.text,options:{
|
|
910
|
-
|
|
959
|
+
const alignment=item.text.placement?.alignment??opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
|
|
960
|
+
for(const [index,line] of item.text.richLines.entries()){
|
|
961
|
+
const runs=line.fragments.map(fragment=>({text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color:normalizeHex(fragment.run.color??slideContext.colors.text),underline:fragment.run.underline?{color:normalizeHex(fragment.run.color??slideContext.colors.text)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}}));
|
|
962
|
+
const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
963
|
+
const area=placed?{...region,x:(placed.x+line.width*factor-item.box.width*factor)/96,y:placed.y/96,h:placed.height/96}:{...region,y:region.y+line.y/96,h:line.height/96};
|
|
964
|
+
if(runs.length)slide.addText(runs,{...textBoxOptions(area,slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
|
|
911
965
|
}
|
|
912
966
|
} else if (item.field === "text" && typeof item.value === "string") {
|
|
913
|
-
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75)
|
|
967
|
+
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle)});
|
|
914
968
|
} else {
|
|
915
|
-
await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options, item.quoteLayout, item.codeLayout);
|
|
969
|
+
await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
|
|
916
970
|
}
|
|
917
971
|
}
|
|
918
972
|
|
|
@@ -933,7 +987,7 @@ function fieldToType(field) {
|
|
|
933
987
|
return field === "items" || field === "bullets" ? "list" : field;
|
|
934
988
|
}
|
|
935
989
|
|
|
936
|
-
async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout) {
|
|
990
|
+
async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout,metricLayout,timelineLayout) {
|
|
937
991
|
const kind = inferPayloadKind(payload);
|
|
938
992
|
switch (kind) {
|
|
939
993
|
case "text":
|
|
@@ -958,13 +1012,13 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
958
1012
|
addCodePayload(slide, payload.code, codeLayout, region, context, path);
|
|
959
1013
|
break;
|
|
960
1014
|
case "metric":
|
|
961
|
-
addMetricPayload(slide, payload.metric,
|
|
1015
|
+
addMetricPayload(slide, payload.metric,metricLayout,context,path);
|
|
962
1016
|
break;
|
|
963
1017
|
case "quote":
|
|
964
1018
|
addQuotePayload(slide, quoteLayout, context, options, path);
|
|
965
1019
|
break;
|
|
966
1020
|
case "timeline":
|
|
967
|
-
addTimelinePayload(slide, payload.timeline,
|
|
1021
|
+
addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
|
|
968
1022
|
break;
|
|
969
1023
|
default:
|
|
970
1024
|
addPlaceholderPayload(slide, "Unsupported OPF payload", payload, region, context);
|
|
@@ -994,7 +1048,7 @@ function addTextPayload(slide, value, region, context) {
|
|
|
994
1048
|
}
|
|
995
1049
|
|
|
996
1050
|
function richLineRuns(line,color) {
|
|
997
|
-
return line.fragments.map(fragment=>({text:fragment.text,options:{
|
|
1051
|
+
return line.fragments.map(fragment=>({text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color:normalizeHex(fragment.run.color??color),underline:fragment.run.underline?{color:normalizeHex(fragment.run.color??color)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}}));
|
|
998
1052
|
}
|
|
999
1053
|
function addMeasuredList(slide,fit,context) {
|
|
1000
1054
|
for(const entry of fit.listEntries){
|
|
@@ -1010,7 +1064,7 @@ function addMeasuredList(slide,fit,context) {
|
|
|
1010
1064
|
// Keep paragraph intent identical across runs. ZIP normalization below
|
|
1011
1065
|
// removes the duplicate paragraph-property nodes emitted by PptxGenJS.
|
|
1012
1066
|
for(const run of runs)Object.assign(run.options,paragraph);
|
|
1013
|
-
slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:entry.marker.style.
|
|
1067
|
+
slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:nativeFontOptions(entry.marker.style).fontFace,objectName,align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
|
|
1014
1068
|
});
|
|
1015
1069
|
};
|
|
1016
1070
|
addLines(entry.text,entry.textBox,context.colors.text,true);
|
|
@@ -1083,21 +1137,36 @@ function addChartPayload(slide, chart, region, context) {
|
|
|
1083
1137
|
return;
|
|
1084
1138
|
}
|
|
1085
1139
|
|
|
1140
|
+
// Keep the resolved palette surface (including alpha) explicit in native
|
|
1141
|
+
// chart/plot areas, so inherited labels are assessed against their own panel.
|
|
1142
|
+
const panelFill = context.colorScheme.surface ?? context.colorScheme[isDarkHex(context.colors.background) ? 'dark2' : 'light2'] ?? `#${context.colors.surface}`;
|
|
1143
|
+
const labelColor = normalizeHex(textColorForFill(panelFill, `#${context.colors.text}`));
|
|
1144
|
+
const transparency = /^#[0-9a-f]{8}$/i.test(panelFill) ? (1 - parseInt(panelFill.slice(7), 16) / 255) * 100 : 0;
|
|
1145
|
+
const fill = {color:normalizeHex(panelFill),transparency};
|
|
1146
|
+
const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
|
|
1147
|
+
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1148
|
+
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chart.data.columns[0],labelColor});
|
|
1086
1149
|
slide.addChart(chartData.type, chartData.series, {
|
|
1150
|
+
objectName,
|
|
1087
1151
|
x: region.x,
|
|
1088
1152
|
y: region.y,
|
|
1089
1153
|
w: region.w,
|
|
1090
1154
|
h: region.h,
|
|
1091
|
-
showLegend: chartData.series.length > 1,
|
|
1155
|
+
showLegend: circular || chartData.series.length > 1,
|
|
1092
1156
|
showTitle: false,
|
|
1093
|
-
chartColors: CHART_COLORS,
|
|
1157
|
+
chartColors: CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`))),
|
|
1158
|
+
chartArea: {fill:{...fill},roundedCorners:false},
|
|
1159
|
+
// Paint alpha once in the chart area, rather than stacking two alpha fills.
|
|
1160
|
+
plotArea: {fill:{color:null}},
|
|
1094
1161
|
catAxisLabelFontFace: context.fonts.body,
|
|
1095
1162
|
catAxisLabelFontSize: 9,
|
|
1096
|
-
catAxisLabelColor:
|
|
1163
|
+
catAxisLabelColor: labelColor,
|
|
1097
1164
|
valAxisLabelFontFace: context.fonts.body,
|
|
1098
1165
|
valAxisLabelFontSize: 9,
|
|
1099
|
-
valAxisLabelColor:
|
|
1100
|
-
legendColor:
|
|
1166
|
+
valAxisLabelColor: labelColor,
|
|
1167
|
+
legendColor: labelColor,
|
|
1168
|
+
legendFontFace: context.fonts.body,
|
|
1169
|
+
dataLabelColor: labelColor,
|
|
1101
1170
|
showValue: false,
|
|
1102
1171
|
valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
|
|
1103
1172
|
barDir: chartData.barDir,
|
|
@@ -1121,8 +1190,8 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1121
1190
|
const rows = layout.rows.map(row => row.cells.map(cell => {
|
|
1122
1191
|
const {header,rich,fit} = cell;
|
|
1123
1192
|
const cellStyle = cell.style ?? {};
|
|
1124
|
-
const baseColor = (cellStyle.color ?? (header ? '#FFFFFF' : '#' + context.colors.text)).replace(/^#/, '');
|
|
1125
1193
|
const baseFill = (cellStyle.fill ?? (header ? '#' + context.colors.accent : '#' + context.colors.surface)).replace(/^#/, '');
|
|
1194
|
+
const baseColor = (cellStyle.color ?? textColorForFill('#' + baseFill, header ? '#FFFFFF' : '#' + context.colors.text)).replace(/^#/, '');
|
|
1126
1195
|
const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
|
|
1127
1196
|
const text = stringifyText(cell.value), style = cell.textStyle;
|
|
1128
1197
|
const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
|
|
@@ -1133,8 +1202,7 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1133
1202
|
const rawColor = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color ?? '') ? run.color.slice(1) : baseColor;
|
|
1134
1203
|
const color = normalizeHex(rawColor), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
|
|
1135
1204
|
const runOptions = {
|
|
1136
|
-
|
|
1137
|
-
bold:runStyle.fontWeight >= 600,italic:runStyle.italic,
|
|
1205
|
+
...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
|
|
1138
1206
|
underline:run.underline ? {color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
|
|
1139
1207
|
color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
|
|
1140
1208
|
hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
|
|
@@ -1150,13 +1218,12 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1150
1218
|
// soft wraps into the text would change a later import or copy operation.
|
|
1151
1219
|
text: rich ? runs : text,
|
|
1152
1220
|
options: {
|
|
1153
|
-
|
|
1221
|
+
...nativeFontOptions(style),
|
|
1154
1222
|
fontSize: fit.fontSize * 0.75,
|
|
1155
1223
|
// PptxGenJS fills falsy run options from cell defaults. Rich runs
|
|
1156
1224
|
// carry their resolved weight, so a bold header default must not turn
|
|
1157
1225
|
// an explicit bold:false run back on.
|
|
1158
|
-
|
|
1159
|
-
italic: rich ? false : style.italic,
|
|
1226
|
+
...(rich ? {bold:false,italic:false} : {}),
|
|
1160
1227
|
lineSpacing: fit.lineHeight * 0.75,
|
|
1161
1228
|
paraSpaceAfter: 0,
|
|
1162
1229
|
align: cellStyle.align ?? context.contentAlignment,
|
|
@@ -1199,6 +1266,8 @@ function pixelBox(region) {
|
|
|
1199
1266
|
// Match the published renderer's payload geometry and shared core text fitting.
|
|
1200
1267
|
// Each fitted line remains native editable text, without PowerPoint rewrapping it.
|
|
1201
1268
|
function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
1269
|
+
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
|
|
1270
|
+
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});
|
|
1202
1271
|
const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
|
|
1203
1272
|
const style = config.textStyle ?? resolveTextStyle({fontFamily: config.fontFamily ?? context.fonts.body, fontWeight: config.fontWeight ?? 400, path: config.path}, options.textMeasurement);
|
|
1204
1273
|
const fit = config.fit ?? fitText(String(text ?? ''), box, config.fontSize * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
|
|
@@ -1208,11 +1277,20 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
1208
1277
|
if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
|
|
1209
1278
|
}
|
|
1210
1279
|
for (const [index, line] of fit.lines.entries()) {
|
|
1211
|
-
if (!line) continue;
|
|
1280
|
+
if (!line&&!config.heading&&!config.sourceText&&!config.timeline) continue;
|
|
1281
|
+
const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1282
|
+
const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
|
|
1283
|
+
const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:undefined;
|
|
1284
|
+
if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
|
|
1285
|
+
else if(config.timeline)context.timelineTags.set(objectName,{v:1,role:'text',group:config.timeline.group,part:config.timeline.part,line:index,count:fit.lines.length,...boundary,...(config.timeline.part===0&&index===0?{anchor:config.timeline.anchor}:{})});
|
|
1286
|
+
else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
|
|
1287
|
+
const area=placed?{x:(placed.x+placed.width*factor-box.width*factor)/96,y:(placed.baseline-fit.fontSize)/96,w:box.width/96,h:placed.height/96}:{x:box.x/96,y:(box.y+index*fit.lineHeight)/96,w:box.width/96,h:fit.lineHeight/96};
|
|
1212
1288
|
slide.addText(line, {
|
|
1213
|
-
...textBoxOptions(
|
|
1214
|
-
|
|
1215
|
-
color: normalizeHex(config.color ?? context.colors.text), align:
|
|
1289
|
+
...textBoxOptions(area, context, fit.fontSize * .75),
|
|
1290
|
+
...nativeFontOptions(style),
|
|
1291
|
+
color: normalizeHex(config.color ?? context.colors.text), align: alignment,
|
|
1292
|
+
tabStops:sourceLine?.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'})),
|
|
1293
|
+
objectName,
|
|
1216
1294
|
fit: 'none', wrap: false, lineSpacingMultiple: 1,
|
|
1217
1295
|
});
|
|
1218
1296
|
}
|
|
@@ -1238,7 +1316,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
|
|
|
1238
1316
|
context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
|
|
1239
1317
|
slide.addText(part.text.slice(line.start,line.end),{
|
|
1240
1318
|
...textBoxOptions({x:part.box.x/96,y:(part.box.y+index*part.fit.lineHeight)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
|
|
1241
|
-
|
|
1319
|
+
...nativeFontOptions(part.style),
|
|
1242
1320
|
color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
1243
1321
|
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
1244
1322
|
});
|
|
@@ -1246,10 +1324,31 @@ function addCodePayload(slide, value, layout, region, context, path) {
|
|
|
1246
1324
|
}
|
|
1247
1325
|
}
|
|
1248
1326
|
|
|
1249
|
-
function addMetricPayload(slide,
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1327
|
+
function addMetricPayload(slide,value,layout,context,path) {
|
|
1328
|
+
if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
|
|
1329
|
+
const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
|
|
1330
|
+
for (const [partIndex,part] of layout.parts.entries()) {
|
|
1331
|
+
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
1332
|
+
if (invalid) throw new OPFPptxError('invalid-metric-text',`Metric text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before exporting.`,{path:part.path});
|
|
1333
|
+
if (!part.visible) continue;
|
|
1334
|
+
if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFPptxError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,issues:layout.diagnostics});
|
|
1335
|
+
for (const [index,line] of part.fit.sourceLines.entries()) {
|
|
1336
|
+
const origin=part.linePositions[index],objectName=`OPF metric ${group} ${part.role} line ${index+1}`;
|
|
1337
|
+
context.metricTags.set(objectName,partIndex===0&&index===0?manifest:{v:1,group,role:'line',part:partIndex,line:index});
|
|
1338
|
+
const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
|
|
1339
|
+
// Anchor the native paragraph to the same accepted left/center/right point.
|
|
1340
|
+
// Left-aligning at a measured glyph origin loses the intended edge when the
|
|
1341
|
+
// native shaper has a slightly different advance. No re-fitting is needed.
|
|
1342
|
+
const factor=layout.alignment==='right'?1:layout.alignment==='center'?.5:0;
|
|
1343
|
+
const anchor=origin.x+line.width*factor;
|
|
1344
|
+
slide.addText(part.text.slice(line.start,line.end),{
|
|
1345
|
+
...textBoxOptions({x:(anchor-part.box.width*factor)/96,y:(origin.baseline-part.fit.fontSize)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
|
|
1346
|
+
...nativeFontOptions(part.style),
|
|
1347
|
+
color:part.role==='value'?context.colors.accent:context.colors.text,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
1348
|
+
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1253
1352
|
}
|
|
1254
1353
|
|
|
1255
1354
|
function addQuotePayload(slide, layout, context, options, path) {
|
|
@@ -1263,20 +1362,23 @@ function addQuotePayload(slide, layout, context, options, path) {
|
|
|
1263
1362
|
}
|
|
1264
1363
|
}
|
|
1265
1364
|
|
|
1266
|
-
function addTimelinePayload(slide, value,
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
const
|
|
1271
|
-
const
|
|
1272
|
-
const
|
|
1273
|
-
const
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
for
|
|
1277
|
-
const
|
|
1278
|
-
slide.addShape('ellipse',
|
|
1279
|
-
|
|
1365
|
+
function addTimelinePayload(slide, value, layout, context, options, path) {
|
|
1366
|
+
if(!layout)throw new OPFPptxError('missing-timeline-layout','Timeline export requires a coordinated core build with shared timeline geometry.',{path});
|
|
1367
|
+
// Validate optional field fits before the provenance manifest dereferences
|
|
1368
|
+
// source lines or any drawing/tag mutation is performed.
|
|
1369
|
+
for(const part of layout.parts)if(!part.fit)throw new OPFPptxError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,issues:layout.diagnostics});
|
|
1370
|
+
const scale=Math.min(context.dimensions.widthInches,context.dimensions.heightInches)*96/720;
|
|
1371
|
+
const group=String(context.timelineTags.size),anchor=timelineManifest(value,layout),connectorName=`OPF timeline ${group} connector`;
|
|
1372
|
+
const {x1,y1,x2,y2}=layout.connector;
|
|
1373
|
+
slide.addShape('line',{objectName:connectorName,x:x1/96,y:y1/96,w:(x2-x1)/96,h:(y2-y1)/96,line:{color:context.colors.border,pt:3*scale*.75}});
|
|
1374
|
+
context.timelineTags.set(connectorName,{v:1,group,role:'connector'});
|
|
1375
|
+
for(const marker of layout.markers){
|
|
1376
|
+
const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
|
|
1377
|
+
slide.addShape('ellipse',{objectName,x:(marker.x-marker.radius)/96,y:(marker.y-marker.radius)/96,w:marker.radius*2/96,h:marker.radius*2/96,fill:{color:context.colors.accent},line:{transparency:100}});
|
|
1378
|
+
context.timelineTags.set(objectName,{v:1,group,role:'marker',eventIndex:marker.eventIndex});
|
|
1379
|
+
}
|
|
1380
|
+
for(const [index,part]of layout.parts.entries()){
|
|
1381
|
+
addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,timeline:{group,part:index,anchor}});
|
|
1280
1382
|
}
|
|
1281
1383
|
}
|
|
1282
1384
|
|
|
@@ -1304,6 +1406,16 @@ function addPlaceholderPayload(slide, label, value, region, context) {
|
|
|
1304
1406
|
});
|
|
1305
1407
|
}
|
|
1306
1408
|
|
|
1409
|
+
function nativeFontOptions(style) {
|
|
1410
|
+
const face=style.fontFace;
|
|
1411
|
+
if(face!==undefined) {
|
|
1412
|
+
if(!face || typeof face.family!=='string' || !face.family.trim() || typeof face.bold!=='boolean' || typeof face.italic!=='boolean')
|
|
1413
|
+
throw new OPFPptxError('invalid-font-selection','The font provider must supply a family and explicit bold/italic style-link flags.',{path:style.path});
|
|
1414
|
+
return {fontFace:face.family,bold:face.bold,italic:face.italic};
|
|
1415
|
+
}
|
|
1416
|
+
return {fontFace:style.fontFamily,bold:style.fontWeight>=600,italic:style.italic};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1307
1419
|
function textBoxOptions(region, context, fontSize) {
|
|
1308
1420
|
return {
|
|
1309
1421
|
x: region.x,
|
|
@@ -1575,6 +1687,26 @@ async function normalizePptxZip(raw, context) {
|
|
|
1575
1687
|
}
|
|
1576
1688
|
|
|
1577
1689
|
attachCodeTags(entries, context.codeTags);
|
|
1690
|
+
attachMetricTags(entries,context.metricTags);
|
|
1691
|
+
attachCardTags(entries,context.cardTags);
|
|
1692
|
+
attachHeadingTags(entries,context.headingTags);
|
|
1693
|
+
attachPlainTextTags(entries,context.plainTextTags);
|
|
1694
|
+
attachTimelineTags(entries,context.timelineTags);
|
|
1695
|
+
for(const [part,bytes]of Object.entries(entries)){
|
|
1696
|
+
if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
|
|
1697
|
+
const relationships=parseRelationships(entries,part);
|
|
1698
|
+
for(const [frame]of decodeText(bytes).matchAll(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g)){
|
|
1699
|
+
const name=frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
1700
|
+
if(!context.chartHeadings.has(name))continue;
|
|
1701
|
+
const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
|
|
1702
|
+
if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
|
|
1703
|
+
const {heading,labelColor}=context.chartHeadings.get(name);
|
|
1704
|
+
if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
|
|
1705
|
+
// PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
|
|
1706
|
+
// Normalize only our generated chart text styles; point/series fills stay intact.
|
|
1707
|
+
entries[chartPart]=encodeText(decodeText(entries[chartPart]).replace(/<c:txPr>[\s\S]*?<\/c:txPr>/g,properties=>properties.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/g,()=>`<a:solidFill><a:srgbClr val="${labelColor}"/></a:solidFill>`)));
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1578
1710
|
const imageSources = new Map();
|
|
1579
1711
|
for (const [part, bytes] of Object.entries(entries)) {
|
|
1580
1712
|
if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
|
|
@@ -1650,6 +1782,11 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
1650
1782
|
if (isXmlPart(path)) {
|
|
1651
1783
|
let xml=decodeText(bytes);
|
|
1652
1784
|
if (path === '[Content_Types].xml') {
|
|
1785
|
+
// PptxGenJS 4.0.1 emits one slide-master override per slide even
|
|
1786
|
+
// though it creates only the actual master parts. Omit phantom master
|
|
1787
|
+
// declarations without changing any existing part or relationship.
|
|
1788
|
+
xml = xml.replace(/<Override PartName="\/(ppt\/slideMasters\/slideMaster\d+\.xml)"[^>]*\/>/g,
|
|
1789
|
+
(override, part) => Object.hasOwn(entries, part) ? override : '');
|
|
1653
1790
|
// Explicit per-part types also correct PptxGenJS's image/jpg default.
|
|
1654
1791
|
const overrides = [...imageMetadata].filter(([, metadata]) => metadata).map(([part, metadata]) =>
|
|
1655
1792
|
`<Override PartName="/${part}" ContentType="${metadata.mediaType}"/>`).join('');
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
+
import {validatePresentation} from '@openpresentation/opf';
|
|
3
|
+
import {attachTextTags,decodeTextTag} from './code-provenance.js';
|
|
4
|
+
|
|
5
|
+
const TAG='OPF_METRIC_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
6
|
+
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
7
|
+
const decoder=new TextDecoder('utf-8',{fatal:true});
|
|
8
|
+
const array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
9
|
+
const scalar=value=>typeof value==='string'||typeof value==='number';
|
|
10
|
+
const sourceFor=(value,role)=>scalar(value)?value:value[role];
|
|
11
|
+
|
|
12
|
+
// The first value line carries the complete manifest: no invisible panel is introduced.
|
|
13
|
+
export function metricManifest(value,layout,group) {
|
|
14
|
+
return {v:1,group,role:'anchor',part:0,line:0,value,parts:layout.parts.map(part=>({role:part.role,visible:part.visible,
|
|
15
|
+
lines:part.fit?.sourceLines.map(({start,end,nextStart,boundary})=>({start,end,nextStart,boundary}))??[]}))};
|
|
16
|
+
}
|
|
17
|
+
export function attachMetricTags(entries,records) {return attachTextTags(entries,records,TAG,'opfMetric','metric');}
|
|
18
|
+
|
|
19
|
+
function validateManifest(manifest) {
|
|
20
|
+
if (!validatePresentation({slides:[{metric:manifest.value}]}).valid) throw new Error('Invalid metric source.');
|
|
21
|
+
const roles=scalar(manifest.value)?['value']:['value','unit','label','description','delta','trend'].filter(role=>manifest.value[role]!==undefined);
|
|
22
|
+
if (!Array.isArray(manifest.parts)||manifest.parts.length!==roles.length||manifest.part!==0||manifest.line!==0) throw new Error('Invalid metric parts.');
|
|
23
|
+
for (const [index,part] of manifest.parts.entries()) {
|
|
24
|
+
const source=String(sourceFor(manifest.value,roles[index])),visible=roles[index]==='value'||source.length>0;
|
|
25
|
+
if (part.role!==roles[index]||part.visible!==visible||!Array.isArray(part.lines)||visible&&!part.lines.length||!visible&&part.lines.length) throw new Error('Invalid source part.');
|
|
26
|
+
let cursor=0;
|
|
27
|
+
for (const [i,line] of part.lines.entries()) {
|
|
28
|
+
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.');
|
|
29
|
+
const separator=source.slice(line.end,line.nextStart),last=i===part.lines.length-1;
|
|
30
|
+
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.');
|
|
31
|
+
cursor=line.nextStart;
|
|
32
|
+
}
|
|
33
|
+
if (cursor!==source.length) throw new Error('Incomplete source range.');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function importMetricGroups(shapes,paragraphs,relationships,entries,report) {
|
|
38
|
+
const groups=new Map(),tagCounts=new Map(),consumed=new Set(),items=[];
|
|
39
|
+
for (const [index,shape] of shapes.entries()) {
|
|
40
|
+
for (const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])) {
|
|
41
|
+
const rel=relationships.get(link['r:id']);
|
|
42
|
+
if (rel?.type!==REL||rel.targetMode==='External'||!rel.path||!entries[rel.path]) continue;
|
|
43
|
+
let tags;
|
|
44
|
+
try {
|
|
45
|
+
const all=array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']);
|
|
46
|
+
tagCounts.set(shape,(tagCounts.get(shape)??0)+all.filter(tag=>/^OPF_/i.test(tag.name)).length);
|
|
47
|
+
tags=all.filter(tag=>tag.name?.toUpperCase()===TAG);
|
|
48
|
+
} catch {report({code:'invalid-metric-provenance',message:'Unreadable metric tags; visible native shapes were retained.'});continue;}
|
|
49
|
+
for (const tag of tags) try {
|
|
50
|
+
const data=decodeTextTag(tag.val);
|
|
51
|
+
if (data.v!==1||typeof data.group!=='string'||!/^\d+$/.test(data.group)) throw new Error('Invalid metric identity.');
|
|
52
|
+
const group=groups.get(data.group)??[];
|
|
53
|
+
group.push({data,shape,text:(paragraphs[index]??[]).map(p=>p.text).join('\n')});groups.set(data.group,group);
|
|
54
|
+
} catch {report({code:'invalid-metric-provenance',message:'Invalid metric tags; visible native shapes were retained.'});}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const group of groups.values()) try {
|
|
58
|
+
if (group.some(item=>tagCounts.get(item.shape)!==1)) throw new Error('Multiple source identities on one shape.');
|
|
59
|
+
const anchors=group.filter(item=>item.data.role==='anchor');
|
|
60
|
+
if (anchors.length!==1) throw new Error('Missing or duplicated metric anchor.');
|
|
61
|
+
const anchor=anchors[0],manifest=anchor.data;validateManifest(manifest);
|
|
62
|
+
const expected=manifest.parts.reduce((sum,part)=>sum+part.lines.length,0);
|
|
63
|
+
if (group.length!==expected||new Set(group.map(item=>item.shape)).size!==expected) throw new Error('Incomplete or duplicated metric shapes.');
|
|
64
|
+
const nativeLines=new Map();
|
|
65
|
+
for (const item of group) {
|
|
66
|
+
const {role,part,line}=item.data,key=`${part}:${line}`;
|
|
67
|
+
if ((item!==anchor&&role!=='line')||!Number.isSafeInteger(part)||!Number.isSafeInteger(line)||!manifest.parts[part]?.lines[line]||nativeLines.has(key)) throw new Error('Ambiguous metric source line.');
|
|
68
|
+
nativeLines.set(key,item.text);
|
|
69
|
+
}
|
|
70
|
+
const value=scalar(manifest.value)?{value:manifest.value}:{...manifest.value},typeChanges=[];
|
|
71
|
+
for (const [partIndex,part] of manifest.parts.entries()) {
|
|
72
|
+
if (!part.visible) continue;
|
|
73
|
+
const original=sourceFor(manifest.value,part.role),source=String(original),newline=source.match(/\r\n|\r|\n/)?.[0]??'\n';
|
|
74
|
+
let rebuilt='';
|
|
75
|
+
for (const [lineIndex,line] of part.lines.entries()) {
|
|
76
|
+
const actual=nativeLines.get(`${partIndex}:${lineIndex}`);
|
|
77
|
+
if (actual===undefined) throw new Error('Missing metric source line.');
|
|
78
|
+
rebuilt+=actual.replace(/\r\n|\r|\n/g,newline)+source.slice(line.end,line.nextStart);
|
|
79
|
+
}
|
|
80
|
+
// Preserve original types for unchanged text. Canonical finite numeric edits remain
|
|
81
|
+
// numeric; other spellings become strings so no whitespace or precision is discarded.
|
|
82
|
+
value[part.role]=typeof original==='number'&&rebuilt===source?original:
|
|
83
|
+
typeof original==='number'&&Number.isFinite(Number(rebuilt))&&String(Number(rebuilt))===rebuilt?Number(rebuilt):rebuilt;
|
|
84
|
+
if (typeof original!==typeof value[part.role]) typeChanges.push(part.role);
|
|
85
|
+
}
|
|
86
|
+
const metric=scalar(manifest.value)?value.value:value;
|
|
87
|
+
if (!validatePresentation({slides:[{metric}]}).valid) throw new Error('Edited native text cannot form a valid metric.');
|
|
88
|
+
for (const item of group) consumed.add(item.shape);
|
|
89
|
+
items.push({shape:anchor.shape,payload:{type:'metric',metric}});
|
|
90
|
+
if (typeChanges.length) report({code:'metric-value-type-changed',message:`Edited numeric ${typeChanges.join(', ')} retained as literal text to avoid discarding spelling or precision.`});
|
|
91
|
+
report({code:'metric-import-reflow',message:'Metric fields, source boundaries and native text were recovered. Native formatting, positioning, alignment and font theme are not reconstructed; review the reflowed OPF.'});
|
|
92
|
+
} catch (error) {report({code:'invalid-metric-provenance',message:`${error.message} Visible native shapes were retained without restoring old source text.`});}
|
|
93
|
+
return {consumed,items};
|
|
94
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
+
import {attachTextTags,decodeTextTag} from './code-provenance.js';
|
|
3
|
+
const TAG='OPF_TEXT_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
4
|
+
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
5
|
+
const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
6
|
+
export const attachPlainTextTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfText','plain text');
|
|
7
|
+
|
|
8
|
+
// Only boundaries are stored. Current native characters, including edits and
|
|
9
|
+
// empty lines, always supply the text; tags cannot restore discarded old words.
|
|
10
|
+
export function sourceLineParagraphs(ordered,paragraphs,{legacy=false}={}) {
|
|
11
|
+
const source=ordered.some(item=>item.data.boundary!==undefined||item.data.separator!==undefined);
|
|
12
|
+
if(!source&&legacy)return ordered.flatMap(item=>paragraphs[item.index]??[]);
|
|
13
|
+
let text='',maxFontSize=0;
|
|
14
|
+
for(const [index,item]of ordered.entries()) {
|
|
15
|
+
const {boundary,separator}=item.data,last=index===ordered.length-1;
|
|
16
|
+
if(typeof separator!=='string'||(boundary==='hard'?!/^(\r\n|\r|\n)$/.test(separator)||last:separator!==''||boundary!==(last?'end':'soft')))throw Error('Invalid source line boundary.');
|
|
17
|
+
const current=paragraphs[item.index]??[];
|
|
18
|
+
if(current.some(p=>p.bullet))throw Error('Tagged plain text now has native bullets.');
|
|
19
|
+
text+=current.map(p=>p.text).join('\n')+separator;
|
|
20
|
+
maxFontSize=Math.max(maxFontSize,...current.map(p=>p.maxFontSize??0));
|
|
21
|
+
}
|
|
22
|
+
return [{text,maxFontSize,bullet:false,level:0}];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function importPlainTextGroups(shapes,paragraphs,relationships,entries,report) {
|
|
26
|
+
const groups=new Map(),consumed=new Set(),items=[];
|
|
27
|
+
for(const [index,shape]of shapes.entries()) {
|
|
28
|
+
const tags=[];let unreadable=false;
|
|
29
|
+
for(const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])) {
|
|
30
|
+
const rel=relationships.get(link['r:id']);if(rel?.type!==REL||rel.targetMode==='External'||!entries[rel.path])continue;
|
|
31
|
+
try{tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));}catch{unreadable=true;}
|
|
32
|
+
}
|
|
33
|
+
for(const tag of tags.filter(tag=>tag.name?.toUpperCase()===TAG))try {
|
|
34
|
+
const data=decodeTextTag(tag.val);
|
|
35
|
+
if(data?.v!==1||typeof data.group!=='string'||data.group.length>4096||!/^slides\.\d+(?:\.[A-Za-z0-9_:+-]+)*\.text$/.test(data.group))throw Error('Invalid plain text identity.');
|
|
36
|
+
const group=groups.get(data.group)??[];group.push({shape,index,data,ambiguous:unreadable||tags.filter(tag=>/^OPF_/i.test(tag.name)).length!==1});groups.set(data.group,group);
|
|
37
|
+
}catch{report({code:'invalid-text-provenance',message:'Invalid plain text tags; ordinary import retains current native text.'});}
|
|
38
|
+
}
|
|
39
|
+
for(const group of groups.values())try {
|
|
40
|
+
const count=group[0].data.count;
|
|
41
|
+
if(!Number.isSafeInteger(count)||count<1||count>10000||group.length!==count||new Set(group.map(item=>item.shape)).size!==count)throw Error('Incomplete or duplicated plain text lines.');
|
|
42
|
+
const ordered=Array(count);
|
|
43
|
+
for(const item of group) {
|
|
44
|
+
const data=item.data;
|
|
45
|
+
if(item.ambiguous||data.count!==count||!Number.isSafeInteger(data.line)||data.line<0||data.line>=count||ordered[data.line])throw Error('Ambiguous plain text line.');
|
|
46
|
+
ordered[data.line]=item;
|
|
47
|
+
}
|
|
48
|
+
const restored=sourceLineParagraphs(ordered,paragraphs);
|
|
49
|
+
for(const item of ordered)consumed.add(item.shape);
|
|
50
|
+
items.push({shapes:ordered.map(item=>item.shape),paragraphs:restored});
|
|
51
|
+
report({code:'text-import-reflow',message:'Complete tagged lines retain current native text, whitespace and authored line boundaries. Native formatting, geometry and OPF nesting are not reconstructed; review the reflowed text.'});
|
|
52
|
+
}catch(error){report({code:'invalid-text-provenance',message:`${error.message} Ordinary import retains current native text without restoring old source words.`});}
|
|
53
|
+
return {consumed,items};
|
|
54
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
+
import {validatePresentation} from '@openpresentation/opf';
|
|
3
|
+
import {attachTextTags,decodeTextTag} from './code-provenance.js';
|
|
4
|
+
import {sourceLineParagraphs} from './text-provenance.js';
|
|
5
|
+
|
|
6
|
+
const TAG='OPF_TIMELINE_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
7
|
+
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
8
|
+
const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
9
|
+
export const attachTimelineTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfTimeline','timeline');
|
|
10
|
+
|
|
11
|
+
// Only topology and line boundaries are retained. Every field value comes from
|
|
12
|
+
// current native text; clearing or editing a field cannot restore old words.
|
|
13
|
+
export function timelineManifest(value,layout) {
|
|
14
|
+
return {shorthand:Array.isArray(value),eventCount:(Array.isArray(value)?value:value.events).length,
|
|
15
|
+
parts:layout.parts.map(part=>({eventIndex:part.eventIndex,field:part.role==='event-description'?'description':part.role,lines:part.fit.sourceLines.length}))};
|
|
16
|
+
}
|
|
17
|
+
function validateManifest(manifest) {
|
|
18
|
+
if(!manifest||typeof manifest.shorthand!=='boolean'||!Number.isSafeInteger(manifest.eventCount)||manifest.eventCount<1||manifest.eventCount>10000||!Array.isArray(manifest.parts)||manifest.parts.length>10000)throw Error('Invalid timeline topology.');
|
|
19
|
+
const keys=[],fields=new Set();let count=0;
|
|
20
|
+
for(const part of manifest.parts){
|
|
21
|
+
if(!part||!Number.isSafeInteger(part.lines)||part.lines<1||part.lines>10000)throw Error('Invalid timeline line count.');
|
|
22
|
+
if(part.eventIndex===undefined){if(manifest.shorthand||!['name','description'].includes(part.field))throw Error('Invalid timeline metadata field.');}
|
|
23
|
+
else if(!Number.isSafeInteger(part.eventIndex)||part.eventIndex<0||part.eventIndex>=manifest.eventCount||!['when','what','description'].includes(part.field))throw Error('Invalid timeline event field.');
|
|
24
|
+
const key=`${part.eventIndex??'metadata'}:${part.field}`;if(fields.has(key))throw Error('Duplicated timeline field.');fields.add(key);keys.push(key);count+=part.lines;
|
|
25
|
+
}
|
|
26
|
+
if(count>10000)throw Error('Too many timeline source lines.');
|
|
27
|
+
const expected=['name','description'].map(field=>`metadata:${field}`).filter(key=>fields.has(key));
|
|
28
|
+
for(let index=0;index<manifest.eventCount;index++){
|
|
29
|
+
if(!fields.has(`${index}:what`))throw Error('Missing timeline event label.');
|
|
30
|
+
for(const field of ['when','what','description'])if(fields.has(`${index}:${field}`))expected.push(`${index}:${field}`);
|
|
31
|
+
}
|
|
32
|
+
if(JSON.stringify(keys)!==JSON.stringify(expected))throw Error('Invalid timeline source order.');
|
|
33
|
+
return count;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function importTimelineGroups(shapes,paragraphs,relationships,entries,report) {
|
|
37
|
+
const groups=new Map(),consumed=new Set(),items=[];
|
|
38
|
+
for(const [index,shape]of shapes.entries()){
|
|
39
|
+
const tags=[];let unreadable=false;
|
|
40
|
+
for(const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])){
|
|
41
|
+
const rel=relationships.get(link['r:id']);if(rel?.type!==REL||rel.targetMode==='External'||!entries[rel.path])continue;
|
|
42
|
+
try{tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));}catch{unreadable=true;}
|
|
43
|
+
}
|
|
44
|
+
for(const tag of tags.filter(tag=>tag.name?.toUpperCase()===TAG))try{
|
|
45
|
+
const data=decodeTextTag(tag.val);if(data?.v!==1||typeof data.group!=='string'||!/^\d{1,12}$/.test(data.group))throw Error('Invalid timeline identity.');
|
|
46
|
+
const group=groups.get(data.group)??[];group.push({shape,index,data,ambiguous:unreadable||tags.filter(tag=>/^OPF_/i.test(tag.name)).length!==1});groups.set(data.group,group);
|
|
47
|
+
}catch{report({code:'invalid-timeline-provenance',message:'Invalid timeline tags; ordinary import retains current native text.'});}
|
|
48
|
+
}
|
|
49
|
+
for(const group of groups.values())try{
|
|
50
|
+
const anchors=group.filter(item=>item.data.anchor!==undefined);if(anchors.length!==1)throw Error('Missing or duplicated timeline anchor.');
|
|
51
|
+
const anchor=anchors[0],manifest=anchor.data.anchor,count=validateManifest(manifest);
|
|
52
|
+
const total=count+manifest.eventCount+1;
|
|
53
|
+
if(anchor.data.role!=='text'||anchor.data.part!==0||anchor.data.line!==0||group.length!==total||new Set(group.map(item=>item.shape)).size!==total)throw Error('Incomplete or duplicated timeline elements.');
|
|
54
|
+
const ordered=manifest.parts.map(part=>Array(part.lines)),markers=new Set();let connectors=0;
|
|
55
|
+
for(const item of group){
|
|
56
|
+
if(item.ambiguous)throw Error('Multiple source identities on one timeline shape.');
|
|
57
|
+
if(item.data.role==='connector'||item.data.role==='marker'){
|
|
58
|
+
const expected=item.data.role==='connector'?'line':'ellipse';
|
|
59
|
+
if(item.shape['p:spPr']?.['a:prstGeom']?.prst!==expected||(paragraphs[item.index]??[]).some(paragraph=>paragraph.text||paragraph.bullet))throw Error('Timeline graphic now contains different geometry or native text.');
|
|
60
|
+
if(item.data.role==='connector'){if(++connectors>1)throw Error('Duplicated timeline connector.');}
|
|
61
|
+
else{const index=item.data.eventIndex;if(!Number.isSafeInteger(index)||index<0||index>=manifest.eventCount||markers.has(index))throw Error('Ambiguous timeline marker.');markers.add(index);}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const {part,line}=item.data;
|
|
65
|
+
if(item.data.role!=='text'||!Number.isSafeInteger(part)||!Number.isSafeInteger(line)||part<0||line<0||!manifest.parts[part]||line>=manifest.parts[part].lines||item.data.count!==manifest.parts[part].lines||ordered[part][line])throw Error('Ambiguous timeline source line.');
|
|
66
|
+
ordered[part][line]=item;
|
|
67
|
+
}
|
|
68
|
+
if(connectors!==1||markers.size!==manifest.eventCount)throw Error('Incomplete timeline graphics.');
|
|
69
|
+
const result={events:Array.from({length:manifest.eventCount},()=>({}))};
|
|
70
|
+
for(const [index,part]of manifest.parts.entries()){
|
|
71
|
+
const text=sourceLineParagraphs(ordered[index],paragraphs)[0].text;
|
|
72
|
+
if(part.eventIndex===undefined)result[part.field]=text;else result.events[part.eventIndex][part.field]=text;
|
|
73
|
+
}
|
|
74
|
+
const timeline=manifest.shorthand?result.events:result;
|
|
75
|
+
if(!validatePresentation({slides:[{timeline}]}).valid)throw Error('Current native text does not form a valid timeline.');
|
|
76
|
+
for(const item of group)consumed.add(item.shape);
|
|
77
|
+
items.push({shapes:group.map(item=>item.shape),payload:{type:'timeline',timeline}});
|
|
78
|
+
report({code:'timeline-import-reflow',message:'Timeline field order, source boundaries and current native text were recovered. Native formatting, positions, marker styling and font theme are not reconstructed; review the reflowed timeline.'});
|
|
79
|
+
}catch(error){report({code:'invalid-timeline-provenance',message:`${error.message} Ordinary import retains current native text without restoring old source words.`});}
|
|
80
|
+
return {consumed,items};
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"private": false,
|
|
8
8
|
"engines": {
|
|
9
|
-
"node": "
|
|
9
|
+
"node": "24.x"
|
|
10
10
|
},
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"sideEffects": false,
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "node scripts/build.mjs",
|
|
40
|
-
"typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs",
|
|
41
|
-
"test": "npm run build && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/shared-quote.mjs",
|
|
40
|
+
"typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check test/shared-timeline.mjs",
|
|
41
|
+
"test": "npm run build && node test/plain-whitespace.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/shared-quote.mjs && npm run test:timeline",
|
|
42
42
|
"validate": "node scripts/validate-package.mjs",
|
|
43
43
|
"test:packed": "node test/packed-install.mjs",
|
|
44
44
|
"test:compare-published": "node test/compare-published.mjs",
|
|
@@ -46,17 +46,20 @@
|
|
|
46
46
|
"prepack": "npm run build",
|
|
47
47
|
"build:browser-check": "node scripts/build-browser-check.mjs",
|
|
48
48
|
"test:styled-table": "node test/styled-table.mjs && node test/styled-table-import.mjs",
|
|
49
|
-
"test:code": "node test/shared-code.mjs && node test/code-provenance.mjs"
|
|
49
|
+
"test:code": "node test/shared-code.mjs && node test/code-provenance.mjs",
|
|
50
|
+
"test:metric": "node test/shared-metric.mjs && node test/metric-provenance.mjs",
|
|
51
|
+
"test:font-variants": "node test/font-variants.mjs",
|
|
52
|
+
"test:timeline": "node test/shared-timeline.mjs"
|
|
50
53
|
},
|
|
51
54
|
"dependencies": {
|
|
52
|
-
"@openpresentation/opf": "^0.
|
|
55
|
+
"@openpresentation/opf": "^0.10.0",
|
|
53
56
|
"fast-xml-parser": "^5.8.0",
|
|
54
57
|
"fflate": "^0.8.3",
|
|
55
58
|
"jszip": "3.10.1",
|
|
56
59
|
"sharp": "0.35.4"
|
|
57
60
|
},
|
|
58
61
|
"peerDependencies": {
|
|
59
|
-
"@openpresentation/opf-render": "^0.
|
|
62
|
+
"@openpresentation/opf-render": "^0.8.0"
|
|
60
63
|
},
|
|
61
64
|
"peerDependenciesMeta": {
|
|
62
65
|
"@openpresentation/opf-render": {
|
|
@@ -64,7 +67,7 @@
|
|
|
64
67
|
}
|
|
65
68
|
},
|
|
66
69
|
"devDependencies": {
|
|
67
|
-
"@openpresentation/opf-render": "0.
|
|
70
|
+
"@openpresentation/opf-render": "0.8.0",
|
|
68
71
|
"esbuild": "0.28.2",
|
|
69
72
|
"playwright": "1.63.0"
|
|
70
73
|
},
|