@openpresentation/opf-pptx 0.20.3 → 0.22.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 +65 -3
- package/dist/annotation-provenance.js +11 -2
- package/dist/body-text-import.js +64 -7
- package/dist/chart-scheme-colors.js +47 -0
- package/dist/chart-workbook.js +7 -5
- package/dist/code-provenance.js +34 -4
- package/dist/errors.js +11 -0
- package/dist/furniture-fields.js +14 -1
- package/dist/furniture-provenance.js +13 -5
- package/dist/heading-provenance.js +3 -3
- package/dist/image-provenance.js +65 -7
- package/dist/index.d.ts +37 -1
- package/dist/index.js +145 -70
- package/dist/input-safety.js +115 -0
- package/dist/metric-provenance.js +4 -4
- package/dist/native-body.js +147 -0
- package/dist/native-footer-classification.js +104 -0
- package/dist/native-furniture.js +52 -17
- package/dist/native-text-style.js +7 -2
- package/dist/quote-provenance.js +3 -3
- package/dist/svg-image.js +9 -2
- package/dist/template-layouts.js +30 -14
- package/dist/text-provenance.js +3 -3
- package/dist/timeline-provenance.js +3 -3
- package/dist/typeface-inventory.js +5 -5
- package/package.json +4 -4
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Internal import safety policy, shared by PPTX and nested workbook/introspection readers.
|
|
2
|
+
// No caller-provided limits and no claim that ZIP CRC/provenance authenticates the source.
|
|
3
|
+
import {unzipSync, Unzip, UnzipInflate} from 'fflate';
|
|
4
|
+
import {OPFPptxError} from './errors.js';
|
|
5
|
+
const MiB=1024*1024;
|
|
6
|
+
export const INPUT_LIMITS=Object.freeze({compressedBytes:64*MiB,members:10000,memberBytes:64*MiB,expandedBytes:256*MiB,xmlBytes:16*MiB,xmlDepth:256,nestedDepth:16});
|
|
7
|
+
const states=new WeakMap();
|
|
8
|
+
const xmlPart=name=>/\.(?:xml|rels)$/i.test(name);
|
|
9
|
+
const invalid=(message,path)=>new OPFPptxError('invalid-pptx',message,path===undefined?{}:{path});
|
|
10
|
+
function limit(name,actual,path){
|
|
11
|
+
const maximum=INPUT_LIMITS[name];
|
|
12
|
+
if(actual>maximum)throw new OPFPptxError('pptx-limit-exceeded',`PPTX ${name} exceeds the supported limit (${maximum}).`,{limit:name,maximum,actual,...(path===undefined?{}:{path})});
|
|
13
|
+
}
|
|
14
|
+
function memberName(name){
|
|
15
|
+
if(typeof name!=='string'||!name||/[\\\x00-\x1f]/.test(name)||name.startsWith('/')||/^[a-z]:/i.test(name)||name.split('/').some((p,i,a)=>p==='.'||p==='..'||p===''&&i!==a.length-1))throw invalid('PPTX contains a noncanonical ZIP member name.',name);
|
|
16
|
+
}
|
|
17
|
+
function checkXml(bytes,path){
|
|
18
|
+
limit('xmlBytes',bytes.length,path);
|
|
19
|
+
// Check nesting before any recursive parser/tree walk; skip comments, CDATA and PIs.
|
|
20
|
+
// Reject DTDs entirely: OOXML parts do not need document entities or external declarations.
|
|
21
|
+
let text;
|
|
22
|
+
const encoding=bytes[0]===0xff&&bytes[1]===0xfe?'utf-16le':bytes[0]===0xfe&&bytes[1]===0xff?'utf-16be':'utf-8';
|
|
23
|
+
try{text=new TextDecoder(encoding,{fatal:true}).decode(bytes);}catch{throw invalid('PPTX XML is not valid encoded text.',path);}
|
|
24
|
+
const declarations=text.replace(/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>/g,'');
|
|
25
|
+
if(/<!DOCTYPE\b|<!ENTITY\b/i.test(declarations))throw invalid('PPTX XML document entities are not supported.',path);
|
|
26
|
+
const stack=[];
|
|
27
|
+
// Count broad XML name tokens, including Unicode/namespace names the OOXML parser
|
|
28
|
+
// accepts. This is a resource preflight, not a replacement XML grammar validator.
|
|
29
|
+
const tokens=/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<\/?[^\s<>\/"'=!?]+(?:[^<>"']|"[^"]*"|'[^']*')*>/g;
|
|
30
|
+
for(const [token]of text.matchAll(tokens)){
|
|
31
|
+
if(token.startsWith('<?')||token.startsWith('<!'))continue;
|
|
32
|
+
const name=/^<\/?([^\s<>\/"'=!?]+)/.exec(token)[1];
|
|
33
|
+
if(token.startsWith('</')){if(stack.pop()!==name)throw invalid('PPTX XML has an unmatched closing element.',path);}
|
|
34
|
+
else {limit('xmlDepth',stack.length+1,path);if(!/\/\s*>$/.test(token))stack.push(name);}
|
|
35
|
+
}
|
|
36
|
+
if(stack.length)throw invalid('PPTX XML has unclosed elements.',path);
|
|
37
|
+
}
|
|
38
|
+
function stateOf(entries){
|
|
39
|
+
let state=states.get(entries);
|
|
40
|
+
if(!state){
|
|
41
|
+
const budget={members:0,expanded:0};state={budget,depth:0,cache:new Map()};
|
|
42
|
+
for(const [name,bytes]of Object.entries(entries)){
|
|
43
|
+
memberName(name);if(!(bytes instanceof Uint8Array))throw invalid('PPTX member is not bytes.',name);
|
|
44
|
+
limit('members',++budget.members,name);limit('memberBytes',bytes.length,name);budget.expanded+=bytes.length;limit('expandedBytes',budget.expanded,name);
|
|
45
|
+
if(xmlPart(name))checkXml(bytes,name);
|
|
46
|
+
}
|
|
47
|
+
states.set(entries,state);
|
|
48
|
+
}
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
export function checkInputEntries(entries){stateOf(entries);return entries;}
|
|
52
|
+
|
|
53
|
+
export function readInputZip(input,{budget={members:0,expanded:0},depth=0,path}={}){
|
|
54
|
+
const bytes=input instanceof ArrayBuffer?new Uint8Array(input):input;
|
|
55
|
+
if(!(bytes instanceof Uint8Array))throw invalid('PPTX input must be a Uint8Array or ArrayBuffer.',path);
|
|
56
|
+
limit('compressedBytes',bytes.length,path);limit('nestedDepth',depth,path);
|
|
57
|
+
const metadata=new Map();let declared=0;
|
|
58
|
+
try{
|
|
59
|
+
// fflate's central-directory iterator supplies names/sizes without allocating expanded data
|
|
60
|
+
// when filter always returns false. Do not use its fixed-size inflate output for untrusted data:
|
|
61
|
+
// a dishonest originalSize can silently truncate a DEFLATE stream in unzipSync.
|
|
62
|
+
unzipSync(bytes,{filter(file){
|
|
63
|
+
memberName(file.name);
|
|
64
|
+
if(metadata.has(file.name))throw invalid('PPTX repeats a ZIP member name.',file.name);
|
|
65
|
+
limit('members',budget.members+metadata.size+1,file.name);
|
|
66
|
+
if(!Number.isSafeInteger(file.size)||file.size<0||file.size>bytes.length||!Number.isSafeInteger(file.originalSize)||file.originalSize<0)throw invalid('PPTX has invalid ZIP member sizes.',file.name);
|
|
67
|
+
if(![0,8].includes(file.compression))throw invalid('PPTX uses an unsupported ZIP compression method.',file.name);
|
|
68
|
+
limit('memberBytes',file.originalSize,file.name);
|
|
69
|
+
if(xmlPart(file.name))limit('xmlBytes',file.originalSize,file.name);
|
|
70
|
+
declared+=file.originalSize;limit('expandedBytes',budget.expanded+declared,file.name);
|
|
71
|
+
metadata.set(file.name,file);return false;
|
|
72
|
+
}});
|
|
73
|
+
const entries=Object.create(null),seen=new Set();let failure;
|
|
74
|
+
const unzip=new Unzip(file=>{
|
|
75
|
+
const expected=metadata.get(file.name);
|
|
76
|
+
if(!expected||seen.has(file.name))throw invalid('PPTX local ZIP members do not match its directory.',file.name);
|
|
77
|
+
seen.add(file.name);
|
|
78
|
+
if(file.compression!==expected.compression||file.size!==undefined&&file.size!==expected.size||file.originalSize!==undefined&&file.originalSize!==expected.originalSize)throw invalid('PPTX local and directory sizes or methods disagree.',file.name);
|
|
79
|
+
let count=0;const chunks=[];
|
|
80
|
+
file.ondata=(error,chunk,final)=>{
|
|
81
|
+
if(failure)return;
|
|
82
|
+
try{
|
|
83
|
+
if(error)throw error;
|
|
84
|
+
count+=chunk.length;limit('memberBytes',count,file.name);
|
|
85
|
+
if(count>expected.originalSize)throw invalid('PPTX expands beyond its declared member size.',file.name);
|
|
86
|
+
budget.expanded+=chunk.length;limit('expandedBytes',budget.expanded,file.name);
|
|
87
|
+
chunks.push(chunk);
|
|
88
|
+
if(final){
|
|
89
|
+
if(count!==expected.originalSize)throw invalid('PPTX expanded member size differs from its directory.',file.name);
|
|
90
|
+
const data=new Uint8Array(count);let at=0;for(const part of chunks){data.set(part,at);at+=part.length;}chunks.length=0;
|
|
91
|
+
if(xmlPart(file.name))checkXml(data,file.name);
|
|
92
|
+
entries[file.name]=data;
|
|
93
|
+
}
|
|
94
|
+
}catch(error){failure=error;}
|
|
95
|
+
};
|
|
96
|
+
file.start();
|
|
97
|
+
});
|
|
98
|
+
unzip.register(UnzipInflate);
|
|
99
|
+
// A 4KiB compressed feed bounds an individual inflater emission (~4MiB at DEFLATE's
|
|
100
|
+
// maximum expansion) before our actual-output counter can reject dishonest metadata.
|
|
101
|
+
for(let at=0;at<bytes.length;at+=4096){unzip.push(bytes.subarray(at,at+4096),at+4096>=bytes.length);if(failure)throw failure;}
|
|
102
|
+
if(seen.size!==metadata.size||Object.keys(entries).length!==metadata.size)throw invalid('PPTX ZIP members are missing or incomplete.',path);
|
|
103
|
+
budget.members+=metadata.size;states.set(entries,{budget,depth,cache:new Map()});return entries;
|
|
104
|
+
}catch(error){
|
|
105
|
+
if(error instanceof OPFPptxError)throw error;
|
|
106
|
+
throw new OPFPptxError('invalid-pptx','PPTX input is not a readable ZIP archive.',{...(path===undefined?{}:{path}),cause:error instanceof Error?error.message:String(error)});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function readNestedInputZip(entries,path){
|
|
110
|
+
const state=stateOf(entries);
|
|
111
|
+
const cached=state.cache.get(path),source=entries[path];
|
|
112
|
+
if(cached?.source===source)return cached.entries;
|
|
113
|
+
const nested=readInputZip(source,{budget:state.budget,depth:state.depth+1,path});
|
|
114
|
+
state.cache.set(path,{source,entries:nested});return nested;
|
|
115
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
2
|
import {isValidFormat} from './format-check.js';
|
|
3
|
-
import {attachTextTags,decodeTextTag} from './code-provenance.js';
|
|
3
|
+
import {attachTextTags,decodeTextTag,groupUnchanged} from './code-provenance.js';
|
|
4
4
|
|
|
5
5
|
const TAG='OPF_METRIC_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
6
6
|
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
@@ -14,7 +14,7 @@ export function metricManifest(value,layout,group) {
|
|
|
14
14
|
return {v:1,group,role:'anchor',part:0,line:0,value,parts:layout.parts.map(part=>({role:part.role,visible:part.visible,
|
|
15
15
|
lines:part.fit?.sourceLines.map(({start,end,nextStart,boundary})=>({start,end,nextStart,boundary}))??[]}))};
|
|
16
16
|
}
|
|
17
|
-
export function attachMetricTags(entries,records,descriptions) {return attachTextTags(entries,records,TAG,'opfMetric','metric',{descriptions});}
|
|
17
|
+
export function attachMetricTags(entries,records,descriptions) {return attachTextTags(entries,records,TAG,'opfMetric','metric',{descriptions,signed:true});}
|
|
18
18
|
|
|
19
19
|
function validateManifest(manifest) {
|
|
20
20
|
if (!isValidFormat({slides:[{metric:manifest.value}]})) throw new Error('Invalid metric source.');
|
|
@@ -50,7 +50,7 @@ export function importMetricGroups(shapes,paragraphs,relationships,entries,repor
|
|
|
50
50
|
const data=decodeTextTag(tag.val);
|
|
51
51
|
if (data.v!==1||typeof data.group!=='string'||!/^\d+$/.test(data.group)) throw new Error('Invalid metric identity.');
|
|
52
52
|
const group=groups.get(data.group)??[];
|
|
53
|
-
group.push({data,shape,text:(paragraphs[index]??[]).map(p=>p.text).join('\n')});groups.set(data.group,group);
|
|
53
|
+
group.push({data,shape,index,text:(paragraphs[index]??[]).map(p=>p.text).join('\n')});groups.set(data.group,group);
|
|
54
54
|
} catch {report({code:'invalid-metric-provenance',message:'Invalid metric tags; visible native shapes were retained.'});}
|
|
55
55
|
}
|
|
56
56
|
}
|
|
@@ -91,7 +91,7 @@ export function importMetricGroups(shapes,paragraphs,relationships,entries,repor
|
|
|
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.`});
|
|
94
|
-
report({code:'metric-import-reflow',message:'Metric fields, source boundaries and native text were recovered. Native formatting, positioning, alignment and font theme are not reconstructed; review the reflowed OPF.'});
|
|
94
|
+
if(!groupUnchanged(group,paragraphs))report({code:'metric-import-reflow',message:'Metric fields, source boundaries and native text were recovered. Native formatting, positioning, alignment and font theme are not reconstructed; review the reflowed OPF.'});
|
|
95
95
|
} catch (error) {report({code:'invalid-metric-provenance',message:`${error.message} Visible native shapes were retained without restoring old source text.`});}
|
|
96
96
|
return {consumed,items};
|
|
97
97
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// RR-86 opt-in prototype. Native paragraphs trade measured line positions for a
|
|
2
|
+
// single editable body. No source words or per-line recovery tags are stored.
|
|
3
|
+
import {paragraphDirection, resolveTextStyle} from '@openpresentation/opf/composition';
|
|
4
|
+
import {autoNumScheme} from './numbered-list.js';
|
|
5
|
+
|
|
6
|
+
const FIELDS = new Set(['text', 'items', 'bullets']);
|
|
7
|
+
const RUN_KEYS = new Set(['text', 'bold', 'italic', 'underline', 'strikethrough', 'color', 'fontFamily', 'fontSize', 'lang', 'link']);
|
|
8
|
+
const runsOf = value => typeof value === 'string' ? [{text: value}] : value;
|
|
9
|
+
const ownText = value => Array.isArray(value) ? value.map(run => typeof run === 'string' ? run : run?.text ?? '').join('') : value;
|
|
10
|
+
const richSupported = value => typeof value === 'string' || Array.isArray(value) && value.every(run => typeof run === 'string' || run && typeof run.text === 'string' && Object.keys(run).every(key => RUN_KEYS.has(key)) && (run.link === undefined || /^(https?:|mailto:|tel:)/i.test(run.link)));
|
|
11
|
+
// An LTR deck does not ask core for paragraph directions. Conservatively reject
|
|
12
|
+
// RTL letters anywhere (including an embedded phrase), using core's own script rule.
|
|
13
|
+
const hasRtl = text => [...text].some(char => /\p{L}/u.test(char) && paragraphDirection(char, 'rtl') === 'rtl');
|
|
14
|
+
const escapeXml = value => String(value).replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char]));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Plan the native bodies of one slide: every text, list or bullets item that can be one editable body, in composition order.
|
|
18
|
+
* The first is returned with the rest as `others`; the first alone binds to the Body placeholder, each other one is an ordinary
|
|
19
|
+
* native text box at its region (RR-88). An item that cannot be a native body falls back to measured shapes with a visible diagnostic.
|
|
20
|
+
*/
|
|
21
|
+
export function planNativeBody(slide, geometry, {automatic = false, report = () => {}} = {}) {
|
|
22
|
+
const candidates = geometry.items.filter(item => FIELDS.has(item.field));
|
|
23
|
+
const reject = (item, reason) => report({code: 'native-body-fallback', path: item.path, reason,
|
|
24
|
+
message: `Native body export cannot represent this ${reason.replaceAll('-', ' ')}; the current measured shapes were exported.`});
|
|
25
|
+
if (!candidates.length) return undefined;
|
|
26
|
+
if (geometry.groups.length) {
|
|
27
|
+
candidates.forEach(item => reject(item, 'complex-composition'));
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const plans = [];
|
|
31
|
+
for (const item of candidates) {
|
|
32
|
+
const plan = planOne(item, geometry, automatic, reason => reject(item, reason));
|
|
33
|
+
if (plan) plans.push(plan);
|
|
34
|
+
}
|
|
35
|
+
return plans.length ? {...plans[0], others: plans.slice(1)} : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function planOne(item, geometry, automatic, reject) {
|
|
39
|
+
const region = geometry.regions?.find(region => region.name === item.region);
|
|
40
|
+
if (!/^slides\.\d+\.(?:blocks\.\d+\.|[A-Za-z][A-Za-z0-9+_-]*\.)?(?:text|items|bullets)$/.test(item.path) || !automatic && (!region || region.collapsed || region.content.length !== 1 || region.overflow?.length) || item.frameBox) {
|
|
41
|
+
reject('complex-composition'); return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (item.listColumns?.length > 1) { reject('multi-column'); return undefined; }
|
|
44
|
+
if (item.text?.overflow) { reject('overflow'); return undefined; }
|
|
45
|
+
if (geometry.direction === 'rtl' || item.text?.directions?.includes('rtl')) { reject('unsupported-text'); return undefined; }
|
|
46
|
+
const list = item.field !== 'text';
|
|
47
|
+
let values, numbering;
|
|
48
|
+
if (list) {
|
|
49
|
+
const entries = item.text?.listEntries;
|
|
50
|
+
if (item.bulletImage || !entries?.length || entries.some(entry => entry.level !== 0 || entry.description) ||
|
|
51
|
+
item.value.some(entry => entry && !Array.isArray(entry) && typeof entry === 'object' && Object.keys(entry).some(key => key !== 'text' && key !== 'level'))) {
|
|
52
|
+
reject('unsupported-list'); return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (item.payload?.numbering !== undefined) {
|
|
55
|
+
// One native numbered sequence: every entry has a number of one style and suffix, counting up from the first by one.
|
|
56
|
+
numbering = nativeNumbering(entries);
|
|
57
|
+
if (!numbering) { reject('unsupported-list'); return undefined; }
|
|
58
|
+
}
|
|
59
|
+
values = item.value.map(entry => entry && !Array.isArray(entry) && typeof entry === 'object' ? entry.text : entry);
|
|
60
|
+
if (values.some(value => /[\r\n]/.test(ownText(value)))) { reject('unsupported-list'); return undefined; }
|
|
61
|
+
} else values = [item.value];
|
|
62
|
+
if (values.some(value => !richSupported(value))) {
|
|
63
|
+
reject(values.some(value => Array.isArray(value) && value.some(run => run?.cite !== undefined || run?.footnote !== undefined)) ? 'annotated-text' : 'unsupported-text');
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
if (values.some(value => hasRtl(ownText(value)) || /\t|[\u0000-\u0008\u000B\u000C\u000E-\u001F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/.test(ownText(value))) || !values.some(value => ownText(value).length)) {
|
|
67
|
+
reject('unsupported-text'); return undefined;
|
|
68
|
+
}
|
|
69
|
+
return {item, values, list, ...(numbering ? {numbering} : {}), region: region?.name ?? 'Body', box: {...(region?.box ?? item.box)}, anchor: region?.anchor ?? 'top'};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// RR-88: the native auto-number (`a:buAutoNum`) that reproduces core's markers, or undefined when the entries are not one sequence.
|
|
73
|
+
function nativeNumbering(entries) {
|
|
74
|
+
const first = entries[0]?.marker?.number;
|
|
75
|
+
if (!first || !Number.isInteger(first.value) || first.value < 1) return undefined;
|
|
76
|
+
const scheme = autoNumScheme(first.style, first.suffix);
|
|
77
|
+
return entries.every((entry, index) => {
|
|
78
|
+
const number = entry.marker?.number;
|
|
79
|
+
return number && number.value === first.value + index && autoNumScheme(number.style, number.suffix) === scheme;
|
|
80
|
+
}) ? {style: scheme, startAt: first.value} : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whole authored paragraphs, retaining hard breaks only. Styling uses selected native families. */
|
|
84
|
+
export function addNativeBody(slide, plan, context, options, helpers) {
|
|
85
|
+
const {item, values, list, box, anchor} = plan;
|
|
86
|
+
const base = item.textStyle;
|
|
87
|
+
const baseFont = helpers.nativeFontOptions(base);
|
|
88
|
+
const paragraphs = [];
|
|
89
|
+
for (const value of values) {
|
|
90
|
+
let paragraph = [];
|
|
91
|
+
paragraphs.push(paragraph);
|
|
92
|
+
for (const source of runsOf(value)) {
|
|
93
|
+
const run = typeof source === 'string' ? {text: source} : source;
|
|
94
|
+
const style = resolveTextStyle({...base, fontFamily: run.fontFamily ?? base.fontFamily,
|
|
95
|
+
fontWeight: run.bold === undefined ? base.fontWeight : run.bold ? 700 : 400, italic: run.italic ?? base.italic}, options.textMeasurement);
|
|
96
|
+
const hex = helpers.exportColor(run.color, context, context.colors.text);
|
|
97
|
+
const color = helpers.linkColor(run, helpers.nativeColor(run.color, hex, context, context.textColor), context);
|
|
98
|
+
const runOptions = {...helpers.nativeFontOptions(style), fontSize: run.fontSize ?? item.text.fontSize * .75,
|
|
99
|
+
color, transparency: hex.length === 8 ? (1 - parseInt(hex.slice(6), 16) / 255) * 100 : 0,
|
|
100
|
+
underline: run.underline ? {style: 'sng', color} : {style: 'none'}, strike: run.strikethrough ? 'sngStrike' : undefined,
|
|
101
|
+
hyperlink: run.link && /^(https?:|mailto:|tel:)/i.test(run.link) ? {url: run.link} : undefined, ...helpers.langOptions(context, run.lang)};
|
|
102
|
+
const pieces = run.text.split(/\r\n|\r|\n/);
|
|
103
|
+
pieces.forEach((text, index) => {
|
|
104
|
+
if (index) { paragraph = []; paragraphs.push(paragraph); }
|
|
105
|
+
paragraph.push({text, options: {...runOptions}});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const indent = list ? item.text.listEntries[0].marker.indent * .75 : undefined;
|
|
110
|
+
const paragraphOptions = !list ? {bullet: false} : plan.numbering ? {bullet: {type: 'number', style: plan.numbering.style, startAt: plan.numbering.startAt, indent}, hanging: indent / 4, indentLevel: 0}
|
|
111
|
+
: {bullet: {characterCode: '2022', indent}, hanging: indent / 4, indentLevel: 0};
|
|
112
|
+
const runs = paragraphs.flatMap((paragraph, index) => {
|
|
113
|
+
if (!paragraph.length) paragraph = [{text: '', options: {}}];
|
|
114
|
+
return paragraph.map((run, position) => ({...run, options: {...run.options, ...paragraphOptions,
|
|
115
|
+
breakLine: position === paragraph.length - 1 && index < paragraphs.length - 1}}));
|
|
116
|
+
});
|
|
117
|
+
const objectName = `OPF native body ${item.path}`;
|
|
118
|
+
slide.addText(runs, {...helpers.textBoxOptions({x: box.x / 96, y: box.y / 96, w: box.width / 96, h: box.height / 96}, context, item.text.fontSize * .75),
|
|
119
|
+
...baseFont, ...paragraphOptions, objectName, align: item.alignment ?? 'left', valign: {top: 'top', middle: 'mid', bottom: 'bottom'}[anchor],
|
|
120
|
+
fit: 'none', wrap: true, lineSpacingMultiple: 1.22, bold: false, italic: false});
|
|
121
|
+
// Defaults for blank/appended paragraphs must name the selected family too.
|
|
122
|
+
const defaults = `<a:defRPr sz="${Math.round(item.text.fontSize * 75)}" b="${baseFont.bold ? 1 : 0}" i="${baseFont.italic ? 1 : 0}"><a:solidFill>${helpers.solidColorXml(context.textColor)}</a:solidFill><a:latin typeface="${escapeXml(baseFont.fontFace)}"/></a:defRPr>`;
|
|
123
|
+
return {objectName, defaults};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** RR-88: a native title is written with an explicit no-autofit body, so the Title placeholder's own autofit cannot shrink or move it. */
|
|
127
|
+
export function nativeTitleAutofit(xml, names) {
|
|
128
|
+
if (!names?.size) return xml;
|
|
129
|
+
return xml.replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape => names.has(shape.match(/<p:cNvPr\b[^>]*\bname="([^"]*)"/)?.[1])
|
|
130
|
+
? shape.replace(/(<a:bodyPr\b[^>]*>)[\s\S]*?<\/a:bodyPr>/, '$1<a:noAutofit/></a:bodyPr>') : shape);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Only prototype objects receive explicit no-autofit and paragraph typing defaults. */
|
|
134
|
+
export function nativeBodyDefaults(xml, records) {
|
|
135
|
+
if (!records?.size) return xml;
|
|
136
|
+
return xml.replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape => {
|
|
137
|
+
const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]*)"/)?.[1];
|
|
138
|
+
const defaults = records.get(name);
|
|
139
|
+
if (!defaults) return shape;
|
|
140
|
+
shape = shape.replace(/(<a:bodyPr\b[^>]*>)[\s\S]*?<\/a:bodyPr>/, '$1<a:noAutofit/></a:bodyPr>');
|
|
141
|
+
const pPr = shape.match(/<a:pPr\b[^>]*(?:\/>|>[\s\S]*?<\/a:pPr>)/)?.[0] ?? '<a:pPr/>';
|
|
142
|
+
const paragraphDefault = pPr.replace(/<a:pPr\b/, '<a:defPPr').replace(/<\/a:pPr>/, '</a:defPPr>').replace(/<a:defRPr\b[^>]*(?:\/>|>[\s\S]*?<\/a:defRPr>)/g, '');
|
|
143
|
+
const withDefaults = paragraphDefault.endsWith('/>') ? paragraphDefault.slice(0, -2) + `>${defaults}</a:defPPr>` : paragraphDefault.replace('</a:defPPr>', `${defaults}</a:defPPr>`);
|
|
144
|
+
shape = shape.replace(/<a:lstStyle\s*\/>/, `<a:lstStyle>${withDefaults}</a:lstStyle>`);
|
|
145
|
+
return shape.replace(/<a:endParaRPr\b[^>]*(?:\/>|>[\s\S]*?<\/a:endParaRPr>)/g, defaults.replaceAll('a:defRPr', 'a:endParaRPr'));
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Bounded current-native fallback for generated literals whose ftr role was
|
|
2
|
+
// stripped. No source words, tags, geometry or fonts are replayed.
|
|
3
|
+
import {escapeLiteralTokens} from './furniture-fields.js';
|
|
4
|
+
|
|
5
|
+
const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
6
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const nameOf = shape => shape?.['p:nvSpPr']?.['p:cNvPr']?.name;
|
|
8
|
+
const generatedName = name => {
|
|
9
|
+
const match = typeof name === 'string' && /^OPF furniture (0|[1-9]\d*) part (0|[1-9]\d*) line 0$/.exec(name);
|
|
10
|
+
return match && match.slice(1).every(value => Number.isSafeInteger(Number(value))) ? {group: match[1], part: match[2]} : undefined;
|
|
11
|
+
};
|
|
12
|
+
const contains = (node, keys) => object(node) ? Object.entries(node).some(([key, value]) => keys.includes(key) || contains(value, keys)) : Array.isArray(node) && node.some(value => contains(value, keys));
|
|
13
|
+
const integer = value => (typeof value === 'number' || typeof value === 'string' && /^-?\d+$/.test(value)) && Number.isSafeInteger(Number(value));
|
|
14
|
+
const disabled = value => value === undefined || ['0','false','off'].includes(String(value));
|
|
15
|
+
|
|
16
|
+
function frame(shape, width, height) {
|
|
17
|
+
const transform = shape['p:spPr']?.['a:xfrm'];
|
|
18
|
+
if (!object(transform) || transform.rot !== undefined && (!integer(transform.rot) || Number(transform.rot) !== 0) || !disabled(transform.flipH) || !disabled(transform.flipV)) return undefined;
|
|
19
|
+
if (![transform['a:off']?.x, transform['a:off']?.y, transform['a:ext']?.cx, transform['a:ext']?.cy].every(integer)) return undefined;
|
|
20
|
+
const x = Number(transform['a:off']?.x), y = Number(transform['a:off']?.y), cx = Number(transform['a:ext']?.cx), cy = Number(transform['a:ext']?.cy);
|
|
21
|
+
if (![x,y,cx,cy,width,height].every(Number.isSafeInteger) || !(cx > 0 && cy > 0 && width > 0 && height > 0)) return undefined;
|
|
22
|
+
const [X,Y,CX,CY,W,H] = [x,y,cx,cy,width,height].map(BigInt);
|
|
23
|
+
if (X < 0n || Y < 0n || X + CX > W || Y + CY > H || 10n * Y < 9n * H || 20n * CY > H || 3n * CX > W) return undefined;
|
|
24
|
+
const center = 3n * (2n * X + CX);
|
|
25
|
+
return {x,y,cx,cy,zone: center < 2n * W ? 'left' : center > 4n * W ? 'right' : 'center'};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function plainFrame(shape) {
|
|
29
|
+
const body = shape['p:txBody'], raw = body?.['a:bodyPr'], props = raw === '' ? {} : raw;
|
|
30
|
+
if (!object(body) || !object(props) || props.rot !== undefined && (!integer(props.rot) || Number(props.rot) !== 0) || ![undefined,'horz'].includes(props.vert) || ![undefined,'none'].includes(props.wrap) ||
|
|
31
|
+
['lIns','tIns','rIns','bIns'].some(key => !Number.isFinite(Number(props[key] ?? 0)) || Number(props[key] ?? 0) < 0) ||
|
|
32
|
+
!disabled(shape['p:nvSpPr']?.['p:cNvPr']?.hidden) || contains(shape, ['a:hlinkClick','a:hlinkMouseOver','a:br','a:tab','a:fld','a:effectLst','a:effectDag','a14:m','m:oMath','m:oMathPara','mc:AlternateContent'])) return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function plainShape(shape, context) {
|
|
37
|
+
if (!plainFrame(shape) || shape['p:txBody']['a:bodyPr'].wrap !== 'none') return undefined;
|
|
38
|
+
const body = shape['p:txBody'];
|
|
39
|
+
const paragraphs = array(body['a:p']);
|
|
40
|
+
if (paragraphs.length !== 1 || !object(paragraphs[0])) return undefined;
|
|
41
|
+
const paragraph = paragraphs[0], run = paragraph['a:r'], pPr = paragraph['a:pPr'];
|
|
42
|
+
if ([pPr, body['a:lstStyle'], run?.['a:rPr']].some(value => value !== undefined && value !== '' && !object(value))) return undefined;
|
|
43
|
+
if (!object(run) || paragraph['a:fld'] !== undefined || Number(pPr?.lvl ?? 0) !== 0) return undefined;
|
|
44
|
+
const text = typeof run['a:t'] === 'string' ? run['a:t'] : object(run['a:t']) && typeof run['a:t']['#text'] === 'string' ? run['a:t']['#text'] : undefined;
|
|
45
|
+
if (text === undefined || /[\r\n\t]/.test(text)) return undefined;
|
|
46
|
+
const other = context.inherited?.().master?.root?.['p:txStyles']?.['p:otherStyle'];
|
|
47
|
+
const defaults = style => style === undefined || style === '' || object(style)
|
|
48
|
+
? [style?.['a:lvl1pPr'], style?.['a:defPPr']] : [style];
|
|
49
|
+
const sources = [pPr, ...defaults(body['a:lstStyle']), ...defaults(other), ...defaults(context.defaultTextStyle)];
|
|
50
|
+
for (const source of sources) {
|
|
51
|
+
if (source === undefined || source === '') continue;
|
|
52
|
+
if (!object(source)) return undefined;
|
|
53
|
+
const membership = ['a:buNone','a:buChar','a:buAutoNum','a:buBlip'].filter(key => Object.hasOwn(source, key));
|
|
54
|
+
if (membership.length) return membership.length === 1 && membership[0] === 'a:buNone' ? text : undefined;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Native number witnesses must already have passed higher-priority collision checks. */
|
|
60
|
+
export function classifyNativeFooters(context, claimed, accepted, occupied, report) {
|
|
61
|
+
const result = [], candidates = [], topLevel = new Set(array(context.root?.['p:cSld']?.['p:spTree']?.['p:sp']));
|
|
62
|
+
const names = new Map();
|
|
63
|
+
const inventory = node => {
|
|
64
|
+
if (Array.isArray(node)) return node.forEach(inventory);
|
|
65
|
+
if (!object(node)) return;
|
|
66
|
+
for (const [key, value] of Object.entries(node)) {
|
|
67
|
+
if (key === 'p:cNvPr' && typeof value?.name === 'string') names.set(value.name, (names.get(value.name) ?? 0) + 1);
|
|
68
|
+
inventory(value);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
inventory(context.root);
|
|
72
|
+
// No complete cross-role ownership registry exists here. Any surviving
|
|
73
|
+
// customer binding conservatively reserves the whole slide from this fallback.
|
|
74
|
+
const tagged = contains(context.root, ['p:custDataLst']);
|
|
75
|
+
const witnesses = accepted.filter(field => field.ph === 'sldNum').map(field => {
|
|
76
|
+
const shape = context.shapes[field.index], name = nameOf(shape), identity = generatedName(name);
|
|
77
|
+
const p = shape?.['p:txBody']?.['a:p'], fld = p?.['a:fld'];
|
|
78
|
+
const withoutField = object(p) ? {...p} : {};
|
|
79
|
+
delete withoutField['a:fld'];
|
|
80
|
+
const bounds = shape && frame(shape, context.slideWidth, context.slideHeight);
|
|
81
|
+
return !claimed.has(field.index) && !contains(fld, ['a:hlinkClick','a:hlinkMouseOver','a:br','a:tab','a14:m','m:oMath','m:oMathPara']) && identity && names.get(name) === 1 && topLevel.has(shape) && object(p) && object(fld) && plainFrame({...shape, 'p:txBody': {...shape['p:txBody'], 'a:p': withoutField}}) && fld.type === 'slidenum' && p['a:r'] === undefined && bounds
|
|
82
|
+
? {identity, bounds, index: field.index} : undefined;
|
|
83
|
+
}).filter(Boolean);
|
|
84
|
+
for (const [index, shape] of context.shapes.entries()) {
|
|
85
|
+
const name = nameOf(shape), identity = generatedName(name);
|
|
86
|
+
if (!identity || claimed.has(index) || shape['p:nvSpPr']?.['p:nvPr']?.['p:ph'] !== undefined) continue;
|
|
87
|
+
const refuse = reason => report({code: 'unclassified-native-footer', path: `shapes.${index}`, message: `The current generated-name literal remains content: ${reason}.`});
|
|
88
|
+
// A cleared shape is not a reason to recover words or warn about absence.
|
|
89
|
+
const current = (context.paragraphs[index] ?? []).map(value => value.text).join('\n');
|
|
90
|
+
if (!current.trim()) continue;
|
|
91
|
+
if (tagged) {refuse('a current customer-tag binding reserves ownership'); continue;}
|
|
92
|
+
const bounds = frame(shape, context.slideWidth, context.slideHeight), text = plainShape(shape, context);
|
|
93
|
+
if (names.get(name) !== 1 || !topLevel.has(shape) || !bounds || text === undefined) {refuse('identity, own footer-band frame or plain unbulleted text is unsupported'); continue;}
|
|
94
|
+
const anchors = witnesses.filter(anchor => anchor.identity.group === identity.group && anchor.identity.part !== identity.part && anchor.bounds.y === bounds.y && anchor.bounds.cy === bounds.cy);
|
|
95
|
+
if (anchors.length !== 1) {refuse('there is no unique accepted same-group live-number row'); continue;}
|
|
96
|
+
if (occupied.has(bounds.zone)) {refuse('higher-priority furniture already owns this zone'); continue;}
|
|
97
|
+
candidates.push({index, identity, bounds, text, refuse});
|
|
98
|
+
}
|
|
99
|
+
for (const item of candidates) {
|
|
100
|
+
if (candidates.filter(other => other.identity.group === item.identity.group).length !== 1 || candidates.filter(other => other.bounds.zone === item.bounds.zone).length !== 1) {item.refuse('several current literal candidates are ambiguous'); continue;}
|
|
101
|
+
result.push({index: item.index, zone: item.bounds.zone, ph: 'ftr', field: 'text', value: escapeLiteralTokens(item.text), settings: {}});
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
package/dist/native-furniture.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// or where core would draw a default footer when no slide has one.
|
|
17
17
|
|
|
18
18
|
import {XMLParser} from 'fast-xml-parser';
|
|
19
|
-
import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatDate, hasSlideNumberField, slideNumberTemplate} from './furniture-fields.js';
|
|
19
|
+
import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatDate, hasSlideNumberField, isBareSlideNumber, slideNumberTemplate} from './furniture-fields.js';
|
|
20
20
|
|
|
21
21
|
const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
|
|
22
22
|
const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
|
|
@@ -35,11 +35,14 @@ export const isNativePlaceholderType = value => Object.hasOwn(NATIVE_PLACEHOLDER
|
|
|
35
35
|
/**
|
|
36
36
|
* The placeholder type a core footer part maps to: a date is `dt`; a zone `text` with a slide-number field
|
|
37
37
|
* (`{{slide.number}}`) is `sldNum`, its words around the field fixed runs; any other zone `text` is `ftr`.
|
|
38
|
+
* With `staticNumbers` (`slideNumberFields: 'static'`, the default, RR-91) a number with words around it is written as fixed text in an
|
|
39
|
+
* ordinary shape, so it has no placeholder: Keynote shows only the number of a slide-number placeholder or field.
|
|
38
40
|
*/
|
|
39
|
-
export function nativePlaceholderForPart(part) {
|
|
41
|
+
export function nativePlaceholderForPart(part, {staticNumbers = false} = {}) {
|
|
40
42
|
if (part.type !== 'text') return undefined;
|
|
41
43
|
if (part.field === 'date') return 'dt';
|
|
42
44
|
if (part.field !== 'text') return undefined;
|
|
45
|
+
if (staticNumbers && hasSlideNumberField(part) && !isBareSlideNumber(part)) return undefined;
|
|
43
46
|
return hasSlideNumberField(part) ? 'sldNum' : 'ftr';
|
|
44
47
|
}
|
|
45
48
|
const ZONES = ['left', 'center', 'right'];
|
|
@@ -50,10 +53,10 @@ const ALIGN = {left: 'l', center: 'ctr', right: 'r'};
|
|
|
50
53
|
* footer text, the first footer date and the first footer text with a slide-number
|
|
51
54
|
* field whose text fits one accepted line. Returns Map<partIndex, 'dt'|'ftr'|'sldNum'>.
|
|
52
55
|
*/
|
|
53
|
-
export function nativeFurnitureParts(layout) {
|
|
56
|
+
export function nativeFurnitureParts(layout, options) {
|
|
54
57
|
const chosen = new Map(), taken = new Set();
|
|
55
58
|
for (const [index, part] of (layout?.parts ?? []).entries()) {
|
|
56
|
-
const ph = nativePlaceholderForPart(part);
|
|
59
|
+
const ph = nativePlaceholderForPart(part, options);
|
|
57
60
|
if (part.kind !== 'footer' || !ph || taken.has(ph)) continue;
|
|
58
61
|
// One native shape holds one line; a part that wraps or breaks stays tagged shapes.
|
|
59
62
|
if (part.fit?.sourceLines?.length !== 1 || part.fit.lines?.length !== 1 || !part.text || part.links?.length) continue;
|
|
@@ -79,9 +82,11 @@ const guid = (kind, n) => `{0F0F2701-${kind}-4000-8000-${n.toString(16).toUpperC
|
|
|
79
82
|
* attached: the shape keeps its name, geometry, runs, fields and tags, and gains
|
|
80
83
|
* `p:ph` (a placeholder is a text shape the layout can supply defaults for).
|
|
81
84
|
* `bands` maps a generated object name to its zone's band (`{x, width}` in pixels) for a part narrower than its zone.
|
|
85
|
+
* `footerText: 'shape'` omits only the slide-local literal ftr binding. Keep locks, selection and master/layout bookkeeping
|
|
86
|
+
* unchanged: the measured shape stays on its slide and all other generated package parts retain their normal semantics.
|
|
82
87
|
* Returns the placeholders in use and the first geometry/alignment seen per type.
|
|
83
88
|
*/
|
|
84
|
-
export function attachNativePlaceholders(entries, records, bands = new Map()) {
|
|
89
|
+
export function attachNativePlaceholders(entries, records, bands = new Map(), footerText = 'native') {
|
|
85
90
|
const used = new Set(), first = new Map(), seen = new Set();
|
|
86
91
|
if (!records.size) return {used, first};
|
|
87
92
|
const slides = Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
|
|
@@ -97,7 +102,7 @@ export function attachNativePlaceholders(entries, records, bands = new Map()) {
|
|
|
97
102
|
if (!/<p:cNvSpPr\b[^>]*\/>/.test(shape)) throw new Error('Generated furniture shape has no plain shape properties.');
|
|
98
103
|
shape = shape.replace(/<p:cNvSpPr\b([^>]*)\/>/, '<p:cNvSpPr$1><a:spLocks noGrp="1"/></p:cNvSpPr>').replace(/<p:nvPr\s*\/>/, '<p:nvPr></p:nvPr>');
|
|
99
104
|
if (!shape.includes('<p:nvPr>')) throw new Error('Generated furniture shape has no native application properties.');
|
|
100
|
-
shape = shape.replace('<p:nvPr>', `<p:nvPr><p:ph type="${ph}" sz="${properties.sz}" idx="${properties.idx}"/>`);
|
|
105
|
+
if (ph !== 'ftr' || footerText !== 'shape') shape = shape.replace('<p:nvPr>', `<p:nvPr><p:ph type="${ph}" sz="${properties.sz}" idx="${properties.idx}"/>`);
|
|
101
106
|
used.add(ph);
|
|
102
107
|
if (!first.has(ph)) {
|
|
103
108
|
const geometry = shape.match(/<a:off x="(-?\d+)" y="(-?\d+)"\/><a:ext cx="(\d+)" cy="(\d+)"\/>/);
|
|
@@ -235,28 +240,58 @@ function xfrmBounds(xfrm) {
|
|
|
235
240
|
return [x, y, cx, cy].every(Number.isFinite) && cx > 0 ? {x, y, cx, cy} : undefined;
|
|
236
241
|
}
|
|
237
242
|
|
|
238
|
-
// A placeholder without its own xfrm inherits it: slide -> layout (same idx, else same
|
|
243
|
+
// A placeholder without its own xfrm (or alignment) inherits it: slide -> layout (same idx, else same
|
|
239
244
|
// type) -> master (same type). Parsed layout/master trees are cached per package read.
|
|
240
|
-
function
|
|
241
|
-
const
|
|
242
|
-
if (own) return own;
|
|
245
|
+
function placeholderChain(shape, ph, slidePath, relationships, readPart) {
|
|
246
|
+
const chain = [shape];
|
|
243
247
|
const index = shape['p:nvSpPr']?.['p:nvPr']?.['p:ph']?.idx;
|
|
244
248
|
const find = (path, byIdx) => {
|
|
245
249
|
const tree = readPart(path)?.[path.includes('slideMaster') ? 'p:sldMaster' : 'p:sldLayout']?.['p:cSld']?.['p:spTree'];
|
|
246
250
|
const candidates = array(tree?.['p:sp']).filter(candidate => candidate['p:nvSpPr']?.['p:nvPr']?.['p:ph']?.type === ph);
|
|
247
|
-
|
|
248
|
-
return match ? xfrmBounds(match['p:spPr']?.['a:xfrm']) : undefined;
|
|
251
|
+
return (byIdx && index !== undefined ? candidates.find(candidate => candidate['p:nvSpPr']['p:nvPr']['p:ph'].idx === index) : undefined) ?? candidates[0];
|
|
249
252
|
};
|
|
250
253
|
const layout = [...relationships(slidePath).values()].find(relationship => relationship.type === REL_LAYOUT)?.path;
|
|
251
|
-
if (!layout) return
|
|
254
|
+
if (!layout) return chain;
|
|
252
255
|
const fromLayout = find(layout, true);
|
|
253
|
-
if (fromLayout)
|
|
256
|
+
if (fromLayout) chain.push(fromLayout);
|
|
254
257
|
const master = [...relationships(layout).values()].find(relationship => relationship.type === REL_MASTER)?.path;
|
|
255
|
-
|
|
258
|
+
const fromMaster = master ? find(master, false) : undefined;
|
|
259
|
+
if (fromMaster) chain.push(fromMaster);
|
|
260
|
+
return chain;
|
|
256
261
|
}
|
|
257
262
|
|
|
258
|
-
function
|
|
263
|
+
function inheritedBounds(chain) {
|
|
264
|
+
for (const shape of chain) {
|
|
265
|
+
const bounds = xfrmBounds(shape['p:spPr']?.['a:xfrm']);
|
|
266
|
+
if (bounds) return bounds;
|
|
267
|
+
}
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const ALIGN_ZONES = {l: 'left', ctr: 'center', r: 'right'};
|
|
272
|
+
// The paragraph alignment of a footer shape: its first paragraph, else its list style, else what the layout and master
|
|
273
|
+
// placeholder say. Read as the physical edge, as the exporter writes it (an RTL zone is `algn="r"` with `rtl="1"`).
|
|
274
|
+
function inheritedAlignment(chain) {
|
|
275
|
+
for (const shape of chain) {
|
|
276
|
+
const body = shape['p:txBody'];
|
|
277
|
+
const paragraph = array(body?.['a:p']).find(item => item?.['a:pPr']?.algn !== undefined);
|
|
278
|
+
const algn = paragraph?.['a:pPr']?.algn ?? body?.['a:lstStyle']?.['a:lvl1pPr']?.algn;
|
|
279
|
+
if (ALIGN_ZONES[algn]) return ALIGN_ZONES[algn];
|
|
280
|
+
}
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A box wider than this share of the slide spans more than one zone, so its centre says nothing about the zone. */
|
|
285
|
+
const WIDE_ZONE_SHARE = .36;
|
|
286
|
+
// A narrow box keeps the zone its centre is in; a box wider than a zone (core gives a lone zone the content width) takes the
|
|
287
|
+
// zone its paragraphs align to, and keeps the centre when nothing says how they align.
|
|
288
|
+
function zoneOf(chain, slideWidth, fallback) {
|
|
289
|
+
const bounds = inheritedBounds(chain);
|
|
259
290
|
if (!bounds || !(slideWidth > 0)) return fallback;
|
|
291
|
+
if (bounds.cx / slideWidth > WIDE_ZONE_SHARE) {
|
|
292
|
+
const aligned = inheritedAlignment(chain);
|
|
293
|
+
if (aligned) return aligned;
|
|
294
|
+
}
|
|
260
295
|
const center = (bounds.x + bounds.cx / 2) / slideWidth;
|
|
261
296
|
return center < 1 / 3 ? 'left' : center > 2 / 3 ? 'right' : 'center';
|
|
262
297
|
}
|
|
@@ -282,7 +317,7 @@ export function readNativePlaceholders(context, claimed, relationships, readPart
|
|
|
282
317
|
for (const field of paragraph.fields ?? []) nativeFields.push({...field, start: base + (field.start ?? text.indexOf(field.text, base) - base)});
|
|
283
318
|
base += paragraph.text.length + 1;
|
|
284
319
|
}
|
|
285
|
-
const zone = zoneOf(
|
|
320
|
+
const zone = zoneOf(placeholderChain(shape, ph, context.path, relationships, readPart), context.slideWidth, NATIVE_PLACEHOLDERS[ph].zone);
|
|
286
321
|
const properties = NATIVE_PLACEHOLDERS[ph];
|
|
287
322
|
if (ph === 'sldNum') {
|
|
288
323
|
// The words around a native slide-number field are the zone text and the field is `{{slide.number}}`.
|
|
@@ -5,6 +5,12 @@ const boolean = value => ['1','true','on'].includes(value) ? true : ['0','false'
|
|
|
5
5
|
|
|
6
6
|
const themeFamilies = context => ['a:majorFont', 'a:minorFont'].map(key => context.fonts?.[key]?.['a:latin']?.typeface?.trim().toLowerCase()).filter(Boolean);
|
|
7
7
|
|
|
8
|
+
export function nativeTypeface(font, context) {
|
|
9
|
+
if (typeof font !== 'string') return undefined;
|
|
10
|
+
const match = font.match(/^\+(mj|mn)-(lt|ea|cs)$/);
|
|
11
|
+
return match ? context.fonts?.[match[1] === 'mj' ? 'a:majorFont' : 'a:minorFont']?.[{lt:'a:latin',ea:'a:ea',cs:'a:cs'}[match[2]]]?.typeface : font;
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
// A run painted with a bare theme slot (a:schemeClr, no color transform) remembers the slot as the OPF color name: the
|
|
9
15
|
// transient `_opfScheme` marker is read, and always removed, by restoreRunColors (run-colors.js) once the document's own
|
|
10
16
|
// colour scheme can confirm the slot holds that colour.
|
|
@@ -36,8 +42,7 @@ export function nativeRunStyle(properties, context, relationships, report, kind
|
|
|
36
42
|
const font = properties['a:latin']?.typeface;
|
|
37
43
|
if (properties['a:latin'] && !font) report(`unsupported-${kind}-font`, 'The native text font has no usable Latin face or theme reference.');
|
|
38
44
|
if (font) {
|
|
39
|
-
const
|
|
40
|
-
const family = match ? context.fonts?.[match[1] === 'mj' ? 'a:majorFont' : 'a:minorFont']?.[{lt:'a:latin',ea:'a:ea',cs:'a:cs'}[match[2]]]?.typeface : font;
|
|
45
|
+
const family = nativeTypeface(font, context);
|
|
41
46
|
if (family) {
|
|
42
47
|
result.fontFamily = family;
|
|
43
48
|
// The exporter writes a bundled family's weight faces (Roboto SemiBold) under their
|
package/dist/quote-provenance.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
2
|
import {isValidFormat} from './format-check.js';
|
|
3
|
-
import {attachTextTags,decodeTextTag} from './code-provenance.js';
|
|
3
|
+
import {attachTextTags,decodeTextTag,groupUnchanged} from './code-provenance.js';
|
|
4
4
|
import {sourceLineParagraphs} from './text-provenance.js';
|
|
5
5
|
import {cleanBase,headingValue,joinRichLines,unwrapQuoteRuns} from './rich-heading.js';
|
|
6
6
|
|
|
@@ -18,7 +18,7 @@ const FOOTERS=['["attribution"]','["role"]','["source"]','["attribution","role"]
|
|
|
18
18
|
const separatorBefore=field=>field==='role'?'\n':SEPARATOR;
|
|
19
19
|
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
20
20
|
const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
21
|
-
export const attachQuoteTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfQuote','quote',{pictures:true});
|
|
21
|
+
export const attachQuoteTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfQuote','quote',{pictures:true,signed:true});
|
|
22
22
|
export const quotePhotoName=group=>`OPF quote photo ${group}`;
|
|
23
23
|
|
|
24
24
|
// The manifest rides on the first body line. `footer` lists which fields the footer part joins (core writes the attribution, the role
|
|
@@ -127,7 +127,7 @@ export function importQuoteGroups(shapes,paragraphs,relationships,entries,report
|
|
|
127
127
|
for(const item of photos)consumedPictures.add(item.picture);
|
|
128
128
|
items.push({shapes:group.map(item=>item.shape),pictures:photos.map(item=>item.picture),payload:{type:'quote',quote}});
|
|
129
129
|
for(const diagnostic of local)report(diagnostic);
|
|
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.'});
|
|
130
|
+
if(!groupUnchanged(group,paragraphs))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.'});
|
|
131
131
|
}catch(error){report({code:'invalid-quote-provenance',message:`${error.message} Ordinary import retains current native text without restoring old source words.`});}
|
|
132
132
|
return {consumed,consumedPictures,items};
|
|
133
133
|
}
|
package/dist/svg-image.js
CHANGED
|
@@ -153,7 +153,9 @@ function sanitize(input, text) {
|
|
|
153
153
|
} else if (!skipping) {
|
|
154
154
|
if (chunk.includes(']]>')) malformed('a stray CDATA terminator');
|
|
155
155
|
const style = stack.at(-1).name === 'style';
|
|
156
|
-
|
|
156
|
+
const filtered = style ? filterCss(chunk) : chunk;
|
|
157
|
+
if (filtered !== chunk) removed.add('external references');
|
|
158
|
+
out.push(references(filtered));
|
|
157
159
|
}
|
|
158
160
|
continue;
|
|
159
161
|
}
|
|
@@ -167,7 +169,12 @@ function sanitize(input, text) {
|
|
|
167
169
|
const end = text.indexOf(']]>', index + 9);
|
|
168
170
|
if (end < 0) malformed('an unterminated CDATA section');
|
|
169
171
|
if (!stack.length) malformed('CDATA outside the root element');
|
|
170
|
-
if (!skipping)
|
|
172
|
+
if (!skipping) {
|
|
173
|
+
const chunk = text.slice(index + 9, end);
|
|
174
|
+
const filtered = stack.at(-1).name === 'style' ? filterCss(chunk) : chunk;
|
|
175
|
+
if (filtered !== chunk) removed.add('external references');
|
|
176
|
+
out.push(`<![CDATA[${filtered}]]>`);
|
|
177
|
+
}
|
|
171
178
|
index = end + 3;
|
|
172
179
|
continue;
|
|
173
180
|
}
|