@openpresentation/opf-pptx 0.8.0 → 0.8.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.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # OPF PPTX
2
2
 
3
+ Version 0.8.1 requires core 0.10.1, including metric, quote and timeline layout placeholders and the corrected text-bullet contract. It retains the existing PowerPoint export APIs.
4
+
5
+ Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
6
+
3
7
  Version 0.8.0 and this checkout require Node 24 (`24.x`). Use `.nvmrc` for local development. Earlier published versions retain their original engine declarations. Browser entrypoints remain browser-safe; native application compatibility is verified separately.
4
8
 
5
9
  Version 0.8.0 consumes shared heading/scalar/rich line placement, including `textRasterPadding`, through editable native line boxes without autofit. Complete heading tags retain title/subtitle/tag roles, line ordering and current edited text during import. Source boundary metadata also records hard-line separators, so complete scalar/heading groups recover current native text with spaces, tabs and exact authored line endings. Incomplete, duplicate, ambiguous or bulleted groups fall back to ordinary import with diagnostics. Legacy heading tags retain their prior native-line behavior; arbitrary formatting, nesting and geometry are not reconstructed. See the [source contract and limits](https://github.com/OpenPresentation/opf/blob/f94125a1ff95bf0974a055fe1c081d348dad54f2/docs/plans/text-placement.md). Native raster certification remains separate.
@@ -232,3 +236,7 @@ Export consumes the same core geometry as SVG. It writes native merged cells and
232
236
  Version 0.5.0 requires core 0.7.0 and renderer 0.5.0 for coordinated previews. `npm run test:styled-table` runs export/import regressions. For local browser verification, set `OPF_CORE_ROOT` to the core repository and `OPF_RENDER_ROOT` to the coordinated renderer worktree when running `npm run build:browser-check`. Serve the repository and open `/artifacts/native-styled-table-import/browser/index.html`. Omit those variables to exercise installed package dependencies.
233
237
 
234
238
  Version 0.8.0 exports editable native lines with explicit four-space tab stops. Boundary tags contain no original source words, so native edits and deletions remain authoritative. XML-unrepresentable scalar controls reject with an actionable path rather than silently losing characters. Twenty wide/portrait measured/estimated export cases, current-text mutation controls and damaged-tag fallbacks pass; native Office acceptance remains separate. The coordinated core browser workflow also verifies editing, undo and exact reimport.
239
+
240
+ The unpublished furniture candidate exports core's accepted header/footer text and picture geometry. Dedicated `OPF_FURNITURE_V1` tags describe roles, line boundaries, inactive flags and global/local scope. A slide manifest represents empty or disabled definitions without adding a visible shape. Tags contain no original text, image bytes or alt text. Reimport reads those values from current native shapes; complete groups recover literal dates, section/organization metadata and page-number intent when the current number matches the current slide position.
241
+
242
+ Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic. Conflicting organization or section values also fall back. Inherited definitions become global only when every slide has a valid definition/override and all inherited values agree; otherwise recovered definitions stay local. Reordering slides with unchanged visible page numbers preserves those numbers as ordinary text instead of silently renumbering them. Native typography, positioning, crop and unrelated metadata are not reconstructed; `furniture-import-reflow` requests review. `npm run test:furniture` exercises XML conversion and mutation controls. Native Office, visual corpus and clean installed-package acceptance remain separate gates; this behavior is not yet published.
@@ -24,9 +24,9 @@ export function attachCodeTags(entries, records) {
24
24
  return attachTextTags(entries,records,TAG,'opfCode','code');
25
25
  }
26
26
 
27
- export {unhex as decodeTextTag};
27
+ export {unhex as decodeTextTag, hex as encodeTextTag};
28
28
 
29
- export function attachTextTags(entries, records, tagName, prefix, kind) {
29
+ export function attachTextTags(entries, records, tagName, prefix, kind, {pictures = false} = {}) {
30
30
  if (!records.size) return;
31
31
  let count = 0;
32
32
  const seen = new Set(), types = [];
@@ -34,7 +34,8 @@ export function attachTextTags(entries, records, tagName, prefix, kind) {
34
34
  const relPath = path.replace('/slides/','/slides/_rels/') + '.rels';
35
35
  let rels = dec.decode(entries[relPath]);
36
36
  const ids = new Set([...rels.matchAll(/\bId="([^"]+)"/g)].map(match=>match[1]));
37
- const xml = dec.decode(entries[path]).replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape=>{
37
+ const pattern = pictures ? /<p:(sp|pic)>[\s\S]*?<\/p:\1>/g : /<p:sp>[\s\S]*?<\/p:sp>/g;
38
+ const xml = dec.decode(entries[path]).replace(pattern, shape=>{
38
39
  const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
39
40
  if (!records.has(name)) return shape;
40
41
  if (seen.has(name)) throw new Error(`Duplicate generated ${kind} shape.`);
@@ -0,0 +1,214 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {attachTextTags, decodeTextTag, encodeTextTag} from './code-provenance.js';
3
+ import {sourceLineParagraphs} from './text-provenance.js';
4
+
5
+ const TAG = 'OPF_FURNITURE_V1';
6
+ const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
7
+ const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
8
+ const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
9
+ const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
10
+ const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
11
+ const kinds = ['header', 'footer'], zones = ['left', 'center', 'right'];
12
+ const fields = ['text', 'image', 'organization', 'section', 'slideNumber', 'date'];
13
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
14
+ const key = part => `${part.kind}.${part.zone}.${part.field}`;
15
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
16
+ const check = (condition, message) => { if (!condition) throw Error(message); };
17
+
18
+ // This manifest contains topology, identities and inactive flags only. Current
19
+ // native shapes supply all words, image bytes and alt text, even after edits.
20
+ export function furnitureManifest(presentation, slide, layout, slideIndex) {
21
+ const definitions = {};
22
+ for (const kind of kinds) {
23
+ const local = slide.design?.[kind] !== undefined;
24
+ const source = local ? slide.design[kind] : presentation.design?.[kind];
25
+ if (source === undefined) continue;
26
+ const value = source === false ? false : {};
27
+ if (value !== false) for (const zone of zones) if (source[zone] !== undefined) {
28
+ value[zone] = {};
29
+ for (const field of fields) if (source[zone][field] !== undefined) {
30
+ const current = source[zone][field];
31
+ value[zone][field] = field === 'image' ? 'native' : typeof current === 'string' ? 'literal' : current;
32
+ }
33
+ }
34
+ definitions[kind] = {scope: local ? 'local' : 'global', value};
35
+ }
36
+ if (!Object.keys(definitions).length) return null;
37
+ const organizations = array(presentation.organization);
38
+ 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}))};
43
+ }
44
+
45
+ export function attachFurnitureTags(entries, records, manifests) {
46
+ attachTextTags(entries, records, TAG, 'opfFurniture', 'furniture', {pictures: true});
47
+ const types = [];
48
+ for (const [path, manifest] of manifests) {
49
+ const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
50
+ const relPath = path.replace('/slides/', '/slides/_rels/') + '.rels';
51
+ let xml = dec.decode(entries[path]), rels = dec.decode(entries[relPath]);
52
+ // Common-slide customer data follows spTree, before controls/extLst.
53
+ // No invisible/fake shape is necessary for false or empty definitions.
54
+ const ids = new Set([...rels.matchAll(/\bId="([^"]+)"/g)].map(match => match[1]));
55
+ let id = 'rIdOpfFurnitureSlide';
56
+ while (ids.has(id)) id += '_';
57
+ check(!entries[part] && xml.includes('</p:spTree>'), 'Missing slide tree or colliding furniture tag part.');
58
+ xml = xml.replace('</p:spTree>', `</p:spTree><p:custDataLst><p:tags r:id="${id}"/></p:custDataLst>`);
59
+ rels = rels.replace('</Relationships>', `<Relationship Id="${id}" Type="${REL}" Target="../tags/opfFurnitureSlide${manifest.group}.xml"/></Relationships>`);
60
+ entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${TAG}" val="${encodeTextTag(manifest)}"/></p:tagLst>`);
61
+ entries[path] = enc.encode(xml);
62
+ entries[relPath] = enc.encode(rels);
63
+ types.push(`<Override PartName="/${part}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
64
+ }
65
+ if (types.length) entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>', types.join('') + '</Types>'));
66
+ }
67
+
68
+ function readTags(container, relationships, entries) {
69
+ const tags = [];
70
+ let unreadable = false;
71
+ for (const link of array(container?.['p:tags'])) {
72
+ const rel = relationships.get(link['r:id']);
73
+ try {
74
+ check(rel?.type === REL && rel.targetMode !== 'External' && entries[rel.path], 'Missing tag relationship.');
75
+ tags.push(...array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));
76
+ } catch { unreadable = true; }
77
+ }
78
+ return {tags: tags.filter(tag => tag.name?.toUpperCase() === TAG),
79
+ ambiguous: unreadable || tags.filter(tag => /^OPF_/i.test(tag.name)).length !== 1};
80
+ }
81
+
82
+ function validateManifest(manifest) {
83
+ check(manifest?.v === 1 && manifest.role === 'slide' && /^\d{1,10}$/.test(manifest.group), 'Invalid slide identity.');
84
+ check(object(manifest.definitions) && Object.keys(manifest.definitions).length > 0 && Object.keys(manifest.definitions).every(kind => kinds.includes(kind)), 'Invalid furniture definitions.');
85
+ check(Array.isArray(manifest.parts) && manifest.parts.length <= 36, 'Invalid furniture parts.');
86
+ const expected = new Map();
87
+ for (const [kind, definition] of Object.entries(manifest.definitions)) {
88
+ check(object(definition) && ['global', 'local'].includes(definition.scope), 'Invalid furniture scope.');
89
+ const value = definition.value;
90
+ check(value === false || object(value), 'Invalid furniture definition.');
91
+ if (value === false) continue;
92
+ check(Object.keys(value).every(zone => zones.includes(zone)), 'Invalid furniture zone.');
93
+ for (const [zone, content] of Object.entries(value)) {
94
+ check(object(content) && Object.keys(content).every(field => fields.includes(field)), 'Invalid furniture fields.');
95
+ for (const [field, flag] of Object.entries(content)) {
96
+ check(field === 'image' ? flag === 'native' : field === 'text' ? flag === 'literal' :
97
+ field === 'date' ? flag === false || flag === 'literal' : typeof flag === 'boolean', 'Invalid furniture field intent.');
98
+ if (flag !== false) expected.set(`${kind}.${zone}.${field}`, field === 'image' ? 'image' : 'text');
99
+ }
100
+ }
101
+ }
102
+ check(manifest.parts.length === expected.size, 'Incomplete furniture topology.');
103
+ for (const part of manifest.parts) {
104
+ check(object(part) && expected.get(key(part)) === part.type, 'Ambiguous furniture part.');
105
+ check(Number.isSafeInteger(part.count) && part.count >= 1 && part.count <= 10000 && (part.type !== 'image' || part.count === 1), 'Invalid furniture line count.');
106
+ expected.delete(key(part));
107
+ }
108
+ if (manifest.parts.some(part => part.field === 'organization')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
109
+ }
110
+
111
+ function readSlide(context, entries, slideIndex, report, taggedText) {
112
+ const {root, shapes, paragraphs, pictures, relationships, readPicture} = context;
113
+ const candidates = {};
114
+ const records = [];
115
+ let invalidRecord = false;
116
+ for (const [type, nodes, properties] of [['text', shapes, 'p:nvSpPr'], ['image', pictures, 'p:nvPicPr']]) {
117
+ for (const [index, node] of nodes.entries()) {
118
+ const result = readTags(node[properties]?.['p:nvPr']?.['p:custDataLst'], relationships, entries);
119
+ if (type === 'text' && result.tags.length) taggedText.add(index);
120
+ for (const tag of result.tags) try {
121
+ check(!result.ambiguous, 'Multiple identities on a furniture shape.');
122
+ records.push({type, index, data: decodeTextTag(tag.val)});
123
+ } catch { invalidRecord = true; }
124
+ }
125
+ }
126
+ const tags = readTags(root['p:cSld']?.['p:custDataLst'], relationships, entries);
127
+ if (!tags.tags.length && !records.length && !invalidRecord) return candidates;
128
+ let manifest;
129
+ try {
130
+ check(!tags.ambiguous && tags.tags.length === 1 && !invalidRecord, 'Missing or ambiguous furniture manifest/shape tags.');
131
+ manifest = decodeTextTag(tags.tags[0].val);
132
+ validateManifest(manifest);
133
+ for (const record of records) {
134
+ const data = record.data, part = manifest.parts[data?.part];
135
+ check(data?.v === 1 && data.group === manifest.group && Number.isSafeInteger(data.part) && part && data.role === record.type && part.type === record.type, 'Unmatched furniture identity.');
136
+ }
137
+ } catch (error) { report(slideIndex, error.message); return candidates; }
138
+ for (const kind of kinds) {
139
+ const definition = manifest.definitions[kind];
140
+ if (!definition) continue;
141
+ try {
142
+ const value = definition.value === false ? false : {};
143
+ if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
144
+ value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
145
+ }
146
+ const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], sections: []};
147
+ for (const [partIndex, part] of manifest.parts.entries()) {
148
+ if (part.kind !== kind) continue;
149
+ const group = records.filter(record => record.data.part === partIndex);
150
+ check(group.length === part.count, 'Incomplete or duplicated furniture part.');
151
+ if (part.type === 'image') {
152
+ const current = readPicture(pictures[group[0].index]);
153
+ check(current?.kind === 'image', 'Furniture image is no longer recoverable.');
154
+ value[part.zone].image = current.payload.image;
155
+ candidate.pictures.push(group[0].index);
156
+ } else {
157
+ const ordered = Array(part.count);
158
+ for (const record of group) {
159
+ const {line, count} = record.data;
160
+ check(count === part.count && Number.isSafeInteger(line) && line >= 0 && line < part.count && !ordered[line], 'Ambiguous furniture line.');
161
+ ordered[line] = record;
162
+ }
163
+ const text = sourceLineParagraphs(ordered, paragraphs)[0].text;
164
+ if (part.field === 'slideNumber') check(text === String(slideIndex + 1), 'Current slide number differs from its position; retain the visible number as ordinary text.');
165
+ if (part.field === 'section') candidate.sections.push(text);
166
+ if (part.field === 'organization') candidate.organizations.push({id: manifest.organizationId, name: text});
167
+ value[part.zone][part.field] = ['text', 'date'].includes(part.field) ? text : true;
168
+ candidate.text.push(...ordered.map(record => record.index));
169
+ }
170
+ }
171
+ check(new Set(candidate.text).size === candidate.text.length && new Set(candidate.pictures).size === candidate.pictures.length, 'Repeated furniture shape identity.');
172
+ candidates[kind] = candidate;
173
+ } catch (error) { report(slideIndex, `${kind}: ${error.message}`); }
174
+ }
175
+ return candidates;
176
+ }
177
+
178
+ // Reconcile metadata before consuming any shapes. Global inheritance is promoted
179
+ // only when every slide has a valid definition/override and all inherited values
180
+ // agree. A missing tag must never cause another slide's furniture to reappear.
181
+ export function importFurniture(contexts, entries, onDiagnostic) {
182
+ 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
+ const taggedText = contexts.map(() => new Set());
184
+ const candidates = contexts.map((context, index) => readSlide(context, entries, index, report, taggedText[index]));
185
+ const organizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
186
+ if (organizations.some(item => !same(item, organizations[0]))) {
187
+ for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.organizations.length) {
188
+ delete slide[kind]; report(index, `${kind}: Current organization metadata disagrees across repeated fields.`);
189
+ }
190
+ }
191
+ for (const [index, slide] of candidates.entries()) {
192
+ const sections = Object.values(slide).flatMap(candidate => candidate.sections);
193
+ if (sections.some(section => section !== sections[0])) for (const kind of kinds) if (slide[kind]?.sections.length) {
194
+ delete slide[kind]; report(index, `${kind}: Current section metadata disagrees on this slide.`);
195
+ }
196
+ }
197
+ const result = {design: {}, slides: candidates.map((slide, index) => {
198
+ const values = Object.values(slide), sections = values.flatMap(candidate => candidate.sections);
199
+ return {design: Object.fromEntries(Object.entries(slide).map(([kind, candidate]) => [kind, candidate.value])),
200
+ ...(sections.length ? {section: sections[0]} : {}),
201
+ text: new Set(values.flatMap(candidate => candidate.text)), pictures: new Set(values.flatMap(candidate => candidate.pictures)), taggedText: taggedText[index]};
202
+ })};
203
+ const validOrganizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
204
+ if (validOrganizations.length) result.organization = validOrganizations[0];
205
+ for (const kind of kinds) {
206
+ const inherited = candidates.map(slide => slide[kind]).filter(candidate => candidate?.scope === 'global');
207
+ if (inherited.length && candidates.every(slide => slide[kind]) && inherited.every(candidate => same(candidate.value, inherited[0].value))) {
208
+ result.design[kind] = inherited[0].value;
209
+ candidates.forEach((slide, index) => { if (slide[kind].scope === 'global') delete result.slides[index].design[kind]; });
210
+ }
211
+ }
212
+ candidates.forEach((slide, index) => { if (Object.keys(slide).length) onDiagnostic?.({code: 'furniture-import-reflow', path: `slides.${index}.design`, message: 'Complete furniture roles retain current text, source line boundaries, image content and unambiguous metadata. Native formatting, positioning, crop and other presentation metadata are not reconstructed; review the reflowed furniture.'}); });
213
+ return result;
214
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import {attachCardTags,importCardFrames} from './card-provenance.js';
6
6
  import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
7
7
  import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
8
8
  import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
9
+ import {attachFurnitureTags, furnitureManifest, importFurniture} from './furniture-provenance.js';
9
10
  import {importImageOrientation} from './image-import.js';
10
11
  import {nativeBackgroundFill} from './background.js';
11
12
  import {importBackground} from './background-import.js';
@@ -181,6 +182,8 @@ export async function toPptx(input, options = {}) {
181
182
  context.headingTags = new Map();
182
183
  context.plainTextTags = new Map();
183
184
  context.timelineTags = new Map();
185
+ context.furnitureTags = new Map();
186
+ context.furnitureManifests = new Map();
184
187
  context.codeTags = new Map();
185
188
  context.metricTags = new Map();
186
189
  context.chartHeadings = new Map();
@@ -233,8 +236,21 @@ export async function fromPptx(input, options = {}) {
233
236
  if (core.author) imported.author = core.author;
234
237
  if (dimensions) imported.design = { dimensions };
235
238
 
239
+ const furnitureContexts = slidePaths.map((slidePath, index) => {
240
+ const root = parseRequiredXml(entries, slidePath)['p:sld'];
241
+ if (!root) throw new OPFPptxError('invalid-pptx', `PPTX slide is not a PresentationML slide: ${slidePath}.`, {path: slidePath});
242
+ const tree = root['p:cSld']?.['p:spTree'], relationships = parseRelationships(entries, slidePath);
243
+ return {root, relationships, shapes: nativeTextShapes(tree), pictures: nativePictures(tree),
244
+ paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
245
+ readPicture: picture => importPicture(entries, picture, slidePath, relationships,
246
+ diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))};
247
+ });
248
+ const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic);
249
+ if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
250
+ if (furniture.organization) imported.organization = furniture.organization;
251
+
236
252
  for (let index = 0; index < slidePaths.length; index += 1) {
237
- imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options));
253
+ imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
238
254
  }
239
255
 
240
256
  const result = validatePresentation(imported);
@@ -375,16 +391,9 @@ function dimensionsFromPresentation(presentationRoot) {
375
391
  };
376
392
  }
377
393
 
378
- function importSlide(entries, slidePath, slideIndex, presentationDimensions, options) {
379
- const doc = parseRequiredXml(entries, slidePath);
380
- const slideRoot = doc["p:sld"];
381
- if (!slideRoot) {
382
- throw new OPFPptxError("invalid-pptx", `PPTX slide is not a PresentationML slide: ${slidePath}.`, {
383
- path: slidePath
384
- });
385
- }
386
-
387
- const relationships = parseRelationships(entries, slidePath);
394
+ function importSlide(entries, slidePath, slideIndex, presentationDimensions, options, furniture, nativeContext) {
395
+ const slideRoot = nativeContext.root;
396
+ const relationships = nativeContext.relationships;
388
397
  const dimensions = presentationDimensions ?? DIMENSION_PRESETS.widescreen;
389
398
  const slide = {};
390
399
  if (slideRoot.show === "0") slide.hidden = true;
@@ -393,8 +402,10 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
393
402
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
394
403
  }, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`}));
395
404
  if (background) slide.design = {background};
405
+ if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
406
+ if (furniture.section !== undefined) slide.section = furniture.section;
396
407
 
397
- const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex)
408
+ const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
398
409
  .sort(comparePositionedItems);
399
410
  // Complete OPF heading roles are authoritative. Nearby body lines must not
400
411
  // fill an absent role by geometry; explicit native placeholders still apply.
@@ -418,12 +429,14 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
418
429
  return slide;
419
430
  }
420
431
 
421
- function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex) {
432
+ const nativePictures = tree => [...asArray(tree?.['p:pic']), ...asArray(tree?.['p:grpSp']).flatMap(nativePictures)];
433
+
434
+ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext) {
422
435
  const tree = slideRoot["p:cSld"]?.["p:spTree"];
423
436
  const items = [];
424
- const shapes = nativeTextShapes(tree);
425
- if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text is retained, but group transforms and non-text group members are not reconstructed; review the reflowed OPF.'});
426
- const paragraphs = nativeShapeParagraphs(decodeText(entries[slidePath]));
437
+ const shapes = nativeContext.shapes;
438
+ if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text and pictures are retained, but group transforms and unsupported group members are not reconstructed; review the reflowed OPF.'});
439
+ const paragraphs = nativeContext.paragraphs;
427
440
  const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
428
441
  const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
429
442
  const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
@@ -446,8 +459,12 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
446
459
  for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
447
460
  for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
448
461
  for (const [index,shape] of shapes.entries()) {
462
+ if (furniture.text.has(index)) continue;
449
463
  if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)) continue;
450
- const item = importShape(shape, dimensions, paragraphs[index]);
464
+ const item = importShape(shape, dimensions, paragraphs[index], furniture.taggedText.has(index));
465
+ // A damaged/edited furniture group falls back to current native text,
466
+ // including cleared text boxes, without inventing a title or shape label.
467
+ if (item && furniture.taggedText.has(index)) item.sourceText = true;
451
468
  if (item) items.push(item);
452
469
  }
453
470
 
@@ -461,7 +478,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
461
478
  if (item) items.push(item);
462
479
  }
