@openpresentation/opf-pptx 0.20.4 → 0.22.1

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.
@@ -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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'}[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
+ }
@@ -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 inheritedBounds(shape, ph, slidePath, relationships, readPart) {
241
- const own = xfrmBounds(shape['p:spPr']?.['a:xfrm']);
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
- const match = (byIdx && index !== undefined ? candidates.find(candidate => candidate['p:nvSpPr']['p:nvPr']['p:ph'].idx === index) : undefined) ?? candidates[0];
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 undefined;
254
+ if (!layout) return chain;
252
255
  const fromLayout = find(layout, true);
253
- if (fromLayout) return fromLayout;
256
+ if (fromLayout) chain.push(fromLayout);
254
257
  const master = [...relationships(layout).values()].find(relationship => relationship.type === REL_MASTER)?.path;
255
- return master ? find(master, false) : undefined;
258
+ const fromMaster = master ? find(master, false) : undefined;
259
+ if (fromMaster) chain.push(fromMaster);
260
+ return chain;
256
261
  }
257
262
 
258
- function zoneOf(bounds, slideWidth, fallback) {
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(inheritedBounds(shape, ph, context.path, relationships, readPart), context.slideWidth, NATIVE_PLACEHOLDERS[ph].zone);
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 match = font.match(/^\+(mj|mn)-(lt|ea|cs)$/);
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
@@ -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
  }
@@ -78,18 +78,27 @@ export function placeholderType(region) {
78
78
  }
79
79
 
80
80
  /** Which slide objects a placeholder of each type takes (binding a `none` region's lone object). */
81
- const BINDS = {title: ['title'], ctrTitle: ['title'], pic: ['picture'], chart: ['chart'], tbl: ['table'], obj: ['picture', 'chart', 'table']};
81
+ const BINDS = {title: ['title'], ctrTitle: ['title'], body: ['body'], pic: ['picture'], chart: ['chart'], tbl: ['table'], obj: ['picture', 'chart', 'table']};
82
82
  export const bindsObject = (type, object) => (BINDS[type] ?? []).includes(object);
83
83
 
84
84
  /** The placeholder index of each region: its kind's base plus its position among the regions of that kind (REGION_IDX_BASE). */
