@openpresentation/opf-pptx 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/code-provenance.js +171 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +60 -35
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Pure local PowerPoint conversion tooling for Open Presentation Format documents. This repo owns the Phase 3 and Phase 4 toolkit lanes: OPF to PPTX export and PPTX to OPF import.
|
|
4
4
|
|
|
5
|
+
Version 0.7.0's [shared-code integration](docs/code-roundtrip.md) preserves exact source/metadata through guarded native tags and editable lines. XML-forbidden code characters reject export with `invalid-code-text`, the OPF field path and UTF-16 offset; schema validation alone does not establish XML representability. Native source recovery does not reconstruct formatting, geometry or font theme.
|
|
6
|
+
|
|
5
7
|
## Scope
|
|
6
8
|
|
|
7
|
-
Version 0.
|
|
9
|
+
Version 0.7.0 requires core 0.9.0 and uses renderer 0.7.0 for coordinated preview/font measurement. Quotes and code export their accepted internal lines and styles without another fitting pass. [Controlled Windows PowerPoint quote evidence](docs/evidence/shared-quote-integration/comparison.json) records glyph containment, separation, save/reopen and text reimport against its exact source/font hashes. Native quote import returns editable text blocks and does not restore the original OPF quote structure, typography or readability policy. Native chart geometry and general scalar-text wrapping also remain different from preview; editability and valid reimport do not establish raster equivalence.
|
|
8
10
|
|
|
9
11
|
- Package: `@openpresentation/opf-pptx`
|
|
10
12
|
- Repository: `OpenPresentation/opf-pptx`
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
2
|
+
|
|
3
|
+
// Native shape tags are standard PresentationML customer data. Uppercase hex
|
|
4
|
+
// protects case-sensitive source from PowerPoint's case-insensitive Tags API.
|
|
5
|
+
const TAG = 'OPF_CODE_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 ordered = new XMLParser({ignoreAttributes:false, attributeNamePrefix:'', parseTagValue:false, trimValues:false, preserveOrder:true});
|
|
11
|
+
const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
12
|
+
const hex = value => [...enc.encode(JSON.stringify(value))].map(byte=>byte.toString(16).padStart(2,'0')).join('').toUpperCase();
|
|
13
|
+
function unhex(value) {
|
|
14
|
+
if (typeof value !== 'string' || !/^(?:[0-9A-Fa-f]{2})+$/.test(value)) throw new Error('Invalid code tag encoding.');
|
|
15
|
+
return JSON.parse(dec.decode(Uint8Array.from(value.match(/../g),byte=>parseInt(byte,16))));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function codeManifest(value, layout, group) {
|
|
19
|
+
return {v:1, group, role:'panel', value, parts:layout.parts.map(part=>({role:part.role, generated:part.generated === true,
|
|
20
|
+
lines:part.fit.sourceLines.map(({start,end,nextStart,boundary})=>({start,end,nextStart,boundary}))}))};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function attachCodeTags(entries, records) {
|
|
24
|
+
if (!records.size) return;
|
|
25
|
+
let count = 0;
|
|
26
|
+
const seen = new Set(), types = [];
|
|
27
|
+
for (const path of Object.keys(entries).filter(path=>/^ppt\/slides\/slide\d+\.xml$/.test(path)).sort()) {
|
|
28
|
+
const relPath = path.replace('/slides/','/slides/_rels/') + '.rels';
|
|
29
|
+
let rels = dec.decode(entries[relPath]);
|
|
30
|
+
const ids = new Set([...rels.matchAll(/\bId="([^"]+)"/g)].map(match=>match[1]));
|
|
31
|
+
const xml = dec.decode(entries[path]).replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape=>{
|
|
32
|
+
const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
33
|
+
if (!records.has(name)) return shape;
|
|
34
|
+
if (seen.has(name)) throw new Error('Duplicate generated code shape.');
|
|
35
|
+
seen.add(name);
|
|
36
|
+
const part = `ppt/tags/opfCode${++count}.xml`;
|
|
37
|
+
let id = `rIdOpfCode${count}`;
|
|
38
|
+
while (ids.has(id)) id += '_';
|
|
39
|
+
ids.add(id);
|
|
40
|
+
entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${TAG}" val="${hex(records.get(name))}"/></p:tagLst>`);
|
|
41
|
+
types.push(`<Override PartName="/${part}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
|
|
42
|
+
rels = rels.replace('</Relationships>',`<Relationship Id="${id}" Type="${REL}" Target="../tags/opfCode${count}.xml"/></Relationships>`);
|
|
43
|
+
shape = shape.replace(/<p:nvPr\s*\/>/,'<p:nvPr></p:nvPr>');
|
|
44
|
+
if (!shape.includes('</p:nvPr>')) throw new Error('Generated code shape has no native application properties.');
|
|
45
|
+
return shape.replace('</p:nvPr>',`<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst></p:nvPr>`);
|
|
46
|
+
});
|
|
47
|
+
entries[path] = enc.encode(xml);
|
|
48
|
+
entries[relPath] = enc.encode(rels);
|
|
49
|
+
}
|
|
50
|
+
if (seen.size !== records.size) throw new Error('Missing generated code shapes.');
|
|
51
|
+
entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>',types.join('')+'</Types>'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const children = (nodes, name) => array(nodes).flatMap(node=>node[name] === undefined ? [] : [node[name]]);
|
|
55
|
+
const plainText = nodes => array(nodes).map(node=>node['#text'] ?? '').join('');
|
|
56
|
+
export const nativeTextShapes = tree => [...array(tree?.['p:sp']),...array(tree?.['p:grpSp']).flatMap(nativeTextShapes)];
|
|
57
|
+
const orderedShapes = tree => [...children(tree,'p:sp'),...children(tree,'p:grpSp').flatMap(orderedShapes)];
|
|
58
|
+
// Preserve the order of runs, fields and explicit line breaks. A keyed XML
|
|
59
|
+
// object groups all a:r before a:fld and cannot represent their original order.
|
|
60
|
+
export function nativeShapeParagraphs(xml) {
|
|
61
|
+
const root = children(ordered.parse(xml),'p:sld')[0];
|
|
62
|
+
const tree = children(children(root,'p:cSld')[0],'p:spTree')[0];
|
|
63
|
+
return orderedShapes(tree).map(shape=>children(children(shape,'p:txBody')[0],'a:p').map(paragraph=>{
|
|
64
|
+
let text = '', maxFontSize = 0, bullet = false, level = 0;
|
|
65
|
+
for (const child of paragraph) {
|
|
66
|
+
if (child['a:br'] !== undefined) text += '\n';
|
|
67
|
+
for (const key of ['a:r','a:fld']) if (child[key] !== undefined) {
|
|
68
|
+
text += children(child[key],'a:t').map(plainText).join('');
|
|
69
|
+
for (const run of child[key]) {
|
|
70
|
+
const size = Number(run[':@']?.sz);
|
|
71
|
+
if (run['a:rPr'] !== undefined && Number.isFinite(size)) maxFontSize = Math.max(maxFontSize,size/100);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (child['a:pPr'] !== undefined) {
|
|
75
|
+
level = Number(child[':@']?.lvl ?? 0);
|
|
76
|
+
bullet = child['a:pPr'].some(node=>node['a:buChar'] !== undefined || node['a:buAutoNum'] !== undefined);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {text,maxFontSize,bullet,level};
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sourceFor(value, role, generated) {
|
|
84
|
+
if (generated) return 'code';
|
|
85
|
+
if (role === 'body') return typeof value === 'string' ? value : value.source;
|
|
86
|
+
return value[role];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateManifest(manifest) {
|
|
90
|
+
const {value,parts} = manifest;
|
|
91
|
+
const object = value && typeof value === 'object' && !Array.isArray(value);
|
|
92
|
+
if (typeof value !== 'string' && (!object || typeof value.source !== 'string' || Object.keys(value).some(key=>!['source','filename','language'].includes(key)) || ['filename','language'].some(key=>value[key] !== undefined && typeof value[key] !== 'string'))) throw new Error('Invalid source value.');
|
|
93
|
+
// Empty metadata is preserved in the manifest but does not produce a label.
|
|
94
|
+
const roles = [...(object && value.filename ? ['filename'] : []), ...(object && value.language ? ['language'] : []), 'body'];
|
|
95
|
+
if (roles.length === 1) roles.unshift('language');
|
|
96
|
+
if (!Array.isArray(parts) || parts.length !== roles.length) throw new Error('Invalid code parts.');
|
|
97
|
+
parts.forEach((part,i)=>{
|
|
98
|
+
const generated = roles.length === 2 && roles[0] === 'language' && !(object && (value.filename || value.language)) && i === 0;
|
|
99
|
+
if (part.role !== roles[i] || part.generated !== generated || !Array.isArray(part.lines) || !part.lines.length) throw new Error('Invalid source part.');
|
|
100
|
+
const source = sourceFor(value,part.role,generated);
|
|
101
|
+
let cursor = 0;
|
|
102
|
+
part.lines.forEach((line,index)=>{
|
|
103
|
+
if (![line.start,line.end,line.nextStart].every(Number.isSafeInteger) || line.start !== cursor || line.end < line.start || line.nextStart < line.end || line.nextStart > source.length) throw new Error('Invalid source range.');
|
|
104
|
+
const separator = source.slice(line.end,line.nextStart);
|
|
105
|
+
const last = index === part.lines.length - 1;
|
|
106
|
+
if (/\r|\n/.test(source.slice(line.start,line.end)) || (line.boundary === 'hard' ? !/^(?:\r\n|\r|\n)$/.test(separator) || last : separator !== '' || line.boundary !== (last ? 'end' : 'soft'))) throw new Error('Invalid source boundary.');
|
|
107
|
+
cursor = line.nextStart;
|
|
108
|
+
});
|
|
109
|
+
if (cursor !== source.length) throw new Error('Incomplete source range.');
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Only complete, unambiguous sets are coalesced. Visible text always wins over
|
|
114
|
+
// old source text; metadata supplies only source boundaries and payload roles.
|
|
115
|
+
export function importCodeGroups(shapes, paragraphs, relationships, entries, report) {
|
|
116
|
+
const groups = new Map(), consumed = new Set(), items = [], tagCounts = new Map();
|
|
117
|
+
for (const [index,shape] of shapes.entries()) {
|
|
118
|
+
const links = array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags']);
|
|
119
|
+
for (const link of links) {
|
|
120
|
+
const rel = relationships.get(link['r:id']);
|
|
121
|
+
if (rel?.type !== REL || rel.targetMode === 'External' || !rel.path || !entries[rel.path]) continue;
|
|
122
|
+
let tags;
|
|
123
|
+
try { tags = array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']).filter(tag=>tag.name?.toUpperCase() === TAG); }
|
|
124
|
+
catch { report({code:'invalid-code-provenance',message:'Unreadable code tags; visible native shapes were retained.'}); continue; }
|
|
125
|
+
for (const tag of tags) {
|
|
126
|
+
tagCounts.set(shape,(tagCounts.get(shape) ?? 0)+1);
|
|
127
|
+
try {
|
|
128
|
+
const data = unhex(tag.val);
|
|
129
|
+
if (data.v !== 1 || typeof data.group !== 'string' || !/^\d+$/.test(data.group)) throw new Error('Invalid code identity.');
|
|
130
|
+
const group = groups.get(data.group) ?? [];
|
|
131
|
+
group.push({data,shape,index,text:paragraphs[index].map(p=>p.text).join('\n')});groups.set(data.group,group);
|
|
132
|
+
} catch { report({code:'invalid-code-provenance',message:'Invalid code tags; visible native shapes were retained.'}); }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const group of groups.values()) {
|
|
137
|
+
try {
|
|
138
|
+
if (group.some(item=>tagCounts.get(item.shape)!==1)) throw new Error('Multiple source identities on one shape.');
|
|
139
|
+
const panels = group.filter(item=>item.data.role === 'panel');
|
|
140
|
+
if (panels.length !== 1 || panels[0].text !== '') throw new Error('Missing, duplicated or edited panel.');
|
|
141
|
+
const panel = panels[0], manifest = panel.data;
|
|
142
|
+
validateManifest(manifest);
|
|
143
|
+
const expectedCount = 1 + manifest.parts.reduce((sum,part)=>sum+part.lines.length,0);
|
|
144
|
+
if (group.length !== expectedCount || new Set(group.map(item=>item.shape)).size !== group.length) throw new Error('Incomplete or duplicated code shapes.');
|
|
145
|
+
const nativeLines = new Map();
|
|
146
|
+
for (const item of group) if (item !== panel) {
|
|
147
|
+
const {role,part,line} = item.data, key = `${part}:${line}`;
|
|
148
|
+
if (role !== 'line' || !Number.isSafeInteger(part) || !Number.isSafeInteger(line) || !manifest.parts[part]?.lines[line] || nativeLines.has(key)) throw new Error('Ambiguous source line.');
|
|
149
|
+
nativeLines.set(key,item.text);
|
|
150
|
+
}
|
|
151
|
+
const value = typeof manifest.value === 'string' ? {source:manifest.value} : {...manifest.value};
|
|
152
|
+
for (const [partIndex,part] of manifest.parts.entries()) {
|
|
153
|
+
const source = sourceFor(manifest.value,part.role,part.generated), newline = source.match(/\r\n|\r|\n/)?.[0] ?? '\n';
|
|
154
|
+
let rebuilt = '';
|
|
155
|
+
for (const [lineIndex,line] of part.lines.entries()) {
|
|
156
|
+
const actual = nativeLines.get(`${partIndex}:${lineIndex}`);
|
|
157
|
+
if (actual === undefined) throw new Error('Missing source line.');
|
|
158
|
+
if (part.generated && actual !== source.slice(line.start,line.end)) throw new Error('Generated label was edited.');
|
|
159
|
+
rebuilt += actual.replace(/\r\n|\r|\n/g,newline) + source.slice(line.end,line.nextStart);
|
|
160
|
+
}
|
|
161
|
+
if (!part.generated) value[part.role === 'body' ? 'source' : part.role] = rebuilt;
|
|
162
|
+
}
|
|
163
|
+
for (const item of group) consumed.add(item.shape);
|
|
164
|
+
items.push({shape:panel.shape,payload:{type:'code',code:typeof manifest.value === 'string' ? value.source : value}});
|
|
165
|
+
report({code:'code-import-reflow',message:'Code source boundaries and native text were recovered. Native positioning, formatting and font theme are not reconstructed; review the reflowed OPF.'});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
report({code:'invalid-code-provenance',message:`${error.message} Visible native shapes were retained without restoring old source text.`});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {consumed,items};
|
|
171
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface ToPptxOptions {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export interface FromPptxOptions {
|
|
51
|
-
/** Reports native
|
|
51
|
+
/** Reports native details that import cannot preserve, including code provenance fallback/reflow and grouped text transforms. Table paths identify native frame and row/cell indexes (including headers). */
|
|
52
52
|
onDiagnostic?: (diagnostic: {code: string; path: string; message: string}) => void;
|
|
53
53
|
fallbackName?: string;
|
|
54
54
|
schema?: string;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {importTableFrames} from './table-import.js';
|
|
2
|
+
import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
2
3
|
import {importImageOrientation} from './image-import.js';
|
|
3
4
|
import {nativeBackgroundFill} from './background.js';
|
|
4
5
|
import {importBackground} from './background-import.js';
|
|
@@ -170,6 +171,7 @@ export async function toPptx(input, options = {}) {
|
|
|
170
171
|
context.tableCells = new Map();
|
|
171
172
|
context.imagePlacements = new Map();
|
|
172
173
|
context.backgroundFills = new Map();
|
|
174
|
+
context.codeTags = new Map();
|
|
173
175
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
174
176
|
const pptx = new PptxGenJS();
|
|
175
177
|
configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
|
|
@@ -383,9 +385,9 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
383
385
|
const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex)
|
|
384
386
|
.sort(comparePositionedItems);
|
|
385
387
|
const titleItem = takeTitleItem(items, dimensions);
|
|
386
|
-
if (titleItem) slide.title =
|
|
388
|
+
if (titleItem) slide.title = titleItem.text;
|
|
387
389
|
const subtitleItem = takeSubtitleItem(items, titleItem, dimensions);
|
|
388
|
-
if (subtitleItem) slide.subtitle =
|
|
390
|
+
if (subtitleItem) slide.subtitle = subtitleItem.text;
|
|
389
391
|
|
|
390
392
|
const blocks = mergeAdjacentBulletShapes(items)
|
|
391
393
|
.map((item) => payloadFromSlideItem(item))
|
|
@@ -401,9 +403,14 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
401
403
|
function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex) {
|
|
402
404
|
const tree = slideRoot["p:cSld"]?.["p:spTree"];
|
|
403
405
|
const items = [];
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
406
|
+
const shapes = nativeTextShapes(tree);
|
|
407
|
+
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.'});
|
|
408
|
+
const paragraphs = nativeShapeParagraphs(decodeText(entries[slidePath]));
|
|
409
|
+
const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
|
|
410
|
+
for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
411
|
+
for (const [index,shape] of shapes.entries()) {
|
|
412
|
+
if (code.consumed.has(shape)) continue;
|
|
413
|
+
const item = importShape(shape, dimensions, paragraphs[index]);
|
|
407
414
|
if (item) items.push(item);
|
|
408
415
|
}
|
|
409
416
|
|
|
@@ -426,9 +433,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
426
433
|
return items;
|
|
427
434
|
}
|
|
428
435
|
|
|
429
|
-
function importShape(shape, dimensions) {
|
|
430
|
-
const
|
|
431
|
-
const text = paragraphs.map((paragraph) => paragraph.text).filter(Boolean).join("\n").trim();
|
|
436
|
+
function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txBody"])) {
|
|
437
|
+
const text = paragraphs.map((paragraph) => paragraph.text).join("\n");
|
|
432
438
|
const placeholder = shapePlaceholderType(shape);
|
|
433
439
|
const bounds = shapeBounds(shape["p:spPr"]?.["a:xfrm"]);
|
|
434
440
|
const name = scalarText(shape["p:nvSpPr"]?.["p:cNvPr"]?.name).trim();
|
|
@@ -544,13 +550,12 @@ function readParagraphs(txBody) {
|
|
|
544
550
|
if (Number.isFinite(size)) sizes.push(size / 100);
|
|
545
551
|
}
|
|
546
552
|
return {
|
|
547
|
-
text: texts.join("")
|
|
553
|
+
text: texts.join(""),
|
|
548
554
|
bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buAutoNum"] !== undefined),
|
|
549
555
|
level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
|
|
550
556
|
maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
|
|
551
557
|
};
|
|
552
|
-
})
|
|
553
|
-
.filter((paragraph) => paragraph.text);
|
|
558
|
+
});
|
|
554
559
|
}
|
|
555
560
|
|
|
556
561
|
function shapePlaceholderType(shape) {
|
|
@@ -624,7 +629,7 @@ function mergeAdjacentBulletShapes(items) {
|
|
|
624
629
|
function payloadFromSlideItem(item) {
|
|
625
630
|
if (item.payload) return item.payload;
|
|
626
631
|
if (item.kind === "text") {
|
|
627
|
-
if (item.paragraphs.
|
|
632
|
+
if (item.paragraphs.some(p=>p.bullet)) {
|
|
628
633
|
return {
|
|
629
634
|
type: "list",
|
|
630
635
|
items: item.paragraphs.map((paragraph) => (
|
|
@@ -907,7 +912,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
907
912
|
} else if (item.field === "text" && typeof item.value === "string") {
|
|
908
913
|
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),fontFace:item.textStyle.fontFamily,bold:item.textStyle.fontWeight>=600,italic:item.textStyle.italic});
|
|
909
914
|
} else {
|
|
910
|
-
await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options);
|
|
915
|
+
await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options, item.quoteLayout, item.codeLayout);
|
|
911
916
|
}
|
|
912
917
|
}
|
|
913
918
|
|
|
@@ -928,7 +933,7 @@ function fieldToType(field) {
|
|
|
928
933
|
return field === "items" || field === "bullets" ? "list" : field;
|
|
929
934
|
}
|
|
930
935
|
|
|
931
|
-
async function addPayload(slide, presentation, payload, region, path, context, options) {
|
|
936
|
+
async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout) {
|
|
932
937
|
const kind = inferPayloadKind(payload);
|
|
933
938
|
switch (kind) {
|
|
934
939
|
case "text":
|
|
@@ -950,13 +955,13 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
950
955
|
addTablePayload(slide, payload.table, region, context, options, path);
|
|
951
956
|
break;
|
|
952
957
|
case "code":
|
|
953
|
-
addCodePayload(slide, payload.code, region, context,
|
|
958
|
+
addCodePayload(slide, payload.code, codeLayout, region, context, path);
|
|
954
959
|
break;
|
|
955
960
|
case "metric":
|
|
956
961
|
addMetricPayload(slide, payload.metric, region, context, options, path);
|
|
957
962
|
break;
|
|
958
963
|
case "quote":
|
|
959
|
-
addQuotePayload(slide,
|
|
964
|
+
addQuotePayload(slide, quoteLayout, context, options, path);
|
|
960
965
|
break;
|
|
961
966
|
case "timeline":
|
|
962
967
|
addTimelinePayload(slide, payload.timeline, region, context, options, path);
|
|
@@ -1195,9 +1200,9 @@ function pixelBox(region) {
|
|
|
1195
1200
|
// Each fitted line remains native editable text, without PowerPoint rewrapping it.
|
|
1196
1201
|
function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
1197
1202
|
const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
|
|
1198
|
-
const style = resolveTextStyle({fontFamily: config.fontFamily ?? context.fonts.body, fontWeight: config.fontWeight ?? 400, path: config.path}, options.textMeasurement);
|
|
1199
|
-
const fit = fitText(String(text ?? ''), box, config.fontSize * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
|
|
1200
|
-
if (fit.overflow) {
|
|
1203
|
+
const style = config.textStyle ?? resolveTextStyle({fontFamily: config.fontFamily ?? context.fonts.body, fontWeight: config.fontWeight ?? 400, path: config.path}, options.textMeasurement);
|
|
1204
|
+
const fit = config.fit ?? fitText(String(text ?? ''), box, config.fontSize * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
|
|
1205
|
+
if (fit.overflow && !config.diagnosticsHandled) {
|
|
1201
1206
|
const diagnostic = {code: 'text-overflow', path: config.path, message: 'Text exceeds its cell at the minimum font size; shorten it, increase its space, or split the slide.'};
|
|
1202
1207
|
options.onDiagnostic?.(diagnostic);
|
|
1203
1208
|
if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
|
|
@@ -1213,16 +1218,32 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
1213
1218
|
}
|
|
1214
1219
|
}
|
|
1215
1220
|
|
|
1216
|
-
function addCodePayload(slide, value, region, context,
|
|
1217
|
-
|
|
1218
|
-
const
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1221
|
+
function addCodePayload(slide, value, layout, region, context, path) {
|
|
1222
|
+
if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
|
|
1223
|
+
for (const part of layout.parts) {
|
|
1224
|
+
// XML 1.0 Char excludes controls and unpaired UTF-16 surrogates. The u flag
|
|
1225
|
+
// keeps valid supplementary characters (surrogate pairs) accepted.
|
|
1226
|
+
const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
1227
|
+
if (invalid) throw new OPFPptxError('invalid-code-text', `Code text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before exporting.`, {path:part.path});
|
|
1228
|
+
}
|
|
1229
|
+
const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
|
|
1230
|
+
for (const part of layout.parts) if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
|
|
1231
|
+
context.codeTags.set(panelName,codeManifest(value,layout,group));
|
|
1232
|
+
slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', pt: .75}, objectName:panelName});
|
|
1233
|
+
for (const [partIndex,part] of layout.parts.entries()) {
|
|
1234
|
+
if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
|
|
1235
|
+
for (const [index,line] of part.fit.sourceLines.entries()) {
|
|
1236
|
+
const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
|
|
1237
|
+
const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
|
|
1238
|
+
context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
|
|
1239
|
+
slide.addText(part.text.slice(line.start,line.end),{
|
|
1240
|
+
...textBoxOptions({x:part.box.x/96,y:(part.box.y+index*part.fit.lineHeight)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
|
|
1241
|
+
fontFace:part.style.fontFamily,bold:part.style.fontWeight>=600,italic:part.style.italic,
|
|
1242
|
+
color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
1243
|
+
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1226
1247
|
}
|
|
1227
1248
|
|
|
1228
1249
|
function addMetricPayload(slide, value, region, context, options, path) {
|
|
@@ -1231,12 +1252,15 @@ function addMetricPayload(slide, value, region, context, options, path) {
|
|
|
1231
1252
|
addMeasuredPayloadText(slide, [metric.label, metric.description, metric.delta].filter(Boolean).join('\n'), {x: box.x, y: box.y + box.height * .45, width: box.width, height: box.height * .55}, context, options, {path, fontSize: 23, fontWeight: 500});
|
|
1232
1253
|
}
|
|
1233
1254
|
|
|
1234
|
-
function addQuotePayload(slide,
|
|
1235
|
-
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1255
|
+
function addQuotePayload(slide, layout, context, options, path) {
|
|
1256
|
+
if (!layout) throw new OPFPptxError('missing-quote-layout', 'Quote export requires a coordinated core build with shared quote geometry.', {path});
|
|
1257
|
+
for (const part of layout.parts) {
|
|
1258
|
+
if (!part.fit) throw new OPFPptxError('layout-overflow', 'Quote content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
|
|
1259
|
+
addMeasuredPayloadText(slide,part.text,part.box,context,options,{
|
|
1260
|
+
path:part.path,fit:part.fit,textStyle:part.style,diagnosticsHandled:true,
|
|
1261
|
+
color:part.role==='footer'?context.colors.mutedText:context.colors.text,
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1240
1264
|
}
|
|
1241
1265
|
|
|
1242
1266
|
function addTimelinePayload(slide, value, region, context, options, path) {
|
|
@@ -1550,6 +1574,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
1550
1574
|
});
|
|
1551
1575
|
}
|
|
1552
1576
|
|
|
1577
|
+
attachCodeTags(entries, context.codeTags);
|
|
1553
1578
|
const imageSources = new Map();
|
|
1554
1579
|
for (const [part, bytes] of Object.entries(entries)) {
|
|
1555
1580
|
if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,24 +38,25 @@
|
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "node scripts/build.mjs",
|
|
40
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",
|
|
41
|
-
"test": "npm run build && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs",
|
|
41
|
+
"test": "npm run build && 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",
|
|
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",
|
|
45
45
|
"test:browser": "npm run build:browser-check && node test/browser-check.mjs",
|
|
46
46
|
"prepack": "npm run build",
|
|
47
47
|
"build:browser-check": "node scripts/build-browser-check.mjs",
|
|
48
|
-
"test:styled-table": "node test/styled-table.mjs && node test/styled-table-import.mjs"
|
|
48
|
+
"test:styled-table": "node test/styled-table.mjs && node test/styled-table-import.mjs",
|
|
49
|
+
"test:code": "node test/shared-code.mjs && node test/code-provenance.mjs"
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
|
-
"@openpresentation/opf": "^0.
|
|
52
|
+
"@openpresentation/opf": "^0.9.0",
|
|
52
53
|
"fast-xml-parser": "^5.8.0",
|
|
53
54
|
"fflate": "^0.8.3",
|
|
54
55
|
"jszip": "3.10.1",
|
|
55
56
|
"sharp": "0.35.4"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
|
-
"@openpresentation/opf-render": "^0.
|
|
59
|
+
"@openpresentation/opf-render": "^0.7.0"
|
|
59
60
|
},
|
|
60
61
|
"peerDependenciesMeta": {
|
|
61
62
|
"@openpresentation/opf-render": {
|
|
@@ -63,7 +64,7 @@
|
|
|
63
64
|
}
|
|
64
65
|
},
|
|
65
66
|
"devDependencies": {
|
|
66
|
-
"@openpresentation/opf-render": "0.
|
|
67
|
+
"@openpresentation/opf-render": "0.7.0",
|
|
67
68
|
"esbuild": "0.28.2",
|
|
68
69
|
"playwright": "1.63.0"
|
|
69
70
|
},
|