463
480
 
464
- for (const [index, picture] of asArray(tree?.["p:pic"]).entries()) {
481
+ for (const [index, picture] of nativeContext.pictures.entries()) {
482
+ if (furniture.pictures.has(index)) continue;
465
483
  const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
466
484
  const item = importPicture(entries, picture, slidePath, relationships, report);
467
485
  if (item) items.push(item);
@@ -538,7 +556,7 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
538
556
  function importPicture(entries, picture, slidePath, relationships, report) {
539
557
  const bounds = shapeBounds(picture["p:spPr"]?.["a:xfrm"]);
540
558
  const name = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.name).trim();
541
- const alt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr).trim();
559
+ const alt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr);
542
560
  const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
543
561
  const relationship = relationships.get(relId);
544
562
  let bytes = relationship?.path ? entries[relationship.path] : null;
@@ -926,8 +944,9 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
926
944
  const { widthInches, heightInches } = slideContext.dimensions;
927
945
  const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
928
946
  if (opfSlide.layout && layout.id !== opfSlide.layout) throw new OPFPptxError("catalog-resolution-failed", `Layout '${opfSlide.layout}' needs an inline or bundled catalog record.`, { path: `slides.${slideIndex}.layout` });
929
- const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, slideIndex, fonts: slideContext.fonts, contentAlignment:opfSlide.design?.contentAlignment??presentation.design?.contentAlignment, titleAlignment:opfSlide.design?.titleAlignment??presentation.design?.titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement });
947
+ const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment:opfSlide.design?.contentAlignment??presentation.design?.contentAlignment, titleAlignment:opfSlide.design?.titleAlignment??presentation.design?.titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement });
930
948
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
949
+ await addFurniture(slide,presentation,opfSlide,geometry.furniture,slideContext,options,slideIndex);
931
950
  for (const item of geometry.items) {
932
951
  const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
933
952
  if (item.frameBox) {
@@ -1128,6 +1147,7 @@ async function addImagePayload(slide, presentation, asset, region, path, context
1128
1147
  h: region.h,
1129
1148
  altText: assetAlt(asset, presentation)
1130
1149
  });
1150
+ return objectName;
1131
1151
  }
