@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 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.5.2 improves native metrics, quotes, code panels, timelines and chart-label contrast, with renderer 0.5.1 compatibility and fitted quote footer separation. See [native content evidence and limitations](docs/native-content-candidate.md). Native chart geometry and general scalar-text wrapping remain different from preview; editability and valid reimport do not establish raster equivalence.
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 background, picture and table details that OPF import cannot preserve. Table paths identify the native graphic-frame and row/cell indexes (including headers). */
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 = firstLine(titleItem.text);
388
+ if (titleItem) slide.title = titleItem.text;
387
389
  const subtitleItem = takeSubtitleItem(items, titleItem, dimensions);
388
- if (subtitleItem) slide.subtitle = firstLine(subtitleItem.text);
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
- for (const shape of asArray(tree?.["p:sp"])) {
406
- const item = importShape(shape, dimensions);
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 paragraphs = readParagraphs(shape["p:txBody"]);
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("").trim(),
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.length > 1 || item.paragraphs.some(p=>p.bullet)) {
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, options, path);
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, payload.quote, region, context, options, path);
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, options, path) {
1217
- const box = pixelBox(region), code = typeof value === 'string' ? value : value?.source ?? JSON.stringify(value);
1218
- const label = typeof value === 'object' && value?.language ? String(value.language) : 'code';
1219
- slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', pt: .75}});
1220
- // The label has a fixed baseline in the SVG renderer; it does not participate in fitting.
1221
- slide.addText(label.toUpperCase(), {
1222
- ...textBoxOptions({x: (box.x + 18) / 96, y: (box.y + 14) / 96, w: Math.max(1, box.width - 36) / 96, h: 20 / 96}, context, 14 * .75),
1223
- fontFace: context.fonts.code, bold: true, color: '93C5FD', fit: 'none', wrap: false,
1224
- });
1225
- addMeasuredPayloadText(slide, code, {x: box.x + 18, y: box.y + 46, width: box.width - 36, height: box.height - 64}, context, options, {path, fontSize: 18, fontFamily: context.fonts.code, color: 'E5E7EB'});
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, value, region, context, options, path) {
1235
- const quote = isPlainObject(value) ? value : {text: value}, box = pixelBox(region);
1236
- const attribution = [quote.attribution, quote.source].filter(Boolean).join(' - ');
1237
- // Keep the footer and an 18px gap outside the body's text fitting area.
1238
- addMeasuredPayloadText(slide, `"${quote.text ?? ''}"`, {x: box.x + 18, y: box.y + 18, width: Math.max(1, box.width - 36), height: Math.max(1, box.height - (attribution ? 94 : 36))}, context, options, {path: path + '.text', fontSize: 28, fontFamily: context.fonts.heading, fontWeight: 600});
1239
- addMeasuredPayloadText(slide, attribution, {x: box.x + 18, y: box.y + box.height - 58, width: box.width - 36, height: 40}, context, options, {path, fontSize: 17, fontWeight: 500, color: context.colors.mutedText});
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.5.2",
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.7.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.5.1"
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.5.1",
67
+ "@openpresentation/opf-render": "0.7.0",
67
68
  "esbuild": "0.28.2",
68
69
  "playwright": "1.63.0"
69
70
  },