@openpresentation/opf-pptx 0.13.2 → 0.15.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/README.md +57 -37
- package/dist/annotation-export.js +1 -1
- package/dist/annotation-provenance.js +4 -0
- package/dist/background-import.js +33 -17
- package/dist/background.js +30 -28
- 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 +61 -75
- 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 +208 -129
- 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/image-provenance.js +273 -0
- package/dist/index.d.ts +57 -35
- package/dist/index.js +724 -399
- 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 +102 -101
- package/dist/theme-colors.js +16 -11
- package/dist/timeline-provenance.js +17 -6
- package/dist/typeface-inventory.js +8 -8
- package/dist/watermark-provenance.js +65 -3
- package/package.json +4 -4
- package/dist/slide-image-provenance.js +0 -177
package/dist/media-provenance.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
|
|
@@ -105,8 +105,8 @@ function readTags(shape, relationships, entries) {
|
|
|
105
105
|
return {tags, unreadable};
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
const validAsset = (id, asset) => /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id) &&
|
|
109
|
-
const validVideo = value =>
|
|
108
|
+
const validAsset = (id, asset) => /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id) && isValidFormat({$schema: 'https://openpresentation.org/schema/opf/v1', name: 'Media', assets: {[id]: asset}, slides: [{title: 'Media'}]});
|
|
109
|
+
const validVideo = value => isValidFormat({$schema: 'https://openpresentation.org/schema/opf/v1', name: 'Media', slides: [{video: value}]});
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
112
|
* Returns {items, consumed, assets}. items are {shape, payload} for restored
|
|
@@ -1,5 +1,5 @@
|
|
|
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
|
|
|
5
5
|
const TAG='OPF_METRIC_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
@@ -17,7 +17,7 @@ export function metricManifest(value,layout,group) {
|
|
|
17
17
|
export function attachMetricTags(entries,records,descriptions) {return attachTextTags(entries,records,TAG,'opfMetric','metric',{descriptions});}
|
|
18
18
|
|
|
19
19
|
function validateManifest(manifest) {
|
|
20
|
-
if (!
|
|
20
|
+
if (!isValidFormat({slides:[{metric:manifest.value}]})) throw new Error('Invalid metric source.');
|
|
21
21
|
const roles=scalar(manifest.value)?['value']:['value','unit','label','description','delta','trend'].filter(role=>manifest.value[role]!==undefined);
|
|
22
22
|
if (!Array.isArray(manifest.parts)||manifest.parts.length!==roles.length||manifest.part!==0||manifest.line!==0) throw new Error('Invalid metric parts.');
|
|
23
23
|
for (const [index,part] of manifest.parts.entries()) {
|
|
@@ -87,7 +87,7 @@ export function importMetricGroups(shapes,paragraphs,relationships,entries,repor
|
|
|
87
87
|
if (typeof original!==typeof value[part.role]) typeChanges.push(part.role);
|
|
88
88
|
}
|
|
89
89
|
const metric=scalar(manifest.value)?value.value:value;
|
|
90
|
-
if (!
|
|
90
|
+
if (!isValidFormat({slides:[{metric}]})) throw new Error('Edited native text cannot form a valid metric.');
|
|
91
91
|
for (const item of group) consumed.add(item.shape);
|
|
92
92
|
items.push({shape:anchor.shape,payload:{type:'metric',metric}});
|
|
93
93
|
if (typeChanges.length) report({code:'metric-value-type-changed',message:`Edited numeric ${typeChanges.join(', ')} retained as literal text to avoid discarding spelling or precision.`});
|
|
@@ -70,6 +70,8 @@ export function nativeRunStyle(properties, context, relationships, report, kind
|
|
|
70
70
|
if (relationship?.type.endsWith('/hyperlink') && relationship.targetMode === 'External' && relationship.target && !hyperlink.action) result.link = relationship.target;
|
|
71
71
|
else report(`unsupported-${kind}-link`, 'The native hyperlink action or relationship cannot be represented as a URL.');
|
|
72
72
|
}
|
|
73
|
+
// A link in the theme hyperlink color (what the export writes for a link with no color of its own) is a link with no color.
|
|
74
|
+
if (result.link && result._opfScheme === 'hyperlink') { delete result.color; delete result._opfScheme; }
|
|
73
75
|
return result;
|
|
74
76
|
}
|
|
75
77
|
|
package/dist/package-fonts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {unzipSync, zipSync} from 'fflate';
|
|
2
|
-
import {
|
|
2
|
+
import {inventoryTypefaces, packageFontsUsed, headingPairs, titlesOfParts} from './typeface-inventory.js';
|
|
3
3
|
|
|
4
4
|
// Package-level font post-processing (FF-08, font-fidelity-everywhere). The
|
|
5
5
|
// vendored PptxGenJS bytes stay untouched; these passes rewrite its output so
|
|
@@ -152,7 +152,7 @@ export function finalizeFontsUsed(output) {
|
|
|
152
152
|
if (!app) return;
|
|
153
153
|
const parts = {};
|
|
154
154
|
for (const [path, [bytes]] of Object.entries(output)) if (/^ppt\/.*\.xml$/.test(path)) parts[path] = bytes;
|
|
155
|
-
const fonts = packageFontsUsed(
|
|
155
|
+
const fonts = packageFontsUsed(inventoryTypefaces(parts, {nested: false}));
|
|
156
156
|
const themeNames = Object.keys(parts).filter(path => /^ppt\/theme\/theme\d+\.xml$/.test(path)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]))
|
|
157
157
|
.map(path => /<a:theme\b[^>]*\bname="([^"]*)"/.exec(text(parts[path]))?.[1]).filter(name => name !== undefined).map(unescapeFace);
|
|
158
158
|
output['docProps/app.xml'] = [encoder.encode(writeFontsUsed(text(app[0]), fonts, themeNames)), app[1]];
|
package/dist/quote-provenance.js
CHANGED
|
@@ -1,24 +1,39 @@
|
|
|
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
|
+
import {cleanBase,headingValue,joinRichLines,unwrapQuoteRuns} from './rich-heading.js';
|
|
5
6
|
|
|
6
7
|
// FF-57: a `quote` payload exports as native text lines (a body part and an optional footer part). Each line shape carries an
|
|
7
8
|
// OPF_QUOTE_V1 tag, so an unchanged export imports back as {quote: ...} instead of loose text blocks. Like timelines, only topology
|
|
8
9
|
// and line boundaries are stored: every word comes from the current native text, so a cleared or edited quote cannot bring back
|
|
9
10
|
// old words, and a group that no longer matches its manifest degrades to ordinary text blocks with a diagnostic.
|
|
11
|
+
// FA-12: a quote with a `photo` also exports one native picture (an ellipse), tagged with the same group; import restores `photo` from
|
|
12
|
+
// that picture's current bytes and description, and `role` from the footer.
|
|
10
13
|
const TAG='OPF_QUOTE_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
11
|
-
const SEPARATOR=' - ',FIELDS=['attribution','
|
|
14
|
+
const SEPARATOR=' - ',FIELDS=['attribution','role','source'];
|
|
15
|
+
// Every non-empty footer field list, in core's order (attribution, role, source).
|
|
16
|
+
const FOOTERS=['["attribution"]','["role"]','["source"]','["attribution","role"]','["attribution","source"]','["role","source"]','["attribution","role","source"]'];
|
|
17
|
+
// Core joins the role to the line above with a line break and the source with " - ".
|
|
18
|
+
const separatorBefore=field=>field==='role'?'\n':SEPARATOR;
|
|
12
19
|
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
13
20
|
const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
14
|
-
export const attachQuoteTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfQuote','quote');
|
|
21
|
+
export const attachQuoteTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfQuote','quote',{pictures:true});
|
|
22
|
+
export const quotePhotoName=group=>`OPF quote photo ${group}`;
|
|
15
23
|
|
|
16
|
-
// The manifest rides on the first body line. `footer` lists which fields the footer part joins (core writes
|
|
17
|
-
// skipping an absent field) and,
|
|
24
|
+
// The manifest rides on the first body line. `footer` lists which fields the footer part joins (core writes the attribution, the role
|
|
25
|
+
// on its own line and the source after " - ", skipping an absent field) and, from two fields on, where each separator starts, so an
|
|
26
|
+
// unchanged footer splits back exactly. `photo` records that a headshot picture belongs to the quote.
|
|
18
27
|
export function quoteManifest(value,layout) {
|
|
19
28
|
const shorthand=typeof value==='string',fields=shorthand?[]:FIELDS.filter(field=>value[field]);
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
const splits=[];let length=0;
|
|
30
|
+
for(const [index,field] of fields.entries()){
|
|
31
|
+
if(index>0){splits.push(length);length+=separatorBefore(field).length;}
|
|
32
|
+
length+=value[field].length;
|
|
33
|
+
}
|
|
34
|
+
return {shorthand,...(fields.length?{footer:{fields,...(fields.length>1?{splits}:{})}}:{}),...(layout.photo?{photo:true}:{}),
|
|
35
|
+
// A rich body (FA-10) has richLines instead of sourceLines; both write one native shape per fitted line.
|
|
36
|
+
parts:layout.parts.map(part=>({role:part.role,lines:(part.fit.sourceLines??part.fit.lines).length}))};
|
|
22
37
|
}
|
|
23
38
|
function validateManifest(manifest) {
|
|
24
39
|
if(!manifest||typeof manifest.shorthand!=='boolean'||!Array.isArray(manifest.parts)||manifest.parts.length<1||manifest.parts.length>2)throw Error('Invalid quote topology.');
|
|
@@ -30,44 +45,59 @@ function validateManifest(manifest) {
|
|
|
30
45
|
count+=part.lines;
|
|
31
46
|
}
|
|
32
47
|
if(count>10000)throw Error('Too many quote source lines.');
|
|
48
|
+
if(manifest.photo!==undefined&&(manifest.photo!==true||manifest.shorthand))throw Error('Invalid quote photo.');
|
|
33
49
|
const footer=manifest.footer;
|
|
34
50
|
if(roles.length===2){
|
|
35
51
|
if(manifest.shorthand||!footer||!FOOTERS.includes(JSON.stringify(footer.fields)))throw Error('Invalid quote footer fields.');
|
|
36
|
-
|
|
52
|
+
const splits=footer.splits;
|
|
53
|
+
if(footer.fields.length>1?!Array.isArray(splits)||splits.length!==footer.fields.length-1||splits.some(at=>!Number.isSafeInteger(at)||at<0):splits!==undefined)throw Error('Invalid quote footer split.');
|
|
37
54
|
}else if(footer!==undefined)throw Error('Invalid quote footer.');
|
|
38
55
|
return count;
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
const unwrap=text=>text.length>=2&&text.startsWith('"')&&text.endsWith('"')?text.slice(1,-1):text;
|
|
42
|
-
// Split the current footer text back into fields. A
|
|
43
|
-
//
|
|
59
|
+
// Split the current footer text back into fields. A separator whose recorded position no longer matches is searched for from the end of
|
|
60
|
+
// the previous field; a footer with a missing separator keeps everything as the attribution.
|
|
44
61
|
function footerFields(text,footer,report) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
const {fields}=footer;
|
|
63
|
+
if(fields.length===1)return {[fields[0]]:text};
|
|
64
|
+
const out={};let from=0;
|
|
65
|
+
for(let index=1;index<fields.length;index++){
|
|
66
|
+
const separator=separatorBefore(fields[index]),recorded=footer.splits[index-1];
|
|
67
|
+
const at=recorded>=from&&text.startsWith(separator,recorded)?recorded:text.indexOf(separator,from);
|
|
68
|
+
if(at<0){
|
|
69
|
+
report({code:'quote-footer-merged',message:'The quote footer no longer separates its attribution, role and source; the current text is kept as the attribution.'});
|
|
70
|
+
return {attribution:text};
|
|
71
|
+
}
|
|
72
|
+
out[fields[index-1]]=text.slice(from,at);from=at+separator.length;
|
|
50
73
|
}
|
|
51
|
-
|
|
74
|
+
out[fields.at(-1)]=text.slice(from);
|
|
75
|
+
return out;
|
|
52
76
|
}
|
|
53
77
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
// `pictures` and `readPicture` (the ordinary picture importer) restore the headshot of a quote with a photo.
|
|
79
|
+
// `readBody` (optional) is the native body reader: with it the quote text keeps its native run formatting as TextRun[] (FA-10).
|
|
80
|
+
export function importQuoteGroups(shapes,paragraphs,relationships,entries,report,pictures=[],readPicture=()=>undefined,readBody) {
|
|
81
|
+
const groups=new Map(),consumed=new Set(),consumedPictures=new Set(),items=[];
|
|
82
|
+
const candidates=[...shapes.map((shape,index)=>({index,shape,container:shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']})),
|
|
83
|
+
...pictures.map((picture,index)=>({index,picture,container:picture['p:nvPicPr']?.['p:nvPr']?.['p:custDataLst']}))];
|
|
84
|
+
for(const {index,shape,picture,container}of candidates){
|
|
57
85
|
const tags=[];let unreadable=false;
|
|
58
|
-
for(const link of array(
|
|
86
|
+
for(const link of array(container?.['p:tags'])){
|
|
59
87
|
const rel=relationships.get(link['r:id']);if(rel?.type!==REL||rel.targetMode==='External'||!entries[rel.path])continue;
|
|
60
88
|
try{tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));}catch{unreadable=true;}
|
|
61
89
|
}
|
|
62
90
|
for(const tag of tags.filter(tag=>tag.name?.toUpperCase()===TAG))try{
|
|
63
91
|
const data=decodeTextTag(tag.val);if(data?.v!==1||typeof data.group!=='string'||!/^\d{1,12}$/.test(data.group))throw Error('Invalid quote identity.');
|
|
64
|
-
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);
|
|
92
|
+
const group=groups.get(data.group)??[];group.push({shape,picture,index,data,ambiguous:unreadable||tags.filter(tag=>/^OPF_/i.test(tag.name)).length!==1});groups.set(data.group,group);
|
|
65
93
|
}catch{report({code:'invalid-quote-provenance',message:'Invalid quote tags; ordinary import retains current native text.'});}
|
|
66
94
|
}
|
|
67
|
-
for(const
|
|
95
|
+
for(const members of groups.values())try{
|
|
96
|
+
const group=members.filter(item=>!item.picture),photos=members.filter(item=>item.picture);
|
|
68
97
|
const anchors=group.filter(item=>item.data.anchor!==undefined);if(anchors.length!==1)throw Error('Missing or duplicated quote anchor.');
|
|
69
98
|
const anchor=anchors[0],manifest=anchor.data.anchor,count=validateManifest(manifest);
|
|
70
99
|
if(anchor.data.role!=='text'||anchor.data.part!==0||anchor.data.line!==0||group.length!==count||new Set(group.map(item=>item.shape)).size!==count)throw Error('Incomplete or duplicated quote lines.');
|
|
100
|
+
if(photos.length>(manifest.photo?1:0)||photos.some(item=>item.ambiguous||item.data.role!=='photo'||item.picture['p:nvPicPr']?.['p:cNvPr']?.name!==quotePhotoName(anchor.data.group)))throw Error('Invalid quote photo identity.');
|
|
71
101
|
const ordered=manifest.parts.map(part=>Array(part.lines));
|
|
72
102
|
for(const item of group){
|
|
73
103
|
if(item.ambiguous)throw Error('Multiple source identities on one quote shape.');
|
|
@@ -77,13 +107,27 @@ export function importQuoteGroups(shapes,paragraphs,relationships,entries,report
|
|
|
77
107
|
}
|
|
78
108
|
const [body,footer]=ordered.map(lines=>sourceLineParagraphs(lines,paragraphs)[0].text),local=[];
|
|
79
109
|
let quote={text:unwrap(body)};
|
|
110
|
+
// The body lines are one logical text: rich runs when any word is formatted differently from the rest.
|
|
111
|
+
// Only a rich export records the body's own look (tag.base); a plain string quote stays a string.
|
|
112
|
+
const base=cleanBase(ordered[0][0]?.data.base);
|
|
113
|
+
const richParts=base?joinRichLines(ordered[0],readBody,(line,last)=>last?'':line.data.boundary==='hard'?(line.data.separator||'\n'):''):undefined;
|
|
114
|
+
const rich=richParts?unwrapQuoteRuns(headingValue(richParts,base)):undefined;
|
|
115
|
+
// Only when it reads as the same words as the plain path (the native text is authoritative).
|
|
116
|
+
if(Array.isArray(rich)&&rich.map(run=>typeof run==='string'?run:run.text).join('')===quote.text)quote={text:rich};
|
|
80
117
|
if(footer)quote={...quote,...footerFields(footer,manifest.footer,diagnostic=>local.push(diagnostic))};
|
|
118
|
+
// The headshot is the current native picture; a deleted or unreadable one is reported, never invented.
|
|
119
|
+
if(manifest.photo){
|
|
120
|
+
const image=photos[0]?readPicture(photos[0].picture)?.payload?.image:undefined;
|
|
121
|
+
if(image)quote={...quote,photo:image};
|
|
122
|
+
else local.push({code:'quote-photo-missing',message:"The quote's headshot picture is missing or unreadable; the quote is imported without a photo."});
|
|
123
|
+
}
|
|
81
124
|
if(manifest.shorthand&&Object.keys(quote).length===1)quote=quote.text;
|
|
82
|
-
if(!
|
|
125
|
+
if(!isValidFormat({slides:[{quote}]}))throw Error('Current native text does not form a valid quote.');
|
|
83
126
|
for(const item of group)consumed.add(item.shape);
|
|
84
|
-
|
|
127
|
+
for(const item of photos)consumedPictures.add(item.picture);
|
|
128
|
+
items.push({shapes:group.map(item=>item.shape),pictures:photos.map(item=>item.picture),payload:{type:'quote',quote}});
|
|
85
129
|
for(const diagnostic of local)report(diagnostic);
|
|
86
|
-
report({code:'quote-import-reflow',message:'Quote text, attribution
|
|
130
|
+
report({code:'quote-import-reflow',message:'Quote text, attribution, role, source and photo were recovered from the current native text and picture. Native formatting, positions and font theme are not reconstructed; review the reflowed quote.'});
|
|
87
131
|
}catch(error){report({code:'invalid-quote-provenance',message:`${error.message} Ordinary import retains current native text without restoring old source words.`});}
|
|
88
|
-
return {consumed,items};
|
|
132
|
+
return {consumed,consumedPictures,items};
|
|
89
133
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Rich headline text on import (FA-10): the title, subtitle, tag and quote body as `string | TextRun[]`.
|
|
2
|
+
//
|
|
3
|
+
// A heading or quote body is read from its current native runs, the way body text is (src/body-text-import.js): formatting that
|
|
4
|
+
// is only the heading's own look (the size, family, weight and colour every run of it shares) is not authored run style, so a
|
|
5
|
+
// uniform heading is a plain string and a heading with a differently formatted word is TextRun[] that carries only what
|
|
6
|
+
// differs. Links are content, never defaults. Tagged line shapes (the export writes one native text box per laid-out line)
|
|
7
|
+
// are joined with the boundaries their tags record; soft wraps insert nothing and hard boundaries keep their separator.
|
|
8
|
+
|
|
9
|
+
import {splitWeightFace} from './font-weights.js';
|
|
10
|
+
|
|
11
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
12
|
+
// The run properties that describe how a heading looks. `link` and `text` are never defaults.
|
|
13
|
+
const LOOK = ['bold', 'italic', 'underline', 'strikethrough', 'superscript', 'subscript', 'fontSize', 'fontFamily', 'color', '_opfScheme'];
|
|
14
|
+
|
|
15
|
+
const asParts = value => Array.isArray(value) ? value : value === undefined ? [] : [value];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The raw runs of an ordered group of native line shapes. `readBody(shapeIndex)` is the native body reader (paragraphs with
|
|
19
|
+
* `richText`); `separatorOf(item, last)` is the text between this line and the next ('' for a soft wrap). Returns undefined when a
|
|
20
|
+
* shape has no readable body, so the caller keeps the plain text path.
|
|
21
|
+
*/
|
|
22
|
+
export function joinRichLines(ordered, readBody, separatorOf) {
|
|
23
|
+
const parts = [];
|
|
24
|
+
for (const [position, item] of ordered.entries()) {
|
|
25
|
+
const body = readBody?.(item.index);
|
|
26
|
+
if (!Array.isArray(body) || !body.length) return undefined;
|
|
27
|
+
body.forEach((paragraph, index) => {
|
|
28
|
+
if (index) parts.push('\n');
|
|
29
|
+
parts.push(...asParts(paragraph.richText ?? paragraph.text));
|
|
30
|
+
});
|
|
31
|
+
const separator = separatorOf(item, position === ordered.length - 1);
|
|
32
|
+
if (separator) parts.push(separator);
|
|
33
|
+
}
|
|
34
|
+
return parts;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const styleKey = run => JSON.stringify(Object.keys(run).filter(key => key !== 'text').sort().map(key => [key, run[key]]));
|
|
38
|
+
const BOOLEANS = ['bold', 'italic', 'underline', 'strikethrough', 'superscript', 'subscript'];
|
|
39
|
+
const sameColor = (a, b) => typeof a === 'string' && typeof b === 'string' && a.slice(0, 7).toUpperCase() === b.slice(0, 7).toUpperCase();
|
|
40
|
+
const sameFamily = (a, b) => typeof a === 'string' && typeof b === 'string' && (a.trim().toLowerCase() === b.trim().toLowerCase() || splitWeightFace(b)?.family.toLowerCase() === a.trim().toLowerCase());
|
|
41
|
+
// Whether a run's value for a look property is the heading's own default (what the exporter wrote on every run).
|
|
42
|
+
const isDefault = {
|
|
43
|
+
bold: (value, base) => value === base.bold,
|
|
44
|
+
color: (value, base) => sameColor(value, base.color),
|
|
45
|
+
fontSize: (value, base) => Number.isFinite(value) && Math.abs(value - base.fontSize) <= 0.011,
|
|
46
|
+
fontFamily: (value, base) => sameFamily(value, base.fontFamily)
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `string | TextRun[]` for raw runs. Formatting that is only the heading's own look is not authored run style:
|
|
51
|
+
* - with `base` (the defaults a tagged export recorded: `{bold, color, fontSize, fontFamily}`) a run's value equal to its default is removed;
|
|
52
|
+
* - without it (a native shape) a value every run shares is removed;
|
|
53
|
+
* - a false flag is the absence of the style (a `bold: false` is kept only where the heading is bold by default).
|
|
54
|
+
* Adjacent runs left with the same formatting merge; no remaining formatting is a plain string. Links are content and are kept.
|
|
55
|
+
*/
|
|
56
|
+
export function headingValue(parts, base) {
|
|
57
|
+
const runs = parts.map(part => typeof part === 'string' ? {text: part} : {...part}).filter(run => typeof run.text === 'string' && run.text !== '');
|
|
58
|
+
if (!runs.length) return '';
|
|
59
|
+
const defaults = base && typeof base === 'object' ? base : undefined;
|
|
60
|
+
// A hyperlink run is drawn underlined natively; that is the link's look, not an authored underline.
|
|
61
|
+
for (const run of runs) if (run.link !== undefined && run.underline === true) delete run.underline;
|
|
62
|
+
// PowerPoint writes no b attribute for a run that is not bold, so where the heading is bold by default an unmarked run is a lighter one.
|
|
63
|
+
const lineBreak = run => /^[\r\n]+$/.test(run.text);
|
|
64
|
+
if (defaults?.bold === true) for (const run of runs) if (run.bold !== true && !lineBreak(run)) run.bold = false;
|
|
65
|
+
for (const key of LOOK) {
|
|
66
|
+
if (!runs.some(run => run[key] !== undefined)) continue;
|
|
67
|
+
if (defaults && isDefault[key] && defaults[key === 'fontFamily' ? 'fontFamily' : key] !== undefined) {
|
|
68
|
+
for (const run of runs) if (run[key] !== undefined && isDefault[key](run[key], defaults)) delete run[key];
|
|
69
|
+
} else if (!defaults) {
|
|
70
|
+
// A bare line break between paragraphs carries no formatting of its own and does not count against uniformity.
|
|
71
|
+
const styled = runs.filter(run => !lineBreak(run));
|
|
72
|
+
const first = JSON.stringify(styled[0]?.[key]);
|
|
73
|
+
if (styled.length && styled[0][key] !== undefined && styled.every(run => run[key] !== undefined && JSON.stringify(run[key]) === first)) for (const run of runs) delete run[key];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const key of BOOLEANS) {
|
|
77
|
+
if (key === 'bold' && (defaults?.bold === true || runs.some(run => run.bold === true))) continue;
|
|
78
|
+
for (const run of runs) if (run[key] === false) delete run[key];
|
|
79
|
+
}
|
|
80
|
+
for (const run of runs) if (run._opfScheme !== undefined && run.color === undefined) delete run._opfScheme;
|
|
81
|
+
const merged = [];
|
|
82
|
+
for (const run of runs) {
|
|
83
|
+
const last = merged.at(-1);
|
|
84
|
+
if (last && styleKey(last) === styleKey(run)) last.text += run.text;
|
|
85
|
+
else merged.push(run);
|
|
86
|
+
}
|
|
87
|
+
if (merged.every(run => Object.keys(run).length === 1)) return merged.map(run => run.text).join('');
|
|
88
|
+
return merged.map(run => Object.keys(run).length === 1 ? run.text : run);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Remove the opening and closing quotation mark an exported quote body carries (core joins them to the first and last run). */
|
|
92
|
+
export function unwrapQuoteRuns(value) {
|
|
93
|
+
if (!Array.isArray(value)) return typeof value === 'string' && value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
|
94
|
+
const text = run => typeof run === 'string' ? run : run.text;
|
|
95
|
+
if (!value.length || !text(value[0]).startsWith('"') || !text(value.at(-1)).endsWith('"') || (value.length === 1 && text(value[0]).length < 2)) return value;
|
|
96
|
+
const runs = value.map(run => typeof run === 'string' ? run : {...run});
|
|
97
|
+
const edit = (index, change) => {
|
|
98
|
+
const run = runs[index];
|
|
99
|
+
if (typeof run === 'string') runs[index] = change(run); else run.text = change(run.text);
|
|
100
|
+
};
|
|
101
|
+
edit(0, text => text.slice(1));
|
|
102
|
+
edit(runs.length - 1, text => text.slice(0, -1));
|
|
103
|
+
const kept = runs.filter(run => text(run) !== '');
|
|
104
|
+
if (!kept.length) return '';
|
|
105
|
+
return kept.every(run => typeof run === 'string') ? kept.join('') : kept;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const isRichValue = value => Array.isArray(value) && value.some(run => object(run));
|
|
109
|
+
|
|
110
|
+
/** The recorded defaults of a rich heading or quote body, kept only where each value has the expected type. */
|
|
111
|
+
export function cleanBase(raw) {
|
|
112
|
+
if (!object(raw)) return undefined;
|
|
113
|
+
const base = {};
|
|
114
|
+
if (typeof raw.bold === 'boolean') base.bold = raw.bold;
|
|
115
|
+
if (typeof raw.color === 'string' && /^#[0-9a-f]{6}$/i.test(raw.color)) base.color = raw.color;
|
|
116
|
+
if (Number.isFinite(raw.fontSize) && raw.fontSize > 0 && raw.fontSize < 10000) base.fontSize = raw.fontSize;
|
|
117
|
+
if (typeof raw.fontFamily === 'string' && raw.fontFamily.length > 0 && raw.fontFamily.length <= 200) base.fontFamily = raw.fontFamily;
|
|
118
|
+
return Object.keys(base).length ? base : undefined;
|
|
119
|
+
}
|
package/dist/run-colors.js
CHANGED
|
@@ -46,10 +46,13 @@ export function slideRunSlots(slide) {
|
|
|
46
46
|
if (Array.isArray(value)) for (const run of value) slots.push(object(run) ? run : null);
|
|
47
47
|
else if (typeof value === 'string') slots.push(null);
|
|
48
48
|
};
|
|
49
|
+
// FA-10: the heading group (tag, title, subtitle) comes first, when it is TextRun[].
|
|
50
|
+
for (const field of ['tag', 'title', 'subtitle']) if (Array.isArray(slide[field])) runs(slide[field]);
|
|
49
51
|
for (const leaf of leaves(slide)) {
|
|
50
52
|
for (const field of ROOT_PAYLOAD_FIELDS) {
|
|
51
53
|
const value = leaf[field];
|
|
52
54
|
if (field === 'text') { if (Array.isArray(value)) runs(value); }
|
|
55
|
+
else if (field === 'quote') { if (object(value) && Array.isArray(value.text)) runs(value.text); }
|
|
53
56
|
else if ((field === 'items' || field === 'bullets') && Array.isArray(value)) {
|
|
54
57
|
for (const item of value) {
|
|
55
58
|
if (Array.isArray(item) || typeof item === 'string') runs(item);
|