1132
1152
 
1133
1153
  function addChartPayload(slide, chart, region, context) {
@@ -1277,12 +1297,13 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
1277
1297
  if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
1278
1298
  }
1279
1299
  for (const [index, line] of fit.lines.entries()) {
1280
- if (!line&&!config.heading&&!config.sourceText&&!config.timeline) continue;
1300
+ if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.keepEmpty) continue;
1281
1301
  const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1282
1302
  const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
1283
- const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:undefined;
1303
+ const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:config.objectName?`${config.objectName} line ${index}`:undefined;
1284
1304
  if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
1285
1305
  else if(config.timeline)context.timelineTags.set(objectName,{v:1,role:'text',group:config.timeline.group,part:config.timeline.part,line:index,count:fit.lines.length,...boundary,...(config.timeline.part===0&&index===0?{anchor:config.timeline.anchor}:{})});
1306
+ else if(config.furniture)context.furnitureTags.set(objectName,{v:1,role:'text',...config.furniture,line:index,count:fit.lines.length,...boundary});
1286
1307
  else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
1287
1308
  const area=placed?{x:(placed.x+placed.width*factor-box.width*factor)/96,y:(placed.baseline-fit.fontSize)/96,w:box.width/96,h:placed.height/96}:{x:box.x/96,y:(box.y+index*fit.lineHeight)/96,w:box.width/96,h:fit.lineHeight/96};
