@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.
@@ -1,5 +1,5 @@
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
 
@@ -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) && validatePresentation({$schema: 'https://openpresentation.org/schema/opf/v1', name: 'Media', assets: {[id]: asset}, slides: [{title: 'Media'}]}).valid;
109
- const validVideo = value => validatePresentation({$schema: 'https://openpresentation.org/schema/opf/v1', name: 'Media', slides: [{video: value}]}).valid;
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 {validatePresentation} from '@openpresentation/opf';
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 (!validatePresentation({slides:[{metric:manifest.value}]}).valid) throw new Error('Invalid metric source.');
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 (!validatePresentation({slides:[{metric}]}).valid) throw new Error('Edited native text cannot form a valid metric.');
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
 
@@ -1,5 +1,5 @@
1
1
  import {unzipSync, zipSync} from 'fflate';
2
- import {inventoryPptxTypefaces, packageFontsUsed, headingPairs, titlesOfParts} from './typeface-inventory.js';
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(inventoryPptxTypefaces(parts, {nested: false}));
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]];
@@ -1,24 +1,39 @@
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
+ 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','source'],FOOTERS=['["attribution"]','["source"]','["attribution","source"]'];
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 "attribution - source",
17
- // skipping an absent field) and, when both are present, where the separator starts, so an unchanged footer splits back exactly.
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
- return {shorthand,...(fields.length?{footer:{fields,...(fields.length===2?{split:value.attribution.length}:{})}}:{}),
21
- parts:layout.parts.map(part=>({role:part.role,lines:part.fit.sourceLines.length}))};
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
- if(footer.fields.length===2?!Number.isSafeInteger(footer.split)||footer.split<1:footer.split!==undefined)throw Error('Invalid quote footer split.');
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 footer edited so the recorded separator no longer matches splits at its
43
- // first separator; one with none keeps everything as the attribution.
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
- if(footer.fields.length===1)return {[footer.fields[0]]:text};
46
- const at=text.startsWith(SEPARATOR,footer.split)?footer.split:text.indexOf(SEPARATOR);
47
- if(at<0){
48
- report({code:'quote-footer-merged',message:'The quote footer no longer separates attribution from source; the current text is kept as the attribution.'});
49
- return {attribution:text};
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
- return {attribution:text.slice(0,at),source:text.slice(at+SEPARATOR.length)};
74
+ out[fields.at(-1)]=text.slice(from);
75
+ return out;
52
76
  }
53
77
 
54
- export function importQuoteGroups(shapes,paragraphs,relationships,entries,report) {
55
- const groups=new Map(),consumed=new Set(),items=[];
56
- for(const [index,shape]of shapes.entries()){
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(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])){
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 group of groups.values())try{
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(!validatePresentation({slides:[{quote}]}).valid)throw Error('Current native text does not form a valid quote.');
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
- items.push({shapes:group.map(item=>item.shape),payload:{type:'quote',quote}});
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 and source were recovered from the current native text. Native formatting, positions and font theme are not reconstructed; review the reflowed quote.'});
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
+ }
@@ -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);
@@ -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
- // Core releases before the resolver (0.11.0 and earlier) export no
8
- // resolveScriptFonts. The exporter then writes exactly what it wrote before
9
- // FF-07 (lang="en-US", empty theme ea/cs, no rtl) and reports
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
- const SCRIPT_SLOTS = [["ea", "eastAsian"], ["cs", "complexScript"]];
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
- /** Whether the installed core exports the FF-18 language/script resolver. */
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 core has no resolver.
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
- const RUN_LANGUAGE = /(<a:(?:rPr|endParaRPr|defRPr)\b[^>]*?\slang=")en-US(")/g;
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. Without core's resolver,
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 => !resolver || resolver({language: record.id}).lang.toLowerCase() === key;
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
- if (resolver) {
415
- const resolved = resolver({language: lang});
416
- // A tag core cannot resolve falls back to its default language; that is no match.
417
- const matched = resolved.languageSource !== "default" && resolved.languageId && records.find(candidate => candidate.id === resolved.languageId);
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 || !resolver) return groups;
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 ? resolver({language: observed.language}).rtl === true : observed.match?.record.direction === "rtl"; } catch { return false; }
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 ? resolver(imported) : null;
500
- const rtl = resolved ? resolved.rtl : match?.record.direction === "rtl";
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 (resolver && imported.slides?.length) { try { themeResolved = resolver(imported, {slideIndex: 0}); } catch { themeResolved = resolved; } }
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 (resolver && Array.isArray(observed.slideThemes)) {
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
- * Without core's resolver the theme's East Asian slot still repeats the latin family of its font group, which keeps
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 => {
@@ -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: ['accent1', 'accent3'],
39
+ accent: ['accent3'],
40
40
  text: ['dark1', 'light1'],
41
41
  textSecondary: ['dark2', 'light2'],
42
42
  surface: ['light2', 'dark2'],