@openpresentation/opf-pptx 0.13.1 → 0.14.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 +1 -1
- package/README.md +34 -16
- package/dist/annotation-export.js +1 -1
- package/dist/annotation-provenance.js +4 -0
- package/dist/background.js +2 -3
- package/dist/body-text-import.js +16 -4
- package/dist/chart-alt.js +54 -0
- package/dist/chart-data.js +29 -54
- package/dist/chart-highlight.js +113 -0
- package/dist/chart-options.js +46 -24
- package/dist/chart-types.js +59 -58
- package/dist/code-provenance.js +20 -4
- package/dist/color-ref.js +12 -6
- package/dist/data-provenance.js +18 -14
- package/dist/document-provenance.js +20 -8
- package/dist/format-check.js +10 -0
- package/dist/furniture-provenance.js +30 -3
- package/dist/heading-provenance.js +10 -3
- package/dist/image-fallback-node.js +4 -1
- package/dist/index.d.ts +32 -25
- package/dist/index.js +481 -235
- package/dist/media-provenance.js +3 -3
- package/dist/metric-provenance.js +3 -3
- package/dist/native-text-style.js +2 -0
- package/dist/package-fonts.js +2 -2
- package/dist/quote-provenance.js +70 -26
- package/dist/rich-heading.js +119 -0
- package/dist/run-colors.js +3 -0
- package/dist/script-fonts.js +81 -54
- package/dist/theme-colors.js +1 -1
- package/dist/timeline-provenance.js +17 -6
- package/dist/typeface-inventory.js +7 -7
- package/dist/watermark-provenance.js +65 -3
- package/package.json +5 -5
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
-
import {
|
|
2
|
+
import {isValidFormat} from './format-check.js';
|
|
3
3
|
import {attachTextTags,decodeTextTag} from './code-provenance.js';
|
|
4
4
|
import {sourceLineParagraphs} from './text-provenance.js';
|
|
5
5
|
|
|
6
|
+
const STATUSES=['done','current','planned'];
|
|
6
7
|
const TAG='OPF_TIMELINE_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
7
8
|
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
8
9
|
const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
@@ -49,16 +50,22 @@ export function importTimelineGroups(shapes,paragraphs,relationships,entries,rep
|
|
|
49
50
|
for(const group of groups.values())try{
|
|
50
51
|
const anchors=group.filter(item=>item.data.anchor!==undefined);if(anchors.length!==1)throw Error('Missing or duplicated timeline anchor.');
|
|
51
52
|
const anchor=anchors[0],manifest=anchor.data.anchor,count=validateManifest(manifest);
|
|
52
|
-
|
|
53
|
+
// A current event's ring is a second native ellipse with its own tag, so the group has one more shape per ring.
|
|
54
|
+
const total=count+manifest.eventCount+1+group.filter(item=>item.data.role==='ring').length;
|
|
53
55
|
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;
|
|
56
|
+
const ordered=manifest.parts.map(part=>Array(part.lines)),markers=new Set(),rings=new Set(),statuses=new Map();let connectors=0;
|
|
55
57
|
for(const item of group){
|
|
56
58
|
if(item.ambiguous)throw Error('Multiple source identities on one timeline shape.');
|
|
57
|
-
if(item.data.role==='connector'||item.data.role==='marker'){
|
|
59
|
+
if(item.data.role==='connector'||item.data.role==='marker'||item.data.role==='ring'){
|
|
58
60
|
const expected=item.data.role==='connector'?'line':'ellipse';
|
|
59
61
|
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
62
|
if(item.data.role==='connector'){if(++connectors>1)throw Error('Duplicated timeline connector.');}
|
|
61
|
-
else{
|
|
63
|
+
else{
|
|
64
|
+
const index=item.data.eventIndex,known=item.data.role==='marker'?markers:rings;
|
|
65
|
+
if(!Number.isSafeInteger(index)||index<0||index>=manifest.eventCount||known.has(index))throw Error('Ambiguous timeline marker.');
|
|
66
|
+
known.add(index);
|
|
67
|
+
if(item.data.role==='marker'&&item.data.status!==undefined){if(!STATUSES.includes(item.data.status))throw Error('Invalid timeline event status.');statuses.set(index,item.data.status);}
|
|
68
|
+
}
|
|
62
69
|
continue;
|
|
63
70
|
}
|
|
64
71
|
const {part,line}=item.data;
|
|
@@ -66,13 +73,17 @@ export function importTimelineGroups(shapes,paragraphs,relationships,entries,rep
|
|
|
66
73
|
ordered[part][line]=item;
|
|
67
74
|
}
|
|
68
75
|
if(connectors!==1||markers.size!==manifest.eventCount)throw Error('Incomplete timeline graphics.');
|
|
76
|
+
// Only a current event has a ring, and every current event has one.
|
|
77
|
+
for(const index of rings)if(statuses.get(index)!=='current')throw Error('Timeline ring without a current event.');
|
|
78
|
+
for(const [index,status] of statuses)if(status==='current'&&!rings.has(index))throw Error('Current timeline event without its ring.');
|
|
69
79
|
const result={events:Array.from({length:manifest.eventCount},()=>({}))};
|
|
70
80
|
for(const [index,part]of manifest.parts.entries()){
|
|
71
81
|
const text=sourceLineParagraphs(ordered[index],paragraphs)[0].text;
|
|
72
82
|
if(part.eventIndex===undefined)result[part.field]=text;else result.events[part.eventIndex][part.field]=text;
|
|
73
83
|
}
|
|
84
|
+
for(const [index,status] of statuses)result.events[index].status=status;
|
|
74
85
|
const timeline=manifest.shorthand?result.events:result;
|
|
75
|
-
if(!
|
|
86
|
+
if(!isValidFormat({slides:[{timeline}]}))throw Error('Current native text does not form a valid timeline.');
|
|
76
87
|
for(const item of group)consumed.add(item.shape);
|
|
77
88
|
items.push({shapes:group.map(item=>item.shape),payload:{type:'timeline',timeline}});
|
|
78
89
|
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.'});
|
|
@@ -7,7 +7,7 @@ import {unzipSync} from 'fflate';
|
|
|
7
7
|
// DrawingML `typeface` attributes (latin, ea, cs, sym, buFont, theme script
|
|
8
8
|
// supplements, embedded-font lists), SpreadsheetML font names (styles `name`,
|
|
9
9
|
// rich-text `rFont`) and the docProps/app.xml "Fonts Used" list.
|
|
10
|
-
//
|
|
10
|
+
// checkTypefaces() then applies the owner font policy: a package names only
|
|
11
11
|
// the fonts its author chose, plus theme references that resolve to them, empty
|
|
12
12
|
// theme script slots when no script font was selected (as in Office's themes; FF-49 checks a selected slot through options.themeScripts) and the documented theme script supplements.
|
|
13
13
|
|
|
@@ -33,7 +33,7 @@ export const THEME_SCRIPT_SUPPLEMENTS = Object.freeze({
|
|
|
33
33
|
minor: supplement(`Jpan=游ゴシック|Hans=等线|Arab=Arial|Hebr=Arial|Thai=Cordia New|Khmr=DaunPenh|Viet=Arial|${SHARED_SCRIPTS}`)
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
export function
|
|
36
|
+
export function inventoryTypefaces(input, options = {}) {
|
|
37
37
|
const typefaces = [];
|
|
38
38
|
const themes = {};
|
|
39
39
|
// Every theme part's fonts, and the theme each part of the presentation package resolves its references against.
|
|
@@ -90,15 +90,15 @@ export function packageFontsUsed(inventory) {
|
|
|
90
90
|
return [...used].sort(compareFontNames);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export function
|
|
94
|
-
if (!Array.isArray(options.
|
|
95
|
-
throw new TypeError('
|
|
93
|
+
export function checkTypefaces(input, options = {}) {
|
|
94
|
+
if (!Array.isArray(options.families) || options.families.some(family => typeof family !== 'string' || !family)) {
|
|
95
|
+
throw new TypeError('checkTypefaces requires options.families, the non-empty family names the author chose.');
|
|
96
96
|
}
|
|
97
|
-
const chosen = new Set(options.
|
|
97
|
+
const chosen = new Set(options.families);
|
|
98
98
|
const monospace = new Set(options.monospace ?? []);
|
|
99
99
|
const allowEmpty = options.allowEmptyThemeScripts ?? true;
|
|
100
100
|
const supplements = options.scriptSupplements ?? THEME_SCRIPT_SUPPLEMENTS;
|
|
101
|
-
const inventory =
|
|
101
|
+
const inventory = inventoryTypefaces(input);
|
|
102
102
|
const violations = [];
|
|
103
103
|
const fail = (entry, reason, details = {}) => violations.push({reason, part: entry.part, element: entry.element, typeface: entry.typeface, ...details});
|
|
104
104
|
const emptyAllowed = entry => allowEmpty && entry.theme && (entry.element === 'ea' || entry.element === 'cs');
|
|
@@ -19,6 +19,8 @@ const canonical = value => JSON.stringify(value, (_key, item) => item && typeof
|
|
|
19
19
|
? Object.fromEntries(Object.keys(item).sort().filter(key => !(key === '#text' && typeof item[key] === 'string' && !item[key].trim())).map(key => [key, item[key]])) : item);
|
|
20
20
|
|
|
21
21
|
export const watermarkName = () => 'OPF watermark';
|
|
22
|
+
// FA-13: a text watermark is one native text box (a p:sp), not a picture.
|
|
23
|
+
export const watermarkTextName = () => 'OPF watermark text';
|
|
22
24
|
|
|
23
25
|
// The preview's frame: x/y at 30% and a 40% by 40% box, in the slide's units.
|
|
24
26
|
export const watermarkBox = (width, height) => ({x: width * .3, y: height * .3, w: width * .4, h: height * .4});
|
|
@@ -80,6 +82,7 @@ function attachTags(entries, manifests) {
|
|
|
80
82
|
const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main', types = [];
|
|
81
83
|
let count = 0;
|
|
82
84
|
for (const [part, manifest] of manifests) {
|
|
85
|
+
const element = manifest.kind === 'text' ? 'sp' : 'pic', name = manifest.kind === 'text' ? watermarkTextName() : watermarkName();
|
|
83
86
|
const relPath = part.replace('/slides/', '/slides/_rels/') + '.rels';
|
|
84
87
|
const rels = decoder.decode(entries[relPath]);
|
|
85
88
|
const ids = new Set([...rels.matchAll(/\bId="([^"]+)"/g)].map(match => match[1]));
|
|
@@ -90,11 +93,11 @@ function attachTags(entries, manifests) {
|
|
|
90
93
|
types.push(`<Override PartName="/${tagPart}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
|
|
91
94
|
entries[relPath] = encoder.encode(rels.replace('</Relationships>', `<Relationship Id="${id}" Type="${REL}" Target="../tags/opfWatermark${count}.xml"/></Relationships>`));
|
|
92
95
|
let attached = false;
|
|
93
|
-
const xml = decoder.decode(entries[part]).replace(
|
|
94
|
-
if (attached || picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] !==
|
|
96
|
+
const xml = decoder.decode(entries[part]).replace(new RegExp(`<p:${element}>[\\s\\S]*?</p:${element}>`, 'g'), picture => {
|
|
97
|
+
if (attached || picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] !== name) return picture;
|
|
95
98
|
attached = true;
|
|
96
99
|
picture = picture.replace(/<p:nvPr\s*\/>/, '<p:nvPr></p:nvPr>');
|
|
97
|
-
if (!picture.includes('</p:nvPr>')) throw new Error('Generated watermark
|
|
100
|
+
if (!picture.includes('</p:nvPr>')) throw new Error('Generated watermark shape has no native application properties.');
|
|
98
101
|
return picture.replace('</p:nvPr>', `<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst></p:nvPr>`);
|
|
99
102
|
});
|
|
100
103
|
entries[part] = encoder.encode(xml);
|
|
@@ -102,6 +105,28 @@ function attachTags(entries, manifests) {
|
|
|
102
105
|
entries['[Content_Types].xml'] = encoder.encode(decoder.decode(entries['[Content_Types].xml']).replace('</Types>', types.join('') + '</Types>'));
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Bind each generated text watermark box to its manifest with a native tag.
|
|
110
|
+
* marks: Map<slidePart, {slide, text, opacity}>. The manifest records the text and the exact native geometry
|
|
111
|
+
* (position, size and rotation), so an unchanged box imports back as design.watermark.
|
|
112
|
+
*/
|
|
113
|
+
export function tagTextWatermarks(entries, marks) {
|
|
114
|
+
if (!marks.size) return;
|
|
115
|
+
const manifests = new Map();
|
|
116
|
+
for (const part of Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path))) {
|
|
117
|
+
const mark = marks.get(part);
|
|
118
|
+
if (!mark) continue;
|
|
119
|
+
let found = 0;
|
|
120
|
+
for (const shape of decoder.decode(entries[part]).match(/<p:sp>[\s\S]*?<\/p:sp>/g) ?? []) {
|
|
121
|
+
if (shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] !== watermarkTextName()) continue;
|
|
122
|
+
found++;
|
|
123
|
+
manifests.set(part, {v: 1, kind: 'text', slide: mark.slide, text: mark.text, opacity: mark.opacity, properties: parser.parse(shape)['p:sp']['p:spPr']});
|
|
124
|
+
}
|
|
125
|
+
if (found !== 1) throw new Error('Missing generated watermark text box.');
|
|
126
|
+
}
|
|
127
|
+
attachTags(entries, manifests);
|
|
128
|
+
}
|
|
129
|
+
|
|
105
130
|
function readTags(container, relationships, entries) {
|
|
106
131
|
const tags = [];
|
|
107
132
|
let unreadable = false;
|
|
@@ -113,6 +138,43 @@ function readTags(container, relationships, entries) {
|
|
|
113
138
|
return {tags, unreadable};
|
|
114
139
|
}
|
|
115
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Recover a text design.watermark from an unchanged tagged text box. paragraphs[index] are the native paragraphs of
|
|
143
|
+
* shapes[index]. Returns the consumed shape indexes and the slide design fields to restore. A box whose text, position,
|
|
144
|
+
* size or rotation changed stays an ordinary text box and is reported.
|
|
145
|
+
*/
|
|
146
|
+
export function importTextWatermark(shapes, paragraphs, relationships, entries, slideIndex, report) {
|
|
147
|
+
const consumed = new Set(), found = [];
|
|
148
|
+
for (const [index, shape] of shapes.entries()) {
|
|
149
|
+
const {tags, unreadable} = readTags(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries);
|
|
150
|
+
const own = tags.filter(tag => tag.name?.toUpperCase() === TAG);
|
|
151
|
+
if (!own.length) continue;
|
|
152
|
+
// The picture watermark has the same tag name; a picture's manifest has no kind and is read by importWatermark.
|
|
153
|
+
let kind;
|
|
154
|
+
try { kind = decodeTextTag(own[0].val)?.kind; } catch { kind = undefined; }
|
|
155
|
+
if (kind !== 'text') continue;
|
|
156
|
+
found.push({index, shape, own, unreadable, others: tags.some(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== TAG)});
|
|
157
|
+
}
|
|
158
|
+
if (!found.length) return {consumed};
|
|
159
|
+
const invalid = () => report({code: 'invalid-watermark-provenance', message: 'An edited, ambiguous or invalid tagged OPF watermark was imported as ordinary text. It is not reconstructed as design.watermark.'});
|
|
160
|
+
if (found.length > 1) { invalid(); return {consumed}; }
|
|
161
|
+
const [{index, shape, own, unreadable, others}] = found;
|
|
162
|
+
try {
|
|
163
|
+
if (unreadable || others || own.length !== 1) throw Error('Ambiguous watermark identity.');
|
|
164
|
+
const manifest = decodeTextTag(own[0].val);
|
|
165
|
+
if (manifest?.v !== 1 || manifest.kind !== 'text' || manifest.slide !== `slides.${slideIndex}` || typeof manifest.text !== 'string' || !manifest.text
|
|
166
|
+
|| typeof manifest.opacity !== 'number' || !(manifest.opacity >= 0 && manifest.opacity <= 1)) throw Error('Invalid watermark manifest.');
|
|
167
|
+
if (shape['p:nvSpPr']?.['p:cNvPr']?.name !== watermarkTextName()) throw Error('Watermark identity changed.');
|
|
168
|
+
if (canonical(shape['p:spPr']) !== canonical(manifest.properties)) throw Error('Watermark geometry changed.');
|
|
169
|
+
if ((paragraphs[index] ?? []).map(paragraph => paragraph.text).join('\n') !== manifest.text) throw Error('Watermark text changed.');
|
|
170
|
+
consumed.add(index);
|
|
171
|
+
return {consumed, design: {watermark: {text: manifest.text, opacity: manifest.opacity}}};
|
|
172
|
+
} catch {
|
|
173
|
+
invalid();
|
|
174
|
+
return {consumed};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
116
178
|
/**
|
|
117
179
|
* Recover design.watermark from an unchanged tagged picture. Returns the
|
|
118
180
|
* consumed picture indexes and the slide design fields to restore.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -65,15 +65,15 @@
|
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"@node-projects/jszip": "4.3.0",
|
|
68
|
-
"@openpresentation/opf": "^0.
|
|
68
|
+
"@openpresentation/opf": "^0.14.0",
|
|
69
69
|
"@rgrove/parse-xml": "4.2.3",
|
|
70
70
|
"fast-xml-parser": "^5.8.0",
|
|
71
71
|
"fflate": "^0.8.3",
|
|
72
72
|
"pako": "3.0.2",
|
|
73
|
-
"sharp": "0.35.
|
|
73
|
+
"sharp": "0.35.5"
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
|
-
"@openpresentation/opf-render": "^0.
|
|
76
|
+
"@openpresentation/opf-render": "^0.14.0"
|
|
77
77
|
},
|
|
78
78
|
"peerDependenciesMeta": {
|
|
79
79
|
"@openpresentation/opf-render": {
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@expo-google-fonts/carlito": "0.4.1",
|
|
85
|
-
"@openpresentation/opf-render": "0.
|
|
85
|
+
"@openpresentation/opf-render": "0.14.0",
|
|
86
86
|
"esbuild": "0.28.2",
|
|
87
87
|
"playwright": "1.63.0"
|
|
88
88
|
},
|