85
- export function regionIndexes(record) {
85
+ export function regionIndexes(record, nativeBodyRegion) {
86
86
  const regions = new Map(layoutTemplate(record).regions.map(region => [region.name, region]));
87
87
  const counts = {};
88
- return Object.fromEntries(Object.keys(record?.regions ?? {}).filter(name => regions.has(name)).map(name => {
88
+ const indexes = Object.fromEntries(Object.keys(record?.regions ?? {}).filter(name => regions.has(name)).map(name => {
89
89
  const type = placeholderType(regions.get(name));
90
90
  counts[type] = (counts[type] ?? -1) + 1;
91
91
  return [name, REGION_IDX_BASE[type] + counts[type]];
92
92
  }));
93
+ // A native variant changes only its selected slot. Other object kinds retain
94
+ // their original indexes, so Change Layout cannot move a neighbouring picture.
95
+ if (nativeBodyRegion !== undefined && regions.has(nativeBodyRegion) && placeholderType(regions.get(nativeBodyRegion)) !== 'body') {
96
+ const used = new Set(Object.values(indexes));
97
+ let idx = REGION_IDX_BASE.body;
98
+ while (used.has(idx)) idx++;
99
+ indexes[nativeBodyRegion] = idx;
100
+ }
101
+ return indexes;
93
102
  }
94
103
 
95
104
  // FNV-1a (64-bit) of the record's canonical JSON: evidence of which record the layout was made from.
@@ -196,7 +205,7 @@ export function layoutPlaceholders(template) {
196
205
  const parsed = layoutTemplate(record);
197
206
  const {areas} = composeLayoutAreas(record, {...template.areaOptions, textMeasurement: undefined});
198
207
  const regions = new Map(parsed.regions.map(region => [region.name, region]));
199
- const indexes = regionIndexes(record);
208
+ const indexes = regionIndexes(record, template.nativeBodyRegion);
200
209
  const emu = box => ({x: Math.round(box.x * EMU), y: Math.round(box.y * EMU), cx: Math.max(0, Math.round(box.width * EMU)), cy: Math.max(0, Math.round(box.height * EMU))});
201
210
  const result = [];
202
211
  for (const area of areas) {
@@ -210,7 +219,8 @@ export function layoutPlaceholders(template) {
210
219
  }
211
220
  if (area.name === 'subtitle') { result.push({name: 'Subtitle', type: 'subTitle', idx: SUBTITLE_IDX, anchor: 't', ...emu(area.box)}); continue; }
212
221
  const region = regions.get(area.name);
213
- if (region) result.push({name: region.name, type: placeholderType(region), idx: indexes[region.name], ...emu(area.box)});
222
+ if (region) result.push({name: region.name, type: region.name === template.nativeBodyRegion ? 'body' : placeholderType(region), idx: indexes[region.name],
223
+ ...(region.name === template.nativeBodyRegion ? {anchor: {top: 't', middle: 'ctr', bottom: 'b'}[region.anchor]} : {}), ...emu(area.box)});
214
224
  }
215
225
  const sample = composeSlide({title: 'Title', subtitle: 'Subtitle', blocks: [{text: 'Body'}]}, {...template.areaOptions, layout: record, textMeasurement: undefined});
216
226
  for (const placeholder of result) {
@@ -244,7 +254,7 @@ function placeholderShape(placeholder, id) {
244
254
  */
245
255
  export function layoutTagValue(template, {record = false} = {}) {
246
256
  return {v: 1, id: template.id, group: template.group, ...(template.source !== undefined ? {source: template.source} : {}), reference: template.reference,
247
- hash: recordHash(template.record), regions: Object.fromEntries(Object.entries(regionIndexes(template.record)).map(([name, idx]) => [String(idx), name])),
257
+ hash: recordHash(template.record), regions: Object.fromEntries(Object.entries(regionIndexes(template.record, template.nativeBodyRegion)).map(([name, idx]) => [String(idx), name])),
248
258
  ...(record && template.origin === 'document' ? {record: template.record} : {})};
249
259
  }
250
260
 
@@ -297,6 +307,7 @@ export function writeTemplateLayouts(output, slides, templates, bindings = new M
297
307
  const key = `${master}\u0001${template.key}`;
298
308
  const path = `ppt/slideLayouts/slideLayout${nextLayout++}.xml`, tagPath = `ppt/tags/opfLayout${nextTag++}.xml`;
299
309
  let name = typeof template.record.name === 'string' && template.record.name.trim() ? template.record.name : template.id;
310
+ if (template.nativeBodyRegion) name += ' (native body)';
300
311
  if (taken.has(name)) name = `${name} (${template.group}:${template.id})`;
301
312
  taken.add(name);
302
313
  const type = POWERPOINT_LAYOUT_TYPES[template.id] ?? 'cust';
@@ -329,17 +340,22 @@ export function writeTemplateLayouts(output, slides, templates, bindings = new M
329
340
  }
330
341
  }
331
342
  // Automatic composition is its own titleOnly layout, independently of catalog region templates.
332
- for (const [master, base] of masters) {
333
- const first = automaticSlides.find((plan, index) => plan && related(related(slidePaths[index], 'slideLayout'), 'slideMaster') === master);
343
+ for (const [master, base] of masters) for (const native of [false, true]) {
344
+ const first = automaticSlides.find((plan, index) => plan && Boolean(plan.nativeBody) === native && related(related(slidePaths[index], 'slideLayout'), 'slideMaster') === master);
334
345
  if (!first) continue;
335
346
  const path = `ppt/slideLayouts/slideLayout${nextLayout++}.xml`, tagPath = `ppt/tags/opfLayout${nextTag++}.xml`;
336
347
  // Reuse accepted title geometry; an empty slide needs only the engine's default prompt, never a re-fit through the caller's measurement provider.
337
348
  const title = first.title ?? composeSlide({title: 'Title'}, {...first.areaOptions, layout: undefined, textMeasurement: undefined}).items.find(item => item.field === 'title');
338
349
  const placeholder = {name: 'Title', type: 'title', x: Math.round(title.box.x * EMU), y: Math.round(title.box.y * EMU), cx: Math.round(title.box.width * EMU), cy: Math.round(title.box.height * EMU), style: {...title.textStyle, fontSize: title.text.fontSize, alignment: title.alignment}};
339
- let xml = read(base).replace(/<p:sldLayout\b[^>]*>/, open => `${open.slice(0, -1).replace(/\s+(?:type|userDrawn|showMasterSp)="[^"]*"/g, '')} type="titleOnly" userDrawn="1">`);
340
- xml = xml.replace(/(<p:cSld\b[^>]*?)(\s+name="[^"]*")?(\s*>)/, (match, open, _name, close) => `${open} name="OPF auto"${close}`);
350
+ const body = native ? {name: 'Body', type: 'body', idx: REGION_IDX_BASE.body,
351
+ ...Object.fromEntries(['x', 'y'].map(key => [key, Math.round(first.nativeBody.box[key] * EMU)])),
352
+ cx: Math.round(first.nativeBody.box.width * EMU), cy: Math.round(first.nativeBody.box.height * EMU),
353
+ anchor: {top: 't', middle: 'ctr', bottom: 'b'}[first.nativeBody.anchor],
354
+ style: {...first.nativeBody.item.textStyle, fontSize: first.nativeBody.item.text.fontSize, alignment: first.nativeBody.item.alignment}} : undefined;
355
+ let xml = read(base).replace(/<p:sldLayout\b[^>]*>/, open => `${open.slice(0, -1).replace(/\s+(?:type|userDrawn|showMasterSp)="[^"]*"/g, '')} type="${native ? 'tx' : 'titleOnly'}" userDrawn="1">`);
356
+ xml = xml.replace(/(<p:cSld\b[^>]*?)(\s+name="[^"]*")?(\s*>)/, (match, open, _name, close) => `${open} name="${native ? 'OPF auto (native body)' : 'OPF auto'}"${close}`);
341
357
  const nextId = Math.max(1, ...[...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match => Number(match[1]))) + 1;
342
- xml = xml.replace('</p:grpSpPr>', `</p:grpSpPr>${placeholderShape(placeholder, nextId)}`);
358
+ xml = xml.replace('</p:grpSpPr>', `</p:grpSpPr>${placeholderShape(placeholder, nextId)}${body ? placeholderShape(body, nextId + 1) : ''}`);
343
359
  const masterRel = relationships(read(relsOf(base))).find(rel => rel.type === `${REL}/slideMaster`);
344
360
  const tagged = addRelationship(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${masterRel.node}</Relationships>`, `${REL}/tags`, relativeTarget(path, tagPath));
345
361
  xml = xml.replace('</p:spTree>', `</p:spTree><p:custDataLst><p:tags r:id="${tagged.rid}"/></p:custDataLst>`);
@@ -349,13 +365,13 @@ export function writeTemplateLayouts(output, slides, templates, bindings = new M
349
365
  const added = addRelationship(read(relsOf(master)), `${REL}/slideLayout`, relativeTarget(master, path));
350
366
  write(relsOf(master), added.relsXml);
351
367
  write(master, read(master).replace('</p:sldLayoutIdLst>', `<p:sldLayoutId id="${nextLayoutId++}" r:id="${added.rid}"/></p:sldLayoutIdLst>`));
352
- created.set(`${master}\u0001auto`, {path, placeholders: new Map([['Title', placeholder]])});
368
+ created.set(`${master}\u0001${native ? 'auto-native-body' : 'auto'}`, {path, placeholders: new Map([['Title', placeholder], ...(body ? [['Body', body]] : [])])});
353
369
  }
354
370
  for (const [index, plan] of slidePaths.map((_, index) => slides[index]).entries()) {
355
371
  if (!plan && !automaticSlides[index]) continue;
356
372
  const slidePath = slidePaths[index];
357
373
  const master = related(related(slidePath, 'slideLayout'), 'slideMaster');
358
- const layout = created.get(`${master}\u0001${plan ? plan.template.key : 'auto'}`);
374
+ const layout = created.get(`${master}\u0001${plan ? plan.template.key : automaticSlides[index].nativeBody ? 'auto-native-body' : 'auto'}`);
359
375
  if (!layout) throw new Error(`Slide ${index + 1} has no template layout.`);
360
376
  const slideRels = relsOf(slidePath);
361
377
  write(slideRels, read(slideRels).replace(/<Relationship\b[^>]*\/>/g, node => attribute(node, 'Type') === `${REL}/slideLayout` ? node.replace(/\sTarget="[^"]*"/, ` Target="${relativeTarget(slidePath, layout.path)}"`) : node));
@@ -366,7 +382,7 @@ export function writeTemplateLayouts(output, slides, templates, bindings = new M
366
382
  const placeholder = layout.placeholders.get(binding.region);
367
383
  if (!placeholder || !bindsObject(placeholder.type, binding.object)) continue;
368
384
  const ph = `<p:ph${placeholder.type === 'obj' ? '' : ` type="${placeholder.type}"`}${placeholder.idx !== undefined ? ` idx="${placeholder.idx}"` : ''}/>`;
369
- const element = binding.object === 'picture' ? 'pic' : binding.object === 'title' ? 'sp' : 'graphicFrame';
385
+ const element = binding.object === 'picture' ? 'pic' : ['title', 'body'].includes(binding.object) ? 'sp' : 'graphicFrame';
370
386
  const pattern = new RegExp(`<p:${element}>[\\s\\S]*?<\\/p:${element}>`, 'g');
371
387
  slideXml = slideXml.replace(pattern, shape => {
372
388
  if (unescapeAttribute(shape.match(/<p:cNvPr\b[^>]*\bname="([^"]*)"/)?.[1] ?? '') !== objectName || /<p:ph\b/.test(shape)) return shape;
@@ -1,9 +1,9 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
- import {attachTextTags,decodeTextTag} from './code-provenance.js';
2
+ import {attachTextTags,decodeTextTag,groupUnchanged} from './code-provenance.js';
3
3
  const TAG='OPF_TEXT_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
4
4
  const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
5
5
  const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
6
- export const attachPlainTextTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfText','plain text');
6
+ export const attachPlainTextTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfText','plain text',{signed:true});
7
7
 
8
8
  // Only boundaries are stored. Current native characters, including edits and
9
9
  // empty lines, always supply the text; tags cannot restore discarded old words.
@@ -48,7 +48,7 @@ export function importPlainTextGroups(shapes,paragraphs,relationships,entries,re
48
48
  const restored=sourceLineParagraphs(ordered,paragraphs);
49
49
  for(const item of ordered)consumed.add(item.shape);
50
50
  items.push({shapes:ordered.map(item=>item.shape),paragraphs:restored});
51
- report({code:'text-import-reflow',message:'Complete tagged lines retain current native text, whitespace and authored line boundaries. Native formatting, geometry and OPF nesting are not reconstructed; review the reflowed text.'});
51
+ if(!groupUnchanged(ordered,paragraphs))report({code:'text-import-reflow',message:'Complete tagged lines retain current native text, whitespace and authored line boundaries. Native formatting, geometry and OPF nesting are not reconstructed; review the reflowed text.'});
52
52
  }catch(error){report({code:'invalid-text-provenance',message:`${error.message} Ordinary import retains current native text without restoring old source words.`});}
53
53
  return {consumed,items};
54
54
  }
@@ -1,13 +1,13 @@
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
 
6
6
  const STATUSES=['done','current','planned'];
7
7
  const TAG='OPF_TIMELINE_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
8
8
  const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
9
9
  const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
10
- export const attachTimelineTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfTimeline','timeline');
10
+ export const attachTimelineTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfTimeline','timeline',{signed:true});
11
11
 
12
12
  // Only topology and line boundaries are retained. Every field value comes from
13
13
  // current native text; clearing or editing a field cannot restore old words.
@@ -86,7 +86,7 @@ export function importTimelineGroups(shapes,paragraphs,relationships,entries,rep
86
86
  if(!isValidFormat({slides:[{timeline}]}))throw Error('Current native text does not form a valid timeline.');
87
87
  for(const item of group)consumed.add(item.shape);
88
88
  items.push({shapes:group.map(item=>item.shape),payload:{type:'timeline',timeline}});
89
- report({code:'timeline-import-reflow',message:'Timeline field order, source boundaries and current native text were recovered. Native formatting, positions, marker styling and font theme are not reconstructed; review the reflowed timeline.'});
89
+ if(!groupUnchanged(group,paragraphs))report({code:'timeline-import-reflow',message:'Timeline field order, source boundaries and current native text were recovered. Native formatting, positions, marker styling and font theme are not reconstructed; review the reflowed timeline.'});
90
90
  }catch(error){report({code:'invalid-timeline-provenance',message:`${error.message} Ordinary import retains current native text without restoring old source words.`});}
91
91
  return {consumed,items};
92
92
  }