@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.
@@ -1,8 +1,9 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
- import {validatePresentation} from '@openpresentation/opf';
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
- const total=count+manifest.eventCount+1;
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{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);}
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(!validatePresentation({slides:[{timeline}]}).valid)throw Error('Current native text does not form a valid timeline.');
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
- // checkPptxTypefaces() then applies the owner font policy: a package names only
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 inventoryPptxTypefaces(input, options = {}) {
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 checkPptxTypefaces(input, options = {}) {
94
- if (!Array.isArray(options.fonts) || options.fonts.some(font => typeof font !== 'string' || !font)) {
95
- throw new TypeError('checkPptxTypefaces requires options.fonts, the non-empty family names the author chose.');
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.fonts);
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 = inventoryPptxTypefaces(input);
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(/<p:pic>[\s\S]*?<\/p:pic>/g, picture => {
94
- if (attached || picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] !== watermarkName()) return picture;
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 picture has no native application properties.');
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.13.1",
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.13.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.4"
73
+ "sharp": "0.35.5"
74
74
  },
75
75
  "peerDependencies": {
76
- "@openpresentation/opf-render": "^0.13.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.13.0",
85
+ "@openpresentation/opf-render": "0.14.0",
86
86
  "esbuild": "0.28.2",
87
87
  "playwright": "1.63.0"
88
88
  },