@openpresentation/opf-pptx 0.9.1 → 0.10.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/DEPENDENCY-NOTES.md +1 -1
- package/README.md +108 -17
- package/dist/background-import.js +59 -3
- package/dist/background.js +86 -1
- package/dist/body-text-import.js +86 -0
- package/dist/code-provenance.js +9 -3
- package/dist/document-provenance.js +998 -0
- package/dist/furniture-fields.js +176 -0
- package/dist/furniture-provenance.js +138 -18
- package/dist/image-geometry.js +77 -5
- package/dist/index.d.ts +112 -2
- package/dist/index.js +869 -106
- package/dist/media-provenance.js +237 -0
- package/dist/native-text-style.js +57 -0
- package/dist/package-fonts.js +161 -0
- package/dist/script-fonts.js +290 -0
- package/dist/slide-image-provenance.js +169 -0
- package/dist/table-import.js +1 -53
- package/dist/theme-colors.js +204 -0
- package/dist/typeface-inventory.js +227 -0
- package/package.json +14 -7
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Native PowerPoint fields inside OPF furniture shapes. Core supplies the
|
|
2
|
+
// displayed text plus half-open UTF-16 ranges for live values; this module turns
|
|
3
|
+
// those ranges into <a:fld> runs and maps them back on import. It never reads a
|
|
4
|
+
// clock: the cached field text is whatever core laid out.
|
|
5
|
+
|
|
6
|
+
const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
|
|
7
|
+
|
|
8
|
+
// PowerPoint's en-US date field types (ECMA-376 ST_TextField "datetime1".."datetime7").
|
|
9
|
+
// Exported runs carry lang="en-US", so these patterns are what PowerPoint displays.
|
|
10
|
+
export const NATIVE_DATE_FIELDS = Object.freeze({
|
|
11
|
+
'M/d/yyyy': 'datetime1',
|
|
12
|
+
'EEEE, MMMM d, yyyy': 'datetime2',
|
|
13
|
+
'd MMMM yyyy': 'datetime3',
|
|
14
|
+
'MMMM d, yyyy': 'datetime4',
|
|
15
|
+
'd-MMM-yy': 'datetime5',
|
|
16
|
+
'MMMM yy': 'datetime6',
|
|
17
|
+
'MMM-yy': 'datetime7',
|
|
18
|
+
});
|
|
19
|
+
export const DEFAULT_DATE_FORMAT = 'M/d/yyyy';
|
|
20
|
+
export const DEFAULT_SLIDE_NUMBER_FORMAT = '{current}';
|
|
21
|
+
|
|
22
|
+
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
|
23
|
+
const weekdays = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
|
|
24
|
+
const widths = {y: [1, 2, 4], M: [1, 2, 3, 4], d: [1, 2], E: [1, 2, 3, 4]};
|
|
25
|
+
const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
const escapeXml = value => value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
27
|
+
const unescapeXml = value => value.replace(/&(lt|gt|quot|apos|amp|#x[0-9A-Fa-f]+|#\d+);/g, (match, entity) =>
|
|
28
|
+
entity === 'lt' ? '<' : entity === 'gt' ? '>' : entity === 'quot' ? '"' : entity === 'apos' ? "'" : entity === 'amp' ? '&' :
|
|
29
|
+
String.fromCodePoint(entity[1] === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1))));
|
|
30
|
+
|
|
31
|
+
/** Tokenize the same LDML-style subset core formats; null when unsupported. */
|
|
32
|
+
function tokens(pattern) {
|
|
33
|
+
if (typeof pattern !== 'string' || !pattern) return null;
|
|
34
|
+
const out = [];
|
|
35
|
+
for (let index = 0; index < pattern.length;) {
|
|
36
|
+
const character = pattern[index];
|
|
37
|
+
if (character === "'") {
|
|
38
|
+
if (pattern[index + 1] === "'") { out.push({text: "'"}); index += 2; continue; }
|
|
39
|
+
let end = index + 1, text = '';
|
|
40
|
+
for (;;) {
|
|
41
|
+
if (end >= pattern.length) return null;
|
|
42
|
+
if (pattern[end] === "'") { if (pattern[end + 1] === "'") { text += "'"; end += 2; continue; } break; }
|
|
43
|
+
text += pattern[end++];
|
|
44
|
+
}
|
|
45
|
+
out.push({text}); index = end + 1; continue;
|
|
46
|
+
}
|
|
47
|
+
if (/[A-Za-z]/.test(character)) {
|
|
48
|
+
let end = index;
|
|
49
|
+
while (pattern[end] === character) end++;
|
|
50
|
+
if (!widths[character]?.includes(end - index)) return null;
|
|
51
|
+
out.push({letter: character, width: end - index}); index = end; continue;
|
|
52
|
+
}
|
|
53
|
+
out.push({text: character}); index++;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isoParts(value) {
|
|
59
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
|
|
60
|
+
if (!match) return null;
|
|
61
|
+
const [year, month, day] = match.slice(1).map(Number), date = new Date(Date.UTC(2000, month - 1, day));
|
|
62
|
+
date.setUTCFullYear(year);
|
|
63
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? {year, month, day, weekday: date.getUTCDay()} : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatDate(iso, pattern) {
|
|
67
|
+
const date = isoParts(iso), list = tokens(pattern);
|
|
68
|
+
if (!date || !list) return null;
|
|
69
|
+
const pad = (value, width) => String(value).padStart(width, '0');
|
|
70
|
+
return list.map(token => token.text ?? (
|
|
71
|
+
token.letter === 'y' ? (token.width === 2 ? pad(date.year % 100, 2) : token.width === 4 ? pad(date.year, 4) : String(date.year)) :
|
|
72
|
+
token.letter === 'M' ? (token.width >= 3 ? months[date.month - 1].slice(0, token.width === 4 ? undefined : 3) : pad(date.month, token.width)) :
|
|
73
|
+
token.letter === 'd' ? pad(date.day, token.width) :
|
|
74
|
+
weekdays[date.weekday].slice(0, token.width === 4 ? undefined : 3))).join('');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Recover the ISO date that `pattern` formats as exactly `text`. Returns null when
|
|
79
|
+
* the pattern omits the day, month or a four-digit year, or the text no longer
|
|
80
|
+
* matches; callers then keep the current text as a literal date.
|
|
81
|
+
*/
|
|
82
|
+
export function parseDate(text, pattern) {
|
|
83
|
+
const list = tokens(pattern);
|
|
84
|
+
if (!list || typeof text !== 'string') return null;
|
|
85
|
+
const letters = new Set(list.filter(token => token.letter).map(token => `${token.letter}${token.letter === 'y' ? token.width : ''}`));
|
|
86
|
+
if (!letters.has('M') || !letters.has('d') || !(letters.has('y4') || letters.has('y1'))) return null;
|
|
87
|
+
const groups = [];
|
|
88
|
+
const source = list.map(token => {
|
|
89
|
+
if (token.text !== undefined) return escapeRegExp(token.text);
|
|
90
|
+
groups.push(token);
|
|
91
|
+
if (token.letter === 'M' && token.width >= 3) return `(${months.map(name => token.width === 4 ? name : name.slice(0, 3)).join('|')})`;
|
|
92
|
+
if (token.letter === 'E') return `(${weekdays.map(name => token.width === 4 ? name : name.slice(0, 3)).join('|')})`;
|
|
93
|
+
return token.width === 1 ? '(\\d+)' : `(\\d{${token.width}})`;
|
|
94
|
+
}).join('');
|
|
95
|
+
const match = new RegExp(`^${source}$`).exec(text);
|
|
96
|
+
if (!match) return null;
|
|
97
|
+
let year, month, day;
|
|
98
|
+
for (const [index, token] of groups.entries()) {
|
|
99
|
+
const value = match[index + 1];
|
|
100
|
+
if (token.letter === 'y' && token.width !== 2) year = Number(value);
|
|
101
|
+
else if (token.letter === 'M') month = token.width >= 3 ? months.findIndex(name => (token.width === 4 ? name : name.slice(0, 3)) === value) + 1 : Number(value);
|
|
102
|
+
else if (token.letter === 'd') day = Number(value);
|
|
103
|
+
}
|
|
104
|
+
if (!(year >= 0 && year <= 9999)) return null;
|
|
105
|
+
const iso = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
106
|
+
// Reformatting must reproduce the current text exactly, including the weekday.
|
|
107
|
+
return formatDate(iso, pattern) === text ? iso : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatSlideNumber(format, current, total) {
|
|
111
|
+
if (typeof format !== 'string' || !format.includes('{current}') || (format.includes('{total}') && !Number.isSafeInteger(total))) return null;
|
|
112
|
+
return format.split(/(\{current\}|\{total\})/).map(piece => piece === '{current}' ? String(current) : piece === '{total}' ? String(total) : piece).join('');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Live field ranges for one accepted furniture text part. Core >= FF-27 supplies
|
|
117
|
+
* `part.fields`; earlier cores only supply a bare generated number, which is
|
|
118
|
+
* still a whole slide-number field.
|
|
119
|
+
*/
|
|
120
|
+
export function furniturePartFields(part) {
|
|
121
|
+
if (Array.isArray(part.fields)) return part.fields;
|
|
122
|
+
return part.field === 'slideNumber' && /^\d+$/.test(part.text) ? [{type: 'slideNumber', start: 0, end: part.text.length}] : [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Native <a:fld> type for a core field, or null when the value must stay fixed text. */
|
|
126
|
+
export function nativeFieldType(field) {
|
|
127
|
+
if (field.type === 'slideNumber') return 'slidenum';
|
|
128
|
+
if (field.type === 'date') return NATIVE_DATE_FIELDS[field.format ?? DEFAULT_DATE_FORMAT] ?? null;
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Split fitted lines into per-line field ranges; a field broken across lines stays fixed text. */
|
|
133
|
+
export function lineFields(text, fields, sourceLines, lines) {
|
|
134
|
+
return lines.map((line, index) => {
|
|
135
|
+
const source = sourceLines?.[index];
|
|
136
|
+
if (!source) return [];
|
|
137
|
+
return fields.filter(field => field.start >= source.start && field.end <= source.end && field.end > field.start)
|
|
138
|
+
.map(field => ({...field, start: field.start - source.start, end: field.end - source.start}))
|
|
139
|
+
.filter(field => line.slice(field.start, field.end) === text.slice(field.start + source.start, field.end + source.start));
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Rewrite the single generated run of each recorded furniture shape into runs and
|
|
145
|
+
* <a:fld> elements. Field ids are deterministic so exports stay byte-stable.
|
|
146
|
+
*/
|
|
147
|
+
export function attachFurnitureFields(entries, records) {
|
|
148
|
+
if (!records.size) return;
|
|
149
|
+
let count = 0;
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
for (const path of 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]))) {
|
|
152
|
+
const xml = dec.decode(entries[path]).replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape => {
|
|
153
|
+
const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
154
|
+
const record = records.get(name);
|
|
155
|
+
if (!record) return shape;
|
|
156
|
+
if (seen.has(name)) throw new Error('Duplicate generated furniture field shape.');
|
|
157
|
+
seen.add(name);
|
|
158
|
+
const runs = [...shape.matchAll(/<a:r>(<a:rPr\b[^>]*\/>|<a:rPr\b[^>]*>[\s\S]*?<\/a:rPr>)<a:t>([^<]*)<\/a:t><\/a:r>/g)];
|
|
159
|
+
if (runs.length !== 1 || unescapeXml(runs[0][2]) !== record.text) throw new Error('Generated furniture field text does not match its accepted line.');
|
|
160
|
+
const [run, properties] = runs[0];
|
|
161
|
+
let cursor = 0, body = '';
|
|
162
|
+
const literal = text => { if (text) body += `<a:r>${properties}<a:t>${escapeXml(text)}</a:t></a:r>`; };
|
|
163
|
+
for (const field of [...record.fields].sort((a, b) => a.start - b.start)) {
|
|
164
|
+
if (field.start < cursor) throw new Error('Overlapping furniture fields.');
|
|
165
|
+
literal(record.text.slice(cursor, field.start));
|
|
166
|
+
const id = `{0F0F2700-0000-4000-8000-${(++count).toString(16).toUpperCase().padStart(12, '0')}}`;
|
|
167
|
+
body += `<a:fld id="${id}" type="${field.nativeType}">${properties}<a:t>${escapeXml(record.text.slice(field.start, field.end))}</a:t></a:fld>`;
|
|
168
|
+
cursor = field.end;
|
|
169
|
+
}
|
|
170
|
+
literal(record.text.slice(cursor));
|
|
171
|
+
return shape.replace(run, body);
|
|
172
|
+
});
|
|
173
|
+
entries[path] = enc.encode(xml);
|
|
174
|
+
}
|
|
175
|
+
if (seen.size !== records.size) throw new Error('Missing generated furniture field shapes.');
|
|
176
|
+
}
|
|
@@ -1,24 +1,79 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
2
|
import {attachTextTags, decodeTextTag, encodeTextTag} from './code-provenance.js';
|
|
3
3
|
import {sourceLineParagraphs} from './text-provenance.js';
|
|
4
|
+
import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatSlideNumber, parseDate} from './furniture-fields.js';
|
|
5
|
+
import {schemas} from '@openpresentation/opf';
|
|
4
6
|
|
|
5
7
|
const TAG = 'OPF_FURNITURE_V1';
|
|
8
|
+
// A slide has one tag list; it also carries the document's OPF_SLIDE_V1 record.
|
|
9
|
+
const SLIDE_TAG = 'OPF_SLIDE_V1';
|
|
6
10
|
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
7
11
|
const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
|
|
8
12
|
const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
|
|
9
13
|
const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
|
|
10
14
|
const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
11
15
|
const kinds = ['header', 'footer'], zones = ['left', 'center', 'right'];
|
|
12
|
-
const fields = ['text', 'image', 'organization', 'section', 'slideNumber', 'date'];
|
|
16
|
+
const fields = ['text', 'image', 'organization', 'socials', 'section', 'slideNumber', 'date'];
|
|
17
|
+
const settings = ['slideNumberFormat', 'dateFormat'];
|
|
18
|
+
const dateFormatForField = Object.fromEntries(Object.entries(NATIVE_DATE_FIELDS).map(([format, type]) => [type, format]));
|
|
19
|
+
// Platform keys follow the Socials schema's propertyNames pattern exactly, so
|
|
20
|
+
// every key a valid document can carry re-imports (and nothing else does).
|
|
21
|
+
const platformId = new RegExp(schemas.presentation.$defs.Socials.propertyNames.pattern, 'u'), schemes = ['', 'https://'];
|
|
22
|
+
// Socials lines show a profile URL without the https:// scheme; the manifest
|
|
23
|
+
// keeps only each line's platform id and the stripped scheme, never its words.
|
|
24
|
+
const socialLines = part => part.links.map(link => ({platform: link.platform,
|
|
25
|
+
scheme: link.href && !/^[a-z][a-z0-9+.-]*:/i.test(link.text) ? 'https://' : ''}));
|
|
13
26
|
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
14
27
|
const key = part => `${part.kind}.${part.zone}.${part.field}`;
|
|
15
28
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
16
29
|
const check = (condition, message) => { if (!condition) throw Error(message); };
|
|
17
30
|
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
// Change detection, not authentication: editable tags store no cached date words.
|
|
32
|
+
function dateLineFingerprint(text) {
|
|
33
|
+
let a = 0xdeadbeef, b = 0x41c6ce57;
|
|
34
|
+
for (let index = 0; index < text.length; index++) {
|
|
35
|
+
a = Math.imul(a ^ text.charCodeAt(index), 2654435761);
|
|
36
|
+
b = Math.imul(b ^ text.charCodeAt(index), 1597334677);
|
|
37
|
+
}
|
|
38
|
+
return `${text.length}:${(a >>> 0).toString(16).padStart(8, '0')}${(b >>> 0).toString(16).padStart(8, '0')}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Only the exporter may mark its own supported, whole-date field flattened by
|
|
42
|
+
// an accepted soft wrap. Literal dates and unsupported patterns stay ordinary.
|
|
43
|
+
export function staticDateFallback(part, field) {
|
|
44
|
+
const format = field.format ?? DEFAULT_DATE_FORMAT;
|
|
45
|
+
if (part.field !== 'date' || field.type !== 'date' || !NATIVE_DATE_FIELDS[format] ||
|
|
46
|
+
field.start !== 0 || field.end !== part.text.length || part.fit.lines.length < 2) return undefined;
|
|
47
|
+
const records = part.fit.sourceLines.map((line, index) => ({index, data: {
|
|
48
|
+
boundary: line.boundary, separator: part.text.slice(line.end, line.nextStart),
|
|
49
|
+
}}));
|
|
50
|
+
if (records.some((record, index) => record.data.boundary !== (index === records.length - 1 ? 'end' : 'soft'))) return undefined;
|
|
51
|
+
try {
|
|
52
|
+
if (sourceLineParagraphs(records, part.fit.lines.map(text => [{text}]))[0].text !== part.text) return undefined;
|
|
53
|
+
} catch { return undefined; }
|
|
54
|
+
return {v: 1, reason: 'wrapped-native-date', format, fingerprints: part.fit.lines.map(dateLineFingerprint)};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function unchangedStaticDate(marker, format, ordered, paragraphs) {
|
|
58
|
+
check(object(marker) && Object.keys(marker).sort().join(',') === 'fingerprints,format,reason,v' &&
|
|
59
|
+
marker.v === 1 && marker.reason === 'wrapped-native-date' && typeof marker.format === 'string' &&
|
|
60
|
+
Object.hasOwn(NATIVE_DATE_FIELDS, marker.format) && marker.format === (format.dateFormat ?? DEFAULT_DATE_FORMAT) &&
|
|
61
|
+
ordered.length > 1 && Array.isArray(marker.fingerprints) && marker.fingerprints.length === ordered.length,
|
|
62
|
+
'Invalid static date fallback evidence.');
|
|
63
|
+
check(ordered.every((record, index) => {
|
|
64
|
+
const current = paragraphs[record.index] ?? [];
|
|
65
|
+
return record.data.boundary === (index === ordered.length - 1 ? 'end' : 'soft') && record.data.separator === '' &&
|
|
66
|
+
typeof marker.fingerprints[index] === 'string' && /^\d+:[0-9a-f]{16}$/.test(marker.fingerprints[index]) &&
|
|
67
|
+
current.length === 1 && !current[0].bullet && dateLineFingerprint(current[0].text) === marker.fingerprints[index];
|
|
68
|
+
}), 'Current static date text or boundaries differ from the exported lines.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// This manifest contains topology, identities, inactive flags and format
|
|
72
|
+
// settings only. Current native shapes supply all words, image bytes and alt
|
|
73
|
+
// text, even after edits; a recorded format is kept only while the current text
|
|
74
|
+
// still matches it exactly.
|
|
75
|
+
export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map()) {
|
|
76
|
+
const definitions = {}, formats = {};
|
|
22
77
|
for (const kind of kinds) {
|
|
23
78
|
const local = slide.design?.[kind] !== undefined;
|
|
24
79
|
const source = local ? slide.design[kind] : presentation.design?.[kind];
|
|
@@ -30,16 +85,20 @@ export function furnitureManifest(presentation, slide, layout, slideIndex) {
|
|
|
30
85
|
const current = source[zone][field];
|
|
31
86
|
value[zone][field] = field === 'image' ? 'native' : typeof current === 'string' ? 'literal' : current;
|
|
32
87
|
}
|
|
88
|
+
const format = Object.fromEntries(settings.filter(setting => typeof source[zone][setting] === 'string').map(setting => [setting, source[zone][setting]]));
|
|
89
|
+
if (Object.keys(format).length) formats[`${kind}.${zone}`] = format;
|
|
33
90
|
}
|
|
34
91
|
definitions[kind] = {scope: local ? 'local' : 'global', value};
|
|
35
92
|
}
|
|
36
93
|
if (!Object.keys(definitions).length) return null;
|
|
37
94
|
const organizations = array(presentation.organization);
|
|
38
95
|
const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
|
|
39
|
-
return {v: 1, role: 'slide', group: String(slideIndex), definitions,
|
|
40
|
-
...(layout.parts.some(part => part.field === 'organization') ? {organizationId: organization?.id} : {}),
|
|
41
|
-
parts: layout.parts.map(part => ({kind: part.kind, zone: part.zone, field: part.field,
|
|
42
|
-
type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length
|
|
96
|
+
return {v: 1, role: 'slide', group: String(slideIndex), definitions, ...(Object.keys(formats).length ? {formats} : {}),
|
|
97
|
+
...(layout.parts.some(part => part.field === 'organization' || part.field === 'socials') ? {organizationId: organization?.id} : {}),
|
|
98
|
+
parts: layout.parts.map((part, index) => ({kind: part.kind, zone: part.zone, field: part.field,
|
|
99
|
+
type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
|
|
100
|
+
...(part.field === 'socials' ? {socials: socialLines(part)} : {}),
|
|
101
|
+
...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {})}))};
|
|
43
102
|
}
|
|
44
103
|
|
|
45
104
|
export function attachFurnitureTags(entries, records, manifests) {
|
|
@@ -76,7 +135,7 @@ function readTags(container, relationships, entries) {
|
|
|
76
135
|
} catch { unreadable = true; }
|
|
77
136
|
}
|
|
78
137
|
return {tags: tags.filter(tag => tag.name?.toUpperCase() === TAG),
|
|
79
|
-
ambiguous: unreadable || tags.filter(tag => /^OPF_/i.test(tag.name)).length !== 1};
|
|
138
|
+
ambiguous: unreadable || tags.filter(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== SLIDE_TAG).length !== 1};
|
|
80
139
|
}
|
|
81
140
|
|
|
82
141
|
function validateManifest(manifest) {
|
|
@@ -94,21 +153,33 @@ function validateManifest(manifest) {
|
|
|
94
153
|
check(object(content) && Object.keys(content).every(field => fields.includes(field)), 'Invalid furniture fields.');
|
|
95
154
|
for (const [field, flag] of Object.entries(content)) {
|
|
96
155
|
check(field === 'image' ? flag === 'native' : field === 'text' ? flag === 'literal' :
|
|
97
|
-
field === 'date' ? flag ===
|
|
156
|
+
field === 'date' ? typeof flag === 'boolean' || flag === 'literal' : typeof flag === 'boolean', 'Invalid furniture field intent.');
|
|
98
157
|
if (flag !== false) expected.set(`${kind}.${zone}.${field}`, field === 'image' ? 'image' : 'text');
|
|
99
158
|
}
|
|
100
159
|
}
|
|
101
160
|
}
|
|
161
|
+
if (manifest.formats !== undefined) {
|
|
162
|
+
check(object(manifest.formats), 'Invalid furniture formats.');
|
|
163
|
+
for (const [slot, format] of Object.entries(manifest.formats)) {
|
|
164
|
+
const [kind, zone, extra] = slot.split('.');
|
|
165
|
+
check(extra === undefined && object(manifest.definitions[kind]?.value?.[zone]) && object(format) && Object.keys(format).length > 0 &&
|
|
166
|
+
Object.entries(format).every(([setting, value]) => settings.includes(setting) && typeof value === 'string'), 'Invalid furniture format.');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
102
169
|
check(manifest.parts.length === expected.size, 'Incomplete furniture topology.');
|
|
103
170
|
for (const part of manifest.parts) {
|
|
104
171
|
check(object(part) && expected.get(key(part)) === part.type, 'Ambiguous furniture part.');
|
|
105
172
|
check(Number.isSafeInteger(part.count) && part.count >= 1 && part.count <= 10000 && (part.type !== 'image' || part.count === 1), 'Invalid furniture line count.');
|
|
173
|
+
if (part.field === 'socials') check(Array.isArray(part.socials) && part.socials.length >= 1 && part.socials.length <= 100
|
|
174
|
+
&& part.socials.every(line => object(line) && platformId.test(line.platform) && schemes.includes(line.scheme))
|
|
175
|
+
&& new Set(part.socials.map(line => line.platform)).size === part.socials.length, 'Invalid social profile lines.');
|
|
176
|
+
else check(part.socials === undefined, 'Unexpected social profile lines.');
|
|
106
177
|
expected.delete(key(part));
|
|
107
178
|
}
|
|
108
|
-
if (manifest.parts.some(part => part.field === 'organization')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
|
|
179
|
+
if (manifest.parts.some(part => part.field === 'organization' || part.field === 'socials')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
|
|
109
180
|
}
|
|
110
181
|
|
|
111
|
-
function readSlide(context, entries, slideIndex, report, taggedText) {
|
|
182
|
+
function readSlide(context, entries, slideIndex, slideCount, report, taggedText) {
|
|
112
183
|
const {root, shapes, paragraphs, pictures, relationships, readPicture} = context;
|
|
113
184
|
const candidates = {};
|
|
114
185
|
const records = [];
|
|
@@ -143,7 +214,7 @@ function readSlide(context, entries, slideIndex, report, taggedText) {
|
|
|
143
214
|
if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
|
|
144
215
|
value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
|
|
145
216
|
}
|
|
146
|
-
const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], sections: []};
|
|
217
|
+
const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], socials: [], sections: []};
|
|
147
218
|
for (const [partIndex, part] of manifest.parts.entries()) {
|
|
148
219
|
if (part.kind !== kind) continue;
|
|
149
220
|
const group = records.filter(record => record.data.part === partIndex);
|
|
@@ -161,10 +232,21 @@ function readSlide(context, entries, slideIndex, report, taggedText) {
|
|
|
161
232
|
ordered[line] = record;
|
|
162
233
|
}
|
|
163
234
|
const text = sourceLineParagraphs(ordered, paragraphs)[0].text;
|
|
164
|
-
|
|
235
|
+
const format = manifest.formats?.[`${kind}.${part.zone}`] ?? {};
|
|
236
|
+
if (part.field === 'slideNumber') {
|
|
237
|
+
// The live field renumbers in PowerPoint; any fixed text around it
|
|
238
|
+
// must still match the recorded format at this slide's position.
|
|
239
|
+
check(text === formatSlideNumber(format.slideNumberFormat ?? '{current}', slideIndex + 1, slideCount), 'Current slide number differs from its position; retain the visible number as ordinary text.');
|
|
240
|
+
if (format.slideNumberFormat !== undefined) value[part.zone].slideNumberFormat = format.slideNumberFormat;
|
|
241
|
+
}
|
|
165
242
|
if (part.field === 'section') candidate.sections.push(text);
|
|
166
243
|
if (part.field === 'organization') candidate.organizations.push({id: manifest.organizationId, name: text});
|
|
167
|
-
|
|
244
|
+
if (part.field === 'socials') {
|
|
245
|
+
const lines = text.split('\n');
|
|
246
|
+
check(lines.length === part.socials.length && lines.every(line => line.trim()), 'Current social profile lines no longer match their platforms.');
|
|
247
|
+
candidate.socials.push({id: manifest.organizationId, socials: Object.fromEntries(part.socials.map((line, index) => [line.platform, line.scheme + lines[index]]))});
|
|
248
|
+
}
|
|
249
|
+
value[part.zone][part.field] = part.field === 'text' ? text : part.field === 'date' ? importedDate(definition.value[part.zone].date, format, text, ordered.flatMap(record => (paragraphs[record.index] ?? []).flatMap(paragraph => paragraph.fields ?? [])), value[part.zone], part.staticDate, ordered, paragraphs, message => report(slideIndex, `${kind}.${part.zone}.date: ${message}`)) : true;
|
|
168
250
|
candidate.text.push(...ordered.map(record => record.index));
|
|
169
251
|
}
|
|
170
252
|
}
|
|
@@ -175,15 +257,40 @@ function readSlide(context, entries, slideIndex, report, taggedText) {
|
|
|
175
257
|
return candidates;
|
|
176
258
|
}
|
|
177
259
|
|
|
260
|
+
// A current native date field wins. Full-mode static-wrap evidence can recover
|
|
261
|
+
// generated OPF intent without turning the existing PPTX text into a live field. A fixed
|
|
262
|
+
// date keeps its ISO value and pattern only when the current text round-trips.
|
|
263
|
+
// Otherwise the current words import as a literal date.
|
|
264
|
+
function importedDate(flag, format, text, nativeFields, zone, marker, ordered, paragraphs, report) {
|
|
265
|
+
if (flag === true) {
|
|
266
|
+
const pattern = nativeFields.length === 1 && nativeFields[0].text === text ? dateFormatForField[nativeFields[0].type] : undefined;
|
|
267
|
+
if (pattern) {
|
|
268
|
+
if (format.dateFormat !== undefined || pattern !== DEFAULT_DATE_FORMAT) zone.dateFormat = pattern;
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
if (marker !== undefined) try {
|
|
272
|
+
check(nativeFields.length === 0, 'A current native field no longer matches the static date evidence.');
|
|
273
|
+
unchangedStaticDate(marker, format, ordered, paragraphs);
|
|
274
|
+
if (format.dateFormat !== undefined) zone.dateFormat = format.dateFormat;
|
|
275
|
+
return true;
|
|
276
|
+
} catch (error) { report(`${error.message} Keep the current date as literal text.`); }
|
|
277
|
+
return text;
|
|
278
|
+
}
|
|
279
|
+
const iso = format.dateFormat === undefined ? null : parseDate(text, format.dateFormat);
|
|
280
|
+
if (iso) zone.dateFormat = format.dateFormat;
|
|
281
|
+
return iso ?? text;
|
|
282
|
+
}
|
|
283
|
+
|
|
178
284
|
// Reconcile metadata before consuming any shapes. Global inheritance is promoted
|
|
179
285
|
// only when every slide has a valid definition/override and all inherited values
|
|
180
286
|
// agree. A missing tag must never cause another slide's furniture to reappear.
|
|
181
287
|
export function importFurniture(contexts, entries, onDiagnostic) {
|
|
182
288
|
const report = (index, message) => onDiagnostic?.({code: 'invalid-furniture-provenance', path: `slides.${index}.design`, message: `${message} Ordinary import retains current native content; no old source words are restored.`});
|
|
183
289
|
const taggedText = contexts.map(() => new Set());
|
|
184
|
-
const candidates = contexts.map((context, index) => readSlide(context, entries, index, report, taggedText[index]));
|
|
290
|
+
const candidates = contexts.map((context, index) => readSlide(context, entries, index, contexts.length, report, taggedText[index]));
|
|
185
291
|
const organizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
|
|
186
|
-
|
|
292
|
+
const organizationConflict = organizations.some(item => !same(item, organizations[0]));
|
|
293
|
+
if (organizationConflict) {
|
|
187
294
|
for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.organizations.length) {
|
|
188
295
|
delete slide[kind]; report(index, `${kind}: Current organization metadata disagrees across repeated fields.`);
|
|
189
296
|
}
|
|
@@ -194,6 +301,15 @@ export function importFurniture(contexts, entries, onDiagnostic) {
|
|
|
194
301
|
delete slide[kind]; report(index, `${kind}: Current section metadata disagrees on this slide.`);
|
|
195
302
|
}
|
|
196
303
|
}
|
|
304
|
+
// Social profiles belong to the organization named by repeated furniture; without
|
|
305
|
+
// that name (or with disagreeing values) the lines stay ordinary current text.
|
|
306
|
+
const owner = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations))[0];
|
|
307
|
+
const socials = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.socials));
|
|
308
|
+
if (socials.length && (!owner || socials.some(item => item.id !== owner.id || !same(item, socials[0])))) {
|
|
309
|
+
for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.socials.length) {
|
|
310
|
+
delete slide[kind]; report(index, `${kind}: ${owner ? 'Current social profile metadata disagrees across repeated fields.' : 'Social profiles need a repeated organization name to rebuild organization metadata.'}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
197
313
|
const result = {design: {}, slides: candidates.map((slide, index) => {
|
|
198
314
|
const values = Object.values(slide), sections = values.flatMap(candidate => candidate.sections);
|
|
199
315
|
return {design: Object.fromEntries(Object.entries(slide).map(([kind, candidate]) => [kind, candidate.value])),
|
|
@@ -202,6 +318,10 @@ export function importFurniture(contexts, entries, onDiagnostic) {
|
|
|
202
318
|
})};
|
|
203
319
|
const validOrganizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
|
|
204
320
|
if (validOrganizations.length) result.organization = validOrganizations[0];
|
|
321
|
+
const validSocials = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.socials));
|
|
322
|
+
if (result.organization && validSocials.length) result.organization = {...result.organization, socials: validSocials[0].socials};
|
|
323
|
+
// Stored document metadata (FF-32) must not override disagreeing visible names.
|
|
324
|
+
if (organizationConflict) result.organizationConflict = true;
|
|
205
325
|
for (const kind of kinds) {
|
|
206
326
|
const inherited = candidates.map(slide => slide[kind]).filter(candidate => candidate?.scope === 'global');
|
|
207
327
|
if (inherited.length && candidates.every(slide => slide[kind]) && inherited.every(candidate => same(candidate.value, inherited[0].value))) {
|
package/dist/image-geometry.js
CHANGED
|
@@ -12,7 +12,15 @@ export function rasterMetadata(bytes) {
|
|
|
12
12
|
const text = (at, n) => String.fromCharCode(...bytes.subarray(at, at + n));
|
|
13
13
|
const size = (width, height, mediaType) => Number.isInteger(width) && Number.isInteger(height) && width > 0 && height > 0 ? { width, height, mediaType } : null;
|
|
14
14
|
if (bytes.length >= 33 && text(0, 8) === '\x89PNG\r\n\x1a\n' && view.getUint32(8) === 13 && text(12, 4) === 'IHDR') {
|
|
15
|
-
|
|
15
|
+
const dimensions = size(view.getUint32(16), view.getUint32(20), "image/png");
|
|
16
|
+
// pHYs precedes IDAT. Only the metre unit defines a physical resolution.
|
|
17
|
+
for (let at = 33; dimensions && at + 12 <= bytes.length;) {
|
|
18
|
+
const length = view.getUint32(at), kind = text(at + 4, 4);
|
|
19
|
+
if (kind === 'IDAT' || kind === 'IEND' || at + 12 + length > bytes.length) break;
|
|
20
|
+
if (kind === 'pHYs' && length === 9 && bytes[at + 16] === 1) return { ...dimensions, ...resolution(view.getUint32(at + 8) * .0254, view.getUint32(at + 12) * .0254) };
|
|
21
|
+
at += 12 + length;
|
|
22
|
+
}
|
|
23
|
+
return dimensions;
|
|
16
24
|
}
|
|
17
25
|
if (bytes.length >= 13 && ['GIF87a', 'GIF89a'].includes(text(0, 6))) {
|
|
18
26
|
return size(view.getUint16(6, true), view.getUint16(8, true), "image/gif");
|
|
@@ -39,7 +47,10 @@ export function rasterMetadata(bytes) {
|
|
|
39
47
|
return null;
|
|
40
48
|
}
|
|
41
49
|
if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
42
|
-
let dimensions = null, orientation = null;
|
|
50
|
+
let dimensions = null, orientation = null, jfif = null, exif = null;
|
|
51
|
+
// JFIF density (in inches or centimetres) takes precedence over EXIF
|
|
52
|
+
// X/YResolution; a JFIF aspect-ratio-only density (unit 0) defines no DPI.
|
|
53
|
+
const result = () => dimensions ? { ...dimensions, ...(jfif ?? exif), ...orientation } : null;
|
|
43
54
|
// JPEG segment lengths include their two length bytes. Every iteration
|
|
44
55
|
// advances within the input, including fill bytes and standalone markers.
|
|
45
56
|
for (let at = 2; at < bytes.length;) {
|
|
@@ -47,7 +58,7 @@ export function rasterMetadata(bytes) {
|
|
|
47
58
|
while (at < bytes.length && bytes[at] === 0xff) at++;
|
|
48
59
|
if (at >= bytes.length) return null;
|
|
49
60
|
const marker = bytes[at++];
|
|
50
|
-
if (marker === 0xda || marker === 0xd9) return
|
|
61
|
+
if (marker === 0xda || marker === 0xd9) return result();
|
|
51
62
|
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
52
63
|
if (at + 2 > bytes.length) return null;
|
|
53
64
|
const length = view.getUint16(at);
|
|
@@ -55,14 +66,55 @@ export function rasterMetadata(bytes) {
|
|
|
55
66
|
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
|
|
56
67
|
dimensions = length >= 8 ? size(view.getUint16(at + 5), view.getUint16(at + 3), "image/jpeg") : null;
|
|
57
68
|
}
|
|
58
|
-
if (marker === 0xe1 && text(at + 2, 6) === 'Exif\x00\x00')
|
|
69
|
+
if (marker === 0xe1 && text(at + 2, 6) === 'Exif\x00\x00') {
|
|
70
|
+
orientation ??= exifOrientation(bytes, at + 8, at + length);
|
|
71
|
+
exif ??= exifResolution(bytes, at + 8, at + length);
|
|
72
|
+
}
|
|
73
|
+
if (marker === 0xe0 && length >= 14 && text(at + 2, 5) === 'JFIF\x00' && (bytes[at + 9] === 1 || bytes[at + 9] === 2)) {
|
|
74
|
+
const unit = bytes[at + 9] === 1 ? 1 : 2.54;
|
|
75
|
+
jfif ??= resolution(view.getUint16(at + 10) * unit, view.getUint16(at + 12) * unit);
|
|
76
|
+
}
|
|
59
77
|
at += length;
|
|
60
78
|
}
|
|
61
|
-
return
|
|
79
|
+
return result();
|
|
62
80
|
}
|
|
63
81
|
return null;
|
|
64
82
|
}
|
|
65
83
|
|
|
84
|
+
// Physical resolution in dots per inch, when both axes are positive.
|
|
85
|
+
function resolution(x, y) {
|
|
86
|
+
return Number.isFinite(x) && Number.isFinite(y) && x > 0 && y > 0 ? { dpiX: x, dpiY: y } : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// IFD0 XResolution/YResolution (RATIONAL) with ResolutionUnit 2 (inch, the
|
|
90
|
+
// default) or 3 (centimetre). Every value read stays within the APP1 segment.
|
|
91
|
+
function exifResolution(bytes, start, end) {
|
|
92
|
+
if (start + 8 > end) return null;
|
|
93
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
94
|
+
const littleEndian = bytes[start] === 0x49 && bytes[start + 1] === 0x49;
|
|
95
|
+
if (!littleEndian && !(bytes[start] === 0x4d && bytes[start + 1] === 0x4d)) return null;
|
|
96
|
+
if (view.getUint16(start + 2, littleEndian) !== 42) return null;
|
|
97
|
+
const directory = start + view.getUint32(start + 4, littleEndian);
|
|
98
|
+
if (directory < start + 8 || directory + 2 > end) return null;
|
|
99
|
+
const count = view.getUint16(directory, littleEndian);
|
|
100
|
+
if (directory + 2 + count * 12 > end) return null;
|
|
101
|
+
const values = {};
|
|
102
|
+
for (let i = 0; i < count; i++) {
|
|
103
|
+
const at = directory + 2 + i * 12, tag = view.getUint16(at, littleEndian), type = view.getUint16(at + 2, littleEndian);
|
|
104
|
+
if (view.getUint32(at + 4, littleEndian) !== 1) continue;
|
|
105
|
+
if ((tag === 0x11a || tag === 0x11b) && type === 5) {
|
|
106
|
+
const value = start + view.getUint32(at + 8, littleEndian);
|
|
107
|
+
if (value < start + 8 || value + 8 > end) continue;
|
|
108
|
+
const denominator = view.getUint32(value + 4, littleEndian);
|
|
109
|
+
if (denominator) values[tag] = view.getUint32(value, littleEndian) / denominator;
|
|
110
|
+
}
|
|
111
|
+
if (tag === 0x128 && type === 3) values.unit = view.getUint16(at + 8, littleEndian);
|
|
112
|
+
}
|
|
113
|
+
const unit = values.unit ?? 2;
|
|
114
|
+
if (unit !== 2 && unit !== 3) return null;
|
|
115
|
+
return resolution(values[0x11a] * (unit === 3 ? 2.54 : 1), values[0x11b] * (unit === 3 ? 2.54 : 1));
|
|
116
|
+
}
|
|
117
|
+
|
|
66
118
|
// Read only IFD0's inline SHORT orientation. All offsets and entry counts
|
|
67
119
|
// stay within the APP1 segment; no linked IFDs or external values are followed.
|
|
68
120
|
function exifOrientation(bytes, start, end) {
|
|
@@ -111,3 +163,23 @@ export function normalizeImageOrientation(bytes, metadata) {
|
|
|
111
163
|
new DataView(output.buffer, output.byteOffset, output.byteLength).setUint16(metadata.orientationOffset, 1, metadata.littleEndian);
|
|
112
164
|
return output;
|
|
113
165
|
}
|
|
166
|
+
|
|
167
|
+
// Keep the picture frame at the allocated box and express crop/fit through
|
|
168
|
+
// a:srcRect. Crop trims the centered overflow (positive insets); fit pads the
|
|
169
|
+
// centered image with negative insets. Frame-based masks, lines and effects
|
|
170
|
+
// therefore apply to the same box as the SVG preview's clipped <image>.
|
|
171
|
+
export function framePicture(image, box, mode) {
|
|
172
|
+
const scale = (mode === 'crop' ? Math.max : Math.min)(box.w / image.width, box.h / image.height);
|
|
173
|
+
const horizontal = Math.round((1 - box.w / (image.width * scale)) * 50000) || 0;
|
|
174
|
+
const vertical = Math.round((1 - box.h / (image.height * scale)) * 50000) || 0;
|
|
175
|
+
return { ...box, crop: horizontal || vertical ? { l: horizontal, t: vertical, r: horizontal, b: vertical } : null };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function framedPictureTransform(metadata, box, mode) {
|
|
179
|
+
const orientation = metadata.orientation ?? 1;
|
|
180
|
+
const target = orientation >= 5
|
|
181
|
+
? { x: box.x + (box.w - box.h) / 2, y: box.y + (box.h - box.w) / 2, w: box.h, h: box.w }
|
|
182
|
+
: box;
|
|
183
|
+
const rotation = [0, 0, 0, 180, 0, 90, 90, 90, 270][orientation];
|
|
184
|
+
return { ...framePicture(metadata, target, mode), rotation, flipH: orientation === 2 || orientation === 7, flipV: orientation === 4 || orientation === 5 };
|
|
185
|
+
}
|