@openpresentation/opf-pptx 0.13.2 → 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/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 +4 -4
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);
|
package/dist/script-fonts.js
CHANGED
|
@@ -4,38 +4,21 @@
|
|
|
4
4
|
// reads it back on import. Design: core docs/programs/font-fidelity-everywhere/
|
|
5
5
|
// script-font-model.md ("OOXML mapping").
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
// `language-export-unavailable` for a document that names a language. A core
|
|
11
|
-
// with the resolver but without paragraphDirection() marks no paragraph
|
|
12
|
-
// direction and reports `paragraph-direction-unavailable` for an RTL deck.
|
|
13
|
-
import * as opfCore from "@openpresentation/opf";
|
|
14
|
-
|
|
15
|
-
const resolver = typeof opfCore.resolveScriptFonts === "function" ? opfCore.resolveScriptFonts : null;
|
|
16
|
-
// RR-05: authored alignment is logical for right-to-left text (`left` is the start edge). Core owns the rule; a core without it
|
|
17
|
-
// reports no line directions either, so the fallback never has anything to flip.
|
|
18
|
-
export const physicalAlignment = typeof opfCore.physicalAlignment === "function" ? opfCore.physicalAlignment : alignment => alignment;
|
|
19
|
-
// The one paragraph-direction rule shared with the renderer (core FF-07).
|
|
20
|
-
const paragraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
|
|
7
|
+
// When the resolver throws for a deck, the exporter writes what it wrote before FF-07 (lang="en-US", empty theme ea/cs, no
|
|
8
|
+
// rtl) and reports `language-export-unavailable`.
|
|
9
|
+
import {paragraphDirection, physicalAlignment, resolveScriptFonts as resolver} from "@openpresentation/opf/composition";
|
|
21
10
|
|
|
22
|
-
|
|
11
|
+
// RR-05: authored alignment is logical for right-to-left text (`left` is the start edge). Core owns the rule, and the
|
|
12
|
+
// paragraph-direction rule is the one the renderer shares (core FF-07).
|
|
13
|
+
export {physicalAlignment};
|
|
23
14
|
|
|
24
|
-
|
|
25
|
-
export function scriptFontsAvailable() {
|
|
26
|
-
return resolver !== null;
|
|
27
|
-
}
|
|
15
|
+
const SCRIPT_SLOTS = [["ea", "eastAsian"], ["cs", "complexScript"]];
|
|
28
16
|
|
|
29
17
|
/**
|
|
30
18
|
* Resolve the deck (slide 0, which also sets the theme) and every slide.
|
|
31
|
-
* Returns null when
|
|
19
|
+
* Returns null when the resolver throws for the deck.
|
|
32
20
|
*/
|
|
33
21
|
export function planScriptFonts(presentation, report) {
|
|
34
|
-
if (!resolver) {
|
|
35
|
-
if (presentation.language !== undefined) report?.({code: "language-export-unavailable", path: "language",
|
|
36
|
-
message: "The installed @openpresentation/opf has no resolveScriptFonts, so the PPTX keeps lang=\"en-US\", empty theme East Asian/complex-script fonts and left-to-right paragraphs. Use a core release with the FF-18 language model."});
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
22
|
const count = Array.isArray(presentation.slides) ? presentation.slides.length : 0;
|
|
40
23
|
let slides, deck;
|
|
41
24
|
try {
|
|
@@ -51,15 +34,63 @@ export function planScriptFonts(presentation, report) {
|
|
|
51
34
|
report?.({code: "language-unresolved", path: "language",
|
|
52
35
|
message: `The presentation language could not be resolved locally (a URL, pkg: reference or unknown id), so the PPTX uses ${deck.lang}.`});
|
|
53
36
|
}
|
|
54
|
-
let rtl = deck.rtl;
|
|
55
|
-
if (rtl && !paragraphDirection) {
|
|
56
|
-
rtl = false;
|
|
57
|
-
report?.({code: "paragraph-direction-unavailable", path: "language",
|
|
58
|
-
message: "The installed @openpresentation/opf has no paragraphDirection, so right-to-left paragraphs are not marked; the preview and export must share that rule. Use a core release that exports it."});
|
|
59
|
-
}
|
|
60
37
|
// opf-pptx#168: which slides have notes (a presentation has one notes master; see reportPerSlideNotesScriptFonts).
|
|
61
38
|
const notes = (presentation.slides ?? []).map(slide => typeof slide?.notes === "string" ? slide.notes.trim() !== "" : Boolean(slide?.notes));
|
|
62
|
-
return {deck, slides, notes, report, lang: deck.lang, rtl, contentEastAsian: contentEastAsianFonts(presentation, deck, report)};
|
|
39
|
+
return {deck, slides, notes, report, lang: deck.lang, rtl: deck.rtl, presentation, contentEastAsian: contentEastAsianFonts(presentation, deck, report)};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* FA-13: the OOXML tag a run's own `lang` (TextRun.lang) is written with: core's curated tag for the language (fr becomes
|
|
44
|
+
* fr-FR), or undefined when it is the deck language (the run then follows the deck). Without a plan the tag is written as given.
|
|
45
|
+
*/
|
|
46
|
+
export function runLanguageTag(plan, tag) {
|
|
47
|
+
if (typeof tag !== "string" || !tag) return undefined;
|
|
48
|
+
if (!plan) return tag.toLowerCase() === "en-us" ? undefined : tag;
|
|
49
|
+
const cache = plan.runLanguages ??= new Map();
|
|
50
|
+
if (!cache.has(tag)) {
|
|
51
|
+
let lang = tag;
|
|
52
|
+
try {
|
|
53
|
+
const resolved = resolver(plan.presentation, {language: tag});
|
|
54
|
+
if (resolved.languageSource !== "default") lang = resolved.lang;
|
|
55
|
+
} catch { /* an unresolvable tag is written as given */ }
|
|
56
|
+
cache.set(tag, lang.toLowerCase() === String(plan.lang).toLowerCase() ? undefined : lang);
|
|
57
|
+
}
|
|
58
|
+
return cache.get(tag);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* FA-13: a run with its own language (TextRun.lang) names the East Asian and complex-script faces of that language, so a Han
|
|
63
|
+
* run tagged ja-JP draws in the Japanese face and one tagged zh-CN in the Simplified Chinese face, as the preview does. Only a
|
|
64
|
+
* slot with a script-specific font for the run's language is written; every other run keeps following the theme. Runs the
|
|
65
|
+
* exporter writes carry no ea/cs of their own (stripRunScriptFonts), so this runs after that strip. `xml` is a slide part.
|
|
66
|
+
*/
|
|
67
|
+
export function runLanguageFonts(xml, plan, slideIndex) {
|
|
68
|
+
if (!plan || !xml.includes(' altLang="en-US"')) return xml;
|
|
69
|
+
return xml.replace(/<p:(sp|graphicFrame)>[\s\S]*?<\/p:\1>/g, shape => {
|
|
70
|
+
const heading = /<p:cNvPr\b[^>]*\bname="OPF heading /.test(shape);
|
|
71
|
+
return shape.replace(OWN_LANGUAGE_RUN, (run, lang) => {
|
|
72
|
+
const resolved = runScriptPlan(plan, slideIndex, lang);
|
|
73
|
+
if (!resolved) return run;
|
|
74
|
+
const slots = heading ? resolved.heading : resolved.body;
|
|
75
|
+
const latin = /<a:latin typeface="([^"]*)"/.exec(run)?.[1];
|
|
76
|
+
const insert = SCRIPT_SLOTS.map(([element, slot]) => {
|
|
77
|
+
const face = slots?.[slot];
|
|
78
|
+
return resolved.sources?.[slot] !== "latin" && face && escapeAttribute(face) !== latin && !new RegExp(`<a:${element}\\b`).test(run) ? `<a:${element} typeface="${escapeAttribute(face)}"/>` : "";
|
|
79
|
+
}).join("");
|
|
80
|
+
return insert ? run.replace(/(<a:latin\b[^>]*\/>)/, `$1${insert}`) : run;
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The script fonts of one slide for a run language (FA-13), memoized; undefined when the language cannot be resolved. */
|
|
86
|
+
function runScriptPlan(plan, slideIndex, lang) {
|
|
87
|
+
const cache = plan.runScripts ??= new Map(), key = `${slideIndex}|${lang}`;
|
|
88
|
+
if (!cache.has(key)) {
|
|
89
|
+
let resolved;
|
|
90
|
+
try { resolved = resolver(plan.presentation, {slideIndex, language: lang}); } catch { resolved = undefined; }
|
|
91
|
+
cache.set(key, resolved && resolved.languageSource !== "default" ? resolved : undefined);
|
|
92
|
+
}
|
|
93
|
+
return cache.get(key);
|
|
63
94
|
}
|
|
64
95
|
|
|
65
96
|
// ---------------------------------------------------------------------------
|
|
@@ -274,7 +305,9 @@ function runScriptFonts(xml, resolved, headingShape) {
|
|
|
274
305
|
});
|
|
275
306
|
}
|
|
276
307
|
|
|
277
|
-
|
|
308
|
+
// A run with its own language (TextRun.lang, FA-13) is written with an altLang attribute; the deck language never replaces it.
|
|
309
|
+
const RUN_LANGUAGE = /(<a:(?:rPr|endParaRPr|defRPr)\b[^>]*?\slang=")en-US("(?! altLang="))/g;
|
|
310
|
+
const OWN_LANGUAGE_RUN = /<a:rPr\b[^>]*?\slang="([^"]+)" altLang="en-US"[^>]*[^/]>[\s\S]*?<\/a:rPr>/g;
|
|
278
311
|
|
|
279
312
|
const decodeText = value => value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|(amp|lt|gt|quot|apos));/gi, (entity, decimal, hex, name) =>
|
|
280
313
|
decimal ? String.fromCodePoint(Number(decimal)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : {amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'"}[name.toLowerCase()]);
|
|
@@ -397,8 +430,7 @@ const attribute = (xml, name) => new RegExp(`\\s${name}="([^"]*)"`).exec(xml)?.[
|
|
|
397
430
|
* used when that record exports exactly this tag again: an exact BCP-47 tag,
|
|
398
431
|
* then a curated `ooxmlLang` (preferring the same primary language). A tag
|
|
399
432
|
* that core still resolves to a catalog record (for example en-NZ to English)
|
|
400
|
-
* is imported as the tag itself, so it round-trips.
|
|
401
|
-
* a record whose tag is the primary language alone is accepted too.
|
|
433
|
+
* is imported as the tag itself, so it round-trips.
|
|
402
434
|
* Returns {language, record, shared} or null when nothing matches.
|
|
403
435
|
*/
|
|
404
436
|
export function matchCatalogLanguage(lang, catalogs) {
|
|
@@ -406,19 +438,15 @@ export function matchCatalogLanguage(lang, catalogs) {
|
|
|
406
438
|
const key = lang.toLowerCase(), primary = key.split("-")[0];
|
|
407
439
|
const lower = value => typeof value === "string" ? value.toLowerCase() : undefined;
|
|
408
440
|
const shared = records.filter(record => lower(record.bcp47) === key || lower(record.ooxmlLang) === key);
|
|
409
|
-
const exports = record =>
|
|
441
|
+
const exports = record => resolver({language: record.id}).lang.toLowerCase() === key;
|
|
410
442
|
const record = shared.find(record => lower(record.bcp47) === key && exports(record))
|
|
411
443
|
?? shared.find(record => lower(record.bcp47)?.split("-")[0] === primary && exports(record))
|
|
412
444
|
?? shared.find(exports);
|
|
413
445
|
if (record) return {language: record.id, record, shared};
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
return matched ? {language: lang, record: matched, shared: []} : null;
|
|
419
|
-
}
|
|
420
|
-
const byPrimary = records.find(candidate => lower(candidate.bcp47) === primary);
|
|
421
|
-
return byPrimary ? {language: byPrimary.id, record: byPrimary, shared: []} : null;
|
|
446
|
+
const resolved = resolver({language: lang});
|
|
447
|
+
// A tag core cannot resolve falls back to its default language; that is no match.
|
|
448
|
+
const matched = resolved.languageSource !== "default" && resolved.languageId && records.find(candidate => candidate.id === resolved.languageId);
|
|
449
|
+
return matched ? {language: lang, record: matched, shared: []} : null;
|
|
422
450
|
}
|
|
423
451
|
|
|
424
452
|
/**
|
|
@@ -454,7 +482,7 @@ export function observeLanguage({slides, theme, themePath, slideThemes, catalogs
|
|
|
454
482
|
*/
|
|
455
483
|
export function reconcileLanguage(groups, observed, report) {
|
|
456
484
|
const index = groups.findIndex(item => item.field === "language");
|
|
457
|
-
if (index < 0 || observed.lang === undefined
|
|
485
|
+
if (index < 0 || observed.lang === undefined) return groups;
|
|
458
486
|
const stored = groups[index].ops.find(op => op.path?.length === 1 && op.path[0] === "language")?.value;
|
|
459
487
|
if (stored === undefined) return groups;
|
|
460
488
|
const resolved = resolver({language: stored});
|
|
@@ -474,7 +502,7 @@ export function reconcileLanguage(groups, observed, report) {
|
|
|
474
502
|
/** Whether the language observed in the runs is written right to left. */
|
|
475
503
|
export function observedRtl(observed) {
|
|
476
504
|
if (observed.lang === undefined) return false;
|
|
477
|
-
try { return resolver
|
|
505
|
+
try { return resolver({language: observed.language}).rtl === true; } catch { return false; }
|
|
478
506
|
}
|
|
479
507
|
|
|
480
508
|
export function languageDiagnostics(imported, observed, report) {
|
|
@@ -488,7 +516,7 @@ export function languageDiagnostics(imported, observed, report) {
|
|
|
488
516
|
const languages = observedRtl(observed) ? ranked.filter(([tag]) => tag === lang || tag !== "en-US") : ranked;
|
|
489
517
|
if (languages.length > 1) {
|
|
490
518
|
report({code: "mixed-run-languages", path: "language",
|
|
491
|
-
message: `Runs use ${languages.length} languages (${languages.map(([tag, count]) => `${tag} x${count}`).join(", ")}). OPF has one presentation language, so ${lang} was imported.`});
|
|
519
|
+
message: `Runs use ${languages.length} languages (${languages.map(([tag, count]) => `${tag} x${count}`).join(", ")}). OPF has one presentation language, so ${lang} was imported; a run in another language keeps it as its own lang where the text imports as rich text.`});
|
|
492
520
|
}
|
|
493
521
|
const kept = imported.language === observed.language;
|
|
494
522
|
if (kept && !match) report({code: "language-uncatalogued", path: "language", message: `Run language ${lang} matches no languages catalog record; it was imported as a BCP-47 tag.`});
|
|
@@ -496,14 +524,13 @@ export function languageDiagnostics(imported, observed, report) {
|
|
|
496
524
|
report({code: "language-ambiguous", path: "language",
|
|
497
525
|
message: `Run language ${lang} is the OOXML tag of ${match.shared.map(record => record.id).join(", ")}; ${match.language} was imported.`});
|
|
498
526
|
}
|
|
499
|
-
const resolved = resolver
|
|
500
|
-
|
|
501
|
-
if (rtlParagraphs && !rtl) {
|
|
527
|
+
const resolved = resolver(imported);
|
|
528
|
+
if (rtlParagraphs && !resolved.rtl) {
|
|
502
529
|
report({code: "rtl-language-mismatch", path: "language", message: `${rtlParagraphs} right-to-left paragraph(s) do not match the left-to-right language ${typeof imported.language === "string" ? imported.language : lang}; paragraph direction is not imported separately.`});
|
|
503
530
|
}
|
|
504
531
|
// The presentation theme carries the first slide's script fonts (planScriptFonts), so it is compared with that slide.
|
|
505
532
|
let themeResolved = resolved;
|
|
506
|
-
if (
|
|
533
|
+
if (imported.slides?.length) { try { themeResolved = resolver(imported, {slideIndex: 0}); } catch { themeResolved = resolved; } }
|
|
507
534
|
if (theme && themeResolved) {
|
|
508
535
|
for (const {tag, slot, face} of themeScriptMismatches(theme, themeResolved)) {
|
|
509
536
|
report({code: "script-font-not-imported", path: "design.fontScheme",
|
|
@@ -511,7 +538,7 @@ export function languageDiagnostics(imported, observed, report) {
|
|
|
511
538
|
}
|
|
512
539
|
}
|
|
513
540
|
// opf-pptx#168: a slide on another slide master carries its own script fonts in that master's theme.
|
|
514
|
-
if (
|
|
541
|
+
if (Array.isArray(observed.slideThemes)) {
|
|
515
542
|
observed.slideThemes.forEach((slideTheme, index) => {
|
|
516
543
|
if (!slideTheme || slideTheme.path === observed.themePath) return;
|
|
517
544
|
let slideResolved;
|
|
@@ -553,8 +580,8 @@ export function stripRunScriptFonts(xml) {
|
|
|
553
580
|
}
|
|
554
581
|
|
|
555
582
|
/**
|
|
556
|
-
*
|
|
557
|
-
* the paragraph end marks from reading an empty font (see themeScriptFonts).
|
|
583
|
+
* When the resolver throws for a deck (see planScriptFonts) the theme's East Asian slot still repeats the latin family of
|
|
584
|
+
* its font group, which keeps the paragraph end marks from reading an empty font (see themeScriptFonts).
|
|
558
585
|
*/
|
|
559
586
|
export function themeEastAsianFromLatin(xml) {
|
|
560
587
|
return xml.replace(/<a:(majorFont|minorFont)>[\s\S]*?<\/a:\1>/g, block => {
|
package/dist/theme-colors.js
CHANGED
|
@@ -36,7 +36,7 @@ const VENDOR_VALUES = new Set(['tx1', 'tx2', 'bg1', 'bg2', 'accent1', 'accent2',
|
|
|
36
36
|
const ROLE_SLOTS = Object.freeze({
|
|
37
37
|
primary: ['accent1'],
|
|
38
38
|
secondary: ['accent2'],
|
|
39
|
-
accent: ['
|
|
39
|
+
accent: ['accent3'],
|
|
40
40
|
text: ['dark1', 'light1'],
|
|
41
41
|
textSecondary: ['dark2', 'light2'],
|
|
42
42
|
surface: ['light2', 'dark2'],
|