1288
1309
  slide.addText(line, {
@@ -1296,6 +1317,27 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
1296
1317
  }
1297
1318
  }
1298
1319
 
1320
+ async function addFurniture(slide,presentation,source,layout,context,options,slideIndex) {
1321
+ if(!layout){
1322
+ if(['header','footer'].some(kind=>(source.design?.[kind]??presentation.design?.[kind])))throw new OPFPptxError('missing-furniture-layout','Header/footer export requires coordinated core furniture geometry.',{path:`slides.${slideIndex}.design`});
1323
+ const manifest = furnitureManifest(presentation, source, {parts: []}, slideIndex);
1324
+ if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1325
+ return;
1326
+ }
1327
+ for(const [index,part]of layout.parts.entries()){
1328
+ if(part.type==='image'){
1329
+ const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
1330
+ const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,{...context,imageFill:'fit'},options);
1331
+ if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
1332
+ }else{
1333
+ if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
1334
+ addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.colors.mutedText,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:index}});
1335
+ }
1336
+ }
1337
+ const manifest = furnitureManifest(presentation, source, layout, slideIndex);
1338
+ if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1339
+ }
1340
+
1299
1341
  function addCodePayload(slide, value, layout, region, context, path) {
1300
1342
  if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
1301
1343
  for (const part of layout.parts) {
@@ -1692,6 +1734,7 @@ async function normalizePptxZip(raw, context) {
1692
1734
  attachHeadingTags(entries,context.headingTags);
1693
1735
  attachPlainTextTags(entries,context.plainTextTags);
1694
1736
  attachTimelineTags(entries,context.timelineTags);
1737
+ attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
1695
1738
  for(const [part,bytes]of Object.entries(entries)){
1696
1739
  if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
1697
1740
  const relationships=parseRelationships(entries,part);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,8 +37,8 @@
37
37
  "sideEffects": false,
38
38
  "scripts": {
39
39
  "build": "node scripts/build.mjs",
40
- "typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check test/shared-timeline.mjs",
41
- "test": "npm run build && node test/plain-whitespace.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/shared-quote.mjs && npm run test:timeline",
40
+ "typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check test/furniture-provenance.mjs",
41
+ "test": "npm run build && npm run test:furniture && node test/plain-whitespace.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/shared-quote.mjs && npm run test:timeline",
42
42
  "validate": "node scripts/validate-package.mjs",
43
43
  "test:packed": "node test/packed-install.mjs",
44
44
  "test:compare-published": "node test/compare-published.mjs",
@@ -49,17 +49,18 @@
49
49
  "test:code": "node test/shared-code.mjs && node test/code-provenance.mjs",
50
50
  "test:metric": "node test/shared-metric.mjs && node test/metric-provenance.mjs",
51
51
  "test:font-variants": "node test/font-variants.mjs",
52
- "test:timeline": "node test/shared-timeline.mjs"
52
+ "test:timeline": "node test/shared-timeline.mjs",
53
+ "test:furniture": "node test/shared-furniture.mjs && node test/furniture-provenance.mjs"
53
54
  },
54
55
  "dependencies": {
55
- "@openpresentation/opf": "^0.10.0",
56
+ "@openpresentation/opf": "^0.10.1",
56
57
  "fast-xml-parser": "^5.8.0",
57
58
  "fflate": "^0.8.3",
58
- "jszip": "3.10.1",
59
+ "jszip": "3.10.2",
59
60
  "sharp": "0.35.4"
60
61
  },
61
62
  "peerDependencies": {
62
- "@openpresentation/opf-render": "^0.8.0"
63
+ "@openpresentation/opf-render": "^0.8.1"
63
64
  },
64
65
  "peerDependenciesMeta": {
65
66
  "@openpresentation/opf-render": {
@@ -67,7 +68,7 @@
67
68
  }
68
69
  },
69
70
  "devDependencies": {
70
- "@openpresentation/opf-render": "0.8.0",
71
+ "@openpresentation/opf-render": "0.8.1",
71
72
  "esbuild": "0.28.2",
72
73
  "playwright": "1.63.0"
73
74
  },